徹底搞懂JavaScript怪異函數——bind
2020.4.30
事實上存在new.target這個變量,在函數中會指向構造函數。如果不是以new操作符調用的構造函數,new.target為undefined。因此我們可以通過new.target判斷函數是否是new操作符調用。不過各瀏覽器對bind的支持比對new.target要早,所以在bind函數的polyfill中使用new.target可能不太合適。
我們可能遇到過實現bind函數這樣的題目,但似乎並不存在完美模擬原生bind函數的可能。ECMAScript 2015中將bind創建的函數稱為exotic function object(怪異函數對象),這很適宜,因為它的確存在一些“怪異”之處。
在繼續之前我們需要先了解bind函數。這可以參考MDN的解釋:
bind() 方法創建一個新的函數,在 bind() 被調用時,這個新函數的 this 被指定為 bind() 的第一個參數,而其餘參數將作為新函數的參數,供調用時使用。
另外還需要了解new操作符的實現,同樣參考MDN的解釋:
- 創建一個空的簡單JavaScript對象(即
{}); - 鏈接該對象(即設置該對象的構造函數)到另一個對象;
- 將步驟1新創建的對象作為
this的上下文 ; - 如果該函數沒有返回對象,則返回
this。
第2步即為對象綁定隱式原型,將對象的__proto__屬性指向構造函數的prototype,而函數默認的prototype擁有constructor屬性指向構造函數,從而實現了所謂的“鏈接構造函數”。
這裡約定一下,bind()方法的實現函數叫bind函數,創建的新函數叫做綁定函數,被封裝的原函數叫做原函數。
bind函數至少有這些特性:
- 除非是被
new操作符調用,否則原函數執行環境中的this總是為bind函數被調用時傳遞的第一個參數; - 綁定函數的prototype為
undefined; - 通過
new操作符調用綁定函數時,原函數執行環境的this仍為new操作符創建的新對象,即bind函數的this綁定被忽略; - 通過
new操作符調用綁定函數返回的對象是原函數的實例。即:function func() {}var fBound = func.bind({});var foo = new fBound();foo instanceof func; // true
MDN推薦的bind函數的Polyfill如下(部分):
Function.prototype.bind = function(that) {
var target = this; //原函数
var args = Array.prototype.slice.call(arguments, 1); //绑定参数
var bound; //绑定函数
//实际上binder才是绑定函数,bound将其再次包装后返回
var binder = function () {
if (this instanceof bound) {
//如果是通过new操作符调用的绑定函数,则绑定函数执行环境的this指向new操作符创建的新对象,而这个对象的隐式原型指向构造函数bound的prototype
//new操作符调用,忽略bind函数绑定的this,以实际执行环境this调用原函数
var result = target.apply(
this,
args.concat(arguments)
);
if (Object(result) === result) {
//如果构造函数返回的是一个对象,返回该对象
return result;
}
return this;
} else {
//非new操作符调用,使用绑定的this
return target.apply(
that,
args.concat(arguments)
);
}
};
//计算bind函数调用时提供的绑定参数数量
var boundLength = Math.max(0, target.length - args.length);
var boundArgs = [];
for (var i = 0; i < boundLength; i++) {
boundArgs.push('$' + i);
}
//相当于返回binder。之所以用函数再包一层,应该是为了使函数的length拥有正确的值
bound = Function('binder', 'return function (' + boundArgs.join(',') + '){ return binder.apply(this,arguments); }')(binder);
//调整原型链,使绑定函数的实例仍然是能通过原函数的原型链检测(因为绑定函数实例的隐式原型(__proto__)的隐式原型指向原函数的prototype)
//如果原函数没有prototype,则绑定函数具有默认prototype,且不会被外界访问到(除非通过绑定函数的prototype属性)
if (target.prototype) {
//构造一个临时的空函数,防止绑定函数的prototype直接引用原函数的prototype。因为原函数的prototype对外界可见,具有不确定性。如果绑定函数被调用时传递一个隐式原型指向原函数prototype的this参数,直接引用原函数就会造成误判为new操作符调用
var Empty = function Empty() {};
//绑定函数.prototype->空函数实例;空函数实例.__proto__->原函数prototype
//从而实现了,通过new得到的绑定函数实例的原型链上有原函数的prototype(文章开头的特性4),但隐式原型为原函数prototype的对象不是绑定函数的实例,因为绑定函数的原型是一个内部的对象(Empty实例),不太可能作为外部某对象的隐式原型(除非是绑定函数被当作了构造函数[new操作]),从而在前面的binder函数中,根据判断绑定函数执行环境的this是否是绑定函数的实例,正确的判断出是否为new操作符调用的绑定函数
//当然这里的漏洞是,绑定函数的prototype并不是完全不可访问的,因为绑定函数是公开的,从而绑定函数的prototype也就可访问了。但这一般不会出问题,除非你写一些奇怪的代码(后面有分析到)
Empty.prototype = target.prototype;
bound.prototype = new Empty();
Empty.prototype = null;
}
return bound;
};
上面的實現已經接近完美了,幾乎也沒有更好的實現方案了。我們來看看它實現了文章開頭列出的哪些特性。
特性2,綁定函數的prototype為undefined。其實這個很容易實現,但為了實現識別new操作符,只好讓綁定函數擁有prototype。幸運的是,這對實際使用幾乎沒有影響。
特性3,通過new操作符調用綁定函數時忽略bind函數綁定的this。OK,通過new調用時一定可以識別到。
特性4,new操作符調用綁定函數返回的對象是原函數的實例。已實現,雖然原型鏈上多了一個節點,但並不影響原型鏈判斷。
原生bind函數實例的原型鏈:原函數prototype->Object.prototype->undefined
polyfill實現的bind函數實例的原型鏈:內部臨時函數Empty.prototype->原函數prototype->Object.prototype->undefined
特性1就耐人尋味了,顯然關鍵在於對new操作符調用的精確判斷。如果是new操作符調用,顯然綁定函數執行環境的this即綁定函數的實例,會被當做new操作符調用對待(忽略bind函數綁定的this)。但非new操作符調用的情況,是否一定能夠被當作一般情況對待?考慮下面的代碼:
function foo(name) {
this.name = name;
}
var obj = {};
var bar = foo.bind(obj);
const mock = Object.create(bar.prototype ? bar.prototype : null);
bar.call(mock, 'Jack');
console.log(obj.name);
var alice = new bar('Alice');
console.log(obj.name);
console.log(alice.name);
console.log(mock);
理想的輸出是:
Jack
Jack
Alice
[Object: null prototype]
原生bind函數的輸出符合預測,但polyfill實現的版本輸出:
undefined
undefined
Alice
foo { name: 'Jack' }
bar.call(Object.create(bar.prototype), 'Jack')被當作了new操作符調用。因此綁定函數忽略了綁定的this,而使用了傳入的this參數調用原函數,因此原函數中this.name = name,this指向mock,所以賦值操作發生在mock上而非obj。
bind函數被成為怪異對象,怪異就怪異在其內部機制無法完全用合法的JavaScript邏輯模擬(至少我沒有找到方法)。不過上面的實現已經可用了,最後的不一致行為其實屬於“明知故犯”的怪癖代碼,實際生產活動中完全可以杜絕。
參考
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function/bind