彻底搞懂JavaScript怪异函数——bind

mattuy 2020年03月26日 586次浏览

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的解释:

  1. 创建一个空的简单JavaScript对象(即{});
  2. 链接该对象(即设置该对象的构造函数)到另一个对象;
  3. 将步骤1新创建的对象作为this的上下文 ;
  4. 如果该函数没有返回对象,则返回this

第2步即为对象绑定隐式原型,将对象的__proto__属性指向构造函数的prototype,而函数默认的prototype拥有constructor属性指向构造函数,从而实现了所谓的“链接构造函数”。

这里约定一下,bind()方法的实现函数叫bind函数,创建的新函数叫做绑定函数,被封装的原函数叫做原函数

bind函数至少有这些特性:

  1. 除非是被new操作符调用,否则原函数执行环境中的this总是为bind函数被调用时传递的第一个参数;
  2. 绑定函数的prototype为undefined
  3. 通过new操作符调用绑定函数时,原函数执行环境的this仍为new操作符创建的新对象,即bind函数的this绑定被忽略;
  4. 通过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 = namethis指向mock,所以赋值操作发生在mock上而非obj

bind函数被成为怪异对象,怪异就怪异在其内部机制无法完全用合法的JavaScript逻辑模拟(至少我没有找到方法)。不过上面的实现已经可用了,最后的不一致行为其实属于“明知故犯”的怪癖代码,实际生产活动中完全可以杜绝。

参考

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function/bind

https://zhuanlan.zhihu.com/p/38968174