今天想談談一道前端面試題,我做面試官的時候經常喜歡用它來考察面試者的基礎是否紮實,以及邏輯、思維能力和臨場表現,題目是:“模擬實現ES5中原生bind函式”。
也許這道題目已經不再新鮮,部分讀者也會有思路來解答。社群上關於原生bind的研究也很多,比如用它來實現函式“顆粒化(currying)”,
或者“反顆粒化(uncurrying)”。
但是,我確信有很多細節是您注意不到的,也是社群上關於這個話題普遍缺失的。
這篇文章面向有較牢固JS基礎的讀者,會從最基本的理解入手,一直到分析ES5-shim實現bind原始碼,相信不同程度的讀者都能有所收穫。
也歡迎大家與我討論。
bind函式究竟是什麼?
在開啟我們的探索之前,有必要先明確一下bind到底實現了什麼:
1)簡單粗暴地來說,bind是用於繫結this指向的。(如果你還不瞭解JS中this的指向問題,以及執行環境上下文的奧祕,這篇文章暫時就不太適合閱讀)。
2)bind使用語法:
fun.bind(thisArg[, arg1[, arg2[, ...]]])
bind方法會建立一個新函式。當這個新函式被呼叫時,bind的第一個引數將作為它執行時的this,之後的一序列引數將會在傳遞的實參前傳入作為它的引數。本文不打算科普基礎,如果您還不清楚,請參考MDN內容。
3)bind返回的繫結函式也能使用new操作符建立物件:這種行為就像把原函式當成構造器。提供的this值被忽略,同時呼叫時的引數被提供給模擬函式。
初級實現
瞭解了以上內容,我們來實現一個初級的bind函式Polyfill:
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進行模擬。函式體內的this,就是需要繫結this的例項函式,或者說是原函式。最後我們使用apply來進行引數(context)繫結,並返回。
同時,將第一個引數(context)以外的其他引數,作為提供給原函式的預設引數,這也是基本的“顆粒化(curring)”基礎。
初級實現的加分項
上面的實現(包括後面的實現),其實是一個典型的“Monkey patching(猴子補丁)”,即“給內建物件擴充套件方法”。所以,如果面試者能進行一下“嗅探”,進行相容處理,就是錦上添花了,我會給10分的附加分。
Function.prototype.bind = Function.prototype.bind || function (context) {
...
}
顆粒化(curring)實現
上述的實現方式中,我們返回的引數列表裡包含:atgsArray.slice(1),他的問題在於存在預置引數功能丟失的現象。
想象我們返回的繫結函式中,如果想實現預設傳參(就像bind所實現的那樣),就面臨尷尬的局面。真正實現顆粒化的“完美方式”是:
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(contenxt, finalArgs);
}
}
如果面試者能夠給出這樣的答案,我內心獨白會是“不錯啊,貌似你就是我要找的那個TA~”。但是,我們注意在上邊bind方法介紹的第三條提到:bind返回的函式如果作為建構函式,搭配new關鍵字出現的話,我們的繫結this就需要“被忽略”。
建構函式場景下的相容
有了上邊的講解,不難理解需要相容建構函式場景的實現:
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.contact(innerArgs);
return me.apply(this instanceof F ? this : context || this, finalArgs);
}
bound.prototype = new fNOP();
return bound;
}
如果面試者能夠寫成這樣,我幾乎要給滿分,會幫忙聯絡HR談薪酬了。當然,還可以做的更加嚴謹。
更嚴謹的做法
我們需要呼叫bind方法的一定要是一個函式,所以可以在函式體內做一個判斷:
if (typeof this !== "function") {
throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
}
做到所有這一切,我會很開心的給滿分。其實MDN上有個自己實現的polyfill,就是如此實現的。
另外,《JavaScript Web Application》一書中對bind()的實現,也是如此。
故事貌似要畫上休止符了——
一切還沒完,高潮即將上演
如果你認為這樣就完了,其實我會告訴你說,高潮才剛要上演。曾經的我也認為上述方法已經比較完美了,直到我看了es5-shim原始碼(已適當刪減):
bind: 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;
}
看到了這樣的實現,心中的困惑太多,不禁覺得我看了“假原始碼”。但是仔細分析一下,剩下就是一個大寫的 。。。服!
這裡先留一個懸念,不進行原始碼分析。讀者可以自己先研究一下。如果想看原始碼分析,點選這篇文章的後續-原始碼解讀。
總結
通過比對幾版的polyfill實現,對於bind應該有了比較深刻的認識。作為這道面試題的考察點,肯定不是讓面試者實現低版本瀏覽器的向下相容,因為我們有了es5-shim,es5-sham處理相容性問題,並且無腦相容我也認為是歷史的倒退。
回到這道題考查點上,他有效的考察了很重要的知識點:比如this的指向,JS的閉包,原型原型鏈功力,設計程式上的相容考慮等等硬素質。
在前端技術快速發展迭代的今天,在“前端市場是否飽和”“前端求職火爆異常”“前端入門簡單,錢多人傻”的浮躁環境下,對基礎內功的修煉就顯得尤為重要,這也是你在前端路上能走多遠、走多久的關鍵。
PS:百度知識搜尋部大前端繼續招兵買馬,有意向者火速聯絡。。。