万神劫

万物天地为剑,神鬼妖邪为剑
劫波万渡,宇宙苍穹尽为剑
是为万神劫!

7条评论 2013-08-26

慎用 Underscore 的 _.bindAll(this)

关于 bindAll

Underscore 是我非常喜欢的一个 JS 库,提供了相当多方便的工具方法来简化前端开发
其中我印象最深,也是最喜欢的一个方法就是 bindAll (对于我等 Rubyist 来说,each/map/where 什么的早就不新鲜了)
因为它可以很好地解决 Javascript 里 this 的问题,尤其是编写 UI 组件时非常有用,比如:

var SomeUI = function(element) {
    _.bindAll(this);
    $(element).click(this.doSomething);
}

SomeUI.prototype.doSomething = function() {
    this.doAnother()
    // ...
}

这里通过 bindAll ,我们可以确保这个组件的所有方法执行时, this 都是指向自身的实例,从而简化代码书写

后来使用 Backbone 的 View 时这个做法更是成为了我的标配,因为 model 事件的监听回调函数默认 this 是指向 model 本身,需要额外传递第三个参数才能改变,这对我等懒人来说实在太难以接受,索性 bindAll 一下一了百了

var SomeView = Backbone.View.extend({
    initialize : function () {
        _.bindAll(this);
        this.model.on('change', this.render);
        this.model.on('xxx', this.xxx);
    }
});

问题产生

这样的好日子持续了很久,直到我开始使用 Marionette 掉进了坑里,这才发现 _.bindAll(this) 会导致很多意想不到的问题。
简单起见,请看这段代码

var Users = Backbone.Collection.extend({
    model : User,

    initialize : function () {
        _.bindAll(this);
    }
});

var users = new Users([
    {name : 'xxx'}
]);

这段代码在 Chrome 等高级浏览器下执行没有问题,可是 IE8 下却会报错,这是为啥呢?
其实原因就在那个 bindAll 上

原因分析

本来我们只是希望能够将 Users 类里定义的方法自动 bind 一遍,但是忽略了很重要的一点:

model 属性所对应的 User 其实是个 function ,所以它也被 bind 了

哎呀太愚蠢了,Javascript 里压根就没有类,只有 function 嘛!

但是为啥 IE 下会报错而 Chrome 下就没问题呢?
因为 _.bind 的功能等同于 ECMAScript5 的 Function.prototype.bind ,由于 Chrome 支持 ECMAScript5 ,这时候它会优先采用浏览器的原生方法。而针对 IE8 等老式浏览器,它会提供相应的 fallback 实现。
查了一下 MDN 上 bind 方法的文档 ,里面提到,如果对被 bind 过的 function 使用 new 操作符,this 是不会被改变的。
不幸的是,Underscore 1.4.4 里的 fallback 实现非常简单,没有考虑到 new 操作符的问题,于是就悲剧了。

解决方法

好吧,事已至此,该肿么办呢?我检查了一下 Underscore 的新版本,惊奇的发现 1.5.1 里面的 bind 已经修复了这个问题,能够与 new 操作符很好的配合了。但是同时,1.5.1 也将 bindAll 方法做了重大调整:

Removed the ability to call _.bindAll with no method name arguments. It's pretty much always wiser to white-list the names of the methods you'd like to bind.

也就是说,新版本已经不支持 _.bindAll(this) 这样的方式了,必须显式地指定所有需要 bind 的方法名称。
呵呵,真是给个萝卜再来一棒啊。但是仔细想想,bindAll 的这个修改还是很有道理的,因为实际上调用 _.bindAll(this) 时,很可能你自己都不清楚发生了什么,这样很容易发生一些意外,并且给解决 bug 造成困难,所以还是指明参数比较好。

几点补充

PS1

说下 Marionette 的坑,因为 Marionette 的 CollectionView/CompositeView 必须传入其他 View 类的字面量作为属性,所以如果习惯性地使用 _.bindAll(this) 就会引起问题,原理同上面的错误例子。

PS2

Backbone 1.0 里面已经提供了 listenTo 方法了,使用它不会有 this 的问题。并且官方推荐在 View 中使用 listenTo,因为这样调用 View 的 remove 方法时,可以自动停止监听所有 model ,以免引起内存泄露

var SomeView = Backbone.View.extend({
    initialize : function () {
        // 不需要 bindAll ,render 执行时的 this 就是当前实例
        this.listenTo(this.model, 'change', this.render);
    }
});

PS3

有趣的是,我查了下 Underscore 的代码,原来关于 bind 是否要兼容 new 操作符,曾经还有过一次反复,有兴趣的可以看看

https://github.com/jashkenas/underscore/commit/e6576cd83e82e8c2a4813eadd978a1abf6a69a79
https://github.com/jashkenas/underscore/commit/ce3d1aec306999aa94926a42cad1daf7eb87a36f

comments powered by Disqus