[譯]D3.js 之 d3-selection 原理

ssthouse發表於2018-06-29

譯者注

原文: 來自 D3.js 作者 Mike Bostock 的How Selections Works

譯者: ssthouse

譯文

在前一篇文章中, 我介紹了關於 D3 selection 的基礎, 這些基礎足以讓你開始使用 D3 selection.

在這篇文章中, 我將介紹 d3-selection 的實現原理. 本文可能需要更長的時間來閱讀, 但它能揭開 selection 的原理 並讓你能真正掌握資料驅動文字的思想(D3的思想)

本文會介紹 selection 內部的工作原理而不是 selection 的設計動機, 所以你剛開始可能會對為什麼使用 selection 這種模式感到疑惑. 但等你讀到本文結尾時, 你自然會明白 selection 如此設計的原因.

D3 是一個 用於資料視覺化的庫, 所以本文也用視覺化的方式, 結合著文字對selection原理進行講解.

data join

我會用圓角矩形, 比如 thing 表示 JavsScript 的各種物件, 從 object ({foo:16}) 到 基礎資料型別 ("hello"), 到陣列 ([1,2,3]) 再到 DOM 元素. 不同種類的物件會用不同的顏色來區分. 物件之間的關係會用灰色的線來表示, 比如一個包含數字 42 的陣列會表示成這樣:

var array = [42]
複製程式碼

sample pic

大部分情況下, 影像對應的程式碼會出現在圖片的上方. 你可以訪問這個網站, 並開啟除錯視窗對文中的程式碼進行試驗, 這樣能幫助你更好的理解本文.

現在, 讓我們開始!

Array 的子類

可能有人和你說過: selection 就是 DOM 元素組成的陣列. 但事實並不是這樣, selection 是 array 的子類,這個子類提供了一些操作選中元素的方法 (比如設定屬性: selection.attr, 設定樣式: selection:style). selection 同樣繼承了 array 的一些方法, 比如 array.forEach, array.map. 然而, 你並不會經常使用這些從 array 繼承來的方法, 因為 D3 提供了一些方便的替代方法(比如 selection.each). 並且, 有一些 array 的方法為了符合 selection 的邏輯而被 overridden, 比如 selection.filterselection.sort.

Group 元素

另一個 selection 不是 DOM 元素陣列的原因是: selection 是 group 的陣列, 而 group 才是 DOM 元素的陣列. 舉個例子, d3.select 返回的 selection 包含了一個 group, 而這個 group 包含了選中的 body 元素:

var selection = d3.select('body')
複製程式碼

single group with body

JavaScript 控制檯, 嘗試執行下面的命令並檢視 selection[0] ==> group 和 元素 selectio[0][0]. 雖然 D3 支援這種通過陣列下標訪問元素的方式, 但是你很快就會意識到用 selection.node 會更好.

相似的, d3.selectAll 也會返回一個 group, 這個 group 中會有若干個元素:

d3.selectAll('h2')
複製程式碼

single group with many h2

d3.selectd3.selectAll 都是返回的一個 group. 唯一獲得包含多個 group 的 selection 的方法是 selection.selectAll . 比如, 如果你選中所有的 table row, 接著再選中這些 row 的 cell:

d3.selectAll('tr').selectAll('td')
複製程式碼

many groups with nodes

當執行上面程式碼的第二個 selectAll 時, 前面 d3.selectAll('tr') 得到的 selection 中, 每一個元素都將變成新 selection 中的一個 group; 每個 group 都會包含老的元素中符合條件的所有子元素. 所以, 如果 table 中每個 td 都包含有一個 span 的話, 我們呼叫下面的程式碼, 會得到:

d3.selectAll('tr')
  .selectAll('td')
  .selectAll('span')
複製程式碼

many groups with spans

每一個 group 都有一個 parentNode 屬性, 這個屬性儲存了 group 中所有元素的父節點. 父節點屬性會在 group 被建立時就被賦值. 因此, 如果你呼叫 d3.selectAll("tr").selectAll("td") , 返回的 group 陣列, 他們的父節點就是 tr. 而 d3.selectd3.selectAll 返回的 group, 他們的父節點就是 html.

通常來說, 你完全不用在意 selection 是由 group 組成的這個事實. 當你對 selection 呼叫 selection.attr 或者 selection.style 的時候, selection 中的所有 group 的所有子元素都會被呼叫. 而 group 存在的唯一影響是: 你在 selection.attr('attrName', function(data, i))時, 傳遞的 function(data, i) 中, 第二個引數 i 是元素在 group 中的索引而不是在整個 selection 中的索引.

select 為何不涉及 group

只有 selectAll 會涉及到 group 元素, select 會保留當前已有的 group. select 方法之所以不同, 是因為在老的 selection 中的每個元素都只會在新的 selection 中對應一個新的元素. 因此 select 操作會直接把資料從父元素傳遞給子元素 (因此也根本沒有 data-join 的過程)

為了方便使用, append 方法和 insert 方法都被掛載到了 selection 上, 這兩個方法都會自動維護 group 的結構, 並且自動傳遞資料. 比如我們現在有一個有四個 section 節點的頁面:

d3.selectAll('section')
複製程式碼

many groups with nodes

如果你呼叫下面的方法, 會為每一個 section 新增一個 p 元素, 你會得到一個有四個 p 元素的 group:

d3.selectAll('section').append('p')
複製程式碼

many groups with spans

需要注意的是, 現在這個 selection 的父節點仍然是 html. 因為 selection.selectAll 還沒有被呼叫, 所以父節點沒有發生變化.

空元素

group 中可以儲存 Null 元素, 用來宣告元素的缺失. Null 會被大部分的操作所忽略, 比如: D3 會在 selection.attrselection.style 的時候自動忽略 Null 元素.

Null 元素會在 selection.select 無法找到符合要求的子元素時被建立. 因為 select 方法會維護 group 的結構, 所以它會在缺失元素的地方填上 Null. 比如下面這個例子, 四個 section 中只有兩個有 aside 元素:

d3.selectAll('section').select('aside')
複製程式碼

4 selectio , 2 of which has aside

雖然在大部分情況下, 你完全可以忽略 group 中的 Null 元素, 但是記住 Null 元素是確實存在於 group 的結構當中的, 並且他們會在計算 index 時被考慮進來.

繫結資料

data 並不是儲存在 selection 中的一個屬性, 這一點可能會讓你感到驚訝, 但確實如此. data 並不是 selection 的一個屬性, 而是被儲存為 DOM 元素的一個屬性. 這就意味著, 當你使用 selection.data 繫結資料時, 其實資料是被繫結到了 DOM 元素上. data 會被賦值給 DOM 元素的 __data__ 屬性. 如果一個 DOM 元素沒有 __data__ 屬性, 就表明它沒有被繫結資料. 所以 selection 是臨時性的, 但資料是被持久化在 DOM 裡的, 你可以重新建立 selection, 而你的 selection 中的 DOM 元素仍會保有它之前被繫結的資料.

資料的繫結可以通過以下幾種方式實現, 接下來我們會分別講解這三種方式:

  • 給每一個單獨的 DOM 元素呼叫 selection.datum
  • 從父節點中繼承來資料, 比如: append , insert , select
  • 呼叫 selection.data() 方法
  1. 給每一個單獨的 DOM 元素呼叫 selection.datum

因為有 selection.datum 方法的存在, 你不需要手動的去給 __data__ 屬性賦值, 雖然 selection.datum 內部就是這樣實現的:

document.body.__data__ = 42
複製程式碼

body with data 42

使用 D3 的方式來達到同樣的效果:

d3.select('body').datum(42)
複製程式碼
  1. 從父節點中繼承來資料, 比如: append, insert, select

body with data 42 in D3 way

如果我們現在向 body 中 插入一個 h1 元素, h1 元素就會自動繼承 body 的資料:

d3.select('body')
  .datum(42)
  .append('h1')
複製程式碼

h1 get data from body

  1. 呼叫 selection.data

最後我們來看 selection.data , 講解這個方法會引入 d3 中非常重要的 data-join 思想. 但在我們講解這個思想之前, 我們需要首先回答一個更加基本的問題: 什麼是資料 ?

什麼是資料?

在 D3 中, 資料可以是裝有基礎資料型別資料的陣列, 比如下面這個:

var numbers = [4, 5, 18, 23, 42]
複製程式碼

或者是物件陣列:

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 }
]
複製程式碼

甚至是矩陣(由陣列組成的陣列):

var matrix = [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]]
複製程式碼

你可以通過 selection 來描述資料和視覺化圖形之間的關係. 下面我們來具體講解. 我們先建立一個有 5 個數字的陣列:

array with 5 number

就像 selection.style 可以傳入一個普通的 string (例: "red") 或者傳入一個返回 string 的 function (例: function(d) => d.color ) 一樣, selection.data 也可以接受這兩種引數.

然而, 和其他 selection 的方法不同, selection.data 是為每一個 group 定義了資料, 而不是為每一個 DOM 元素定義資料: 對於 group 來說, 資料應該是一個陣列或者是一個返回陣列的 function. 因此, 一個有多個 group 的 selection 其對應的資料也應該是一個包含多個子陣列的陣列.

array with 5 number

上圖中, 藍色的線條表示 data() 方法返回的是多個陣列. 你傳入 selection.data() 的 function 會有兩個引數: parentNodegroupIndex. 然後我們根據這兩個引數, 返回對應的資料. 因此,這裡傳入的 function 相當於是持有父級的資料, 然後根據 parentNodegroupIndex 將父級資料拆分為每個 group 的子級資料.

selection.data(function(parentNode, groupIndex) {
  return data[groupIndex]
})
複製程式碼

對於只有一個 group 的 selection, 你可以直接傳入 group 對應的陣列資料即可. 只有當你遇到需要處理多個 group 的情況時, 你才需要一個 function 來為不同的 group 返回不同的陣列資料.

data-join 的思想

現在, 我們終於可以開始討論 d3-selection 的核心思想了.

為了繫結 data 到 DOM 元素, 我們必須知道哪一個資料是對應的哪一個 DOM 元素. 這在D3中是通過比較 key 值來實現的. 一個 key 其實就是一個簡單的字串, 就比如一個名字. 當一個資料和一個 DOM 節點的 key 值相同時, 我們就認為這個資料和這個 DOM 元素是繫結的.

最簡單的指定 key 值的方法是使用索引: 第一個資料和第一個 DOM 元素會被賦予 key 值 "0", 第二個會被賦予 "1", 以此類推. 將一個數字陣列和一個 key 值匹配的 DOM 元素陣列進行 join 操作, 效果如圖所示:

maching data array and dom array

下面的程式碼得到的繫結好資料的 selection:

d3.selectAll('div').data(numbers)
複製程式碼

maching data array and dom array

如果你的資料和 DOM 元素的順序恰好相同(或者對順序並不在意)時, 通過下標索引作為 key 值是非常方便的. 但是, 一旦資料的順序發生變化, 通過下表索引作為 key值就變得不可行了. 這時, 你需要手動設定一個 key functon, 將這個 function 作為第二個引數傳入 selection.data(data, keyFunction). 這個 keyFunction 需要根據當前的資料, 返回一個對應的 key 值. 比如, 你有一個物件陣列作為資料. 每個資料有一個 name 屬性, 你的 key function 就可以返回資料的 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)
複製程式碼

maching data array and dom array

同樣的, 現在 DOM 元素和資料完成了繫結.

d3.selectAll('div').data(letters, name)
複製程式碼

maching data array and dom array

當有多個 group 時, 上面的情況會變得更加複雜. 但是不用擔心, 因為每一個 group 會獨立的進行 join 操作. 因此, 你只需要關心如何在一個 group 中保持 key 值的唯一性即可.

maching data array and dom array

上面的例子假設資料和 DOM 元素的數量是恰好 1:1. 那麼當 DOM 元素和資料的數量不相同時呢? 比如有一個 DOM元素 沒有對應 key 的資料, 或者有一個資料沒有對應 key 的 DOM 元素?

進入, 重新整理, 離開 (Enter, Update, Exit)

當我們用 key 值來匹配 DOM 元素和資料時, 有三種可能的情況會出現:

  • Update - 對於某一個資料, 有相同 key 值的 DOM 元素想對應
  • Enter - 對於某一個資料, 沒有相同 key 至的 DOM 元素相對應
  • Exit - 對於某一個 DOM 元素, 沒有相同 key 值的資料相對應

想對應的, selection 也會返回三種狀態的選擇集: selection.data, selection.enter, selection.exit. 假設我們現在有一個柱狀圖, 柱狀圖有 5 列, 分別對應的 ABCDE 這五個字母. 現在你想將柱狀圖對應的資料從 ABCDE 切換成 YEAOI. 你可以通過設定一個 key function 來為此這五個字母和五列柱狀圖之間的關係, 資料轉換的過程如圖: ABCDE ==> YEAOI

maching data array and dom array

其中 A 和 E 是一直都存在的. 所以他們被劃入了 Update 選擇集, 並且順序會切換為新資料集中的順序, 如圖:

var div = d3.selectAll('div').data(vowels, name)
複製程式碼

abcde to yeaoi

剩下的 B, C, D 因為在新的資料(YEAOI)中沒有對應的資料, 所以被劃入了 Exit 選擇集. 注意, Exit 選擇集中資料的順序保持原有資料集中的順序, 這個順序會在我們需要加入移除動畫時很有幫助.

div.exit()
複製程式碼

maching data array and dom array

最後, 新加入的三個字母: Y, O, I 因為沒有對應的 DOM 元素, 所以被劃分到了 Enter 選擇集:

div.enter()
複製程式碼

maching data array and dom array

在這三種狀態的選擇集中, Update 和 Exit 都是常規的選擇集, 他們都是 selection 的子類. 而 Enter 不同, 因為 Enter 選擇集中的 DOM 元素在 Enter 選擇集建立時還並不存在. Enter 選擇集包含的是 DOM 元素的佔位符而不是真正的 DOM 元素. 這個佔位符其實並沒有什麼特別的地方, 它就是一個有 __data__ 屬性的 普通 JavaScript 物件而已. 當對 Enter 選擇集呼叫 selection.append 方法時, d3 會進行特殊的處理, 讓新插入的元素插入到 group 的父節點中去, 並且用新插入的元素取代佔位符.

這也就是為什麼我們需要先呼叫 selection.selectAll 再呼叫 selection.data : 因為我們要為 Enter 選擇集的 group 指定好用於插入新元素的父節點.

同時操作 Enter & Update 選擇集

注: 此處作者的描述針對的是老版本 api, 本文在此使用新版本 api 進行講解, 會和原文內容有所不同

通常我們使用 D3 都會分別的處理:

  • Enter 選擇集 ==> 建立新 DOM 元素, 為新元素跟新屬性和樣式
  • Update 選擇集 ==> 跟新屬性和樣式
  • Exit 選擇集 ==> 移除 DOM 元素

但是, 對於 Enter 選擇集和 Update 選擇集的操作, 經常會有重複的部分, 比如更新 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 中的佔位符已經被真實的 DOM 元素取代, 因而可以和 Update 選擇集合並操作.

致謝

感謝: Anna Powell-Smith, Scott Murray, Nelson Minar, Tom Carden, Shan Carter, Jason Davies, Tom MacWright, John Firebaugh. 感謝你們的審閱和建議幫助本文變的更好.

進一步閱讀

如果想進一步的學習 d3-selection, 閱讀原始碼是一個不錯的方式. 這裡也列出有一些其他人的演講和文章, 方便進一步閱讀:

如果覺得不錯的話, 不妨關注一下 : )

github主頁

知乎專欄

掘金

相關文章