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

關注 高級前端進階,回覆「加羣javascript

加入咱們一塊兒學習,每天進步html


做者:木易楊前端

引言

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

Array.prototype.map

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

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

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

  1. Let O be ? ToObject(this value).微信

  2. Let len be ? LengthOfArrayLike(O).app

  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

    1. Let kValue be ? Get(O, Pk).

    2. Let mappedValue be ? Call(callbackfn, T, « kValue, k, O »).

    3. Perform ? CreateDataPropertyOrThrow(A, Pk, mappedValue).

  9. Set k to k + 1.

  10. 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 個參數,回調函數具體的執行邏輯這裏並不關心,只須要拿到返回結果並賦值給新數組就行了。

只有 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 自身的執行嗎?

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

  • 原數組新增元素:由於 map 第一次執行時 length 已經肯定了,因此不影響

  • 原數組修改元素:傳遞給 callbackfn 的元素是 map 遍歷到它們那一瞬間的值,因此可能受影響

  • 修改當前索引以前的元素,不受影響

  • 修改當前索引以後的元素,受影響

  • 原數組刪除元素:被刪除的元素沒法被訪問到,因此可能受影響

  • 刪除當前索引以前的元素,已經訪問過了,因此不受影響

  • 刪除當前索引以後的元素,受影響

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

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


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


// 三、原數組修改當前索引以後的元素,受影響
let arr = [123]
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。

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

// 一、傳入 thisArg 但使用箭頭函數
let name = 'Muyiy'
let obj = {
    name'Hello',
    callback(ele) => {
        return this.name + ele
    }
}
let arr = [123]
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',
    callbackfunction (ele{
        return this.name + ele
    }
}
let arr = [123]
let result = arr.map(obj.callback, obj);
console.log(result) 
// ["Hello1", "Hello2", "Hello3"],完美
// ----------- 完美分割線 -----------

// 三、不傳入 thisArg,name 使用 let 聲明
let name = 'Muyiy'
let obj = {
    name'Hello',
    callbackfunction (ele{
        return this.name + ele
    }
}
let arr = [123]
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',
    callbackfunction (ele{
        return this.name + ele
    }
}
let arr = [123]
let result = arr.map(obj.callback);
console.log(result)
// ["Muyiy1", "Muyiy2", "Muyiy3"]
// 看看,改爲 var 就行了
// ----------- 完美分割線 -----------

// 五、嚴格模式
'use strict'
var name = 'Muyiy'
let obj = {
    name'Hello',
    callbackfunction (ele{
        return this.name + ele
    }
}
let arr = [123]
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 kValue be ? Get(O, Pk).

    2. Let selected be ! ToBoolean(? Call(callbackfn, T, « kValue, k, O »)).

    3. If selected is true, then

    4. Set k to k + 1.

    5. Perform ? CreateDataPropertyOrThrow(A, ! ToString(to), kValue).

    6. Set to to to + 1.

    7. Let Pk be ! ToString(k).

    8. Let kPresent be ? HasProperty(O, Pk).

    9. If kPresent is true, then

  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 Pk be ! ToString(k).

    2. Set kPresent to ? HasProperty(O, Pk).

    3. If kPresent is true, then

    4. Set k to k + 1.

    5. Set accumulator to ? Get(O, Pk).

    6. Let kPresent be false.

    7. Repeat, while kPresent is false and k < len

    8. If kPresent is false, throw a TypeError exception.

  9. Repeat, while k < len

    1. Let kValue be ? Get(O, Pk).

    2. Set accumulator to ? Call(callbackfn, undefined, « accumulator, kValue, k, O »).

    3. Let Pk be ! ToString(k).

    4. Let kPresent be ? HasProperty(O, Pk).

    5. If kPresent is true, then

    6. 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 等。

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

參考

  • TC39 Array.prototype.map

  • TC39 Array.prototype.filter

  • TC39 Array.prototype.reduce

  • MDN Array.prototype.map

  • MDN Array.prototype.filter

  • MDN Array.prototype.reduce

References

[1] 【進階 6-1 期】: https://www.muyiy.cn/blog/6/6.1.html
[2] 【進階 3-3 期】: https://muyiy.cn/blog/3/3.3.html
[3] 博客: https://muyiy.cn/blog/
[4] TC39 Array.prototype.map: https://tc39.es/ecma262/#sec-array.prototype.map
[5] TC39 Array.prototype.filter: https://tc39.es/ecma262/#sec-array.prototype.filter
[6] TC39 Array.prototype.reduce: https://tc39.es/ecma262/#sec-array.prototype.reduce
[7] MDN Array.prototype.map: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/map
[8] MDN Array.prototype.filter: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
[9] MDN Array.prototype.reduce: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce

❤️ 看完三件事

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

  1. 點個「在看」,讓更多的人也能看到這篇內容(喜歡不點在看,都是耍流氓 -_-)

  2. 關注個人官網 https://muyiy.cn,讓咱們成爲長期關係

  3. 關注公衆號「高級前端進階」,每週重點攻克一個前端面試重難點,公衆號後臺回覆「資料」免費送給你精心準備的前端進階資料。

點擊我開始留言

本文分享自微信公衆號 - 全棧大佬的修煉之路(gh_7795af32a259)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索