Codelet Keep code simple stupid

JavaScript绑定函数

在JS中,我们经常需要指定函数的this值,通常我们会用call/apply这样的函数为this绑定值, 除此之外函数还可以通过bind方法绑定this,现在就来深入分析一下bind函数。

先看下面的例子

function test () {}

test.call(obj, 0, 1)
test.apply(obj, [0, 1])

var fn = test.bind(obj, 0, 1)
fn()

可见,不同于call/apply在调用时指定thisbind完成绑定后会返回一个函数,当调用此函数时 原函数才会执行,这种方式很适合为回调函数绑定上下文,因此在Web环境中被广泛的使用。

之前我一直以为bind的实现并不困难,在我看来它应该差不多是这样的:

Function.prototype.bind = function (thisArg) {
  var that = this
  var args = Array.prototype.slice.call(arguments, 1)
  return function () {
    var rest = Array.prototype.slice.call(arguments)
    return that.apply(thisArg, args.concat(rest))
  }
}

但后来看了You-Dont-Know-JS才发现原来事情并不简单,下面就来解释为什么

还记得之前讲过的构造函数变长参数调用的问题吗?

var arg = [1, 2, 3];
var t = new (Function.prototype.bind.apply(Test, [null].concat([0], arg)))();

可以简化为

var t = new (Test.bind(null, 0, 1, 2, 3))

发现了问题吗?如果通过new关键字调用bind返回的函数,按照我之前的写法t应该是个空对象{} 才对呀,可是为什么会执行原函数的绑定呢?看来bind里面针对new有特殊的处理,下面就来分析一下

MDN上对于构造函数绑定有如下说明:

Bound functions are automatically suitable for use with the new operator to construct new instances created by the target function. When a bound function is used to construct a value, the provided this is ignored. However, provided arguments are still prepended to the constructor call.

bind函数的polyfill代码如下:

if (!Function.prototype.bind) {
  Function.prototype.bind = function(oThis) {
    if (typeof this !== 'function') {
      // closest thing possible to the ECMAScript 5
      // internal IsCallable function
      throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
    }

    var aArgs   = Array.prototype.slice.call(arguments, 1),
        fToBind = this,
        fNOP    = function() {},
        fBound  = function() {
          return fToBind.apply(this instanceof fNOP
                 ? this
                 : oThis,
                 aArgs.concat(Array.prototype.slice.call(arguments)));
        };

    if (this.prototype) {
      // Function.prototype doesn't have a prototype property
      fNOP.prototype = this.prototype;
    }
    fBound.prototype = new fNOP();

    return fBound;
  };
}
  1. 首先检查自身是否callable
  2. 然后得到绑定参数
  3. 最后是创建并返回绑定后的函数fBound

其中的关键就在于第三步中的fBound以及fNOP的作用,又可以分为两个阶段:

  1. 绑定时 设置fNOP的原型为原函数的原型,并设置fBound的原型,也就是说通过fBound构造产生的实例 具有原型链fBound->fNOP==this,也就是说fBound原型继承自fNOP

  2. 调用时 通过this instanceof fNOP判断是否是new调用,如果是则忽略指定的oThis,而是以new 创建的新对象作为this调用原函数初始化,这就是bind对于new的特殊处理

好了,过程大致梳理清楚了,但是还有一个疑问,那就是为什么一定要通过fNOP继承呢,调用时直接通过 this instanceof fBound一样可以区分构造函数调用啊,至于原型也可以通过Object.create() 函数来设置,也就是下面这样

var fToBind = this
var fBound  = function () {
  return fToBind.apply(this instanceof fBound
         ? this
         : oThis)
}

fBound.prototype = Object.create(this.prototype)

return fBound

因为IE9以下不支持Object.create()函数,所以还需要一个polyfill,其中对于对象隐式原型的设置 其实也是通过一个dummy function来实现的,这样一来fNOP的意义就清楚了

if (typeof Object.create !== "function") {
  Object.create = function (proto, propertiesObject) {
    if (typeof proto !== 'object' && typeof proto !== 'function') {
      throw new TypeError('Object prototype may only be an Object: ' + proto);
    } else if (proto === null) {
      throw new Error("This browser's implementation of Object.create is a shim and doesn't support 'null' as the first argument.");
    }

    if (typeof propertiesObject != 'undefined') {
      throw new Error("This browser's implementation of Object.create is a shim and doesn't support a second argument.");
    }

    function F() {}
    F.prototype = proto;

    return new F();
  };
}

fNOP用于以兼容的方式设置对象的隐式原型

还有一个疑问,为什么MDN用的是this instanceof fNOP,而不是this instanceof fBound呢? 这样明显有一个问题,那就是instanceof检查的‘区间’变大了,比如像下面这样:

function Test () {
  console.log(this.foo)
}

var t = new Test() // undefined
t.foo = 'instance'

var bindTest = Test.bind({
  foo: 'strong'
})

bindTest.call({   // strong
  foo: 'explicit'
})
bindTest.call(t)  // instance

可见,如果使用原函数的实例call绑定函数,强绑定会失效,造成不一致的行为,所以在这一块MDN可能 实现的并不好,我们还可以参考es5-shimbind 实现,其中就是用的this instanceof fBound,这样更严格一些。

注意: 其实这个polyfill还有一些问题,首先bind方式构造的实例会被添加一个额外的fBound原型,也就是 说bind实际会产生一个子类;其次创建的绑定函数具有prototype,而正确的绑定应该是没有的;还有 绑定函数的length属性始终为0,而不是原函数的形参个数。

function Test () {}
var bindTest = Test.bind({})

var t0 = new Test()
var t1 = new bindTest()

t0 instanceof Test     // true
t1 instanceof bindTest // true
t1 instanceof Test     // true
t0 instanceof bindTest // false

通过上面的代码,可以得知bindTestTest的子类,因此t0不是bindTest的实例,但如果使用 的是原生bind实现,则这两者是平级的。

其实bind的标准实现是很复杂的,polyfill只满足了大多数情况下的功能,我们平常使用时还是要多用 正常用法,少用黑魔法,避免掉到坑里。

参考资料:
[1] MDN - Function bind
[2] MDN - Object create