給不耐煩的程式設計師的 JavaScript(ES2022 版)
請支持這本書:購買捐款
(廣告,請不要封鎖。)

31 陣列 (Array)



31.1 秘笈:陣列

JavaScript 陣列是一種非常靈活的資料結構,可用作清單、堆疊、佇列、元組(例如成對)等。

有些與陣列相關的操作會對陣列造成破壞性的變更。其他操作則是非破壞性的,會產生新的陣列,並將變更套用至原始內容的副本。

31.1.1 使用陣列

建立陣列、讀取和寫入元素

// Creating an Array
const arr = ['a', 'b', 'c']; // Array literal
assert.deepEqual(
  arr,
  [ // Array literal
    'a',
    'b',
    'c', // trailing commas are ignored
  ]
);

// Reading elements
assert.equal(
  arr[0], 'a' // negative indices don’t work
);
assert.equal(
  arr.at(-1), 'c' // negative indices work
);

// Writing an element
arr[0] = 'x';
assert.deepEqual(
  arr, ['x', 'b', 'c']
);

陣列的長度

const arr = ['a', 'b', 'c'];
assert.equal(
  arr.length, 3 // number of elements
);
arr.length = 1; // removing elements
assert.deepEqual(
  arr, ['a']
);
arr[arr.length] = 'b'; // adding an element
assert.deepEqual(
  arr, ['a', 'b']
);

透過 .push() 具破壞性地新增元素

const arr = ['a', 'b'];

arr.push('c'); // adding an element
assert.deepEqual(
  arr, ['a', 'b', 'c']
);

// Pushing Arrays (used as arguments via spreading (...)):
arr.push(...['d', 'e']);
assert.deepEqual(
  arr, ['a', 'b', 'c', 'd', 'e']
);

透過展開(...)非破壞性地新增元素

const arr1 = ['a', 'b'];
const arr2 = ['c'];
assert.deepEqual(
  [...arr1, ...arr2, 'd', 'e'],
  ['a', 'b', 'c', 'd', 'e']
);

清除陣列(移除所有元素)

// Destructive – affects everyone referring to the Array:
const arr1 = ['a', 'b', 'c'];
arr1.length = 0;
assert.deepEqual(
  arr1, []
);

// Non-destructive – does not affect others referring to the Array:
let arr2 = ['a', 'b', 'c'];
arr2 = [];
assert.deepEqual(
  arr2, []
);

迴圈遍歷元素

const arr = ['a', 'b', 'c'];
for (const value of arr) {
  console.log(value);
}

// Output:
// 'a'
// 'b'
// 'c'

迴圈處理索引值對

const arr = ['a', 'b', 'c'];
for (const [index, value] of arr.entries()) {
  console.log(index, value);
}

// Output:
// 0, 'a'
// 1, 'b'
// 2, 'c'

建立並填入陣列,當我們無法使用陣列文字時(例如因為我們不知道它們的長度或它們太大)

const four = 4;

// Empty Array that we’ll fill later
assert.deepEqual(
  new Array(four),
  [ , , , ,] // four holes; last comma is ignored
);

// An Array filled with a primitive value
assert.deepEqual(
  new Array(four).fill(0),
  [0, 0, 0, 0]
);

// An Array filled with objects
// Why not .fill()? We’d get single object, shared multiple times.
assert.deepEqual(
  Array.from({length: four}, () => ({})),
  [{}, {}, {}, {}]
);

// A range of integers
assert.deepEqual(
  Array.from({length: four}, (_, i) => i),
  [0, 1, 2, 3]
);

31.1.2 陣列方法

本節簡要概述陣列 API。在本章的最後面有一個 更全面的快速參考

從現有陣列衍生新陣列

> ['■','●','▲'].slice(1, 3)
['●','▲']
> ['■','●','■'].filter(x => x==='■') 
['■','■']

> ['▲','●'].map(x => x+x)
['▲▲','●●']
> ['▲','●'].flatMap(x => [x,x])
['▲','▲','●','●']

移除陣列中特定索引的元素

// .filter(): remove non-destructively
const arr1 = ['■','●','▲'];
assert.deepEqual(
  arr1.filter((_, index) => index !== 1),
  ['■','▲']
);
assert.deepEqual(
  arr1, ['■','●','▲'] // unchanged
);

// .splice(): remove destructively
const arr2 = ['■','●','▲'];
arr2.splice(1, 1); // start at 1, delete 1 element
assert.deepEqual(
  arr2, ['■','▲'] // changed
);

計算陣列的摘要

> ['■','●','▲'].some(x => x==='●')
true
> ['■','●','▲'].every(x => x==='●')
false

> ['■','●','▲'].join('-')
'■-●-▲'

> ['■','▲'].reduce((result,x) => result+x, '●')
'●■▲'
> ['■','▲'].reduceRight((result,x) => result+x, '●')
'●▲■'

反轉和填入

// .reverse() changes and returns `arr`
const arr = ['■','●','▲'];
assert.deepEqual(
  arr.reverse(), arr
);
// `arr` was changed:
assert.deepEqual(
  arr, ['▲','●','■']
);

// .fill() works the same way:
assert.deepEqual(
  ['■','●','▲'].fill('●'),
  ['●','●','●']
);

.sort() 也會修改陣列並傳回

// By default, string representations of the Array elements
// are sorted lexicographically:
assert.deepEqual(
  [200, 3, 10].sort(),
  [10, 200, 3]
);

// Sorting can be customized via a callback:
assert.deepEqual(
  [200, 3, 10].sort((a,b) => a - b), // sort numerically
  [ 3, 10, 200 ]
);

尋找陣列元素

> ['■','●','■'].includes('■')
true
> ['■','●','■'].indexOf('■')
0
> ['■','●','■'].lastIndexOf('■')
2
> ['■','●','■'].find(x => x==='■')
'■'
> ['■','●','■'].findIndex(x => x==='■')
0

在開頭或結尾新增或移除元素

// Adding and removing at the start
const arr1 = ['■','●'];
arr1.unshift('▲');
assert.deepEqual(
  arr1, ['▲','■','●']
);
arr1.shift();
assert.deepEqual(
  arr1, ['■','●']
);

// Adding and removing at the end
const arr2 = ['■','●'];
arr2.push('▲');
assert.deepEqual(
  arr2, ['■','●','▲']
);
arr2.pop();
assert.deepEqual(
  arr2, ['■','●']
);

31.2 在 JavaScript 中使用陣列的兩種方式

在 JavaScript 中有兩種使用陣列的方式

在實務上,這兩種方式通常會混合使用。

值得注意的是,序列陣列非常靈活,我們可以使用它們作為(傳統的)陣列、堆疊和佇列。我們稍後會看到。

31.3 基本陣列操作

31.3.1 建立、讀取、寫入陣列

建立陣列的最佳方式是透過陣列文字

const arr = ['a', 'b', 'c'];

陣列文字以方括號 [] 開頭和結尾。它建立一個具有三個元素的陣列:'a''b''c'

陣列文字中允許尾隨逗號並忽略它們

const arr = [
  'a',
  'b',
  'c',
];

要讀取陣列元素,我們在方括號中放入一個索引(索引從 0 開始)

const arr = ['a', 'b', 'c'];
assert.equal(arr[0], 'a');

要變更陣列元素,我們指定一個具有索引的陣列

const arr = ['a', 'b', 'c'];
arr[0] = 'x';
assert.deepEqual(arr, ['x', 'b', 'c']);

陣列索引的範圍為 32 位元(不包括最大長度):[0, 232−1)

31.3.2 陣列的 .length

每個陣列都有屬性 .length,可用於讀取和變更(!)陣列中的元素數量。

陣列的長度永遠是最高索引加一

> const arr = ['a', 'b'];
> arr.length
2

如果我們寫入陣列的長度索引,我們會附加一個元素

> arr[arr.length] = 'c';
> arr
[ 'a', 'b', 'c' ]
> arr.length
3

透過陣列方法 .push() 附加元素的另一種方式(具破壞性)

> arr.push('d');
> arr
[ 'a', 'b', 'c', 'd' ]

如果我們設定 .length,我們會透過移除元素來修剪陣列

> arr.length = 1;
> arr
[ 'a' ]

  練習:透過 .push() 移除空行

exercises/arrays/remove_empty_lines_push_test.mjs

31.3.3 透過負數索引參照元素

多種陣列方法支援負數索引。如果索引為負數,它會加到陣列長度以產生可用的索引。因此,下列兩個 .slice() 呼叫等效:它們都從最後一個元素開始複製 arr

> const arr = ['a', 'b', 'c'];
> arr.slice(-1)
[ 'c' ]
> arr.slice(arr.length - 1)
[ 'c' ]
31.3.3.1 .at():讀取單一元素(支援負數索引)[ES2022]

陣列方法 .at() 會傳回指定索引的元素。它支援正數和負數索引(-1 參照最後一個元素,-2 參照倒數第二個元素,依此類推)

> ['a', 'b', 'c'].at(0)
'a'
> ['a', 'b', 'c'].at(-1)
'c'

相反地,方括號運算子 [] 不支援負數索引(且無法變更,因為這會損壞現有的程式碼)。它會將它們解釋為非元素屬性的鍵

const arr = ['a', 'b', 'c'];

arr[-1] = 'non-element property';
// The Array elements didn’t change:
assert.deepEqual(
  Array.from(arr), // copy just the Array elements
  ['a', 'b', 'c']
);

assert.equal(
  arr[-1], 'non-element property'
);

31.3.4 清除陣列

要清除(清空)陣列,我們可以將其 .length 設定為零

const arr = ['a', 'b', 'c'];
arr.length = 0;
assert.deepEqual(arr, []);

或者我們可以將新的空陣列指定給儲存陣列的變數

let arr = ['a', 'b', 'c'];
arr = [];
assert.deepEqual(arr, []);

後一種方法的優點是不會影響指向相同陣列的其他位置。然而,如果我們確實想要為所有人重設共用陣列,則需要前一種方法。

31.3.5 擴散到陣列文字 [ES6]

在陣列文字中,擴散元素包含三個點(...),後面接一個表示式。它會導致表示式經過評估,然後進行反覆運算。每個反覆運算的值都會變成額外的陣列元素,例如

> const iterable = ['b', 'c'];
> ['a', ...iterable, 'd']
[ 'a', 'b', 'c', 'd' ]

這表示我們可以使用擴散來建立陣列的副本,並將可反覆運算的項目轉換為陣列

const original = ['a', 'b', 'c'];

const copy = [...original];

const iterable = original.keys();
assert.deepEqual(
  [...iterable], [0, 1, 2]
);

然而,對於前兩個使用案例,我認為 Array.from() 更具自述性,而且較喜歡它

const copy2 = Array.from(original);

assert.deepEqual(
  Array.from(original.keys()), [0, 1, 2]
);

擴散也很方便用於將陣列(和其他可反覆運算的項目)串接成陣列

const arr1 = ['a', 'b'];
const arr2 = ['c', 'd'];

const concatenated = [...arr1, ...arr2, 'e'];
assert.deepEqual(
  concatenated,
  ['a', 'b', 'c', 'd', 'e']);

由於擴散使用反覆運算,因此它僅在值可反覆運算時有效

> [...'abc'] // strings are iterable
[ 'a', 'b', 'c' ]
> [...123]
TypeError: 123 is not iterable
> [...undefined]
TypeError: undefined is not iterable

  擴散和 Array.from() 會產生淺層副本

透過擴散或透過 Array.from() 複製陣列是淺層的:我們會在新的陣列中取得新的項目,但值會與原始陣列共用。淺層複製的後果會在 §28.4「擴散到物件文字 (...) [ES2018]」 中示範。

31.3.6 陣列:列出索引和條目 [ES6]

方法 .keys() 列出陣列的索引

const arr = ['a', 'b'];
assert.deepEqual(
  Array.from(arr.keys()), // (A)
  [0, 1]);

.keys() 回傳一個可疊代的物件。在 A 行中,我們將該可疊代物件轉換為陣列。

列出陣列索引與列出屬性不同。前者產生數字;後者產生字串化的數字(除了非索引屬性金鑰之外)

const arr = ['a', 'b'];
arr.prop = true;

assert.deepEqual(
  Object.keys(arr),
  ['0', '1', 'prop']);

方法 .entries() 將陣列的內容列為 [index, element] 對

const arr = ['a', 'b'];
assert.deepEqual(
  Array.from(arr.entries()),
  [[0, 'a'], [1, 'b']]);

31.3.7 值是否為陣列?

以下是檢查值是否為陣列的兩種方法

> [] instanceof Array
true
> Array.isArray([])
true

instanceof 通常很好用。如果值可能來自另一個領域,我們需要 Array.isArray()。粗略來說,領域是 JavaScript 全域範圍的執行個體。有些領域彼此隔離(例如,瀏覽器中的 Web Workers),但也有我們可以在其中移動資料的領域,例如,瀏覽器中的同源 iframe。x instanceof Array 會檢查 x 的原型鏈,因此如果 x 是來自另一個領域的陣列,它會回傳 false

typeof 將陣列分類為物件

> typeof []
'object'

31.4 for-of 和陣列 [ES6]

我們已經在本書籍的前面章節中遇到 for-of 迴圈。本節將簡要回顧如何將它用於陣列。

31.4.1 for-of:疊代元素

以下 for-of 迴圈會疊代陣列中的元素

for (const element of ['a', 'b']) {
  console.log(element);
}
// Output:
// 'a'
// 'b'

31.4.2 for-of:疊代索引

for-of 迴圈會疊代陣列中的索引

for (const element of ['a', 'b'].keys()) {
  console.log(element);
}
// Output:
// 0
// 1

31.4.3 for-of:疊代 [index, element] 對

以下 for-of 迴圈會疊代 [index, element] 對。解構(稍後說明),為我們提供了在 for-of 的開頭設定 indexelement 的便利語法。

for (const [index, element] of ['a', 'b'].entries()) {
  console.log(index, element);
}
// Output:
// 0, 'a'
// 1, 'b'

31.5 類陣列物件

某些與陣列一起運作的運算只要最基本的東西:值必須是類陣列。類陣列值是一個具有以下屬性的物件

例如,Array.from() 接受類陣列物件並將它們轉換為陣列

// If we omit .length, it is interpreted as 0
assert.deepEqual(
  Array.from({}),
  []);

assert.deepEqual(
  Array.from({length:2, 0:'a', 1:'b'}),
  [ 'a', 'b' ]);

類陣列物件的 TypeScript 介面為

interface ArrayLike<T> {
  length: number;
  [n: number]: T;
}

  類陣列物件在現代 JavaScript 中相對罕見

在 ES6 之前,類陣列物件很常見;現在我們不太常看到它們。

31.6 將可迭代物件和類陣列值轉換為陣列

有兩種常見的方法將可迭代物件和類陣列值轉換為陣列

我比較喜歡後者,我覺得它比較不言自明。

31.6.1 透過擴散 (...) 將可迭代物件轉換為陣列

在陣列字面中,透過 ... 擴散會將任何可迭代物件轉換為一系列陣列元素。例如

// Get an Array-like collection from a web browser’s DOM
const domCollection = document.querySelectorAll('a');

// Alas, the collection is missing many Array methods
assert.equal('map' in domCollection, false);

// Solution: convert it to an Array
const arr = [...domCollection];
assert.deepEqual(
  arr.map(x => x.href),
  ['https://2ality.com', 'https://exploringjs.dev.org.tw']);

轉換之所以可行,是因為 DOM 集合是可迭代的。

31.6.2 透過 Array.from() 將可迭代物件和類陣列物件轉換為陣列

Array.from() 可用於兩種模式。

31.6.2.1 Array.from() 的模式 1:轉換

第一種模式具有下列類型簽章

.from<T>(iterable: Iterable<T> | ArrayLike<T>): T[]

介面 Iterable 顯示在 同步迭代章節 中。介面 ArrayLike 出現在 本章節前面

使用單一參數時,Array.from() 會將任何可迭代或類陣列物件轉換為陣列

> Array.from(new Set(['a', 'b']))
[ 'a', 'b' ]
> Array.from({length: 2, 0:'a', 1:'b'})
[ 'a', 'b' ]
31.6.2.2 Array.from() 的模式 2:轉換和對應

Array.from() 的第二種模式包含兩個參數

.from<T, U>(
  iterable: Iterable<T> | ArrayLike<T>,
  mapFunc: (v: T, i: number) => U,
  thisArg?: any)
  : U[]

在此模式中,Array.from() 會執行多件事

換句話說:我們從具有 T 類型元素的可迭代物件轉換為具有 U 類型元素的陣列。

以下是一個範例

> Array.from(new Set(['a', 'b']), x => x + x)
[ 'aa', 'bb' ]

31.7 建立並填入長度任意的陣列

建立陣列的最佳方式是透過陣列字面。不過,我們無法總是使用陣列字面:陣列可能太大,我們可能在開發過程中不知道它的長度,或者我們可能想要保持它的長度彈性。因此,我建議使用下列技術來建立(並可能填入)陣列。

31.7.1 我們是否需要建立一個稍後會完全填入的空陣列?

> new Array(3)
[ , , ,]

請注意,結果有三個 空洞(空槽),陣列字面中的最後一個逗號總是會被忽略。

31.7.2 我們是否需要建立一個填入基本值的陣列?

> new Array(3).fill(0)
[0, 0, 0]

注意事項:如果我們對物件使用 .fill(),則每個陣列元素都會參考這個物件(共用它)。

const arr = new Array(3).fill({});
arr[0].prop = true;
assert.deepEqual(
  arr, [
    {prop: true},
    {prop: true},
    {prop: true},
  ]);

下一個小節說明如何修正這個問題。

31.7.3 我們需要建立一個填滿物件的陣列嗎?

> new Array(3).fill(0)
[0, 0, 0]

對於大型陣列,暫時的陣列可能會消耗相當多的記憶體。下列方法沒有這個缺點,但較不具自述性

> Array.from({length: 3}, () => ({}))
[{}, {}, {}]

我們使用暫時的 類陣列物件,而不是暫時的陣列。

31.7.4 我們需要建立一個整數範圍嗎?

function createRange(start, end) {
  return Array.from({length: end-start}, (_, i) => i+start);
}
assert.deepEqual(
  createRange(2, 5),
  [2, 3, 4]);

以下是建立從零開始的整數範圍的替代方法,有點駭客手法

/** Returns an iterable */
function createRange(end) {
  return new Array(end).keys();
}
assert.deepEqual(
  Array.from(createRange(4)),
  [0, 1, 2, 3]);

這會奏效,因為 .keys() 會將 視為 undefined 元素,並列出其索引。

31.7.5 如果元素都是整數或浮點數,請使用型化陣列

在處理整數或浮點數陣列時,我們應該考慮 型化陣列,這是為此目的而建立的。

31.8 多維陣列

JavaScript 沒有真正的多維陣列;我們需要使用元素為陣列的陣列

function initMultiArray(...dimensions) {
  function initMultiArrayRec(dimIndex) {
    if (dimIndex >= dimensions.length) {
      return 0;
    } else {
      const dim = dimensions[dimIndex];
      const arr = [];
      for (let i=0; i<dim; i++) {
        arr.push(initMultiArrayRec(dimIndex+1));
      }
      return arr;
    }
  }
  return initMultiArrayRec(0);
}

const arr = initMultiArray(4, 3, 2);
arr[3][2][1] = 'X'; // last in each dimension
assert.deepEqual(arr, [
  [ [ 0, 0 ], [ 0, 0 ], [ 0, 0 ] ],
  [ [ 0, 0 ], [ 0, 0 ], [ 0, 0 ] ],
  [ [ 0, 0 ], [ 0, 0 ], [ 0, 0 ] ],
  [ [ 0, 0 ], [ 0, 0 ], [ 0, 'X' ] ],
]);

31.9 更多陣列功能(進階)

在本節中,我們將探討在使用陣列時不常遇到的現象。

31.9.1 陣列索引是(稍微特別的)屬性金鑰

您可能會認為陣列元素很特別,因為我們透過數字存取它們。但是,用於執行此操作的中括號運算子 [] 與用於存取屬性的運算子相同。它會將任何值(不是符號)強制轉換為字串。因此,陣列元素是(幾乎)正常的屬性(A 行),而且我們使用數字或字串作為索引並無所謂(B 行和 C 行)

const arr = ['a', 'b'];
arr.prop = 123;
assert.deepEqual(
  Object.keys(arr),
  ['0', '1', 'prop']); // (A)

assert.equal(arr[0], 'a');  // (B)
assert.equal(arr['0'], 'a'); // (C)

更令人困惑的是,這只是語言規範定義事物的方式(如果您願意,這是 JavaScript 的理論)。大多數 JavaScript 引擎會在後台最佳化,並使用實際整數來存取陣列元素(如果您願意,這是 JavaScript 的實務)。

用於陣列元素的屬性金鑰(字串!)稱為 索引。字串 str 是索引,如果將其轉換為 32 位元無符號整數再轉換回來,就會產生原始值。寫成公式如下

ToString(ToUint32(str)) === str
31.9.1.1 列出索引

列出屬性金鑰時,索引會受到特別處理,它們總是會先出現,並像數字一樣排序('2' 出現在 '10' 之前)

const arr = [];
arr.prop = true;
arr[1] = 'b';
arr[0] = 'a';

assert.deepEqual(
  Object.keys(arr),
  ['0', '1', 'prop']);

請注意,.length.entries().keys() 會將陣列索引視為數字,並忽略非索引屬性

assert.equal(arr.length, 2);
assert.deepEqual(
  Array.from(arr.keys()), [0, 1]);
assert.deepEqual(
  Array.from(arr.entries()), [[0, 'a'], [1, 'b']]);

我們使用 Array.from().keys().entries() 傳回的迭代物件轉換為陣列。

31.9.2 陣列是字典,可以有孔洞

我們在 JavaScript 中區分兩種陣列

陣列在 JavaScript 中可以是稀疏的,因為陣列實際上是從索引到值的字典。

  建議:避免孔洞

到目前為止,我們只看過密集陣列,建議避免孔洞:它們使我們的程式碼更複雜,而且陣列方法無法一致地處理它們。此外,JavaScript 引擎會最佳化密集陣列,使它們執行得更快。

31.9.2.1 建立孔洞

我們可以在指定元素時略過索引來建立孔洞

const arr = [];
arr[0] = 'a';
arr[2] = 'c';

assert.deepEqual(Object.keys(arr), ['0', '2']); // (A)

assert.equal(0 in arr, true); // element
assert.equal(1 in arr, false); // hole

在 A 行中,我們使用 Object.keys(),因為 arr.keys() 會將孔洞視為 undefined 元素,不會顯示它們。

建立孔洞的另一種方法是在陣列文字中略過元素

const arr = ['a', , 'c'];

assert.deepEqual(Object.keys(arr), ['0', '2']);

我們也可以刪除陣列元素

const arr = ['a', 'b', 'c'];
assert.deepEqual(Object.keys(arr), ['0', '1', '2']);
delete arr[1];
assert.deepEqual(Object.keys(arr), ['0', '2']);
31.9.2.2 陣列操作如何處理孔洞?

唉,陣列操作處理孔洞的方式有很多種。

有些陣列操作會移除孔洞

> ['a',,'b'].filter(x => true)
[ 'a', 'b' ]

有些陣列操作會忽略孔洞

> ['a', ,'a'].every(x => x === 'a')
true

有些陣列操作會忽略但保留孔洞

> ['a',,'b'].map(x => 'c')
[ 'c', , 'c' ]

有些陣列操作會將孔洞視為 undefined 元素

> Array.from(['a',,'b'], x => x)
[ 'a', undefined, 'b' ]
> Array.from(['a',,'b'].entries())
[[0, 'a'], [1, undefined], [2, 'b']]

Object.keys() 的運作方式與 .keys() 不同(字串與數字,孔洞沒有金鑰)

> Array.from(['a',,'b'].keys())
[ 0, 1, 2 ]
> Object.keys(['a',,'b'])
[ '0', '2' ]

這裡沒有規則可以記住。如果陣列操作如何處理孔洞很重要,最好的方法是在主控台中進行快速測試。

31.10 新增和移除元素(具破壞性和非破壞性)

JavaScript 的 Array 非常靈活,更像是陣列、堆疊和佇列的組合。本節探討新增和移除陣列元素的方法。大多數運算都可以以破壞性(修改陣列)和非破壞性(產生修改後的副本)兩種方式執行。

31.10.1 預置元素和陣列

在以下程式碼中,我們以破壞性方式將單一元素預置到 arr1,並將陣列預置到 arr2

const arr1 = ['a', 'b'];
arr1.unshift('x', 'y'); // prepend single elements
assert.deepEqual(arr1, ['x', 'y', 'a', 'b']);

const arr2 = ['a', 'b'];
arr2.unshift(...['x', 'y']); // prepend Array
assert.deepEqual(arr2, ['x', 'y', 'a', 'b']);

散佈讓我們可以將陣列 unshift 到 arr2

非破壞性預置是透過散佈元素完成的

const arr1 = ['a', 'b'];
assert.deepEqual(
  ['x', 'y', ...arr1], // prepend single elements
  ['x', 'y', 'a', 'b']);
assert.deepEqual(arr1, ['a', 'b']); // unchanged!

const arr2 = ['a', 'b'];
assert.deepEqual(
  [...['x', 'y'], ...arr2], // prepend Array
  ['x', 'y', 'a', 'b']);
assert.deepEqual(arr2, ['a', 'b']); // unchanged!

31.10.2 附加元素和陣列

在以下程式碼中,我們以破壞性方式將單一元素附加到 arr1,並將陣列附加到 arr2

const arr1 = ['a', 'b'];
arr1.push('x', 'y'); // append single elements
assert.deepEqual(arr1, ['a', 'b', 'x', 'y']);

const arr2 = ['a', 'b'];
arr2.push(...['x', 'y']); // (A) append Array
assert.deepEqual(arr2, ['a', 'b', 'x', 'y']);

散佈 (...) 讓我們可以將陣列 push 到 arr2(A 行)。

非破壞性附加是透過散佈元素完成的

const arr1 = ['a', 'b'];
assert.deepEqual(
  [...arr1, 'x', 'y'], // append single elements
  ['a', 'b', 'x', 'y']);
assert.deepEqual(arr1, ['a', 'b']); // unchanged!

const arr2 = ['a', 'b'];
assert.deepEqual(
  [...arr2, ...['x', 'y']], // append Array
  ['a', 'b', 'x', 'y']);
assert.deepEqual(arr2, ['a', 'b']); // unchanged!

31.10.3 移除元素

以下是移除陣列元素的三種破壞性方式

// Destructively remove first element:
const arr1 = ['a', 'b', 'c'];
assert.equal(arr1.shift(), 'a');
assert.deepEqual(arr1, ['b', 'c']);

// Destructively remove last element:
const arr2 = ['a', 'b', 'c'];
assert.equal(arr2.pop(), 'c');
assert.deepEqual(arr2, ['a', 'b']);

// Remove one or more elements anywhere:
const arr3 = ['a', 'b', 'c', 'd'];
assert.deepEqual(arr3.splice(1, 2), ['b', 'c']);
assert.deepEqual(arr3, ['a', 'd']);

.splice()本章末尾的快速參考 中有更詳細的說明。

透過 rest 元素進行解構讓我們可以從陣列開頭非破壞性地移除元素(解構會在 稍後 說明)。

const arr1 = ['a', 'b', 'c'];
// Ignore first element, extract remaining elements
const [, ...arr2] = arr1;

assert.deepEqual(arr2, ['b', 'c']);
assert.deepEqual(arr1, ['a', 'b', 'c']); // unchanged!

唉,rest 元素必須出現在陣列的最後。因此,我們只能使用它來萃取字尾。

  練習:透過陣列實作佇列

exercises/arrays/queue_via_array_test.mjs

31.11 方法:迭代和轉換 (.find().map().filter() 等)

在本節中,我們將探討用於迭代陣列和轉換陣列的陣列方法。

31.11.1 迭代和轉換方法的回呼函式

所有迭代和轉換方法都使用回呼函式。前者將所有迭代值傳送給它們的回呼函式;後者詢問它們的回呼函式如何轉換陣列。

這些回呼函式的類型簽章如下所示

callback: (value: T, index: number, array: Array<T>) => boolean

也就是說,回呼函式會取得三個參數(它可以忽略其中任何一個)

回呼函式預期回傳的內容取決於它傳遞給的方法。可能性包括

稍後會更詳細說明這兩個方法。

31.11.2 搜尋元素:.find().findIndex()

.find() 會傳回第一個元素,其 callback 會傳回真值 (如果找不到任何項目,則傳回 undefined)

> [6, -5, 8].find(x => x < 0)
-5
> [6, 5, 8].find(x => x < 0)
undefined

.findIndex() 會傳回第一個元素的索引,其 callback 會傳回真值 (如果找不到任何項目,則傳回 -1)

> [6, -5, 8].findIndex(x => x < 0)
1
> [6, 5, 8].findIndex(x => x < 0)
-1

.findIndex() 可以實作如下

function findIndex(arr, callback) {
  for (const [i, x] of arr.entries()) {
    if (callback(x, i, arr)) {
      return i;
    }
  }
  return -1;
}

31.11.3 .map():複製並提供元素新值

.map() 會傳回接收者的修改後副本。副本的元素是將 map 的 callback 套用至接收者元素的結果。

透過範例可以更輕鬆地理解所有這些內容

> [1, 2, 3].map(x => x * 3)
[ 3, 6, 9 ]
> ['how', 'are', 'you'].map(str => str.toUpperCase())
[ 'HOW', 'ARE', 'YOU' ]
> [true, true, true].map((_x, index) => index)
[ 0, 1, 2 ]

.map() 可以實作如下

function map(arr, mapFunc) {
  const result = [];
  for (const [i, x] of arr.entries()) {
    result.push(mapFunc(x, i, arr));
  }
  return result;
}

  練習:透過 .map() 編號行

exercises/arrays/number_lines_test.mjs

31.11.4 .flatMap():對應到零個或多個值

Array<T>.prototype.flatMap() 的類型簽章為

.flatMap<U>(
  callback: (value: T, index: number, array: T[]) => U|Array<U>,
  thisValue?: any
): U[]

.map().flatMap() 都將函式 callback 作為參數,用來控制輸入陣列如何轉換為輸出陣列

這是 .flatMap() 的實際應用

> ['a', 'b', 'c'].flatMap(x => [x,x])
[ 'a', 'a', 'b', 'b', 'c', 'c' ]
> ['a', 'b', 'c'].flatMap(x => [x])
[ 'a', 'b', 'c' ]
> ['a', 'b', 'c'].flatMap(x => [])
[]

我們會在探討如何實作這個方法之前考慮用例。

31.11.4.1 用例:同時過濾和對應

陣列方法 .map() 的結果總與它被呼叫的陣列長度相同。也就是說,其 callback 無法略過它不感興趣的陣列元素。.flatMap() 能夠這樣做,這在以下範例中很有用。

我們會使用下列函式 processArray() 來建立一個陣列,然後我們會透過 .flatMap() 過濾並對應它

function processArray(arr, callback) {
  return arr.map(x => {
    try {
      return { value: callback(x) };
    } catch (e) {
      return { error: e };
    }
  });
}

接下來,我們透過 processArray() 建立一個陣列 results

const results = processArray([1, -5, 6], throwIfNegative);
assert.deepEqual(results, [
  { value: 1 },
  { error: new Error('Illegal value: -5') },
  { value: 6 },
]);

function throwIfNegative(value) {
  if (value < 0) {
    throw new Error('Illegal value: '+value);
  }
  return value;
}

現在我們可以使用 .flatMap()results 中僅擷取值或僅擷取錯誤

const values = results.flatMap(
  result => result.value ? [result.value] : []);
assert.deepEqual(values, [1, 6]);
  
const errors = results.flatMap(
  result => result.error ? [result.error] : []);
assert.deepEqual(errors, [new Error('Illegal value: -5')]);
31.11.4.2 用例:將單一輸入值對應到多個輸出值

陣列方法 .map() 會將每個輸入陣列元素對應到一個輸出元素。但是,如果我們想要將它對應到多個輸出元素呢?

這在以下範例中是必要的

> stringsToCodePoints(['many', 'a', 'moon'])
['m', 'a', 'n', 'y', 'a', 'm', 'o', 'o', 'n']

我們想將字串陣列轉換為 Unicode 字元陣列(代碼點)。下列函式透過 .flatMap() 達到此目的

function stringsToCodePoints(strs) {
  return strs.flatMap(str => Array.from(str));
}
31.11.4.3 一個簡單的實作

我們可以實作 .flatMap() 如下。注意:此實作比內建版本更簡單,例如,它執行更多檢查。

function flatMap(arr, mapFunc) {
  const result = [];
  for (const [index, elem] of arr.entries()) {
    const x = mapFunc(elem, index, arr);
    // We allow mapFunc() to return non-Arrays
    if (Array.isArray(x)) {
      result.push(...x);
    } else {
      result.push(x);
    }
  }
  return result;
}

  練習:.flatMap()

31.11.5 .filter():僅保留部分元素

陣列方法 .filter() 會傳回一個陣列,收集所有呼叫回傳為真值的元素。

例如

> [-1, 2, 5, -7, 6].filter(x => x >= 0)
[ 2, 5, 6 ]
> ['a', 'b', 'c', 'd'].filter((_x,i) => (i%2)===0)
[ 'a', 'c' ]

.filter() 可以實作如下

function filter(arr, filterFunc) {
  const result = [];
  for (const [i, x] of arr.entries()) {
    if (filterFunc(x, i, arr)) {
      result.push(x);
    }
  }
  return result;
}

  練習:透過 .filter() 移除空白行

exercises/arrays/remove_empty_lines_filter_test.mjs

31.11.6 .reduce():從陣列推導值(進階)

方法 .reduce() 是用於計算陣列 arr「摘要」的強大工具。摘要可以是任何類型的值

reduce 在函數式程式設計中也稱為 foldl(「向左折疊」),並在其中很受歡迎。一個需要注意的是,它可能會讓程式碼難以理解。

.reduce()Array<T> 內具有下列類型簽章

.reduce<U>(
  callback: (accumulator: U, element: T, index: number, array: T[]) => U,
  init?: U)
  : U

T 是陣列元素的類型,U 是摘要的類型。兩者可能相同或不同。accumulator 只是「摘要」的另一個名稱。

為了計算陣列 arr 的摘要,.reduce() 會一次將所有陣列元素提供給其呼叫回傳

const accumulator_0 = callback(init, arr[0]);
const accumulator_1 = callback(accumulator_0, arr[1]);
const accumulator_2 = callback(accumulator_1, arr[2]);
// Etc.

callback 會將先前計算的摘要(儲存在其參數 accumulator 中)與目前的陣列元素結合,並傳回下一個 accumulator.reduce() 的結果是最後的累加器,也就是 callback 在拜訪所有元素後最後的結果。

換句話說:callback 會執行大部分的工作;.reduce() 僅以有用的方式呼叫它。

我們可以說呼叫回傳會將陣列元素折疊到累加器中。這就是為什麼在函數式程式設計中,這個操作稱為「折疊」。

31.11.6.1 第一個範例

讓我們來看一個 .reduce() 實際運作的範例:函式 addAll() 會計算陣列 arr 中所有數字的總和。

function addAll(arr) {
  const startSum = 0;
  const callback = (sum, element) => sum + element;
  return arr.reduce(callback, startSum);
}
assert.equal(addAll([1,  2, 3]), 6); // (A)
assert.equal(addAll([7, -4, 2]), 5);

在這種情況下,累加器會儲存所有陣列元素的總和,而 callback 已經拜訪過這些元素。

結果 6 是如何從 A 行的陣列中衍生的?透過以下 callback 呼叫

callback(0, 1) --> 1
callback(1, 2) --> 3
callback(3, 3) --> 6

備註

或者,我們也可以透過 for-of 迴圈來實作 addAll()

function addAll(arr) {
  let sum = 0;
  for (const element of arr) {
    sum = sum + element;
  }
  return sum;
}

很難說這兩種實作哪一個「比較好」:基於 .reduce() 的實作稍微簡潔一點,而基於 for-of 的實作可能比較容易理解,特別是對於不熟悉函數式程式設計的人來說。

31.11.6.2 範例:透過 .reduce() 尋找索引

下列函式是陣列方法 .indexOf() 的實作。它會傳回給定的 searchValue 在陣列 arr 中出現的第一個索引

const NOT_FOUND = -1;
function indexOf(arr, searchValue) {
  return arr.reduce(
    (result, elem, index) => {
      if (result !== NOT_FOUND) {
        // We have already found something: don’t change anything
        return result;
      } else if (elem === searchValue) {
        return index;
      } else {
        return NOT_FOUND;
      }
    },
    NOT_FOUND);
}
assert.equal(indexOf(['a', 'b', 'c'], 'b'), 1);
assert.equal(indexOf(['a', 'b', 'c'], 'x'), -1);

.reduce() 的一個限制是我們無法提早結束(在 for-of 迴圈中,我們可以使用 break)。在這裡,一旦我們找到結果,就會立即傳回結果。

31.11.6.3 範例:將陣列元素加倍

函式 double(arr) 會傳回 inArr 的一份副本,其中所有元素都乘以 2

function double(inArr) {
  return inArr.reduce(
    (outArr, element) => {
      outArr.push(element * 2);
      return outArr;
    },
    []);
}
assert.deepEqual(
  double([1, 2, 3]),
  [2, 4, 6]);

我們透過將元素推入 [] 來修改初始值。一個非破壞性的、更具函數性的 double() 版本如下所示

function double(inArr) {
  return inArr.reduce(
    // Don’t change `outArr`, return a fresh Array
    (outArr, element) => [...outArr, element * 2],
    []);
}
assert.deepEqual(
  double([1, 2, 3]),
  [2, 4, 6]);

這個版本比較優雅,但執行速度較慢,而且會使用更多記憶體。

  練習:.reduce()

31.12 .sort():排序陣列

.sort() 具有下列類型定義

sort(compareFunc?: (a: T, b: T) => number): this

預設情況下,.sort() 會對元素的字串表示進行排序。這些表示會透過 < 進行比較。這個運算子會以 字彙順序 進行比較(第一個字元最重要)。我們可以從對數字進行排序時看到這一點

> [200, 3, 10].sort()
[ 10, 200, 3 ]

在對人類語言的字串進行排序時,我們需要知道它們會根據其代碼單元值(字元代碼)進行比較

> ['pie', 'cookie', 'éclair', 'Pie', 'Cookie', 'Éclair'].sort()
[ 'Cookie', 'Pie', 'cookie', 'pie', 'Éclair', 'éclair' ]

所有未加重音的英文字母都出現在所有未加重音的小寫字母之前,而所有未加重音的小寫字母又出現在所有加重音字母之前。如果我們想要針對人類語言進行適當的排序,可以使用 JavaScript 國際化 API Intl

.sort() 以「就地」方式排序;它會變更並傳回接收者

> const arr = ['a', 'c', 'b'];
> arr.sort() === arr
true
> arr
[ 'a', 'b', 'c' ]

31.12.1 自訂排序順序

我們可以透過參數 compareFunc 自訂排序順序,它必須傳回一個數字,該數字為

  記住這些規則的提示

負數「小於」零(等等)。

31.12.2 排序數字

我們可以使用這個輔助函式來排序數字

function compareNumbers(a, b) {
  if (a < b) {
    return -1;
  } else if (a === b) {
    return 0;
  } else {
    return 1;
  }
}
assert.deepEqual(
  [200, 3, 10].sort(compareNumbers),
  [3, 10, 200]);

以下是一個快速且簡陋的替代方案。

> [200, 3, 10].sort((a,b) => a - b)
[ 3, 10, 200 ]

這種方法的缺點是

31.12.3 排序物件

如果我們要排序物件,也需要使用比較函式。以下程式碼示範如何依年齡排序物件。

const arr = [ {age: 200}, {age: 3}, {age: 10} ];
assert.deepEqual(
  arr.sort((obj1, obj2) => obj1.age - obj2.age),
  [{ age: 3 }, { age: 10 }, { age: 200 }] );

  練習:依名稱排序物件

exercises/arrays/sort_objects_test.mjs

31.13 快速參考:Array

圖例

31.13.1 new Array()

new Array(n) 建立一個長度為 n 的陣列,其中包含 n 個洞

// Trailing commas are always ignored.
// Therefore: number of commas = number of holes
assert.deepEqual(new Array(3), [,,,]);

new Array() 建立一個空的陣列。但是,我建議總是改用 []

31.13.2 Array 的靜態方法

31.13.3 Array.prototype 的方法

31.13.4 來源

  測驗

請參閱 測驗應用程式