陣列

陣列是一種有順序的複合式的資料結構,用於定義、存放複數的資料類型,在JavaScript的陣列中,並沒有規定它能放什麼資料進去,可以是原始的資料類型、其他陣列、函式等等。

陣列是用於存放大量資料的結構,要如何有效地處理資料,需要更加注意。它的搭配方法與語法很多,也有很多作相同事情的不同方式,並不是每樣都要學,有些只需要用到再查詢相關用法即可。

註: 雖然陣列資料類型是屬於物件,但Array這個包裝物件的typeof Array也是回傳'function'

陣列定義

陣列定義有兩種方式,一種是使用陣列字面文字,以下說明定義的方式。

陣列的索引值(index)是從0開始的順序整數值,陣列可以用方括號([])來取得成員的指定值,用這個方式也可以改變成員包含的值:

const aArray = []
const bArray = [1, 2, 3]

console.log(aArray.length) //0

aArray[0] = 1
aArray[1] = 2
aArray[2] = 3
aArray[2] = 5

console.log(typeof aArray) // object
console.log(aArray) // [1,2,5]
console.log(aArray.length) //3
console.log(aArray[3]) //undefined

註: 陣列為參照的(reference)資料類型,其中包含的值是可以再更改的,這與const的常數宣告無關。

另一種是使用Array包裝物件的預先分配空間的方式,但這種方式並不建議使用,這種定義語法除了容易搞混之外,經測試過它對效能並沒有太大幫助。而且這語法在分配後,一定會把長度值(成員個數)固定住,除非你百分之百確定陣列裡面的成員個數,不然千萬不要用。以下為範例:

註: JavaScript的內建物件都不建議new作初始定義的。不過有一些特例是一定要的,例如Date、Error等等。

陣列定義注意事項

一開始就搞混的語法

注意初學者很容易犯的一個錯誤,就是用下面這種陣列定義語法,這語法並不會導致執行錯誤,這是很怪異的合法定義語法,特別在這裡指出來就是希望你不要搞混了:

實際上這相當於:

多維陣列

對JavaScript中的陣列結構來說,多維陣列指的是"陣列中的陣列"結構,只是在陣列中保存其他的陣列值,例如像下面的範例:

維數過多時在處理上會愈複雜,一般常見的只有二維。通常需要另外撰寫專屬的處理函式,或是搭配額外的函式庫在容易上會較為方便。

關聯陣列

JavaScript中並沒有關聯陣列(Associative Array)這種資料結構,關聯陣列指的是"鍵-值"的資料陣列,也常被稱為字典(dictionary)的資料陣列,有很多程式語言有這樣的資料結構。

基本上在JavaScript中有複合類型只有物件和陣列,物件屬性的確是"鍵-值"對應的,但它並不是陣列,也沒有像陣列有這麼多方法可使用。雖然在ES6標準加入幾個特殊的物件結構,例如Set與Map,但目前的使用還不廣泛,其中支援的方法也少。在處理大量複合資料時,陣列的處理效率明顯高出物件許多,陣列的用途相當廣泛。

儲存多種資料類型

雖然並沒有規定說,你只能在同一個陣列中使用單一種資料類型。但是,在陣列中儲存多種不同的資料類型,絕對是個壞主意。在包含有大量資料的陣列中會嚴重影響處理效能,例如像下面這樣的例子。如果是多種資料類型,還不如先直接都用字串類型,需要取值時再作轉換。

另外你需要注意的是,雖然數字類型在JavaScript中並沒有分浮點數或整數,但實際上在瀏覽器的JavaScript引擎(例如Google Chrome的V8引擎)中,整數的陣列的處理效率高於浮點數的陣列,可見其實引擎可以分辦各種不同的資料類型,然後會作最有效的儲存與運算,比你想像中聰明得很。

在ES6後加入了一種新式的進階資料結構,稱為型別陣列(Typed Arrays),它是類似陣列的物件,但並非一般的陣列,也沒有大部份的陣列方法。這種資料結構是儲存特定的資料時使用的,主要是為了更有效率的處理二進位的資料(raw binary data),例如檔案、圖片、聲音與影像等等。(註: Typed Arrays標準)

不過,就像上面一段說明的,聰明的JavaScript引擎在執行時會認得在一般陣列中儲存的資料類型,然後作最有效率的運算處理,在某些情況型別陣列(Typed Arrays)在運算上仍然不見得會比一般陣列還有效率。

多洞的陣列(Holey Arrays)或稀疏陣列(Sparse Arrays)

多洞的陣列代表你在定義陣列時,它的陣列值和索引(index)並沒有塞滿,或是從一個完整的陣列刪除其中一個(留了個空位)。像下面這樣的陣列定義:

另一種情況是,陣列大部份的值都是根本不使用的,例如用new Array(100)先定義出一個很大的陣列,但實際上裡面的值很少,這叫作稀疏陣列(Sparse Arrays),其實多洞的陣列(Holey Arrays)或稀疏陣列(Sparse Arrays)都差不多指的是這一類的陣列,稀疏陣列可以再重新設計讓它在程式上的效率更高。這種陣列在處理效能上都會有很大的影響,在大的陣列中要避免使用到這樣的情況。

拷貝(copy)陣列

把原有的陣列指定給另一個變數(或常數)並不會讓它成為一個全新的陣列,這是因為當指定值是陣列時,是指定到同一個參照,也就是同一個陣列,看下面的範例:

由範例可以看到,bArrayaArray是共享同一陣列中的值,就像是不同名字的連體嬰,不論你在aArraybArray中修改其中的值,增加或減少其中的值,都是對同一值作這件事。這種不叫拷貝陣列,只是指向同一個陣列而已。

拷貝陣列並不是像這樣作的,而且它可能是件複雜的事情,複雜的原因是陣列中包含的值,可以是各種不同的值,包含數字、字串、布林這些基本原始資料,也可以是其他陣列、物件、函式、其他特殊物件。而且物件類型的資料,都是使用參照(reference)指向的,裡面的資料也是複合式的,所以有可能也是複雜的。題外話是當你在進行拷貝物件資料時,也是會遇到同樣的複雜的情況。

拷貝陣列的情況,大致可以區分為淺拷貝(shallow copy)與深拷貝(deep copy)兩種。淺拷貝只能完全複製原陣列中,包含像數字、字串之類的基本原始資料值,而且當然是只有一維的平坦陣列,如果其中包含巢狀(多維)陣列、物件、函式、其他物件,只會複製到參照,意思是還是只能指向原來同一個值。

深拷貝(deep copy)反而容易理解,它是真正複製出另一個完全獨立的陣列。不過,深拷貝是一種高花費的執行程序,所以對於效率與精確,甚至能包含的特殊物件範圍都需要考慮。如果要進行深拷貝,一般就不直接用JavaScript內建的語法來達成,而是要用外部的函式庫例如jQuery、understore或lodash來作,而這些函式庫中的深拷貝不是只有支援陣列結構而已,同樣也支援一般物件或特殊物件的資料結構。不過,如果你的陣列結構很簡單,也可以用for或while迴圈自己作深拷貝這件事。

以下的方式主要是針對"淺拷貝"部份,一樣也有很多種方式可以作同樣這件事,以下列出四種:

展開(spread)運算符

推薦使用

ES6後的新運算符,長得像之前在函式章節講到的其餘參數,使用的也是三個點的省略符號(ellipsis)(...),語法相當簡單,現在很常被使用:

它也可以用來組合陣列

註: 展開(spread)運算符目前用babel轉換為ES5相容語法時,是使用concat方法

slice

slice(分割)原本是用在分割陣列為子陣列用的,當用0當參數或不加參數,相當於淺拷貝,這個方式是目前是效率較好的方式,語法也很簡單:

concat

concat(串聯)是用於合併多個陣列用的,把一個空的陣列和原先陣列合併,相當於拷貝的概念。在這裡寫出來是為了比較一下展開運算符:

for/while迴圈語句

迴圈語句也可以作為淺拷貝,語句寫起來不難也很直覺,只是相較於其他方式要多打很多字,通常不會單純只用來作淺拷貝。以下為範例程式:

判別是否為陣列

最常見的情況是,如果有個函式要求它的傳入參數之一為陣列,而且確定不能是物件或其他類型。你要如何判斷傳進來的值是真的一個陣列?

直接使用typeof來作判斷是沒辦法作這件事的,它對陣列資料類型只會直接回傳'object'

在JavaScript中,有很多種方式可以作同一件事,這件事也不意外,不過每種方法都有一些些不同的細節或問題。以下的variable代表要被判斷的變數(或常數)值。

isArray

推薦使用

最簡單的判斷語法應該是這個,用的是內建Array物件中的isArray,它是個ES5標準方法:

constructor

下面這個是在Chrome瀏覽器中效能最佳的判斷方法,它是直接用物件的建構式來判斷:

如果你是要判斷物件中的其中屬性是否為陣列,你可以先判斷這個屬性是否存在,像下面這樣(prop指的是物件屬性):

失效情況: 當使用在一個繼承自陣列的陣列會失效

instanceof

這也是用物件的相關判別方法來判斷,instanceof是用於判斷是否為某個物件的實例,優點為語法簡潔清楚:

失效情況: 處理不同window或iframe時的變數會失效

toString.call

推薦使用

這也是用物件中的toString方法來判斷,這是所有情況都可以正確判斷的一種。它也是萬用方式,可以判斷陣列以外的其他特別物件,缺點是效率最差:

註: jQuery、underscore函式庫中的判斷陣列的API是用這種判斷方法

註: 在JavaScript: The Definitive Guide, 6th Edition書中有提到,Array.isArray其實就是用這個方式的實作方法。

方式結論

這幾個方式的選擇,我的建議是只要學最後一種就行了(不考慮舊瀏覽器就用第一種),它可以正確判斷並應用在各種情況,有時候正確比再快的效能更重要,更何況它其實是萬用的,除了陣列之外也可以用於其它的判斷情況。雖然它的語法對初學者來說,可能無法在此時完全理解,不過就先知道要這樣用就行了。

參考資料: How do you check if a variable is an array in JavaScript?

陣列屬性與方法

陣列屬性與方法的細節多如牛毛,以下只列出常用到的方法與屬性。

屬性

length長度(成員個數)

length用來回傳陣列的長度(成員個數),這個屬性有時候是不可信的,就如同上面用new Array(10)定義時,會被固定住為10,不論現在的裡面的值有多少個。多洞的陣列中也是與目前有值成員的個數不同:

多維陣列的情況上面有說明過了,只是陣列中的陣列而已,它的length只會回傳最上層陣列的個數:

length的整數值竟然是可以更動的,它並不是只能讀不能寫的屬性:

更動length經測試過,事實上是從陣列最後面"截短(truncate)"的語法,它的效率是所有類似功能語法中最好的:

另外,length指定為0也可以用於清空陣列,清空陣列一樣也是有好幾種方式,以下為各種清空陣列的範例程式碼,注意第一種的原陣列不能使用const宣告,就意義上它不是真的把原來的陣列清空。一般情況下第一種效率最好,第四種最差:

方法

indexOf

indexOf是簡便的搜尋索引值用的方法,它可以給定一個要在陣列中搜尋的成員(值),如果找到的話就會回傳成員的索引值,沒找到就會回傳"-1"數字。多個成員符合的話,它只會回傳最先找到的那個(一律是從左至右),它的比對是使用完全符合的比較運算符(===),可加入第二個參數,這是可選擇的,它是"開始搜尋的索引值",如果是負整數則從最後面倒過來計算位置的(最後一個索引值相當於-1)。

pop與push、shift與unshift

副作用方法

陣列的傳統處理方法,pop是"砰出"最後面一個值,然後把這個值從陣列移除掉。push是"塞入"一個值到陣列最後面。shift與pop類似,不過它是砰出最前面的值。unshift則與push類似,它是塞到陣列列前面。

pop的例子如下,它會回傳被砰出的值:

push的例子如下,它則是回傳新的長度值:

口訣記法: 有"p"的pop與push是針對陣列的"屁股"(最後面)。pop-corn是爆米花,所以pop用來爆出值的。有u的push與unshift是同一掛的。

concat

concat(串聯)是用於合併其他陣列或值,到一個陣列上的方法。它最後會回傳一個新的陣列。

語法: array.concat(value1[, value2[, ...[, valueN]]])

前面已經有看到它可以作陣列的淺拷貝,它與展開運算符可以互為替代,以下為一個簡單的範例:

注意: 陣列沒有運算符這種東西。但是字串類型可以用合併運算符(+),或是用同名方法concat作合併。

slice

slice(分割)是用於分割出子陣列的方法,它會用淺拷貝(shallow copy)的方式,回傳一個新的陣列。這個方法與字串中的分割子字串所使用的的同名稱方法slice,類似的作法。

語法: array.slice(start[, end])

slice使用陣列的索引值作為前後參數,大部份的重點都是這兩個索引值的正負值情況。以下是對於特殊情況的大致規則:

  • 當開頭索引值為undefined時(空白沒寫),它會以0計算,也就是陣列最開頭。

  • 當索引值有負值的情況下,它是從最後面倒過來計算位置的(最後一個索引值相當於-1)

  • 只要遵守"開頭索引值"比"結束索引值"的位置更靠左,就有回傳值。

  • slice(0)相當於陣列淺拷貝

splice

副作用方法

splice(粘接)這個字詞與上面的slice(分割)長得很像,但用途不相同,這個方法是用於刪除或增加陣列中的成員,以此來改變原先的陣列的。

為何會有這種用途?主要是要在陣列的中間"插入"幾個新的成員(值),或是"刪掉"其中的幾個成員(值)用的。

語法: array.splice(start, deleteCount[, item1[, item2[, ...]]])

這個方法的參數值會比較多用起來會複雜些,先說明它的參數如下:

  • start 是開始要作這件事的索引值,左邊從0開始,如果是負數則從右邊(最後一個)開始計算

  • deleteCount 是要刪除幾個成員(值),最少為0代表不刪除成員(值)

  • item1... 這是要加進來的成員(值),不給這些成員(值)的話,就只會作刪除的工作,不會新增成員(值)。注意如果是陣列值,會變成巢狀(子)陣列成員(值)。

實際上有幾個基本的用法範例,splice通常會用在一些特定的情況,也可能會搭配搜尋語法或迴圈語句。以下為常用的幾個範例:

插入一個新成員(值)在某個值之後

插入某個值之後需要先找出這個某個值的索引值,所以會用indexOf來找,不過如果是多個值的情況,就要用迴圈語句了,這是只有單個"某個值"的情況。下面的兩個範例的結果是相同的,所以你要在"某個值"的後面插入新成員(值),"後面"代表新成員(值)的索引值還需要加1才行。

用新成員(值)取代某個值

單個值的情況:

多個值的情況,用迴圈語句:

用於刪除成員(值)

註: 其實完全與取代範例幾乎一樣

單個值的情況:

多個值的情況,用迴圈語句:

陣列與字串 join與split

join(結合)與split(分離)是相對的兩個方法,join(結合)是陣列的方法,用途是把陣列中的成員(值)組合為一個字串,組合的時候可以加入組合時用的分隔符號(或空白字元)。

split(分離)是倒過來,它是字串中的方法,把字串拆解成陣列的成員,拆解時需要給定一個拆解基準的符號(或空白),通常是用逗號(,)分隔,以下為範例:

迭代(Iteration)

forEach為副作用方法

陣列的迭代可以單純地使用迴圈語句都可以作得到,但在ES6後加入幾個新的方法,例如forEachmapreduce提供更多彈性的運用。

forEach

forEach類似於for迴圈,但它執行語句的是放在回調函式(callback)的語句中,下面這兩個範例是相等功能的:

forEach的回調函式(callback)共可使用三個參數:

  • value 目前成員的值

  • index 目前成員的索引

  • array 要進行尋遍的陣列

forEach雖然可以與for迴圈語句相互替代,但他們兩個設計原理不同,也有一些明顯的差異,在選擇時你需要考慮這些。不同之處在於以下幾點:

  • forEach無法提早結束或中斷

  • forEach被呼叫時,this會傳遞到回調函式(callback)裡

  • forEach方法具有副作用

那為什麼要使用迭代方法forEach,而不直接用for語法?原因有幾個:

  • 可閱讀性提高

  • 安全性提高,減少潛在錯誤發生:變數(常數)作用域必位於callback內

for迴圈的優點:

  • 效能最佳

  • 可作流程控制與中斷

map(映射)

map(映射)反而是比較常被使用的迭代方法,由於它並不會改變輸入陣列(呼叫map的陣列)的成員值,所以並不會產生副作用,現在有很多程式設計師改用它來作為陣列迭代的首選使用方法。

map(映射)一樣會使用回調函式(callback)與三個參數,而且行為與forEach幾乎一模一樣,不同的地方是它會回傳一個新的陣列,也因為它可以回傳新陣列,所以map(映射)可以用於串接(chain)語法結構。

reduce(歸納)

reduce(歸納)這個方法是一種應用於特殊情況的迭代方法,它可以藉由一個回調(callback)函式,來作前後值兩相運算,然後不斷縮減陣列中的成員數量,最終回傳一個值。reduce(歸納)並不會更動作為傳入的陣列(呼叫reduce的陣列),所以它也沒有副作用。

reduce(歸納)比map又更複雜了一些,它多了一個參數值,代表前一個值,主要就是它至少要兩個值才能進行前後值兩相運算。你也可以給定它一個初始值,這時候reduce(歸納)會從索引值的0接著取第二個值進行迭代,如果你沒給初始值,reduce(歸納)會從索引值1開始開始迭代。可以用下面這個範例來觀察它是如何運作的:

按照這個邏輯,reduce(歸納)具有分散運算的特點,可以用於下面幾個應用之中:

  • 兩相比較最後取出特定的值(最大或最小值)

  • 計算所有成員(值),總合或相乘

  • 其它需要兩兩處理的情況(組合巢狀陣列等等)

排序與反轉 sort與reverse

sort

副作用方法

sort是一個簡單的排序方法,以Unicode字串碼順序來排序,對於英文與數字有用,但中文可能就不是你要的。以下為簡單的範例:

中文的排序一般來說只會有兩種情況,一種是要依big5編碼來排序,另一種是要依筆劃來排序,這時候需要在sort方法傳入參數中,另外加入比較的回調(callback)函式,這個回調函式中將使用localeCompare這個可以比較本地字串的方法,以下為範例程式,其中本地(locale)的參數請參考locales argument:

註: 這個字串比較方法應該可以再最佳化其效率,有需要可以進一步參考其文件的選項設定

reverse

副作用方法

reverse(反轉)這語法用於把整個陣列中的成員順序整個反轉,就這麼簡單。以下為範例:

另一個情況是字串中的字元,如果要進行反轉的話,並沒有字串中的reverse方法,要用這個陣列的reverse方法加上字串與陣列的互相轉換的split與join方法,可以使用以下的函式:

過濾與搜尋 filter與find/findIndex

filter(過濾)是使用一個回調(callback)函式作為傳入參數,將的陣列成員(值)進行過濾,最後回傳符合條件(通過測試函式)的陣列成員(值)的新陣列。它的傳入參數與用法與上面說的迭代方法類似,實際上也是另一種特殊用途的迭代方法:

find與findIndex方法都是在搜尋陣列成員(值)用的,一個在有尋找到時會回傳值,一個則是回傳索引值,當沒找到值會回傳undefined。它們一樣是使用一個回調(callback)函式作為傳入參數,來進行尋找的工作,回調函式的參數與上面說的迭代方法類似。

findIndex與最上面說的indexOf不同的地方也是在於,findIndex因為使用了回調(callback)函式,可以提供更多的在尋找時的彈性應用。以下為範例:

陣列處理純粹函式

改寫原有的處理方法(或函式)為純粹函式並不困難,相當於要拷貝一個新的陣列出來,進行處理後回傳它。ES6後的展開運算子(...)可以讓語法更簡潔。

註: 以下範例並沒有作傳入參數的是否為陣列的檢查判斷語句,使用時請再自行加上。

註: 下列範例來自Pure javascript immutable arrays,更多其他的純粹函式可以參考這裡的範例

push

pop

shift

unshift

splice

這方法完全要使用slice與展開運算符(...)來取代,是所有的純粹函式最難的一個。

sort

reverse

delete

刪除(delete)其中一個成員,再組合所有子字串:

英文解說

常見問題

為什麼要那麼強調副作用?

副作用的概念早已經存在於程式語言中很久了,但在最近幾年才受到很大的重視。在過去,我們在撰寫這種腳本直譯式程式語言,最重視的其實是程式效率與相容性,因為同樣的功能,不同的寫法有時候效率會相差很多,也有可能這是不同瀏覽器品牌與版本造成的差異。

但現在的電腦硬體已經進步太多,所謂的執行資源的限制早就與10年前不同。效率在今天的程式開發早就已經不是唯一的重點,更多其他的因素都需要加入來一併考量,以前的應用程式也可能只是小小的特效或某個小功能,現在的應用程式將會是很龐大而且結構複雜的。所以,程式碼的閱讀性與語法簡潔、易於測試、維護與除錯、易於規模化等等,都會變成要考量的其他重點。純粹函式的確是未來的主流想法,當一個應用程式慢慢變大、變複雜,純粹函式可以提供的好處會變成非常明顯,所以一開始學習這個概念是必要的。

參考

Last updated

Was this helpful?