有很多剛入行的同窗跟我說:「JavaScript 不少 API 記不清楚怎麼辦?數組的這方法、那方法老是傻傻分不清楚,該如何是好?操做 DOM 的方式今天記,明天忘,真讓人奔潰!」前端
甚至有的開發者在討論面試時,總向我抱怨:「面試官總愛糾結 API 的使用,甚至 jQuery 某些方法的參數順序都須要讓我說清楚!」node
我認爲,對於反覆使用的方法,全部人都要作到「機械記憶」,可以反手寫出。一些貌似永遠記不清的 API 只是由於用得不夠多而已。git
在作面試官時,我歷來不強求開發者準確無誤地「背誦」 API。相反,我喜歡從另一個角度來考察面試者:「既然記不清使用方法,那麼我告訴你它的使用方法,你來實現一個吧!」實現一個 API,除了能夠考察面試者對這個 API 的理解,更能體現開發者的編程思惟和代碼能力。對於積極上進的前端工程師,模仿並實現一些經典方法,應該是「屢見不鮮」,這是比較基本的要求。面試
本小節,我根據瞭解的面試題目和做爲面試官的經歷,挑了幾個典型的 API,經過對其不一樣程度,不一樣方式的實現,來覆蓋 JavaScript 中的部分知識點和編程要領。經過學習本節內容,期待你不只能領會代碼奧義,更應該學習觸類旁通的方法。npm
API 主題的相關知識點以下:編程
這個話題演變自今日頭條某部門面試題。當時面試官提問:「如何獲取文檔中任意一個元素距離文檔
document
頂部的距離?」
熟悉 jQuery 的同窗應該對 offset
方法並不陌生,它返回或設置匹配元素相對於文檔的偏移(位置)。這個方法返回的對象包含兩個整型屬性:top
和 left
,以像素計。若是可使用 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 類型的對象。這個對象表示一個矩形盒子,它含有:left
、top
、right
和 bottom
等只讀屬性。
請參考實現:
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.clientTop
,clientTop
是一個元素頂部邊框的寬度,不包括頂部外邊距或內邊距。
從這道題目看出,相比考察「死記硬背」 API,這樣的實現更有意義。站在面試官的角度,我每每會給面試者(開發者)提供相關的方法提示,以引導其給出最後的方案實現。
數組方法很是重要:由於數組就是數據,數據就是狀態,狀態反應着視圖。對數組的操做咱們不能陌生,其中 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,不然爲 1array
可選,調用 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
的實現:pipe(f, g, h)
是一個 curry 化函數,它返回一個新的函數,這個新的函數將會完成 (...args) => h(g(f(...args)))
的調用。即 pipe
方法返回的函數會接收一個參數,這個參數傳遞給 pipe
方法第一個參數,以供其調用。
const pipe = (...functions) => input => functions.reduce( (acc, fn) => fn(acc), input )
仔細體會 runPromiseInSequence
和 pipe
這兩個方法,它們都是 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
有一個全面透徹的瞭解。
經過了解並實現 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
其實和前面提到的 pipe
同樣,就是執行一連串不定長度的任務(方法),好比:
let funcs = [fn1, fn2, fn3, fn4] let composeFunc = compose(...funcs)
執行:
composeFunc(args)
就至關於:
fn1(fn2(fn3(fn4(args))))
總結一下 compose
方法的關鍵點:
compose
的參數是函數數組,返回的也是一個函數compose
的參數是任意長度的,全部的參數都是函數,執行方向是自右向左的,所以初始函數必定放到參數的最右面compose
執行後返回的函數能夠接收參數,這個參數將做爲初始函數的參數,因此初始函數的參數是多元的,初始函數的返回結果將做爲下一個函數的參數,以此類推。所以除了初始函數以外,其餘函數的接收值是一元的。咱們發現,實際上,compose
和 pipe
的差異只在於調用順序的不一樣:
// 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())
經過前面的學習,結合 call
、apply
方法,這樣的實現並不難理解。
咱們繼續開拓思路,「既然涉及串聯和流程控制」,那麼咱們還可使用 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
方法。
函數式概念確實有些抽象,須要開發者仔細琢磨,並動手調試。一旦頓悟,必然會感覺到其中的優雅和簡潔。
面試中關於 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
的屬性值,那麼在初始化時賦值總能夠吧!因而咱們可經過 eval
和 new 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 的實現並不算複雜,卻能恰如其分地考驗開發者的 JavaScript 基礎。基礎是地基,是探究更深刻內容的鑰匙,是進階之路上最重要的一環,須要每一個開發者重視。在前端技術快速發展迭代的今天,在「前端市場是否飽和」,「前端求職火爆異常」,「前端入門簡單,錢多人傻」等衆說紛紜的浮躁環境下,對基礎內功的修煉就顯得尤其重要。這也是你在前端路上能走多遠、走多久的關鍵。
從面試的角度看,面試題歸根結底是對基礎的考察,只有對基礎爛熟於胸,才能具有突破面試的基本條件。
本篇文章出自個人課程:前端開發核心知識進階 當中的一篇基礎部分章節。
感興趣的讀者能夠:
移動端點擊瞭解更多:
大綱內容:
Happy coding!