剖析 D3.js 中的 this 相关

2,358 阅读4分钟

前言

D3.js作为著名的数据可视化框架,在自定义图表领域是无可争议的No.1。使用频率最高的api当属d3.select,因此它被称为"svg界的jquery"(目前已经支持canvas)。jquery中有this,那么D3.js中当然也有this。比如如下代码:

d3.selectAll("p").on("click", function() {
    d3.select(this).style("color", "red");
});

上述代码是一个简单的事件绑定和响应。其中的this指向哪里呢?
(以下分析与结论均基于v4版本。)

javascript中的this

这真是一个老掉牙的话题了,随便百度谷歌一下应该就会有无数篇文章了。简单来说this指向调用它的对象,仅此而已。其他的本文不再也没必要赘述啦。

D3.js中的this

常规事件中this的指向及实现

继续完善上述示例代码,并打印以下this

<body>
    <p>one</p>
    <p>two</p>
    <p>three</p>
    <p>four</p>

    <script src="https://d3js.org/d3.v4.min.js"></script>
    <script>
        d3.selectAll("p").on("click", function() {
            console.log(this);
            d3.select(this).style("color", "red");
        });    
    </script>
</body>

点击以后我们看到this指向的就是DOM,与document.getElementById()这样的方法返回的是同样的结果。那么D3是如何让this指向DOM的呢?

这就要求助于源码了。D3.js的源码阅读起来非常舒服,不像React那样找一个函数要跳很大几段或者横跨多个文件,反而更像诗一样一行一行写成,不过也与其本身的简洁的设计思想有关。我们看下selection/on.js的源码:

function(typename, value, capture) {
    var typenames = parseTypenames(typename + ""), i, n = typenames.length, t;
    
    on = value ? onAdd : onRemove;
    if (capture == null) capture = false;
        for (i = 0; i < n; ++i) this.each(on(typenames[i], value, capture));
        return this;
    }

typenames是一个将输入的事件类型字符串进行格式化的函数,我们暂时不用管它。与addEventListener类似,value参数即为传入的listener function。通过三元表达式的判断,on将被赋值onAdd,我们看下onAdd的实现:

function onAdd(typename, value, capture) {
    var wrap = filterEvents.hasOwnProperty(typename.type) ? filterContextListener : contextListener;
    return function(d, i, group) {
        var on = this.__on, o, listener = wrap(value, i, group);
        if (on) for (var j = 0, m = on.length; j < m; ++j) {
            if ((o = on[j]).type === typename.type && o.name === typename.name) {
                this.removeEventListener(o.type, o.listener, o.capture);
                this.addEventListener(o.type, o.listener = listener, o.capture = capture);
                o.value = value;
                return;
            }
        }
        this.addEventListener(typename.type, listener, capture);
        o = {type: typename.type, name: typename.name, value: value, listener: listener, capture: capture};
        if (!on) this.__on = [o];
        else on.push(o);
    };
}

onAdd返回一个函数,首先会将type,name,value等参数作为对象存在变量o中,如果一个DOM元素绑定了多个事件,那么将这些数据集o依次存入数组内。接着对数组on进行遍历,依次调用addEventListener方法。

分析到这里我们知道了,selection.on(typenames[, listener[, capture]])方法实际上就是调用原生的addEventListener,而根据MDN文档的内容,listener中的this默认指向绑定事件的元素。所以对于上述的示例代码,我们可以简写成这样:

addEventListener('click',function(){
    // ...
    console.log(this)
})

综上可以得出这样的结论:D3.js事件监听函数中的this与原生事件相同,指向绑定对应事件的DOM元素。

D3.js的拖拽事件与this

既然事件都是用类似addEventListener来实现的,那D3.js中常用的drag事件是不是也是addEventListener(drag,fn)的形式去实现呢?阅读下v4文档答案是否定的:

d3.selectAll(".node").call(d3.drag().on("start", started));

很明显比原生的写法麻烦了许多,而且居然有call方法,我们知道call是用来改变this的指向,但传入call的参数似乎又跟this没什么关系,为什么要这样写呢?

最开始这个问题我也思索了很久,从未见过call方法这么用的场景。直到我打开源码,发现原来作者很调皮的把call方法重写了,此call非彼call,它的作用更像是唤起(如果作者把这个方法命名为invoke 我就不用走弯路了)。那么看下call.js的实现:

function() {
    var callback = arguments[0];
    arguments[0] = this;
    callback.apply(null, arguments);
    return this;
}

很简单,把上述代码的d3.drag().on("start", started)赋值给callback,再把此时的this,也就是d3.selectAll('node')中每一个node赋值给arguments[0],然后使用apply方法将arguments作为参数传入callback中。这样做的好处是什么呢?

举个例子,我们想基于D3.js设计一个设置class属性的函数,可能会这么写:

function setClass(selection,class1,class2){
    selection.attr('class1',class1);
    selection.attr('class2',class2);
};
setClass(d3.selectAll("div"), "header", "footer");

现在有了重写的call方法,我们就可以使用更快捷的链式调用写法:

d3.selectAll('div').call(setClass,'header','footer');

依据上面对call函数的分析我们可以观察到,setClass赋值给了callbackd3.selectAll('div')赋值给了arguments[0],接着将d3.selectAll('div')headerfooter作为参数传入setClass,这样就实现了第一段代码直接调用setClass函数的逻辑。可以说,call方法是作者利用this特性而设计的语法糖。

总结

上述内容主要记述和讲解了关于D3.js中this的主要使用场景。毕竟是发布于2011年的框架,那时候这样数据驱动的框架还是非常新颖的,但和近几年的MVVM等思潮相比,D3.js的学习和开发成本确实高了不少。在掘金上D3.js相关资料少得可怜,近期我会多分享几篇对于D3.js的经验与心得,欢迎关注我的掘金账号~