JS常用陣列方法總結筆記

AnneBai發表於2019-04-18

陣列(Array)和物件(Object)幾乎是很多程式語言中最常用的型別。在ECMAScript中,陣列的長度可以動態變化,陣列中的資料可以是不同型別,相比其他語言更加靈活。另外,ECMAScript陣列原生支援很多實用的方法,給資料的儲存和處理帶來很大的方便。

由於陣列是引用型別,需要注意方法的可變性,簡單理解就是“是否會改變原陣列”。這對於函數語言程式設計尤其重要,因為可變方法可能會產生我們呼叫它的目的之外的副作用,導致一些不可預知的結果,更容易造成bug且給bug的定位增加了難度。

這裡把陣列的常用方法總結一下。由於是個人總結,如果有差錯的地方還望大家及時指出。

tips:

  1. 示例程式碼可以直接開啟瀏覽器console進行執行和實驗;
  2. 為閱讀方便,方法介紹時會用如sort(?compareFn)表示函式名和引數列表,引數前有?代表是可選引數。
  3. 檢視瀏覽器中實現的所有陣列方法,可以直接在console中執行console.dir(Array)console.log("%O", Array), 能看到Array上的靜態方法和其prototype上的方法列表;

靜態方法

類似於ES6中在class類中定義的static的方法,不會被例項繼承,只能通過類本身來訪問:

Array.isArray

這個方法用於檢驗傳入值是否是陣列,與instanceof相比,具有“跨iframe”的優點;因為一個瀏覽器中的多個window是不共享全域性物件的,所以通過全域性變數直接訪問的Array也不一定指向同一個Array建構函式;而當執行value instanceof Array時,這裡的Array不一定是建立value時所在全域性物件下的Array, 可能會返回錯誤的資訊。

// 插入一個新iframe
var iframe = document.createElement('iframe');
document.body.appendChild(iframe);
var a = new window.frames[0].Array(2); // 在iframe中建立一個長度為2的陣列

// 兩個window的Array是不同的物件(建構函式)
a instanceof window.frames[0].Array // true
a instanceof window.Array // false

window.frames[0].Array.isArray(a); // true
window.Array.isArray(a) // true

// 因為不同window下的全域性屬性是不同的引用
window.frames[0].Array === window.Array // false
複製程式碼

Array.from, Array.of

這兩個方法都可以用來建立新的陣列例項

  • Array.from(arrayLike, ?mapfn, ?thisArg)接收3個引數,第一個是類陣列或可迭代物件,後兩個是可選的回撥函式mapFnmap的回撥函式)和thisArg(設定this),相當於把可迭代物件轉為陣列後對其執行map(mapFn, thisArg); 可迭代物件中如果有“空元素”(empty), 會處理為undefined返回;
Array.from(new Array(3)); // [undefined, undefined, undefined]
Array.from("abcdefg", (s, i) => s + i); // ["a0", "b1", "c2", "d3", "e4", "f5", "g6"]
複製程式碼
  • Array.of(...arg)接收1個或多個引數,會把引數列表按順序作為新陣列的元素,並返回該新陣列;
Array.of("a", 1, "b", 2); // ["a", 1, "b", 2]
複製程式碼

不可變(immutable)方法

不可變方法不會影響原陣列,對返回的陣列本身進行的操作也和原陣列無關;但需要區別的一種情況是,由於元素複製時都是淺複製,新陣列中引用型別值的元素與原陣列元素引用的是同一個物件,修改會相互影響,同時影響其他引用該物件的所有變數。

map, filter, forEach

這三個方法屬於ECMAScript定義的迭代方法,可以對陣列中每個元素執行一定操作後返回一定的結果;它們都可以接收兩個引數(callbackfn, ?thisArg),第一個是要在每個元素上執行的回撥函式,第二個引數是可選的,即執行該函式的作用域物件(指定this值);需要注意的是如果使用箭頭函式作為回撥函式,this值是建立時繫結的,不會被第二個引數影響。

// 第二個引數對this值的影響
[1, 2, 3].map(function (n) {
    return this.name;
}, {name: "Anne"})
// ["Anne", "Anne", "Anne"]

// 箭頭函式回撥, 建立時繫結了當前所在執行環境的this--> Window作為固定的this的值
[1, 2, 3].map(n => this, {name: "Anne"}) // [Window, Window, Window]
複製程式碼

傳入的回撥函式會接收到三個引數(item, index, array),即當前元素、當前索引和源陣列;一般使用最多的是前兩個。

  • map對每個元素執行該回撥後,將回撥函式返回值組成新的陣列返回,用於對元素成員轉換或取值;
[1,2,3,4,5].map((n, i) => n + i); // [1, 3, 5, 7, 9]
複製程式碼
  • filter是將執行回撥函式後返回的是truthy值的元素保留組成新陣列返回,通常用於陣列的過濾;
[1, 2, 3, 4, 5].filter((n, i) => n % 2 === 0); // [2, 4]
複製程式碼
  • forEach則只是對每個元素執行回撥函式的操作,沒有返回值。
let a = [1, 2, 3, 4, 5];
let b = [];
a.forEach((n, i) => b[i] = n + i) // (返回)undefined
a // (未改變)[1, 2, 3, 4, 5]
b // [1, 3, 5, 7, 9]
複製程式碼

some, every

這兩個方法也屬於迭代方法,同樣接收一個回撥函式引數和一個可選的作用域物件引數,回撥函式會接收(item, index, array)作為引數並需要返回布林型別值,作為每個元素是否符合條件的判斷依據;

與上面方法不同的是它們返回的是布林值;從字面可以看出,some代表的是“只要有符合條件的元素就返回true”而every則是“所有元素都符合條件才返回true”.所以:

[1, 2, 3, 4, 5].some((n) => n % 2 === 0) // true
[1, 2, 3, 4, 5].every((n) => n % 2 === 0) // false
複製程式碼

它們不一定會遍歷所有的元素,當some遇到符合條件的元素或every遇到不符合條件的元素它們就會停止遍歷直接返回結果,因為後面的遍歷不再必要;

// 輸出true之前 執行了3次
[1, 2, 3, 4, 5].some(n => {
    console.count("some");
    return n === 3;
});

// 輸出false之前 執行了1次
[1, 2, 3, 4, 5].every(n => {
    console.count("every");
    return n === 3;
});

複製程式碼

reduce, reduceRight

我覺得reduce函式值得是陣列方法中被運用最多的方法之一(還有mapfilter)。初學JavaScript時我對它認知較淺,只有在遇到類似書中所舉的“陣列求和”問題時才會想到它。但現在認識到它其實比我想象的能做更多事(本質還是一樣的),我寫在另一篇總結裡(擴充套件一下使用reduce的思路)。

reducereduceRight是ES5中增加的陣列歸併方法。reduce會從第一項到最後一項遍歷陣列所有元素,構建一個最終返回的值(取決於回撥函式);reduceRightreduce一樣,只是遍歷方向相反,從最後一項開始到第一項進行歸併操作。

它們接收兩個引數(callbackfn, ?initialValue),第一個是在每一項上呼叫的回撥函式,第二個是可選引數,用於設定初始值;例如書中的例子:

[1, 2, 3, 4, 5].reduce((prev, cur) => prev + cur); // (陣列元素的和)15
複製程式碼

在每一項上呼叫的回撥函式可以接收到四個引數,即(accumulator, currentValue, currentIndex, sourceArray);

  • accumulator: 可理解為累積器,每次執行回撥函式後的返回值,傳入下一項中作為此引數;在reduce初始值(第二個引數)沒有設定時,執行時會預設把陣列中第一個元素作為這個引數直接在第二個元素上執行;如果傳入了初始值,則先在第一個元素上執行,初始值作為回撥的該引數。
  • currentValue: 當前元素
  • currentIndex: 當前索引
  • sourceArray: 源陣列

可以驗證,沒有設定初始值時,執行回撥函式的次數比元素個數少一個;而設定初始值時執行次數與元素個數相同。因為有初始值時遍歷從第一個元素開始。

[1, 2, 3, 4, 5].reduce((sum, cur) => {
    console.count("reduce-no-initail");
    return sum + cur;
});
// 輸出結果 15 前,"reduce-no-initail"列印了4次

[1, 2, 3, 4, 5].reduce((sum, cur) => {
    console.count("reduce-initail");
    return sum + cur;
}, 0);
// 輸出結果 15 前,"reduce-initail"列印了5次
複製程式碼

concat, slice

這兩個方法不傳入引數時都會簡單淺複製已知陣列並返回這個副本,所有也常用於複製陣列或類陣列/可迭代物件(通過Array.prototype.concat.call(someObj)[].concat.call(someObj)的方式).

  • concat(...items)用於對陣列副本的拼接和合並,接收0或多個引數,不傳入引數時會返回將原陣列淺複製後的新陣列;傳入1個或多個引數時,會在淺複製一份原陣列的基礎上,將每個引數(如果引數是陣列則按序取出其中的元素,否則直接取該引數)作為元素按順序拼接在其後;相當於直接將引數合併後執行了一次flat()再與原函式合併。
[[0], 1].concat(2, [3, 4], [[5, 6], 7]); // [[0], 1, 2, 3, 4, [5, 6], 7]
複製程式碼
  • slice(?start, ?end)用於返回陣列的某一部分的副本,接收2個可選引數,代表起始索引和結束索引(左閉右開), 不傳引數的情況與concat相似,返回將原陣列淺複製的新陣列;傳入一個引數則預設從該引數位置到陣列末尾; 傳入的負值引數會取絕對值後從後向前數,例如-1會被解釋為倒數第一個元素的位置(其他陣列方法對代表索引的負數引數的處理都與此相同)。
[1,2,3,4].slice(2) // [3, 4]
[1,2,3,4].slice(2, -1) // [3]
複製程式碼

find, findIndex, indexOf, lastIndexOf, includes

這幾個方法的相似之處都是執行對陣列的查詢操作;不同之處在於:

  • find(predicate, ?thisArg)findIndex(predicate, ?thisArg)接收一個回撥函式作為查詢標準,該函式接收每個迭代元素的(item, index, array)引數,一旦執行後返回值為truthy則視為找到該元素,find將會返回該元素(或其引用)而findIndex返回該元素的索引,並停止查詢;它們還可以接收第二個可選引數用於繫結this所指的物件;
[
    {name: "a", val: 1},
    {name: "b", val: 2},
    {name: "c", val: 1}
].find(item => item.val === 1)
// {name: "a", val: 1}

function getVal2(o) {
    return o.val === this.val;
}

[
    {name: "a", val: 1},
    {name: "b", val: 2},
].find(getVal2, {name: "d", val: 2})
// {name: "b", val: 2}

複製程式碼
  • indexOf(searchElement, ?fromIndex)lastIndexOf(searchElement, ?fromIndex)接收的第一個引數是一個要查詢的元素,並在迭代陣列元素時使用===來判斷是否是查詢的元素,如果是則返回該元素的索引,如果最後都沒有找到則返回-1; lastIndexOfindexOf一樣,只是是從陣列末尾開始向前查詢;它們可以接收第二個引數,用於設定從哪個位置開始查詢;
[NaN, +0, -0].indexOf(NaN) // -1
[NaN, -0].indexOf(0) // 1
複製程式碼
  • includes(searchElement, ?fromIndex)的引數與indexOf相似,也是一個要查詢的值,和一個可選的“起始位置”;但不同的是這個方法在對比元素時使用sameValueZero的判斷方式,即NaNNaN視為相等,其他與===判斷相同。
[NaN, -0].includes(NaN) // true
[NaN, -0].includes(0) // true

複製程式碼

關於===和'sameValueZero'相等性判斷

  • ===不進行型別轉換,直接對比值,如果是引用型別值則對比其是否指向同一個物件;NaN不等於自身,0+0-0互相相等;

  • 'sameValueZero'判斷時除了對NaN處理為其與自身相等,其他均與===一樣;

  • 另外ES6中新增的Object.is(),則在sameValueZero的基礎上,增加了對0的符號的限制,用它來判斷時0等於+0, 但它們不等於-0

flat, flatMap

flat(?depth)用於鋪平陣列,可以接收一個引數設定要鋪平的深度(層數), 預設為1,如果傳入的深度值比陣列本身的深度大,則與傳入陣列的最大深度效果相同, 陣列被完全鋪平成為一維陣列。

let a = [1, [2, [3, [4, 5], 6], 7], [8], 9];
a.flat() // [1, 2, [3, [4, 5], 6], 7, 8, 9]
a.flat(2) // [1, 2, 3, [4, 5], 6, 7, 8, 9]
a.flat(10) // [1, 2, 3, 4, 5, 6, 7, 8, 9]
a.flat(Infinity) // [1, 2, 3, 4, 5, 6, 7, 8, 9]
複製程式碼

flatMap(callback, ?thisArg)則是map方法與flat的方法的結合體,接收一個回撥函式和一個可選的this引數,作為第一步map的引數對每個元素執行並返回新的值,然後對構建的新陣列進行展平1層。它與我們自己呼叫map方法後再呼叫flat方法效果相同,但效率可能更高一點點。

比較常見的情況如從物件陣列中取出某些值為陣列的屬性值,然後希望變成一個一維陣列方便執行其他操作,就可以用這個方法;

let b = [
    {memberIds: [1,2,3]},
    {memberIds: [4,5,6]},
    {memberIds: [7,8,9,10]}
];
// 獲取b中所有memberId組成一個一維陣列
b.flatMap(obj => obj.memberIds) // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
// 和下面的方法結果一樣
b.map(obj => obj.memberIds).flat() // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
複製程式碼

可變(mutable)方法

這裡可變方法是指會改變陣列本身的方法。並不是說可變方法不能用,它們有時候可能非常有用。如果開發人員自己清楚使用它們的目的和結果並只在需要的時候使用,有利於提高程式碼的可維護性和健壯性。

fill, copyWithin, splice

這三個方法的相似性不是很高,但使用目的有一定的相似,即將陣列中某些元素改變為我們希望的值,甚至插入/刪除一些值。

  • fill(value, ?start, ?end)是對已有的陣列填充,它的作用範圍僅限於當前陣列長度之內,不能改變陣列長度。它接收三個引數,第一個是需要填充的值,第二個和第三個是可選的位置引數,設定填充的起始索引(含)和結束索引(不含),如果不傳則分別預設為0和陣列的length。對負數位置引數的處理與上面提到的相同;如果超出了陣列本身,則轉為與它們最近的有效值(0 或 array.length)。執行結束返回改變後的陣列。例如:
[1,2,3,4].fill(5) // [5,5,5,5]
[1,2,3,4].fill(5, 2, 4) // [1,2,5,5]
[1,2,3,4].fill(5, -2, 4) // [1,2,5,5]
[1,2,3,4].fill(5, -3, 4) // [1,5,5,5]
[1,2,3,4].fill(5, -5, 4) // [5,5,5,5]
複製程式碼
  • copyWithin(target, start, ?end)在陣列內部複製一部分值到另一部分,也不能改變陣列長度。它接收第一個引數是要放置複製元素的目標位置索引,第二個引數是要複製的部分的起始索引(含),結束索引(不含)便是可選的第三個引數,如果沒有傳入則預設為函式長度,即從起始一直複製到末尾。複製的部分將從目標位置開始填充,覆蓋對應位置原有的元素。執行結束後返回改變後的陣列。
[1,2,3,4].copyWithin(2, 0, 2) // [1,2,1,2]
[1,2,3,4].copyWithin(2, 0, 4) // [1,2,1,2]
[1,2,3,4].copyWithin(2, 4, 4) // [1,2,3,4] (沒有複製到元素,也不會對原來的陣列有影響)
複製程式碼
  • splice(start, ?deleteCount, ...items)幾乎可以算這些方法中最強大的方法了。它可以對陣列任意位置執行插入、刪除、替換操作,也可以改變陣列長度。就像對陣列做手術,而具體會做什麼樣的手術(執行什麼操作)則完全由引數決定。它可以接收1個或多個引數,第一個設定起始位置,第二個為可選的“刪除數量”,設定從起始位置(含)開始刪除多少個元素,如果沒有傳入則預設將起始位置及其之後的元素全部刪除。如果設定為0則不刪除元素,並將其後的引數列表按順序都從起始位置開始插入陣列中。最後返回的是被刪除的元素組成的陣列。
let a = [1,2,3,4,5,6];
a.splice(4) // [5, 6] 刪除了5,6

a // [1,2,3,4] 陣列a本身長度改變

a.splice(2, 0, 7, 8, 9, 0); // [] 沒有刪除元素
a // [1,2,7,8,9,0,3,4] // 7,8,9,0被作為插入元素從索引2開始插入,陣列原來的元素被放到插入元素列表的後面
複製程式碼

push, shift, pop, unshift

這幾個方法都用於對陣列的頭部或尾部進行插入和刪除。push(末尾推入)和pop(末尾刪除)操作在陣列末尾,像使用棧一樣使用陣列,遵循“後進先出”的規則;shift(頭部刪除)和unshift(頭部推入)作用於陣列頭部,結合使用shiftpushunshiftpop可以從正向和反向模擬佇列行為,像使用佇列一樣使用陣列,遵循“先進先出”的規則。

  • push(...items)方法可以接收任意多個引數,把它們按序依次新增到陣列末尾,返回改變後的陣列的長度;
  • pop()方法不接收引數,每次執行都會從陣列中刪除掉最後一項,並返回這個元素;
  • unshift(...items)方法與push的方向相反,把接收到的任意多個引數放在陣列頭部,返回改變後的陣列長度;
  • shift()方法也不接收引數,每次執行都會從陣列頭部刪除掉第一項並返回這個元素;

這幾個方法也可以應用在類陣列物件上,或者有length屬性和數值字串屬性的物件,它們會根據length屬性確定陣列的末尾位置並訪問對應位置,而與物件實際存在的元素個數或其他屬性無關。

var a = [];
a.push(1,2,3) // 3 (新增元素後的陣列長度)
a // [1,2,3]
a.pop() // 3 (刪除的尾部元素)
a // [1,2]
a.unshift(5,6,7,8); // 6 (新增元素後的陣列長度)
a // [5,6,7,8,1,2]
a.shift() // 5 (刪除的頭部元素)
a // [6,7,8,1,2]
複製程式碼

reverse, sort

這兩個是陣列的重排序方法;

  • reverse()直接按元素的位置進行反序操作,並返回改變後的陣列;
[4,14,3,23].reverse() // [23,3,14,4]
[4,14,3,23].sort() // [14,23,3,4]
複製程式碼
  • sort(?compareFn)方法則是預設根據對比元素的字串表示的先後順序升序排列--即使每個元素都是數值,也會先把它們轉換為字串,然後按照字串的對比規則(對比它們的UTF-16字元編碼值)確定排序關係。如果陣列中有undefined,則它們不參與排序並被放置在最後。

一般情況下我們更多需要的是對一組數值或擁有數值型別屬性的物件進行排序,直接呼叫sort是無法滿足的,需要自己傳入一個“比較函式”,接收兩個值(a, b)作為引數並返回一個數值,如果返回負數則a排在b之前,如果返回正數則相反,如果返回是0, 則一般將這兩個值保持原來的先後順序一起與其他值按序排列。ECMAScript中沒有保證對比時返回0的兩個值一定保持先後順序,所以並非所有的瀏覽器都能保證做到這一點(引用)。

[4,14,3,23].sort() // [14,23,3,4] 按數值轉換為字串後的字元編碼排序而非數值本身
[4,14,3,23].sort((a, b) => a - b) // [3,4,14,23] 按數值的大小進行升序排列

let a = ["x", "u", "m", "a"];
[undefined, ...a, "undefined"].sort() // ["a", "m", "u", "undefined", "x", undefined]

複製程式碼

可變方法的不可變替代

使用不可變方法複製陣列

例如slice, concat, map, filter等,根據不同需要選擇代替;

// concat 代替 push / unshift
let a = [1,2,3];
a.push(4);
a // [1,2,3,4];

let b = a.concat(5);
b // [1,2,3,4,5]
a // [1,2,3,4]

// slice 代替 pop / shift
b.pop();
b // [1,2,3,4]
let c = b.slice(0, -1);
c // [1,2,3]
b // [1,2,3,4]
複製程式碼

使用擴充套件操作符複製陣列

擴充套件運算子可以方便地對陣列進行復制或部分複製,不會改變原陣列;

let a = [1,2,3,4];
let b = [..a, 5];
b // [1,2,3,4,5]
a // [1,2,3,4]
複製程式碼

但需要注意的是,不論是不可變方法還是擴充套件運算子,陣列的複製都是淺複製,對於引用型別的元素複製的是其引用,而非整個物件。

注意:不可變方法隱蔽下的可變操作

還有一點值得注意,雖然不可變方法本身不會改變原陣列,但是因為陣列本身是引用型別的值,如果在回撥函式中引用陣列本身並對其元素進行改變操作或重新賦值,還是會“隱蔽地”修改原陣列。這種做法應該儘量避免,因為在後續維護時可能會給別人帶來不必要的困擾(不知在哪裡莫名其妙值就被改變了)。例如:

let c = [9, 8, 7, 6, 5];
c.map((n, i) => c[i] = i);
// 此時c已變成[0, 1, 2, 3, 4]
複製程式碼

當回撥函式非常長的時候這種問題更難定位,其他引用c值的變數很有可能也同時被影響。所以每個函式最好都目的明確只做一件事,把確實需要改變原引用值的操作放在一個專門的函式中操作,而不是散佈在任何看起來不會發生這種改變的地方。

參考

相關文章