剖析 D3.js 中的 this 相關

ssssyoki發表於2019-02-27

前言

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>
複製程式碼
剖析 D3.js 中的 this 相關

點選以後我們看到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的經驗與心得,歡迎關注我的掘金賬號~

相關文章