iT邦幫忙

2024 iThome 鐵人賽

DAY 20
0
JavaScript

Don't make JavaScript Just Surpise系列 第 20

陣列(Array)與相關操作

  • 分享至 

  • xImage
  •  

決定給個篇幅給陣列,畢竟是個數一數二常用的資料結構。
在實作上,和遠端拿資料時,往往多筆資料的回傳都會包裝在陣列裡,如何過濾、分組、排序等等,是非常常見的場景。

如果有寫過 C# 或 SQL 語言的經驗,C# 就有所謂的 LINQ 語法(Language-Integrated Query)是專門在做這種集合操作,能夠像存取資料庫的概念一樣去撰寫程式。

JS 透過陣列上的方法和箭頭函數,能夠達到相近的語感體驗,我們會在文中一併討論。

陣列基本特性

這部分主要是複習了,畢竟前面零散地講很多陣列的特性。
以下陣列陳樹接針對 JS 中的陣列進行描述,可能和過往寫過其他強型別語言的陣列體驗上會很不一樣。
陣列是種允許儲存多個值,可以透過索引訪問各個值,且有長度屬性的一種資料結構。

  1. typeof [] 會回傳 object

  2. 確認一個物件是不是陣列有幾種方法

    • 直接基於原型鏈,可以使用 instanceofArray.prototype.isPrototypeOf()
      限制上就是基於原型鏈的限制,在不同全域下(如 iframe) 可能會有辨識不到的狀況,要注意變數的域。
      const iframe = document.createElement('iframe');
      document.body.appendChild(iframe);
      const iframeArray = iframe.contentWindow.Array;
      
      const arr = new iframeArray(1, 2, 3);
      console.log(arr instanceof Array); // false
      console.log(Array.prototype.isPrototypeOf(arr));//false
      
    • 基於原型物件上的屬性,透過物件上的 Object.prototype.toString.call() 方法來識別,如果是陣列,回傳的字串裡會帶 Array
      可以處理上面的例子,一般只有在人為修改原型時會無法辨別。
      ...//接續上面的例子
      console.log(Object.prototype.toString.call(arr));//"[object Array]"
      
    • 優化上面那個用原型物件上的toString()判別的的方法,基本上是相同原理,但簡單好記好用: Array.isArray()
      一般來說都推薦使用這個方法,畢竟人為改原型應該不是一個正常的開發寫法,只要於 ES 5(2009)以後的環境都能使用。
      ...//接續上面的例子
      console.log(Array.isArray(arr));//true
      
  3. 索引從 0 開始(0-indexed),索引皆為數字(用字串會變為屬性而非陣列的可迭代內容,不建議使用),可以跳著對索引進行宣告,中間會自動補上 undefined,但這些不是一般的 undefined。(甚至 length 都可被修改,但不建議直接這樣操作)。

    透過這樣的方式創建的陣列,會被稱作稀疏陣列(Sparse Array)。中間補上的那些 undefined 會被稱作未初始化的值。但即使一個元素是 undefined,也不代表他是未初始化的,手動指向 undefined 是一個賦值行為,這樣也算初始化。
    那要怎麼判斷一個索引是否被初始化?我們可以使用 in 關鍵字。

    let arr = [];
    arr[0] = 0;
    arr[3] = 3;
    arr.length = 5;
    console.log(arr);//[0, undefined, undefined, 3, undefined]
    console.log(arr.length);//5
    
    console.log(2 in arr, arr[2]);//false, undefined,未初始化
    arr[2] = undefined;
    console.log(2 in arr, arr[2]);//true, undefined,已初始化,即使值為 undefined
    
    let arr2 = new Array(100); //有特定大小想要強調,這樣是比較推薦的做法
    console.log(arr2.length)
    
  4. 陣列大小是動態的,無須事先宣告大小,可任意增加或刪除元素

  5. 陣列內可以包含的元素可以是任意型別,同時存在 StringNumberObject 是被允許的

  6. 陣列可以是多維陣列,維度沒有明確的維度上限,但會消耗對應的記憶體跟影響效能,過多維度也會增加閱讀和撰寫的困難性。(甚至 JS 中同一個維度下的內容長度可以不一致,因為陣列大小是可以動態決定的)

    一般建議使用至多二至三維,更複雜的資料結構可以考慮如物件等。

    let arr = [[1, 2], [3, 4]];
    console.log(arr[1][0]); // 3
    
  7. 陣列是一個可迭代也可枚舉的物件,但相較之下,通常建議使用迭代方法如 for of 來遍歷陣列內容。(不會意外的存取到陣列上的屬性(雖然一般使用情況也不該有除了迭代內容以外的可枚舉屬性存在),只存取可迭代內容)
    for of 直接對陣列使用可以直接存取值,若需要存取索引的狀況,則可以針對 ES 6 引入的 Array.prototype.entries() 方法返回的物件來使用 for of,這樣便能同時獲取陣列中可迭代元素的索引和值。

    let arr = [1,2,3,4];
    
    for (const value of arr) {
        console.log(value);
    }
    for (const [index, value] of arr.entries()) {
        console.log(`${index} : ${value}`);
    }
    

陣列的相關操作

基本的常用方法

看一個資料結構通常就是看 CRUD,增查改刪。
都是蠻基本的語法,稍微提一下,只會提到大概常用的語法。

JS 的陣列有直接提供從頭或尾直接插入或移除的方式,如果用過其他強型別語言的話應該會覺得 JS 的陣列算相當靈活,不管是大小或頭尾插入、刪改元素都能直接操作。

訪問與修改
訪問後就能直接用 = 賦值修改。

  • []:輸入 0 以上的正整數來訪問對應索引的元素
  • at.(): ES 2022 引入的方法,可以訪問負向元素(正負整數)
let a = [1,2,3,4,5];
console.log(a[0]);//1
console.log(a[-1]);//undefined
console.log(a.at(0));//1
console.log(a.at(-1));//5

插入與刪除

  • push():在陣列尾端插入元素

  • unshift():在陣列的開頭插入元素

  • pop():刪除並返回陣列的最後一個元素

  • shift():刪除並返回陣列的第一個元素

  • slice():用於取子陣列的方式,簽名為Array.prototype.slice(beginIndex, endIndex)。 回傳一個由原陣列的傳入兩個索引間(含)的元素構成的新陣列。

  • splice():集結增與刪,多工的方法,會修改陣列本身。簽名為Array.prototype.splice(start, deleteCount, item1, item2, ...)start 的位置是要操作的索引位置,根據後面的 deleteCount 來刪除對應數量的元素,接著逐項插入 item1item2

let arr = [1, 2, 3, 4, 5];
console.log(arr.slice(2, 4));//[3,4]
arr.splice(2, 2, 6);  // delete 2 elements from index 2, and insert 5 after 
console.log(arr);  // [1, 2, 6, 5]

進階操作

如前面提到的 LINQ,我們主要聚焦在三個情景下,針對陣列資料的:過濾、分組、排序。
資料操作語法重要的一點是能鏈式串連,當中間的物件只是過場,不用額外宣告。
以下語法只要有回傳陣列,多為一個新證列,且不會動到原本的陣列。

注意,上面有提到稀疏陣列,以下方法大多對稀疏陣列中的未初始化元素會直接略過,請務必注意自己的陣列是否不小心混入了未初始化的值,可能會導致非預期的結果(長度上看起來未初始化元素會被算入,但這些操作則否)。

  • 寫在最前面:陣列操作:mapforEach
    雖然不屬於上面三個場景任一個,但使用率其實蠻高的。

    函式簽名 Array.prototype.map(callback(element, index, array), thisArg);
    callback 是一個會對陣列中每個元素執行的內容,indexarray 是可選的參數,通常省略的時候也不少,thisArg 也是可選的,用於綁定執行 callback functionthis

    用於對陣列中的屬性做同樣操作或賦值,把陣列捏成你想要的形狀,類似 C# 中 Select 的語法。
    會返回一個新的陣列。常用場景包含平面化(當你只需要特定屬性的陣列)、為陣列添加額外數值也能使用(因為 ele 本身是物件,傳位址的情況下操作也會反應到陣列本身)。

    let arr = [{name: 'Ken', age: 27},{name: 'Ryu', age: 26}];
    let arr2 = arr.map((ele)=>{return ele.name});
    console.log(arr2);
    //["Ken", "Ryu"]
    

    可能會覺得和 Array.prototype.forEach 有點相像,基本上確實兩者都是對陣列中的元素作逐項遍歷的行為,差別在 forEach 的修改是直接修改原本陣列中,且無回傳值。(undefined)
    選用邏輯就是看今天是要對該函式做的操作希望另外產生新的陣列還是要在原本陣列上發生改動,要直接改動原本陣列就用 forEach,要做新的陣列就用 map
    但要小心當 map 對物件陣列做操作的情況,如上所說,傳入的參數是物件位址,所以其實還是會影響原陣列上的物件內容(不改變原陣列中的物件位址)。

  • 過濾

    1. every some
      這兩個其實不算直接對陣列過濾,比較像是省語句的寫法,做的事情很單純,就是判斷陣列中的元素是否符合某個條件。分類在這裡是因為靠條件對元素做判斷其實抽象來說也可以算是過濾啦。

      every 需要整個陣列都符合這個條件才回傳 true,否則回傳 false
      some 只要至少一個符合該條件就回傳 true,否則回傳 false
      小巧的語句,簡單好用於條件判斷。搭配 ! 運算子使用可以做到全部皆非的判定(every 的反邏輯)。

      let a = [1,2,3,4];
      function isAllElementBiggerThanZero(arr){
          for(const val of arr){
              if(val <= 0) return false;
          }
          return true;
      }
      console.log(isAllElementBiggerThanZero(a));//true
      //簡化寫法
      console.log(a.every((ele)=> ele > 0));//true
      
      console.log(a.every((ele)=> ele > 3));//false
      console.log(a.some((ele)=> ele > 3));//true
      
    2. filter
      C# 的 LINQ 裡是用 Where 語句來做,在 filter 中寫條件,回傳一個只由符合該條件的元素構成的陣列,如果沒有符合的,則回傳空陣列。

      let arr = [{name: 'Ken', age: 27},{name: 'Ryu', age: 26}];
      console.log(arr.filter(x=>x.name === 'Ryu'));
      //[{name: 'Ryu', age: 26}]
      
      //firstOrDefault
      console.log(arr.filter(x=>x.name === 'Ryu')[0]?.age);//26
      console.log(arr.filter(x=>x.name === 'Zangief')[0]?.age);//undefined
      

      配合 ? 運算子,可以處理像這種回傳新陣列並沒有符合該條件元素的情況。

  • 排序:sort
    傳入一個計算排序依據的函式。
    排序依據的函式預期會有兩個參數 (a,b),若前者比後者大回傳 1,若後者比前者大回傳 -1,若兩者一樣大則回傳 0。
    根據該排序依據回傳的結果重新排序內容,建構新的陣列並回傳。

    let arr = [{name: 'Ryu', age: 26},{name: 'Ken', age: 27},{name:'Chun-Li', age:18}];
    console.log(arr.sort((a, b) => a.age - b.age));//ASC
    console.log(arr.sort((a, b) => b.age - a.age));//DSC
    
  • 分組:reduce
    reduce 是一個相對不直覺的函數,他用了一個概念:累加。
    函數的簽名是這樣的:Array.prototype.reduce(callback(accumulator, currentValue, currentIndex, array), initialValue)
    說的累加指的是 accumulator 這個,他是一個累加器。

    累加的意思是什麼呢?
    執行 reduce 時一樣會遍歷陣列,但遍歷的過程中,第一個參數的 accumulator 會是一個跨越每個迭代塊的類本次遍歷中全域的存在(每個迭代存取的 accumulator 都是同一個位址)。而 initialValue 指的是 accumulator 的初始值。

    reduce 的回傳值就會是 accumulator 本身。
    其中,callback function 預期要回傳一個值,每次的回傳值都會覆蓋到 accumulator 上。
    如果沒有回傳,則 accumulator 就會得到 undefined 且被覆蓋。

    如果我們要做一個最簡單的累加器,可以這樣寫

    let nums = Array.from({ length: 100 }).map((x,idx)=> idx+1);
    //1 ~ 100 的整數陣列
    console.log(nums.reduce((ac,x)=> {
        return ac + x;
    }));//5050
    console.log(nums.reduce((ac,x)=> ac + x));//5050
    

    為什麼會把它放在分組這個區塊呢,因為我們可以把 accumulator 做初始化設定為一個物件。
    JS 中的物件就是用於儲存鍵值對的資料結構,而 C# 中用來分組的 GroupBy 白話來說就是依據某個標準來把元素們分成符合不同標準的堆。鍵值對的鍵就會是標準,值就會是符合該標準的物件。

    let arr = [{name: 'Ryu', age: 26},{name: 'Ken', age: 27},{name:'Chun-Li', age:18},{name: 'Ryuu', age: 24}];
    console.log(arr.reduce((ac,ele)=>{
        const firstCharOfName = ele.name?.[0];
        if(! (firstCharOfName in ac)) ac[firstCharOfName] = [];
        ac[firstCharOfName].push(ele);
        return ac;
    },{}));
    /*{ C: [{ age: 18, name: "Chun-Li" }], 
    K: [{ age: 27, name: "Ken" }],
    R: [{ age: 26, name: "Ryu" }, { age: 24, name: "Ryuu" }] }
    */
    

    像這樣就能以名字的字首來分組。
    reduce 的常用情景通常就會像是這樣的分類或累進。


以上大概把 JS 常用的陣列相關操作都有介紹到,配合上箭頭函示,能把程式寫的簡潔且好讀,在處理陣列資料的時候,懂得使用這些工具會大有助益。


上一篇
async 和 await 關鍵字
下一篇
例外(Exception)、錯誤物件(Error)與攔截
系列文
Don't make JavaScript Just Surpise31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言