咱們不背誦 API,只實現 API

有很多剛入行的同窗跟我說:「JavaScript 不少 API 記不清楚怎麼辦?數組的這方法、那方法老是傻傻分不清楚,該如何是好?操做 DOM 的方式今天記,明天忘,真讓人奔潰!前端

甚至有的開發者在討論面試時,總向我抱怨:「面試官總愛糾結 API 的使用,甚至 jQuery 某些方法的參數順序都須要讓我說清楚!node

我認爲,對於反覆使用的方法,全部人都要作到「機械記憶」,可以反手寫出。一些貌似永遠記不清的 API 只是由於用得不夠多而已。git

在作面試官時,我歷來不強求開發者準確無誤地「背誦」 API。相反,我喜歡從另一個角度來考察面試者:「既然記不清使用方法,那麼我告訴你它的使用方法,你來實現一個吧!」實現一個 API,除了能夠考察面試者對這個 API 的理解,更能體現開發者的編程思惟和代碼能力。對於積極上進的前端工程師,模仿並實現一些經典方法,應該是「屢見不鮮」,這是比較基本的要求。面試

本小節,我根據瞭解的面試題目和做爲面試官的經歷,挑了幾個典型的 API,經過對其不一樣程度,不一樣方式的實現,來覆蓋 JavaScript 中的部分知識點和編程要領。經過學習本節內容,期待你不只能領會代碼奧義,更應該學習觸類旁通的方法。npm

API 主題的相關知識點以下:編程

目錄

jQuery offset 實現

這個話題演變自今日頭條某部門面試題。當時面試官提問:「如何獲取文檔中任意一個元素距離文檔 document 頂部的距離?」

熟悉 jQuery 的同窗應該對 offset 方法並不陌生,它返回或設置匹配元素相對於文檔的偏移(位置)。這個方法返回的對象包含兩個整型屬性:topleft,以像素計。若是可使用 jQuery, 咱們能夠直接調取該 API 得到結果。可是,若是用原生 JavaScript 實現,也就是說手動實現 jQuery offset 方法,該如何着手呢?數組

主要有兩種思路:promise

  • 經過遞歸實現
  • 經過 getBoundingClientRect API 實現

遞歸實現方案

咱們經過遍歷目標元素、目標元素的父節點、父節點的父節點......依次溯源,並累加這些遍歷過的節點相對於其最近祖先節點(且 position 屬性非 static)的偏移量,向上直到 document,累加便可獲得結果。瀏覽器

其中,咱們須要使用 JavaScript 的 offsetTop 來訪問一個 DOM 節點上邊框相對離其自己最近、且 position 值爲非 static 的祖先元素的垂直偏移量。具體實現爲:安全

const offset = ele => {
    let result = {
        top: 0,
        left: 0
    }

    // 當前 DOM 節點的 display === 'none' 時, 直接返回 {top: 0, left: 0}
    if (window.getComputedStyle(ele)['display'] === 'none') {
        return result
    }
 
    let position

    const getOffset = (node, init) => {
        if (node.nodeType !== 1) {
            return
        }

        position = window.getComputedStyle(node)['position']
 
        if (typeof(init) === 'undefined' && position === 'static') {
            getOffset(node.parentNode)
            return
        }

        result.top = node.offsetTop + result.top - node.scrollTop
        result.left = node.offsetLeft + result.left - node.scrollLeft
 
        if (position === 'fixed') {
            return
        }
 
        getOffset(node.parentNode)
    }
 
    getOffset(ele, true)
 
    return result
}

上述代碼並不難理解,使用遞歸實現。若是節點 node.nodeType 類型不是 Element(1),則跳出;若是相關節點的 position 屬性爲 static,則不計入計算,進入下一個節點(其父節點)的遞歸。若是相關屬性的 display 屬性爲 none,則應該直接返回 0 做爲結果。

這個實現很好地考察了開發者對於遞歸的初級應用、以及對 JavaScript 方法的掌握程度。

接下來,咱們換一種思路,用一個相對較新的 API: getBoundingClientRect 來實現 jQuery offset 方法。

getBoundingClientRect 方法

getBoundingClientRect 方法用來描述一個元素的具體位置,這個位置的下面四個屬性都是相對於視口左上角的位置而言的。對某一節點執行該方法,它的返回值是一個 DOMRect 類型的對象。這個對象表示一個矩形盒子,它含有:lefttoprightbottom 等只讀屬性。

圖示

請參考實現:

const offset = ele => {
    let result = {
        top: 0,
        left: 0
    }
    // 當前爲 IE11 如下,直接返回 {top: 0, left: 0}
    if (!ele.getClientRects().length) {
        return result
    }

    // 當前 DOM 節點的 display === 'none' 時,直接返回 {top: 0, left: 0}
    if (window.getComputedStyle(ele)['display'] === 'none') {
        return result
    }

    result = ele.getBoundingClientRect()
    var docElement = ele.ownerDocument.documentElement

    return {
        top: result.top + window.pageYOffset - docElement.clientTop,
        left: result.left + window.pageXOffset - docElement.clientLeft
    }
}

須要注意的細節有:

  • node.ownerDocument.documentElement 的用法可能你們比較陌生,ownerDocument 是 DOM 節點的一個屬性,它返回當前節點的頂層的 document 對象。ownerDocument 是文檔,documentElement 是根節點。事實上,ownerDocument 下含 2 個節點:

    • <!DocType>
    • documentElement

docElement.clientTopclientTop 是一個元素頂部邊框的寬度,不包括頂部外邊距或內邊距。

  • 除此以外,該方法實現就是簡單的幾何運算,邊界 case 和兼容性處理,也並不難理解。

從這道題目看出,相比考察「死記硬背」 API,這樣的實現更有意義。站在面試官的角度,我每每會給面試者(開發者)提供相關的方法提示,以引導其給出最後的方案實現。

數組 reduce 方法的相關實現

數組方法很是重要:由於數組就是數據,數據就是狀態,狀態反應着視圖。對數組的操做咱們不能陌生,其中 reduce 方法更要作到得心應手。我認爲這個方法很好地體現了「函數式」理念,也是當前很是熱門的考察點之一。

咱們知道 reduce 方法是 ES5 引入的,reduce 英文解釋翻譯過來爲「減小,縮小,使還原,使變弱」,MDN 對該方法直述爲:

The reduce method applies a function against an accumulator and each value of the array (from left-to-right) to reduce it to a single value.

它的使用語法:

arr.reduce(callback[, initialValue])

這裏咱們簡要介紹一下。

  • reduce 第一個參數 callback 是核心,它對數組的每一項進行「疊加加工」,其最後一次返回值將做爲 reduce 方法的最終返回值。 它包含 4 個參數:

    • previousValue 表示「上一次」 callback 函數的返回值
    • currentValue 數組遍歷中正在處理的元素
    • currentIndex 可選,表示 currentValue 在數組中對應的索引。若是提供了 initialValue,則起始索引號爲 0,不然爲 1
    • array 可選,調用 reduce() 的數組
  • initialValue 可選,做爲第一次調用 callback 時的第一個參數。若是沒有提供 initialValue,那麼數組中的第一個元素將做爲 callback 的第一個參數。

reduce 實現 runPromiseInSequence

咱們看它的一個典型應用:按順序運行 Promise:

const runPromiseInSequence = (array, value) => array.reduce(
    (promiseChain, currentFunction) => promiseChain.then(currentFunction),
    Promise.resolve(value)
)

runPromiseInSequence 方法將會被一個每一項都返回一個 Promise 的數組調用,而且依次執行數組中的每個 Promise,請讀者仔細體會。若是以爲晦澀,能夠參考示例:

const f1 = () => new Promise((resolve, reject) => {
    setTimeout(() => {
        console.log('p1 running')
        resolve(1)
    }, 1000)
})

const f2 = () => new Promise((resolve, reject) => {
    setTimeout(() => {
        console.log('p2 running')
        resolve(2)
    }, 1000)
})


const array = [f1, f2]

const runPromiseInSequence = (array, value) => array.reduce(
    (promiseChain, currentFunction) => promiseChain.then(currentFunction),
    Promise.resolve(value)
)

runPromiseInSequence(array, 'init')

執行結果以下圖:

代碼執行

reduce 實現 pipe

reduce 的另一個典型應用能夠參考函數式方法 pipe 的實現:pipe(f, g, h) 是一個 curry 化函數,它返回一個新的函數,這個新的函數將會完成 (...args) => h(g(f(...args))) 的調用。即 pipe 方法返回的函數會接收一個參數,這個參數傳遞給 pipe 方法第一個參數,以供其調用。

const pipe = (...functions) => input => functions.reduce(
    (acc, fn) => fn(acc),
    input
)

仔細體會 runPromiseInSequencepipe 這兩個方法,它們都是 reduce 應用的典型場景。

實現一個 reduce

那麼咱們該如何實現一個 reduce 呢?參考來自 MDN 的 polyfill:

if (!Array.prototype.reduce) {
  Object.defineProperty(Array.prototype, 'reduce', {
    value: function(callback /*, initialValue*/) {
      if (this === null) {
        throw new TypeError( 'Array.prototype.reduce ' + 
          'called on null or undefined' )
      }
      if (typeof callback !== 'function') {
        throw new TypeError( callback +
          ' is not a function')
      }
    
      var o = Object(this)
    
      var len = o.length >>> 0
    
      var k = 0
      var value
    
      if (arguments.length >= 2) {
        value = arguments[1]
      } else {
        while (k < len && !(k in o)) {
          k++
        }
    
        if (k >= len) {
          throw new TypeError( 'Reduce of empty array ' +
            'with no initial value' )
        }
        value = o[k++]
      }
    
      while (k < len) {
        if (k in o) {
          value = callback(value, o[k], k, o)
        }
    
        k++
      }
    
      return value
    }
  })
}

上述代碼中使用了 value 做爲初始值,並經過 while 循環,依次累加計算出 value 結果並輸出。可是相比 MDN 上述實現,我我的更喜歡的實現方案是:

Array.prototype.reduce = Array.prototype.reduce || function(func, initialValue) {
    var arr = this
    var base = typeof initialValue === 'undefined' ? arr[0] : initialValue
    var startPoint = typeof initialValue === 'undefined' ? 1 : 0
    arr.slice(startPoint)
        .forEach(function(val, index) {
            base = func(base, val, index + startPoint, arr)
        })
    return base
}

核心原理就是使用 forEach 來代替 while 實現結果的累加,它們本質上是相同的。

我也一樣看了下 ES5-shim 裏的 pollyfill,跟上述思路徹底一致。惟一的區別在於:我用了 forEach 迭代而 ES5-shim 使用的是簡單的 for 循環。實際上,若是「槓精」一些,咱們會指出數組的 forEach 方法也是 ES5 新增的。所以,用 ES5 的一個 API(forEach),去實現另一個 ES5 的 API(reduce),這並沒什麼實際意義——這裏的 pollyfill 就是在不兼容 ES5 的狀況下,模擬的降級方案。此處很少作追究,由於根本目的仍是但願讀者對 reduce 有一個全面透徹的瞭解。

經過 Koa only 模塊源碼認識 reduce

經過了解並實現 reduce 方法,咱們對它已經有了比較深刻的認識。最後,咱們再來看一個 reduce 使用示例——經過 Koa 源碼的 only 模塊,加深印象:

var o = {
    a: 'a',
    b: 'b',
    c: 'c'
}
only(o, ['a','b'])   // {a: 'a',  b: 'b'}

該方法返回一個通過指定篩選屬性的新對象。

only 模塊實現:

var only = function(obj, keys){
    obj = obj || {}
    if ('string' == typeof keys) keys = keys.split(/ +/)
    return keys.reduce(function(ret, key) {
        if (null == obj[key]) return ret
        ret[key] = obj[key]
        return ret
    }, {})
}

小小的 reduce 及其衍生場景有不少值得咱們玩味、探究的地方。觸類旁通,活學活用是技術進階的關鍵。

compose 實現的幾種方案

函數式理念——這一古老的概念現在在前端領域「遍地開花」。函數式不少思想都值得借鑑,其中一個細節:compose 由於其巧妙的設計而被普遍運用。對於它的實現,從面向過程式到函數式實現,風格迥異,值得咱們探究。在面試當中,也常常有面試官要求實現 compose 方法,咱們先看什麼是 compose

compose 其實和前面提到的 pipe 同樣,就是執行一連串不定長度的任務(方法),好比:

let funcs = [fn1, fn2, fn3, fn4]
let composeFunc = compose(...funcs)

執行:

composeFunc(args)

就至關於:

fn1(fn2(fn3(fn4(args))))

總結一下 compose 方法的關鍵點:

  • compose 的參數是函數數組,返回的也是一個函數
  • compose 的參數是任意長度的,全部的參數都是函數,執行方向是自右向左的,所以初始函數必定放到參數的最右面
  • compose 執行後返回的函數能夠接收參數,這個參數將做爲初始函數的參數,因此初始函數的參數是多元的,初始函數的返回結果將做爲下一個函數的參數,以此類推。所以除了初始函數以外,其餘函數的接收值是一元的。

咱們發現,實際上,composepipe 的差異只在於調用順序的不一樣:

// compose
fn1(fn2(fn3(fn4(args))))
    
// pipe
fn4(fn3(fn2(fn1(args))))

即然跟咱們先前實現的 pipe 方法一模一樣,那麼還有什麼好深刻分析的呢?請繼續閱讀,看看還能玩出什麼花兒來。

compose 最簡單的實現是面向過程的:

const compose = function(...args) {
    let length = args.length
    let count = length - 1
    let result
    return function f1 (...arg1) {
        result = args[count].apply(this, arg1)
        if (count <= 0) {
            count = length - 1
            return result
        }
        count--
        return f1.call(null, result)
    }
}

這裏的關鍵是用到了閉包,使用閉包變量儲存結果 result 和函數數組長度以及遍歷索引,並利用遞歸思想,進行結果的累加計算。總體實現符合正常的面向過程思惟,不難理解。

聰明的同窗可能也會意識到,利用上文所講的 reduce 方法,應該能更函數式地解決問題:

const reduceFunc = (f, g) => (...arg) => g.call(this, f.apply(this, arg))
const compose = (...args) => args.reverse().reduce(reduceFunc, args.shift())

經過前面的學習,結合 callapply 方法,這樣的實現並不難理解。

咱們繼續開拓思路,「既然涉及串聯和流程控制」,那麼咱們還可使用 Promise 實現:

const compose = (...args) => {
    let init = args.pop()
    return (...arg) => 
    args.reverse().reduce((sequence, func) => 
      sequence.then(result => func.call(null, result))
    , Promise.resolve(init.apply(null, arg)))
}

這種實現利用了 Promise 特性:首先經過 Promise.resolve(init.apply(null, arg)) 啓動邏輯,啓動一個 resolve 值爲最後一個函數接收參數後的返回值,依次執行函數。由於 promise.then() 仍然返回一個 Promise 類型值,因此 reduce 徹底能夠按照 Promise 實例執行下去。

既然可以使用 Promise 實現,那麼 generator 固然應該也能夠實現。這裏給你們留一個思考題,感興趣的同窗能夠嘗試,歡迎在評論區討論。

最後,咱們再看下社區上著名的 lodash 和 Redux 的實現。

lodash 版本

// lodash 版本
var compose = function(funcs) {
    var length = funcs.length
    var index = length
    while (index--) {
        if (typeof funcs[index] !== 'function') {
            throw new TypeError('Expected a function');
        }
    }
    return function(...args) {
        var index = 0
        var result = length ? funcs.reverse()[index].apply(this, args) : args[0]
        while (++index < length) {
            result = funcs[index].call(this, result)
        }
        return result
    }
}

lodash 版本更像咱們的第一種實現方式,理解起來也更容易。

Redux 版本

// Redux 版本
function compose(...funcs) {
    if (funcs.length === 0) {
        return arg => arg
    }
    
    if (funcs.length === 1) {
        return funcs[0]
    }
    
    return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

總之,仍是充分利用了數組的 reduce 方法。

函數式概念確實有些抽象,須要開發者仔細琢磨,並動手調試。一旦頓悟,必然會感覺到其中的優雅和簡潔。

apply、bind 進階實現

面試中關於 this 綁定的相關話題現在已經「氾濫」,同時對 bind 方法的實現,社區上也有相關討論。可是不少內容尚不繫統,且存在一些瑕疵。這裏簡單摘錄我 2017 年年初寫的文章 從一道面試題,到「我可能看了假源碼」 來遞進討論。在《一網打盡 this》一課,咱們介紹過對 bind 的實現,這裏咱們進一步展開。

此處再也不贅述 bind 函數的使用,尚不清楚的讀者能夠自行補充一下基礎知識。咱們先來看一個初級實現版本:

Function.prototype.bind = Function.prototype.bind || function (context) {
    var me = this;
    var argsArray = Array.prototype.slice.call(arguments);
    return function () {
        return me.apply(context, argsArray.slice(1))
    }
}

這是通常合格開發者提供的答案,若是面試者能寫到這裏,給他 60 分。

先簡要解讀一下:

基本原理是使用 apply 進行模擬 bind。函數體內的 this 就是須要綁定 this 的函數,或者說是原函數。最後使用 apply 來進行參數(context)綁定,並返回。

與此同時,將第一個參數(context)之外的其餘參數,做爲提供給原函數的預設參數,這也是基本的「 curry 化」基礎。

上述實現方式,咱們返回的參數列表裏包含:argsArray.slice(1)它的問題在於存在預置參數功能丟失的現象。

想象咱們返回的綁定函數中,若是想實現預設傳參(就像 bind 所實現的那樣),就面臨尷尬的局面。真正實現「 curry 化」的「完美方式」是:

Function.prototype.bind = Function.prototype.bind || function (context) {
    var me = this;
    var args = Array.prototype.slice.call(arguments, 1);
    return function () {
        var innerArgs = Array.prototype.slice.call(arguments);
        var finalArgs = args.concat(innerArgs);
        return me.apply(context, finalArgs);
    }
}

但繼續探究,咱們注意 bind 方法中:bind 返回的函數若是做爲構造函數,搭配 new 關鍵字出現的話,咱們的綁定 this 就須要「被忽略」,this 要綁定在實例上。也就是說,new 的操做符要高於 bind 綁定,兼容這種狀況的實現:

Function.prototype.bind = Function.prototype.bind || function (context) {
    var me = this;
    var args = Array.prototype.slice.call(arguments, 1);
    var F = function () {};
    F.prototype = this.prototype;
    var bound = function () {
        var innerArgs = Array.prototype.slice.call(arguments);
        var finalArgs = args.concat(innerArgs);
        return me.apply(this instanceof F ? this : context || this, finalArgs);
    }
    bound.prototype = new F();
    return bound;
}

若是你認爲這樣就完了,其實我會告訴你說,高潮纔剛要上演。曾經的我也認爲上述方法已經比較完美了,直到我看了 es5-shim 源碼(已適當刪減):

function bind(that) {
    var target = this;
    if (!isCallable(target)) {
        throw new TypeError('Function.prototype.bind called on incompatible ' + target);
    }
    var args = array_slice.call(arguments, 1);
    var bound;
    var binder = function () {
        if (this instanceof bound) {
            var result = target.apply(
                this,
                array_concat.call(args, array_slice.call(arguments))
            );
            if ($Object(result) === result) {
                return result;
            }
            return this;
        } else {
            return target.apply(
                that,
                array_concat.call(args, array_slice.call(arguments))
            );
        }
    };
    var boundLength = max(0, target.length - args.length);
    var boundArgs = [];
    for (var i = 0; i < boundLength; i++) {
        array_push.call(boundArgs, '$' + i);
    }
    bound = Function('binder', 'return function (' + boundArgs.join(',') + '){ return binder.apply(this, arguments); }')(binder);
    
    if (target.prototype) {
        Empty.prototype = target.prototype;
        bound.prototype = new Empty();
        Empty.prototype = null;
    }
    return bound;
}

es5-shim 的實現到底在」搞什麼鬼「呢?你可能不知道,其實每一個函數都有 length 屬性。對,就像數組和字符串那樣。函數的 length 屬性,用於表示函數的形參個數。更重要的是函數的 length 屬性值是不可重寫的。我寫了個測試代碼來證實:

function test (){}
test.length  // 輸出 0
test.hasOwnProperty('length')  // 輸出 true
Object.getOwnPropertyDescriptor('test', 'length') 
// 輸出:
// configurable: false, 
// enumerable: false,
// value: 4, 
// writable: false

說到這裏,那就好解釋了:es5-shim 是爲了最大限度地進行兼容,包括對返回函數 length 屬性的還原。而若是按照咱們以前實現的那種方式,length 值始終爲零。所以,既然不能修改 length 的屬性值,那麼在初始化時賦值總能夠吧!因而咱們可經過 evalnew Function 的方式動態定義函數。可是出於安全考慮,在某些瀏覽器中使用 eval 或者 Function() 構造函數都會拋出異常。然而巧合的是,這些沒法兼容的瀏覽器基本上都實現了 bind 函數,這些異常又不會被觸發。上述代碼裏,重設綁定函數的 length 屬性:

var boundLength = max(0, target.length - args.length)

構造函數調用狀況,在 binder 中也有效兼容:

if (this instanceof bound) { 
    ... // 構造函數調用狀況
} else {
    ... // 正常方式調用
}
    
if (target.prototype) {
    Empty.prototype = target.prototype;
    bound.prototype = new Empty();
    // 進行垃圾回收清理
    Empty.prototype = null;
}

對比過幾版的 polyfill 實現,對於 bind 應該有了比較深入的認識。這一系列實現有效地考察了很重要的知識點:好比 this 的指向、JavaScript 閉包、原型與原型鏈,設計程序上的邊界 case 和兼容性考慮經驗等硬素質。

一道更好的面試題

最後,現現在在不少面試中,面試官都會以「實現 bind」做爲題目。若是是我,如今可能會規避這個很容易「應試」的題目,而是別出心裁,讓面試者實現一個 「call/apply」。咱們每每用 call/apply 模擬實現 bind,而直接實現 call/apply 也算簡單:

Function.prototype.applyFn = function (targetObject, argsArray) {
    if(typeof argsArray === 'undefined' || argsArray === null) {
        argsArray = []
    }
    
    if(typeof targetObject === 'undefined' || targetObject === null){
        targetObject = this
    }
    
    targetObject = new Object(targetObject)
    
    const targetFnKey = 'targetFnKey'
    targetObject[targetFnKey] = this
    
    const result = targetObject[targetFnKey](...argsArray)
    delete targetObject[targetFnKey]
    return result
}

這樣的代碼不難理解,函數體內的 this 指向了調用 applyFn 的函數。爲了將該函數體內的 this 綁定在 targetObject 上,咱們採用了隱式綁定的方法: targetObject[targetFnKey](...argsArray)

細心的讀者會發現,這裏存在一個問題:若是 targetObject 對象自己就存在 targetFnKey 這樣的屬性,那麼在使用 applyFn 函數時,原有的 targetFnKey 屬性值就會被覆蓋,以後被刪除。解決方案可使用 ES6 Sybmol() 來保證鍵的惟一性;另外一種解決方案是用 Math.random() 實現獨一無二的 key,這裏咱們再也不贅述。

實現這些 API 帶來的啓示

這些 API 的實現並不算複雜,卻能恰如其分地考驗開發者的 JavaScript 基礎。基礎是地基,是探究更深刻內容的鑰匙,是進階之路上最重要的一環,須要每一個開發者重視。在前端技術快速發展迭代的今天,在「前端市場是否飽和」,「前端求職火爆異常」,「前端入門簡單,錢多人傻」等衆說紛紜的浮躁環境下,對基礎內功的修煉就顯得尤其重要。這也是你在前端路上能走多遠、走多久的關鍵。

從面試的角度看,面試題歸根結底是對基礎的考察,只有對基礎爛熟於胸,才能具有突破面試的基本條件。

分享交流

本篇文章出自個人課程:前端開發核心知識進階 當中的一篇基礎部分章節。

感興趣的讀者能夠:

PC 端點擊瞭解更多《前端開發核心知識進階》

移動端點擊瞭解更多:

移動端點擊瞭解更多《前端開發核心知識進階

大綱內容:

image

Happy coding!

相關文章
相關標籤/搜索