【進階 6-3 期】Array 原型方法源碼實現大揭祕

引言

幾個經常使用數組方法的使用方式已經在【進階 6-1 期】 中介紹過了,今天這篇文章主要看看 ECMA-262 規範中是如何定義這些方法的,而且在看完規範後咱們用 JS 模擬實現下,透過源碼探索一些底層的知識,但願本文對你有所幫助。html

Array.prototype.map

完整的結構是 Array.prototype.map(callbackfn[, thisArg])map 函數接收兩個參數,一個是必填項回調函數,另外一個是可選項 callbackfn 函數執行時的 this 值。前端

map 方法的主要功能就是把原數組中的每一個元素按順序執行一次 callbackfn 函數,而且把全部返回的結果組合在一塊兒生成一個新的數組,map 方法的返回值就是這個新數組。面試

ECMA-262 規範文檔實現以下。數組

展開查看規範
  1. Let O be ? ToObject(this value).
  2. Let len be ? LengthOfArrayLike(O).
  3. If IsCallable(callbackfn) is false, throw a TypeError exception.
  4. If thisArg is present, let T be thisArg; else let T be undefined.
  5. Let A be ? ArraySpeciesCreate(O, len).
  6. Let k be 0.
  7. Repeat, while k < len
    1. Let Pk be ! ToString(k).
    2. Let kPresent be ? HasProperty(O, Pk).
  8. If kPresent is true, then
  9. Let kValue be ? Get(O, Pk).
  10. Let mappedValue be ? Call(callbackfn, T, « kValue, k, O »).
  11. Perform ? CreateDataPropertyOrThrow(A, Pk, mappedValue).
  12. Set k to k + 1.
  13. Return A.

用 JS 來模擬實現,核心邏輯以下:
Array.prototype.map = function(callbackfn, thisArg) {
  // 異常處理
  if (this == null) {
  	throw new TypeError("Cannot read property 'map' of null or undefined");
  }
  // Step 1. 轉成數組對象,有 length 屬性和 K-V 鍵值對
  let O = Object(this)
  // Step 2. 無符號右移 0 位,左側用 0 填充,結果非負
  let len = O.length >>> 0
  // Step 3. callbackfn 不是函數時拋出異常
  if (typeof callbackfn !== 'function') {
    throw new TypeError(callbackfn + ' is not a function')
  }
  // Step 4.
  let T = thisArg
  // Step 5.
  let A = new Array(len)
  // Step 6.
  let k = 0
  // Step 7.
  while(k < len) {
    // Step 7.一、7.二、7.3
    // 檢查 O 及其原型鏈是否包含屬性 k
    if (k in O) {
      // Step 7.3.1
      let kValue = O[k]
      // Step 7.3.2 執行 callbackfn 函數
      // 傳入 this, 當前元素 element, 索引 index, 原數組對象 O
      let mappedValue = callbackfn.call(T, kValue, k, O)
    	// Step 7.3.3 返回結果賦值給新生成數組
      A[k] = mappedValue
    }
    // Step 7.4
    k++
  }
  // Step 8. 返回新數組
  return A
}

// 代碼親測已經過
複製代碼

看完代碼其實挺簡單,核心就是在一個 while 循環中執行 callbackfn,並傳入 4 個參數,回調函數具體的執行邏輯這裏並不關心,只須要拿到返回結果並賦值給新數組就行了。app

只有 O 及其原型鏈上包含屬性 k 時纔會執行 callbackfn 函數,因此對於稀疏數組 empty 元素或者使用 delete 刪除後的索引則不會被調用函數

let arr = [1, , 3, , 5]
console.log(0 in arr) // true
delete arr[0]
console.log(0 in arr) // false
console.log(arr) // [empty × 2, 3, empty, 5]
arr.map(ele => {
  console.log(ele) // 3, 5
})
複製代碼

map 並不會修改原數組,不過也不是絕對的,若是你在 callbackfn 中修改了原數組,那仍是會改變。那問題來了,修改後會影響到 map 自身的執行嗎?測試

答案是會的!不過得區分如下幾種狀況。ui

  • 原數組新增元素:由於 map 第一次執行時 length 已經肯定了,因此不影響
  • 原數組修改元素:傳遞給 callbackfn 的元素是 map 遍歷到它們那一瞬間的值,因此可能受影響
    • 修改當前索引以前的元素,不受影響
    • 修改當前索引以後的元素,受影響
  • 原數組刪除元素:被刪除的元素沒法被訪問到,因此可能受影響
    • 刪除當前索引以前的元素,已經訪問過了,因此不受影響
    • 刪除當前索引以後的元素,受影響

簡單看下面幾個例子,在 callbackfn 中不要改變原數組,否則會有意想不到的狀況發生。this

// 一、原數組新增元素,不受影響
let arr = [1, 2, 3]
let result = arr.map((ele, index, array) => {
  array.push(4);
  return ele * 2
})
console.log(result) 
// 2, 4, 6
// ----------- 完美分割線 -----------


// 二、原數組修改當前索引以前的元素,不受影響
let arr = [1, 2, 3]
let result = arr.map((ele, index, array) => {
  if (index === 1) {
    array[0] = 4
  }
  return ele * 2
})
console.log(result) 
// 2, 4, 6
// ----------- 完美分割線 -----------


// 三、原數組修改當前索引以後的元素,受影響
let arr = [1, 2, 3]
let result = arr.map((ele, index, array) => {
  if (index === 1) {
    array[2] = 4
  }
  return ele * 2
})
console.log(result) 
// 2, 4, 8
複製代碼

最後來講說 this,源碼中有這麼一段 callbackfn.call(T, kValue, k, O),其中 T 就是 thisArg 值,若是沒有設置,那就是 undefined。spa

根據【進階 3-3 期】 中對於 call 的解讀,傳入 undefined 時,非嚴格模式下指向 Window,嚴格模式下爲 undefined。記住這時候回調函數不能用箭頭函數,由於箭頭函數是沒有本身的 this 的。

// 一、傳入 thisArg 但使用箭頭函數
let name = 'Muyiy'
let obj = {
    name: 'Hello',
    callback: (ele) => {
        return this.name + ele
    }
}
let arr = [1, 2, 3]
let result = arr.map(obj.callback, obj);
console.log(result) 
// ["1", "2", "3"],此時 this 指向 window
// 那爲啥不是 "Muyiy1" 這樣呢,不急,第 3 步介紹
// ----------- 完美分割線 -----------


// 二、傳入 thisArg,使用普通函數
let name = 'Muyiy'
let obj = {
    name: 'Hello',
    callback: function (ele) {
        return this.name + ele
    }
}
let arr = [1, 2, 3]
let result = arr.map(obj.callback, obj);
console.log(result) 
// ["Hello1", "Hello2", "Hello3"],完美
// ----------- 完美分割線 -----------

// 三、不傳入 thisArg,name 使用 let 聲明
let name = 'Muyiy'
let obj = {
    name: 'Hello',
    callback: function (ele) {
        return this.name + ele
    }
}
let arr = [1, 2, 3]
let result = arr.map(obj.callback);
console.log(result)
// ["1", "2", "3"]
// 爲何呢,由於 let 和 const 聲明的變量不會掛載到 window 上
// ----------- 完美分割線 -----------

// 四、不傳入 thisArg,name 使用 var 聲明
var name = 'Muyiy'
let obj = {
    name: 'Hello',
    callback: function (ele) {
        return this.name + ele
    }
}
let arr = [1, 2, 3]
let result = arr.map(obj.callback);
console.log(result)
// ["Muyiy1", "Muyiy2", "Muyiy3"]
// 看看,改爲 var 就行了
// ----------- 完美分割線 -----------

// 五、嚴格模式
'use strict'
var name = 'Muyiy'
let obj = {
    name: 'Hello',
    callback: function (ele) {
        return this.name + ele
    }
}
let arr = [1, 2, 3]
let result = arr.map(obj.callback);
console.log(result)
// TypeError: Cannot read property 'name' of undefined
// 由於嚴格模式下 this 指向 undefined
複製代碼

上面這部分實操代碼介紹了 5 種狀況,分別是傳入 thisArg 兩種狀況,非嚴格模式下兩種狀況,以及嚴格模式下一種狀況。這部分的知識在以前的文章中都有介紹過,這裏主要是溫故下。若是這塊知識不熟悉,能夠詳細看個人 博客

Array.prototype.filter

完整的結構是 Array.prototype.filter(callbackfn[, thisArg]),和 map 是同樣的。

filter 字如其名,它的主要功能就是過濾,callbackfn 執行結果若是是 true 就返回當前元素,false 則不返回,返回的全部元素組合在一塊兒生成新數組,並返回。若是沒有任何元素經過測試,則返回空數組。

因此這部分源碼相比 map 而言,多了一步判斷 callbackfn 的返回值。

ECMA-262 規範文檔實現以下。

展開查看規範
  1. Let O be ? ToObject(this value).
  2. Let len be ? LengthOfArrayLike(O).
  3. If IsCallable(callbackfn) is false, throw a TypeError exception.
  4. If thisArg is present, let T be thisArg; else let T be undefined.
  5. Let A be ? ArraySpeciesCreate(O, 0).
  6. Let k be 0.
  7. Let to be 0.
  8. Repeat, while k < len
    1. Let Pk be ! ToString(k).
    2. Let kPresent be ? HasProperty(O, Pk).
    3. If kPresent is true, then
      1. Let kValue be ? Get(O, Pk).
      2. Let selected be ! ToBoolean(? Call(callbackfn, T, « kValue, k, O »)).
      3. If selected is true, then
        1. Perform ? CreateDataPropertyOrThrow(A, ! ToString(to), kValue).
        2. Set to to to + 1.
      4. Set k to k + 1.
  9. Return A.

用 JS 來模擬實現,核心邏輯以下:
Array.prototype.filter = function(callbackfn, thisArg) {
  // 異常處理
  if (this == null) {
  	throw new TypeError("Cannot read property 'map' of null or undefined");
  }
  if (typeof callbackfn !== 'function') {
    throw new TypeError(callbackfn + ' is not a function')
  }

  let O = Object(this), len = O.length >>> 0,
      T = thisArg, A = new Array(len), k = 0
  // 新增,返回數組的索引
  let to = 0
  
  while(k < len) {
    if (k in O) {
      let kValue = O[k]
      // 新增
      if (callbackfn.call(T, kValue, k, O)) {
        A[to++] = kValue;
      }
    }
    k++
  }
  
  // 新增,修改 length,初始值爲 len
  A.length = to;
  return A
}

// 代碼親測已經過
複製代碼

看懂 map 再看這個實現就簡單多了,改動點在於判斷 callbackfn 返回值,新增索引 to,這樣主要避免使用 k 時生成空元素,並在返回以前修改 length 值。

這部分源碼仍是挺有意思的,驚喜點在於 A.length = to,以前還沒用過。

Array.prototype.reduce

reduce 能夠理解爲「歸一」,意爲海納百川,萬劍歸一,完整的結構是 Array.prototype.reduce(callbackfn[, initialValue]),這裏第二個參數並非 thisArg 了,而是初始值 initialValue,關於初始值以前有介紹過。

  • 若是沒有提供 initialValue,那麼第一次調用 callback 函數時,accumulator 使用原數組中的第一個元素,currentValue 便是數組中的第二個元素。
  • 若是提供了 initialValueaccumulator 將使用這個初始值,currentValue 使用原數組中的第一個元素。
  • 在沒有初始值的空數組上調用 reduce 將報錯。

ECMA-262 規範文檔實現以下。

展開查看規範
  1. Let O be ? ToObject(this value).
  2. Let len be ? LengthOfArrayLike(O).
  3. If IsCallable(callbackfn) is false, throw a TypeError exception.
  4. If len is 0 and initialValue is not present, throw a TypeError exception.
  5. Let k be 0.
  6. Let accumulator be undefined.
  7. If initialValue is present, then
    1. Set accumulator to initialValue.
  8. Else,
    1. Let kPresent be false.
    2. Repeat, while kPresent is false and k < len
      1. Let Pk be ! ToString(k).
      2. Set kPresent to ? HasProperty(O, Pk).
      3. If kPresent is true, then
        1. Set accumulator to ? Get(O, Pk).
      4. Set k to k + 1.
    3. If kPresent is false, throw a TypeError exception.
  9. Repeat, while k < len
    1. Let Pk be ! ToString(k).
    2. Let kPresent be ? HasProperty(O, Pk).
    3. If kPresent is true, then
      1. Let kValue be ? Get(O, Pk).
      2. Set accumulator to ? Call(callbackfn, undefined, « accumulator, kValue, k, O »).
    4. Set k to k + 1.
  10. Return accumulator.

用 JS 來模擬實現,核心邏輯以下:
Array.prototype.reduce = function(callbackfn, initialValue) {
  // 異常處理
  if (this == null) {
  	throw new TypeError("Cannot read property 'map' of null or undefined");
  }
  if (typeof callbackfn !== 'function') {
    throw new TypeError(callbackfn + ' is not a function')
  }
  let O = Object(this)
  let len = O.length >>> 0
  let k = 0, accumulator
  
  // 新增
  if (initialValue) {
    accumulator = initialValue
  } else {
    // Step 4.
    if (len === 0) {
      throw new TypeError('Reduce of empty array with no initial value');
    }
    // Step 8.
    let kPresent = false
    while(!kPresent && (k < len)) {
      kPresent = k in O
      if (kPresent) {
        accumulator = O[k] 
      }
      k++
    }
  }
  
  while(k < len) {
    if (k in O) {
      let kValue = O[k]
      accumulator = callbackfn.call(undefined, accumulator, kValue, k, O)
    }
    k++
  }
  return accumulator
}

// 代碼親測已經過
複製代碼

這部分源碼主要多了對於 initialValue 的處理,有初始值時比較簡單,即 accumulator = initialValuekValue = O[0]

無初始值處理在 Step 8,循環判斷當 O 及其原型鏈上存在屬性 k 時,accumulator = O[k] 並退出循環,由於 k++,因此 kValue = O[k++]

更多的數組方法有 findfindIndexforEach 等,其源碼實現也是大同小異,無非就是在 callbackfn.call 這部分作些處理,有興趣的能夠看看 TC39 和 MDN 官網,參考部分連接直達。

注意

forEach 的源碼和 map 很相同,在 map 的源碼基礎上作些改造就是啦。

Array.prototype.forEach = function(callbackfn, thisArg) {
  // 相同
  ...
  while(k < len) {
    if (k in O) {
      let kValue = O[k]
 
      // 這部分是 map
      // let mappedValue = callbackfn.call(T, kValue, k, O)
      // A[k] = mappedValue
      
      // 這部分是 forEach
      callbackfn.call(T, kValue, k, O)
    }
    k++
  }
  // 返回 undefined
  // return undefined
}
複製代碼

能夠看到,不一樣之處在於不處理 callbackfn 執行的結果,也不返回。

特地指出來是由於在此以前看到過一種錯誤的說法,叫作「forEach 會跳過空,可是 map 不跳過」

爲何說 map 不跳過呢,由於原始數組有 empty 元素時,map 返回的結果也有 empty 元素,因此不跳過,可是這種說法並不正確。

let arr = [1, , 3, , 5]
console.log(arr) // [1, empty, 3, empty, 5]

let result = arr.map(ele => {
  console.log(ele) // 1, 3, 5
  return ele
})
console.log(result) // [1, empty, 3, empty, 5]
複製代碼

ele 輸出就會明白 map 也是跳空的,緣由就在於源碼中的 k in O,這裏是檢查 O 及其原型鏈是否包含屬性 k,因此有的實現中用 hasOwnProperty 也是不正確的。

另外 callbackfn 中不可使用 break 跳出循環,是由於 break 只能跳出循環,而 callbackfn 並非循環體。若是有相似的需求可使用for..offor..insomeevery 等。

熟悉源碼以後不少問題就迎刃而解啦,感謝閱讀。

參考

❤️ 看完三件事

若是你以爲這篇內容對你挺有啓發,我想邀請你幫我三個小忙:

  1. 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
  2. 關注個人官網 muyiy.cn,讓咱們成爲長期關係
  3. 關注公衆號「高級前端進階」,每週重點攻克一個前端面試重難點,公衆號後臺回覆「資料」 送你精選前端優質資料。

相關文章
相關標籤/搜索