JavaScript 資料處理 - 運算元組

邊城發表於2022-03-05

程式中的常用資料集合無非兩類,列表 (List) 和對映 (Map)。在 JavaScript 的語言基礎中就提供了這兩種集合結構的支援 —— 用陣列 (Array) 表示列表,用直接物件 (Plain Object) 表示對映(屬性鍵值對對映)。

今天我們只說陣列。

Array 類中提供的例項方法可以看出來,陣列涵蓋了一般的列表操作,增刪改查俱全,更提供了 shift()/unshift()push()/pop() 這樣的方法,使陣列具有佇列和棧的基本功能。

除了日常的 CRUD 之外,最重要的就是對列表進行完全或部分遍歷,拿到預期的結果,這些遍歷操作包括

  1. 逐一遍歷:forforEach()map() 等;
  2. 篩選/過濾:filter()find()findIndex()indexOf() 等;
  3. 遍歷計算(歸約):reduce()some()every()includes() 等。

Array 物件提供來用於遍歷類的例項方法,大多數都是接收一個處理函式在遍歷過程中對每個元素進行處理。而且處理函式通常會具有三個引數:(el, index, array),分別表示當前處理的元素、當前元素的索引以及當前處理的陣列(即原陣列)。當然,這裡說的是大多數,也有一些例外,比如 includes() 就不是這樣,而 reduce 的處理函式會多一個表示中間結果的引數。具體情況不用多說,查閱 MDN 即可。

一、簡單遍歷

大家都知道 for 語法在 JavaScript 中除了基本的 for ( ; ; ) 之外,還包含了兩種 for each 遍歷。一種是 for ... in 用來遍歷鍵/索引;另一種是 for ... of 用來遍歷值/元素。兩種 for each 結構都不能同時拿到鍵/索引和值/元素,而 forEach() 方法可以拿到,這是 forEach() 的便利所在。不過在 for each 結構中要終止迴圈,可以使用 break,而在 forEach() 中要想終止迴圈只能通過 throw。使用 throw 來終止迴圈需要在外面進行 try ... catch 處理,不夠靈活。舉例:

try {
    list.forEach(n => {
        console.log(n);
        if (n >= 3) { throw undefined; }
    });
} catch {
    console.log("The loop is broken");
}

如果沒有 try ... catch,裡面的 throw 會直接中斷程式執行。

當然,其實也有更簡單的方法。注意到 some()every() 這兩個方法都是對陣列進行遍歷,直到遇到符合條件/不符合條件的元素。簡單地說它們是根據處理函式的返回值來判斷是否中斷遍歷。對於 some() 來說,是要找到一個符合條件的元素,處理函式如果返回 true,就中斷遍歷;而 every() 正好相反,它是要判斷每個元素都符合條件,所以只要遇到返回 false 就會中斷遍歷。

根據我們對一般 for 迴圈和 while 迴圈的理解,都是條件為真是進行迴圈,所以看起來 every() 更符合習慣。上面的示例用 every() 改寫:

list.every(n => {
    console.log(n);
    return n < 3;
});

使用 some()every() 特別需要注意一點:它不需要精確返回 boolean 型別的值,只需要判斷真值 (truthy) 和 假值(falsy) 即可。 JavaScript 函式在沒有顯式返回值的情況下等同於 return undefined,也就是返回假值,效果和 return false 等同。

關於 JavaScript 的假值,可以查閱 MDN - Falsy。除了假值,都是真值。

二、遍歷對映

有時候我們需要對一個陣列進行遍歷,根據其每個元素提供的資訊,產生另一個數值和物件,而結果仍然放在一個陣列中。前端開發中這種操作最常見的場景就是將從後端拿到的模型資料列表,處理成前端呈現需要的檢視資料列表。常規操作是這樣:

// 源資料
const source = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// 建立目標陣列容器
const target = [];
// 迴圈處理每一個源資料元素,並將結果新增到目標陣列中
for (const n of source) {
    target.push({ id: n, label: `label${n}` });
}

// 消費目標陣列
console.log(target);

map() 就是用來封裝這樣的遍歷的,它可以用來處理一對一的元素資料對映。上例改用 map() 只需要一句話代替迴圈:

const source = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const target = source.map(n => ({ id: n, label: `label${n}` }));
console.log(target);

除了減少語句之外,使用 map() 還把原來的若干語句,變成了一個表示式,可以靈活地用於上下邏輯銜接。

三、處理多層結構 - 展開 (flat 和 flatMap)

展開,即 flat() 操作可以把多維度的陣列減少 1 個或多個維度。舉例來說

const source = [1, 2, [3, 4], [5, [6, 7], 8, 9], 10];
console.log(source.flat());
// [ 1, 2, 3, 4, 5, [ 6, 7 ], 8, 9, 10 ]

這個例子是個包含了三個維度(雖然不整齊)的陣列,使用 flat() 減少了一個維度,其結果變成了兩個維度。flat() 可以通過引數指定展開的維度層數,這裡只需要指定一個大於等於 2 的值,它就能把所有元素全部展平到一個一維陣列中:

const source = [1, 2, [3, 4], [5, [6, 7], 8, 9], 10];
console.log(source.flat(10));
// [ 1, 2, 3, 4,  5,  6, 7, 8, 9, 10 ]

有了這個東西,我們在處理一些子項的時候就會比較方便。比如一個常見問題:

有一個二層的選單資料,我想拿到所有選單項列表,應該怎麼辦?資料如下

const data = [
 {
     label: "檔案",
     items: [
         { label: "開啟", id: 11 },
         { label: "儲存", id: 12 },
         { label: "關閉", id: 13 }
     ]
 },
 {
     label: "幫助",
     items: [
         { label: "檢視幫助", id: 91 },
         { label: "關於", id: 92 }
     ]
 }
];

怎麼辦?毫無懸念應該是使用一個雙層迴圈來處理。不過利用 map()flat() 可以簡化程式碼:

const allItems = data.map(({ items }) => items).flat();
//                   ^^^^                      ^^^^^

第一步 map(){ label, items } 型別的元素對映成為 [...items] 這種形式的陣列,對映結果是一個二維陣列(示意):

[
    [...檔案選單項],
    [...幫助選單項]
]

再用 flat() 展平,就得到了 [...檔案選單項, ...幫助選單項],也就是預期的結果。

通常我們直接拿到二維陣列來處理的情況極少,一般都需要先 map()flat(),所以 JavaScript 為這兩個常用組合邏輯提供了 flatMap() 方法。要理解 flatMap() 的作用,就理解為先 map(...)flat() 即可。上面的示例可改為

const allItems = data.flatMap(({ items }) => items);
//                   ^^^^^^^^

這裡解決了一個兩層結構的資料,如果是多層結構呢?多層結構不就是普通的樹形結構,使用遞迴對所有子項進行 flatMap() 處理即可。程式碼先不提供,請讀者動動腦。

四、過濾

如果我們有一組資料,需要把其中符合某種條件的篩選出來使用,就會用到過濾,filter()filter() 接收一個用於判斷的處理函式,並對每個元素使用該處理函式進行判斷。如果該函式對某個元素的判斷結果是真值,該元素會被保留;否則不會收錄到結果中。filter() 的結果是原陣列的子集。

filter() 的用法很好理解,比如下面這個示例篩選出能被 3 整除的數:

const a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const r = a.filter(n => n % 3 === 0);
// [ 3, 6, 9 ]

有兩點需要強調:

第一,如果所有元素都不符合條件,會得到一個空陣列。既不是 null 也不是 undefined,而是 []

第二,如果所有元素都符合條件,會得到一個包含所有元素的陣列。它與原陣列進行 ===== 比較均會得到 false

過濾雖然簡單,但是要注意靈活運用。比如說需要統計某組資料中符合條件的個數,一般會想到遍歷計數。但我們也可以先按指定條件過濾,再取結果陣列的 length

五、查詢

查詢和過濾的區別在於:查詢是找到一個符合條件的元素即可,而過濾是找到全部。從實現效果上來說,arr.filter(fn)[0] 就可以達到查詢效果。只不過 filter() 一定會遍歷完整個陣列。

而專業的 find() 則會在找到第一個符合條件的元素之後立即終止遍歷,節約時間和計算資源。從結果上來說,find() 可以看作是 filter()[0] 的便捷實現(當然效能也更好),其引數(處理函式)和 filter() 相同。

find() 的結果是找到的元素,或者在什麼都沒找到的情況下返回 undefined。所以在使用 find() 的時候一定要注意其結果有可能是 undefined,使用前應該進行有效性判斷。當然,如果結合可選鏈運算子 (?.)空值合併運算子 (??),也很容易參與表示式。

不過有時候,我們查詢一個元素並不是想使用它,而是想替換或者刪除它。這時候拿到元素本身是很難辦的,我們更需要索引號。查 MDN 很容易就能查到 findIndex() 方法。它的用法和 find() 相同,只是返回的是元素索引而不是元素本身。如果沒有找到符合條件的元素,findIndex() 會返回 -1

說到 findIndex() 就很容易聯想到 indexOf()indexOf() 的引數是一個值(或物件),它會在陣列中去尋找這個值的位置並返回出來。對於基本型別的值來說,很好用。但是對於物件元素,就要小心了,看看下面這個例子

const m = { v: 1 };
const a = [m, { v: 2 }, { v: 3 }];

console.log(a.indexOf(m));          // 0
console.log(a.indexOf({ v: 1 }));   // -1

同樣表示為 { v: 1 },但他們真的不是同一個物件!

順便提一提,有人喜歡用 arr.indexOf(v) >= 0 來判斷陣列中是否包含某個元素,其實不妨使用專業的 includes() 方法。includes() 直接返回一個布林值,而且它允許通過第 2 個引數指定開始查詢的位置。詳見 MDN

那麼,如果想根據某個判斷方法(函式)來判斷資料中是否存在某個符合條件的元素,是不是要用 arr.find(fn) !== undefined 來判斷呢?一般情況下是可以,但如果遇到特殊情況 ——

// 查詢判斷是否包含假值
const a = [undefined, 1, 2, 3];
const hasFalsy = a.find(it => !it) !== undefined;  // false

很不幸,這個結果是錯的,肉眼可見,它確實包含假值!

按條件查詢是否存在的正確做法是使用 some() 方法(之前提到過,忘了沒?):

const hasFalsy = a.some(it => !it);

六、歸約

歸約是對 reduce 的直譯,而 reduce() 也是陣列的一個方法。

之所以需要歸約,因為有時候我們需要進行的處理並不會像前面提到的那麼簡單,比如一個常見的應用 —— 累加。想想看,使之前的處理方式,只能通過 for 或者 forEach() 迴圈累加。這兩種方式都需要額外的臨時變數,對函數語言程式設計不太友好。如果用 reduce(),大概是這樣:

const a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const sum = a.reduce((sum, n) => sum + n);

再複雜一點,如果想把陣列中的奇偶數分離出來,分別放在兩個陣列中:

const a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const [even, odd] = a.reduce(
    (acc, n) => {
        acc[n % 2].push(n);
        return acc;
    },
    [[], []]
);

和上面一段程式碼不同,這裡使用 reduce() 時給了第二 個引數 [[], []]。這是一個初始值,會在第一次呼叫處理函式的時候作為第 1 個引數傳入函式,也就是上例中的 acc 引數。

那麼有心的讀者會提出疑問,為什麼第一個示例不需要初始值,那種情況下初始的 sum 引數是什麼東西?

這裡不得不說 reduce() 的兩個特點:

  1. reduce() 每一次的處理結果,也就是處理函式的返回值,會作為下一次處理的第一個引數;
  2. 呼叫 reduce() 時如果給了第二個引數,即初始值,它會用作第一次處理時的第一個引數;但如果沒給初始值,在第一次處理時,陣列中的前兩個元素會分別作為傳入處理函式的前兩個引數。

看到第 2 條,是不是又產生了一個疑問:如果陣列裡只有一個元素數呢?—— 那麼處理函式會被忽略,這個元素就是 reduce() 的結果。

那麼……陣列是空的會怎樣?這個問題能把 reduce() 問哭 —— 我報錯還不行嗎?!

學會 reduce() 會發現前面提到的所有遍歷過程都可以用 reduce() 來實現,畢竟它應用起來很靈活 —— 但是何必呢?何況,reduce() 也只能通過 throw 來中斷 —— 當然不用擔心 throw 中斷拿不到結果,把結果作為 throw 的物件丟擲來,外面不就拿到了嗎?hiahiahia~~

七、擷取

從資料中擷取一部分,毫無疑問,當然是用 slice() 方法。該方法兩個參數列示要擷取的索引起點和終點,其中終點索引對應的元素不包含在內,用數學語言來說,這是一個左閉右開區間。需要注意的是起點必須小於終點才有可能取到元素,否則結果一定是一個空陣列。這表示,如果使用 arr.slice(-Infinity, Infinity) 可以取到所有有元素 —— 但誰會這麼幹呢,直接 arr.slice(0) 不香麼(不給第二個參數列示一直擷取到最後一個元素)?

另外,slice() 還有兩個有意思的特點:

  • 不管是起點還是終點,如果給出了超出陣列索引範圍的值,不會引起錯誤,它會取陣列索引範圍和指定範圍相交的部分;
  • 如果給的索引是負數,比如 -n,它會按照 arr.length - n 來計算索引。這樣一來,想根據結尾位置來獲取元素就變得容易了。

示例就不寫了,slice() 文件說得很明白,不難理解。

有人要問,很多集合的流式處理都會有 take()skip() 方法,用於擷取指定位置一定數量的元素,JavaScript 有嗎?—— slice() 可不就是?它和 .skip().take() 基本等價。說基本等價,是因為 take() 的參數列示的是一個長度,而 slice() 的第二個參數列示的是一個位置,所以(下面的 .skip().take() 是虛擬碼,僅示意):

  • arr.skip(m).take(n) 等價於 arr.slice(m, m + n)
  • arr.skip(m) 等價於 arr.slice(m)
  • arr.take(n) 等價於 arr.slice(0, n)

不過在使用 slice() 的同時,別忘了 JavaScript 的解構賦值語法也可以用於簡單地擷取陣列。比如說

const a = [1, 2, 3, 4, 5, 6];
const [,, ...rest] = a;
// rest = [3, 4, 5, 6]

它和 a.slice(2) 的結果一樣的,但是相比之外,slice() 語義更明確,怎麼都比數逗號個數強吧。但是解構語法在某些情況下還是挺方便的,比如在 CLI 中拆分命令和引數的時候:

const args = "git config --list --global".split(/\s+/);
const [exec, cmd, ...params] = args;
// exec: "git"
// cmd: "config"
// params: ["--list", "--global"]

八、建立陣列

雖然本文主要是講基於遍歷的陣列操作,但既然都說到了 slice() 這個非遍歷類的操作,不妨順便再提一下建立陣列。

  • 使用 [] 建立空陣列,或已知少量元素的陣列,最常用
  • Array(n) 建立指定長度的陣列,但注意這個陣列雖然有長度,卻沒有元素,也不能遍歷。想讓它有元素
  • Array.from(Array(n)),得到一個長度 n,所有元素都是 undefined 的陣列;
  • [...Array(n)] 和上面一條結果一樣;[...Array(n).values()] 也是一樣;
  • Array(n).fill(1024),建立一個長度 n,元素均是 1024 的陣列。當然也可以指定其他元素值;
  • Array.from 的第二個引數是個 mapper,所以 Array.from(Array(n), (_, i) => i) 可以建立一個元素是從 0n - 1 的陣列;
  • [...Array(n).keys()] 可以建立和上一條一樣的陣列;

現在有一個問題,想建立一個 7x4 的二維陣列,預設元素填 0,該怎麼辦?那還不簡單,這樣

const matrix = Array(4).fill(Array(7).fill(0));
// [
//   [ 0, 0, 0, 0, 0, 0, 0 ],
//   [ 0, 0, 0, 0, 0, 0, 0 ],
//   [ 0, 0, 0, 0, 0, 0, 0 ],
//   [ 0, 0, 0, 0, 0, 0, 0 ]
// ]

似乎沒毛病,來進行一個操作,看看效果如何?

matrix[0][4] = 4;
// [
//   [ 0, 0, 0, 0, 4, 0, 0 ],
//   [ 0, 0, 0, 0, 4, 0, 0 ],
//   [ 0, 0, 0, 0, 4, 0, 0 ],
//   [ 0, 0, 0, 0, 4, 0, 0 ]
// ]

所有第二層陣列索引為 4 的元素值都變成了 4 …… Why?

我們把上面的初始化語句拆分一下,可能就明白了 ——

const row = Array(7).fill(0);
const matrix = Array(4).fill(row);

你看,這 4 行引用的都是一個陣列,所以不管改變哪個,輸出來 4 行資料都會完全一樣(同一個陣列能不一樣嗎)。

這是在初始化多維陣列時最常見的坑。所以 Array(n).fill(v) 雖然好用,但一定要謹慎。這裡如果使用帶對映的 Array.from() 就沒問題了:

const matrix = Array.from(
    Array(4),
    () => Array.from(Array(7), () => 0)
);

小結

由於 JavaScript 的動態特性,不需要定義一大堆的資料型別來表示不同的列表,就一個陣列搞定。雖然還是有一定的侷限性,但是已經能適應大部分應用場景了。本文主要介紹了陣列的基本操作,更多更詳細的內容可以參閱 MDN - Array 文件。下次我會再講講對映(物件)的基本操作,以及陣列和物件之間的聯合應用。關於 JavaScript 的資料處理,讀者們也可以去了解一下 Lodash,提供了非常多的工具。

相關文章