[譯]D3.js 之 d3-selection 原理
譯者注
原文: 來自 D3.js 作者 Mike Bostock 的How Selections Works
譯者: ssthouse
譯文
在前一篇文章中, 我介紹了關(guān)于 D3 selection 的基礎(chǔ), 這些基礎(chǔ)足以讓你開始使用 D3 selection.
在這篇文章中, 我將介紹 d3-selection 的實現(xiàn)原理. 本文可能需要更長的時間來閱讀, 但它能揭開 selection 的原理 并讓你能真正掌握數(shù)據(jù)驅(qū)動文本的思想(D3的思想)
本文會介紹 selection 內(nèi)部的工作原理而不是 selection 的設(shè)計動機, 所以你剛開始可能會對為什么使用 selection 這種模式感到疑惑.
但等你讀到本文結(jié)尾時, 你自然會明白 selection 如此設(shè)計的原因.
D3 是一個 用于數(shù)據(jù)可視化的庫, 所以本文也用可視化的方式, 結(jié)合著文字對selection原理進行講解.
[圖片上傳失敗...(image-96f3a4-1530241621119)]
我會用圓角矩形, 比如 thing
表示 JavsScript 的各種對象, 從 object ({foo:16}) 到 基礎(chǔ)數(shù)據(jù)類型 ("hello"), 到數(shù)組 ([1,2,3]) 再到 DOM 元素. 不同種類的對象會用不同的顏色來區(qū)分.
對象之間的關(guān)系會用灰色的線來表示, 比如一個包含數(shù)字 42 的數(shù)組會表示成這樣:
var array = [42]
[圖片上傳失敗...(image-162abd-1530241621119)]
大部分情況下, 圖像對應的代碼會出現(xiàn)在圖片的上方. 你可以訪問這個網(wǎng)站, 并打開調(diào)試窗口對文中的代碼進行試驗, 這樣能幫助你更好的理解本文.
現(xiàn)在, 讓我們開始!
Array 的子類
可能有人和你說過: selection 就是 DOM 元素組成的數(shù)組. 但事實并不是這樣, selection 是 array 的子類,這個子類提供了一些操作選中元素的方法 (比如設(shè)置屬性: selection.attr, 設(shè)置樣式: selection:style
). selection 同樣繼承了 array 的一些方法, 比如 array.forEach
, array.map
. 然而, 你并不會經(jīng)常使用這些從 array 繼承來的方法, 因為 D3 提供了一些方便的替代方法(比如 selection.each
). 并且, 有一些 array 的方法為了符合 selection 的邏輯而被 overridden, 比如 selection.filter
和 selection.sort
.
Group 元素
另一個 selection 不是 DOM 元素數(shù)組的原因是: selection 是 group 的數(shù)組, 而 group 才是 DOM 元素的數(shù)組. 舉個例子, d3.select
返回的 selection 包含了一個 group, 而這個 group 包含了選中的 body 元素:
var selection = d3.select('body')
[圖片上傳失敗...(image-44237f-1530241621119)]
在 JavaScript 控制臺, 嘗試運行下面的命令并查看 selection[0] ==> group 和 元素 selectio[0][0]
. 雖然 D3 支持這種通過數(shù)組下標訪問元素的方式, 但是你很快就會意識到用 selection.node
會更好.
相似的, d3.selectAll 也會返回一個 group, 這個 group 中會有若干個元素:
d3.selectAll('h2')
[圖片上傳失敗...(image-9a5354-1530241621119)]
d3.select 和 d3.selectAll 都是返回的一個 group. 唯一獲得包含多個 group 的 selection 的方法是 selection.selectAll . 比如, 如果你選中所有的 table row, 接著再選中這些 row 的 cell:
d3.selectAll('tr').selectAll('td')
[圖片上傳失敗...(image-9faa08-1530241621120)]
當運行上面代碼的第二個 selectAll 時, 前面 d3.selectAll('tr')
得到的 selection 中, 每一個元素都將變成新 selection 中的一個 group; 每個 group 都會包含老的元素中符合條件的所有子元素. 所以, 如果 table 中每個 td 都包含有一個 span 的話, 我們調(diào)用下面的代碼, 會得到:
d3.selectAll('tr')
.selectAll('td')
.selectAll('span')
[圖片上傳失敗...(image-62d71a-1530241621120)]
每一個 group 都有一個 parentNode 屬性, 這個屬性存儲了 group 中所有元素的父節(jié)點. 父節(jié)點屬性會在 group 被創(chuàng)建時就被賦值. 因此, 如果你調(diào)用 d3.selectAll("tr").selectAll("td")
, 返回的 group 數(shù)組, 他們的父節(jié)點就是 tr. 而 d3.select 和 d3.selectAll 返回的 group, 他們的父節(jié)點就是 html.
通常來說, 你完全不用在意 selection 是由 group 組成的這個事實. 當你對 selection 調(diào)用 selection.attr 或者 selection.style 的時候, selection 中的所有 group 的所有子元素都會被調(diào)用. 而 group 存在的唯一影響是: 你在 selection.attr('attrName', function(data, i))
時, 傳遞的 function(data, i) 中, 第二個參數(shù) i 是元素在 group 中的索引而不是在整個 selection 中的索引.
select 為何不涉及 group
只有 selectAll
會涉及到 group 元素, select
會保留當前已有的 group. select 方法之所以不同, 是因為在老的 selection 中的每個元素都只會在新的 selection 中對應一個新的元素. 因此 select 操作會直接把數(shù)據(jù)從父元素傳遞給子元素 (因此也根本沒有 data-join 的過程)
為了方便使用, append 方法和 insert 方法都被掛載到了 selection 上, 這兩個方法都會自動維護 group 的結(jié)構(gòu), 并且自動傳遞數(shù)據(jù). 比如我們現(xiàn)在有一個有四個 section 節(jié)點的頁面:
d3.selectAll('section')
[圖片上傳失敗...(image-1f7b1a-1530241621120)]
如果你調(diào)用下面的方法, 會為每一個 section 添加一個 p 元素, 你會得到一個有四個 p 元素的 group:
d3.selectAll('section').append('p')
[圖片上傳失敗...(image-a13bcf-1530241621120)]
需要注意的是, 現(xiàn)在這個 selection 的父節(jié)點仍然是 html. 因為 selection.selectAll 還沒有被調(diào)用, 所以父節(jié)點沒有發(fā)生變化.
空元素
group 中可以保存 Null 元素, 用來聲明元素的缺失. Null 會被大部分的操作所忽略, 比如: D3 會在 selection.attr 和 selection.style 的時候自動忽略 Null 元素.
Null 元素會在 selection.select 無法找到符合要求的子元素時被創(chuàng)建. 因為 select 方法會維護 group 的結(jié)構(gòu), 所以它會在缺失元素的地方填上 Null. 比如下面這個例子, 四個 section 中只有兩個有 aside 元素:
d3.selectAll('section').select('aside')
[圖片上傳失敗...(image-475661-1530241621120)]
雖然在大部分情況下, 你完全可以忽略 group 中的 Null 元素, 但是記住 Null 元素是確實存在于 group 的結(jié)構(gòu)當中的, 并且他們會在計算 index 時被考慮進來.
綁定數(shù)據(jù)
data 并不是保存在 selection 中的一個屬性, 這一點可能會讓你感到驚訝, 但確實如此. data 并不是 selection 的一個屬性, 而是被保存為 DOM 元素的一個屬性.
這就意味著, 當你使用 selection.data 綁定數(shù)據(jù)時, 其實數(shù)據(jù)是被綁定到了 DOM 元素上. data 會被賦值給 DOM 元素的 __data__
屬性. 如果一個 DOM 元素沒有 __data__
屬性, 就表明它沒有被綁定數(shù)據(jù). 所以 selection 是臨時性的, 但數(shù)據(jù)是被持久化在 DOM 里的, 你可以重新創(chuàng)建 selection, 而你的 selection 中的 DOM 元素仍會保有它之前被綁定的數(shù)據(jù).
數(shù)據(jù)的綁定可以通過以下幾種方式實現(xiàn), 接下來我們會分別講解這三種方式:
- 給每一個單獨的 DOM 元素調(diào)用 selection.datum
- 從父節(jié)點中繼承來數(shù)據(jù), 比如: append , insert , select
- 調(diào)用 selection.data() 方法
- 給每一個單獨的 DOM 元素調(diào)用
selection.datum
因為有 selection.datum 方法的存在, 你不需要手動的去給 __data__
屬性賦值, 雖然 selection.datum 內(nèi)部就是這樣實現(xiàn)的:
document.body.__data__ = 42
[圖片上傳失敗...(image-6c6c3e-1530241621120)]
使用 D3 的方式來達到同樣的效果:
d3.select('body').datum(42)
- 從父節(jié)點中繼承來數(shù)據(jù), 比如: append, insert, select
[圖片上傳失敗...(image-693939-1530241621120)]
如果我們現(xiàn)在向 body 中 插入一個 h1 元素, h1 元素就會自動繼承 body 的數(shù)據(jù):
d3.select('body')
.datum(42)
.append('h1')
[圖片上傳失敗...(image-748392-1530241621120)]
- 調(diào)用 selection.data
最后我們來看 selection.data , 講解這個方法會引入 d3 中非常重要的 data-join 思想. 但在我們講解這個思想之前, 我們需要首先回答一個更加基本的問題: 什么是數(shù)據(jù) ?
什么是數(shù)據(jù)?
在 D3 中, 數(shù)據(jù)可以是裝有基礎(chǔ)數(shù)據(jù)類型數(shù)據(jù)的數(shù)組, 比如下面這個:
var numbers = [4, 5, 18, 23, 42]
或者是對象數(shù)組:
var letters = [
{ name: 'A', frequency: 0.08167 },
{ name: 'B', frequency: 0.01492 },
{ name: 'C', frequency: 0.0278 },
{ name: 'D', frequency: 0.04253 },
{ name: 'E', frequency: 0.12702 }
]
甚至是矩陣(由數(shù)組組成的數(shù)組):
var matrix = [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]]
你可以通過 selection 來描述數(shù)據(jù)和可視化圖形之間的關(guān)系. 下面我們來具體講解. 我們先創(chuàng)建一個有 5 個數(shù)字的數(shù)組:
[圖片上傳失敗...(image-561e6f-1530241621120)]
就像 selection.style 可以傳入一個普通的 string (例: "red") 或者傳入一個返回 string 的 function (例: function(d) => d.color
) 一樣, selection.data 也可以接受這兩種參數(shù).
然而, 和其他 selection 的方法不同, selection.data 是為每一個 group 定義了數(shù)據(jù), 而不是為每一個 DOM 元素定義數(shù)據(jù): 對于 group 來說, 數(shù)據(jù)應該是一個數(shù)組或者是一個返回數(shù)組的 function. 因此, 一個有多個 group 的 selection 其對應的數(shù)據(jù)也應該是一個包含多個子數(shù)組的數(shù)組.
[圖片上傳失敗...(image-b7fbec-1530241621120)]
上圖中, 藍色的線條表示 data() 方法返回的是多個數(shù)組. 你傳入 selection.data() 的 function 會有兩個參數(shù): parentNode 和 groupIndex. 然后我們根據(jù)這兩個參數(shù), 返回對應的數(shù)據(jù). 因此,這里傳入的 function 相當于是持有父級的數(shù)據(jù), 然后根據(jù) parentNode 和 groupIndex 將父級數(shù)據(jù)拆分為每個 group 的子級數(shù)據(jù).
selection.data(function(parentNode, groupIndex) {
return data[groupIndex]
})
對于只有一個 group 的 selection, 你可以直接傳入 group 對應的數(shù)組數(shù)據(jù)即可. 只有當你遇到需要處理多個 group 的情況時, 你才需要一個 function 來為不同的 group 返回不同的數(shù)組數(shù)據(jù).
data-join 的思想
現(xiàn)在, 我們終于可以開始討論 d3-selection 的核心思想了.
為了綁定 data 到 DOM 元素, 我們必須知道哪一個數(shù)據(jù)是對應的哪一個 DOM 元素. 這在D3中是通過比較 key 值來實現(xiàn)的. 一個 key 其實就是一個簡單的字符串, 就比如一個名字. 當一個數(shù)據(jù)和一個 DOM 節(jié)點的 key 值相同時, 我們就認為這個數(shù)據(jù)和這個 DOM 元素是綁定的.
最簡單的指定 key 值的方法是使用索引: 第一個數(shù)據(jù)和第一個 DOM 元素會被賦予 key 值 "0", 第二個會被賦予 "1", 以此類推. 將一個數(shù)字數(shù)組和一個 key 值匹配的 DOM 元素數(shù)組進行 join 操作, 效果如圖所示:
[圖片上傳失敗...(image-69bc4e-1530241621120)]
下面的代碼得到的綁定好數(shù)據(jù)的 selection:
d3.selectAll('div').data(numbers)
[圖片上傳失敗...(image-dcad1e-1530241621120)]
如果你的數(shù)據(jù)和 DOM 元素的順序恰好相同(或者對順序并不在意)時, 通過下標索引作為 key 值是非常方便的. 但是, 一旦數(shù)據(jù)的順序發(fā)生變化, 通過下表索引作為 key值就變得不可行了. 這時, 你需要手動設(shè)置一個 key functon, 將這個 function 作為第二個參數(shù)傳入 selection.data(data, keyFunction)
. 這個 keyFunction 需要根據(jù)當前的數(shù)據(jù), 返回一個對應的 key 值. 比如, 你有一個對象數(shù)組作為數(shù)據(jù). 每個數(shù)據(jù)有一個 name 屬性, 你的 key function 就可以返回數(shù)據(jù)的 name 屬性, 就像這樣:
var letters = [
{ name: 'A', frequency: 0.08167 },
{ name: 'B', frequency: 0.01492 },
{ name: 'C', frequency: 0.0278 },
{ name: 'D', frequency: 0.04253 },
{ name: 'E', frequency: 0.12702 }
]
function name(d) {
return d.name
}
selection.data(data, name)
[圖片上傳失敗...(image-87e47a-1530241621120)]
同樣的, 現(xiàn)在 DOM 元素和數(shù)據(jù)完成了綁定.
d3.selectAll('div').data(letters, name)
[圖片上傳失敗...(image-1b1ee5-1530241621120)]
當有多個 group 時, 上面的情況會變得更加復雜. 但是不用擔心, 因為每一個 group 會獨立的進行 join 操作. 因此, 你只需要關(guān)心如何在一個 group 中保持 key 值的唯一性即可.
[圖片上傳失敗...(image-124ff2-1530241621120)]
上面的例子假設(shè)數(shù)據(jù)和 DOM 元素的數(shù)量是恰好 1:1. 那么當 DOM 元素和數(shù)據(jù)的數(shù)量不相同時呢? 比如有一個 DOM元素 沒有對應 key 的數(shù)據(jù), 或者有一個數(shù)據(jù)沒有對應 key 的 DOM 元素?
進入, 刷新, 離開 (Enter, Update, Exit)
當我們用 key 值來匹配 DOM 元素和數(shù)據(jù)時, 有三種可能的情況會出現(xiàn):
- Update - 對于某一個數(shù)據(jù), 有相同 key 值的 DOM 元素想對應
- Enter - 對于某一個數(shù)據(jù), 沒有相同 key 至的 DOM 元素相對應
- Exit - 對于某一個 DOM 元素, 沒有相同 key 值的數(shù)據(jù)相對應
想對應的, selection 也會返回三種狀態(tài)的選擇集: selection.data, selection.enter, selection.exit. 假設(shè)我們現(xiàn)在有一個柱狀圖, 柱狀圖有 5 列, 分別對應的 ABCDE 這五個字母. 現(xiàn)在你想將柱狀圖對應的數(shù)據(jù)從 ABCDE 切換成 YEAOI. 你可以通過設(shè)置一個 key function 來為此這五個字母和五列柱狀圖之間的關(guān)系, 數(shù)據(jù)轉(zhuǎn)換的過程如圖: ABCDE ==> YEAOI
[圖片上傳失敗...(image-b52ace-1530241621120)]
其中 A 和 E 是一直都存在的. 所以他們被劃入了 Update 選擇集, 并且順序會切換為新數(shù)據(jù)集中的順序, 如圖:
var div = d3.selectAll('div').data(vowels, name)
[圖片上傳失敗...(image-bc71d4-1530241621120)]
剩下的 B, C, D 因為在新的數(shù)據(jù)(YEAOI)中沒有對應的數(shù)據(jù), 所以被劃入了 Exit 選擇集. 注意, Exit 選擇集中數(shù)據(jù)的順序保持原有數(shù)據(jù)集中的順序, 這個順序會在我們需要加入移除動畫時很有幫助.
div.exit()
[圖片上傳失敗...(image-7cb305-1530241621120)]
最后, 新加入的三個字母: Y, O, I 因為沒有對應的 DOM 元素, 所以被劃分到了 Enter 選擇集:
div.enter()
[圖片上傳失敗...(image-7903d3-1530241621120)]
在這三種狀態(tài)的選擇集中, Update 和 Exit 都是常規(guī)的選擇集, 他們都是 selection 的子類. 而 Enter 不同, 因為 Enter 選擇集中的 DOM 元素在 Enter 選擇集創(chuàng)建時還并不存在. Enter 選擇集包含的是 DOM 元素的占位符而不是真正的 DOM 元素. 這個占位符其實并沒有什么特別的地方, 它就是一個有 __data__
屬性的 普通 JavaScript 對象而已. 當對 Enter 選擇集調(diào)用 selection.append 方法時, d3 會進行特殊的處理, 讓新插入的元素插入到 group 的父節(jié)點中去, 并且用新插入的元素取代占位符.
這也就是為什么我們需要先調(diào)用 selection.selectAll 再調(diào)用 selection.data : 因為我們要為 Enter 選擇集的 group 指定好用于插入新元素的父節(jié)點.
同時操作 Enter & Update 選擇集
注: 此處作者的描述針對的是老版本 api, 本文在此使用新版本 api 進行講解, 會和原文內(nèi)容有所不同
通常我們使用 D3 都會分別的處理:
- Enter 選擇集 ==> 創(chuàng)建新 DOM 元素, 為新元素跟新屬性和樣式
- Update 選擇集 ==> 跟新屬性和樣式
- Exit 選擇集 ==> 移除 DOM 元素
但是, 對于 Enter 選擇集和 Update 選擇集的操作, 經(jīng)常會有重復的部分, 比如更新 DOM 元素的坐標, 更新 DOM 元素的 style 樣式.
為了減少這部分冗余的代碼, selection 提供了 merge 方法, 使用方法如下:
var updateSelection = div
div
.enter()
.append('text')
.text(d => d)
.merge(updateSelection)
.attr('x', function(d, i) {
return i * 10
})
.attr('y', 10)
之所以 Enter 選擇集和 Update 選擇集可以 merge 是因為, div.enter().append('text')后, Enter 中的占位符已經(jīng)被真實的 DOM 元素取代, 因而可以和 Update 選擇集合并操作.
致謝
感謝: Anna Powell-Smith, Scott Murray, Nelson Minar, Tom Carden, Shan Carter, Jason Davies, Tom MacWright, John Firebaugh. 感謝你們的審閱和建議幫助本文變的更好.
進一步閱讀
如果想進一步的學習 d3-selection, 閱讀源代碼是一個不錯的方式. 這里也列出有一些其他人的演講和文章, 方便進一步閱讀:
想繼續(xù)了解 D3.js ?
這里是我的 D3.js 、 數(shù)據(jù)可視化 的github 地址, 歡迎 start & fork :tada: