原文: 来自 D3.js 做者 Mike Bostock 的How Selections Worksjavascript
译者: ssthousehtml
在前一篇文章中, 我介绍了关于 D3 selection 的基础, 这些基础足以让你开始使用 D3 selection.java
在这篇文章中, 我将介绍 d3-selection 的实现原理. 本文可能须要更长的时间来阅读, 但它能揭开 selection 的原理 并让你能真正掌握数据驱动文本的思想(D3的思想)node
本文会介绍 selection 内部的工做原理而不是 selection 的设计动机, 因此你刚开始可能会对为何使用 selection 这种模式感到疑惑. 但等你读到本文结尾时, 你天然会明白 selection 如此设计的缘由.git
D3 是一个 用于数据可视化的库, 因此本文也用可视化的方式, 结合着文字对selection原理进行讲解.github
我会用圆角矩形, 好比 thing
表示 JavsScript 的各类对象, 从 object ({foo:16}) 到 基础数据类型 ("hello"), 到数组 ([1,2,3]) 再到 DOM 元素. 不一样种类的对象会用不一样的颜色来区分. 对象之间的关系会用灰色的线来表示, 好比一个包含数字 42 的数组会表示成这样:api
var array = [42]
复制代码
大部分状况下, 图像对应的代码会出如今图片的上方. 你能够访问这个网站, 并打开调试窗口对文中的代码进行试验, 这样能帮助你更好的理解本文.数组
如今, 让咱们开始!app
可能有人和你说过: selection 就是 DOM 元素组成的数组. 但事实并非这样, selection 是 array 的子类,这个子类提供了一些操做选中元素的方法 (好比设置属性: selection.attr, 设置样式: selection:style
). selection 一样继承了 array 的一些方法, 好比 array.forEach
, array.map
. 然而, 你并不会常用这些从 array 继承来的方法, 由于 D3 提供了一些方便的替代方法(好比 selection.each
). 而且, 有一些 array 的方法为了符合 selection 的逻辑而被 overridden, 好比 selection.filter
和 selection.sort
.dom
另外一个 selection 不是 DOM 元素数组的缘由是: selection 是 group 的数组, 而 group 才是 DOM 元素的数组. 举个例子, d3.select
返回的 selection 包含了一个 group, 而这个 group 包含了选中的 body 元素:
var selection = d3.select('body')
复制代码
在 JavaScript 控制台, 尝试运行下面的命令并查看 selection[0] ==> group 和 元素 selectio[0][0]
. 虽然 D3 支持这种经过数组下标访问元素的方式, 可是你很快就会意识到用 selection.node
会更好.
类似的, d3.selectAll 也会返回一个 group, 这个 group 中会有若干个元素:
d3.selectAll('h2')
复制代码
d3.select 和 d3.selectAll 都是返回的一个 group. 惟一得到包含多个 group 的 selection 的方法是 selection.selectAll . 好比, 若是你选中全部的 table row, 接着再选中这些 row 的 cell:
d3.selectAll('tr').selectAll('td')
复制代码
当运行上面代码的第二个 selectAll 时, 前面 d3.selectAll('tr')
获得的 selection 中, 每个元素都将变成新 selection 中的一个 group; 每一个 group 都会包含老的元素中符合条件的全部子元素. 因此, 若是 table 中每一个 td 都包含有一个 span 的话, 咱们调用下面的代码, 会获得:
d3.selectAll('tr')
.selectAll('td')
.selectAll('span')
复制代码
每个 group 都有一个 parentNode 属性, 这个属性存储了 group 中全部元素的父节点. 父节点属性会在 group 被建立时就被赋值. 所以, 若是你调用 d3.selectAll("tr").selectAll("td")
, 返回的 group 数组, 他们的父节点就是 tr. 而 d3.select 和 d3.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 中的索引.
只有 selectAll
会涉及到 group 元素, select
会保留当前已有的 group. select 方法之因此不一样, 是由于在老的 selection 中的每一个元素都只会在新的 selection 中对应一个新的元素. 所以 select 操做会直接把数据从父元素传递给子元素 (所以也根本没有 data-join 的过程)
为了方便使用, append 方法和 insert 方法都被挂载到了 selection 上, 这两个方法都会自动维护 group 的结构, 而且自动传递数据. 好比咱们如今有一个有四个 section 节点的页面:
d3.selectAll('section')
复制代码
若是你调用下面的方法, 会为每个 section 添加一个 p 元素, 你会获得一个有四个 p 元素的 group:
d3.selectAll('section').append('p')
复制代码
须要注意的是, 如今这个 selection 的父节点仍然是 html. 由于 selection.selectAll 尚未被调用, 因此父节点没有发生变化.
group 中能够保存 Null 元素, 用来声明元素的缺失. Null 会被大部分的操做所忽略, 好比: D3 会在 selection.attr 和 selection.style 的时候自动忽略 Null 元素.
Null 元素会在 selection.select 没法找到符合要求的子元素时被建立. 由于 select 方法会维护 group 的结构, 因此它会在缺失元素的地方填上 Null. 好比下面这个例子, 四个 section 中只有两个有 aside 元素:
d3.selectAll('section').select('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
由于有 selection.datum 方法的存在, 你不须要手动的去给 __data__
属性赋值, 虽然 selection.datum 内部就是这样实现的:
document.body.__data__ = 42
复制代码
使用 D3 的方式来达到一样的效果:
d3.select('body').datum(42)
复制代码
- 从父节点中继承来数据, 好比: append, insert, select
若是咱们如今向 body 中 插入一个 h1 元素, h1 元素就会自动继承 body 的数据:
d3.select('body')
.datum(42)
.append('h1')
复制代码
- 调用 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 个数字的数组:
就像 selection.style 能够传入一个普通的 string (例: "red") 或者传入一个返回 string 的 function (例: function(d) => d.color
) 同样, selection.data 也能够接受这两种参数.
然而, 和其余 selection 的方法不一样, selection.data 是为每个 group 定义了数据, 而不是为每个 DOM 元素定义数据: 对于 group 来讲, 数据应该是一个数组或者是一个返回数组的 function. 所以, 一个有多个 group 的 selection 其对应的数据也应该是一个包含多个子数组的数组.
上图中, 蓝色的线条表示 data() 方法返回的是多个数组. 你传入 selection.data() 的 function 会有两个参数: parentNode 和 groupIndex. 而后咱们根据这两个参数, 返回对应的数据. 所以,这里传入的 function 至关因而持有父级的数据, 而后根据 parentNode 和 groupIndex 将父级数据拆分为每一个 group 的子级数据.
selection.data(function(parentNode, groupIndex) {
return data[groupIndex]
})
复制代码
对于只有一个 group 的 selection, 你能够直接传入 group 对应的数组数据便可. 只有当你遇到须要处理多个 group 的状况时, 你才须要一个 function 来为不一样的 group 返回不一样的数组数据.
如今, 咱们终于能够开始讨论 d3-selection 的核心思想了.
为了绑定 data 到 DOM 元素, 咱们必须知道哪个数据是对应的哪个 DOM 元素. 这在D3中是经过比较 key 值来实现的. 一个 key 其实就是一个简单的字符串, 就好比一个名字. 当一个数据和一个 DOM 节点的 key 值相同时, 咱们就认为这个数据和这个 DOM 元素是绑定的.
最简单的指定 key 值的方法是使用索引: 第一个数据和第一个 DOM 元素会被赋予 key 值 "0", 第二个会被赋予 "1", 以此类推. 将一个数字数组和一个 key 值匹配的 DOM 元素数组进行 join 操做, 效果如图所示:
下面的代码获得的绑定好数据的 selection:
d3.selectAll('div').data(numbers)
复制代码
若是你的数据和 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)
复制代码
一样的, 如今 DOM 元素和数据完成了绑定.
d3.selectAll('div').data(letters, name)
复制代码
当有多个 group 时, 上面的状况会变得更加复杂. 可是不用担忧, 由于每个 group 会独立的进行 join 操做. 所以, 你只须要关心如何在一个 group 中保持 key 值的惟一性便可.
上面的例子假设数据和 DOM 元素的数量是刚好 1:1. 那么当 DOM 元素和数据的数量不相同时呢? 好比有一个 DOM元素 没有对应 key 的数据, 或者有一个数据没有对应 key 的 DOM 元素?
当咱们用 key 值来匹配 DOM 元素和数据时, 有三种可能的状况会出现:
想对应的, selection 也会返回三种状态的选择集: selection.data, selection.enter, selection.exit. 假设咱们如今有一个柱状图, 柱状图有 5 列, 分别对应的 ABCDE 这五个字母. 如今你想将柱状图对应的数据从 ABCDE 切换成 YEAOI. 你能够经过设置一个 key function 来为此这五个字母和五列柱状图之间的关系, 数据转换的过程如图: ABCDE ==> YEAOI
其中 A 和 E 是一直都存在的. 因此他们被划入了 Update 选择集, 而且顺序会切换为新数据集中的顺序, 如图:
var div = d3.selectAll('div').data(vowels, name)
复制代码
剩下的 B, C, D 由于在新的数据(YEAOI)中没有对应的数据, 因此被划入了 Exit 选择集. 注意, Exit 选择集中数据的顺序保持原有数据集中的顺序, 这个顺序会在咱们须要加入移除动画时颇有帮助.
div.exit()
复制代码
最后, 新加入的三个字母: Y, O, I 由于没有对应的 DOM 元素, 因此被划分到了 Enter 选择集:
div.enter()
复制代码
在这三种状态的选择集中, Update 和 Exit 都是常规的选择集, 他们都是 selection 的子类. 而 Enter 不一样, 由于 Enter 选择集中的 DOM 元素在 Enter 选择集建立时还并不存在. Enter 选择集包含的是 DOM 元素的占位符而不是真正的 DOM 元素. 这个占位符其实并无什么特别的地方, 它就是一个有 __data__
属性的 普通 JavaScript 对象而已. 当对 Enter 选择集调用 selection.append 方法时, d3 会进行特殊的处理, 让新插入的元素插入到 group 的父节点中去, 而且用新插入的元素取代占位符.
这也就是为何咱们须要先调用 selection.selectAll 再调用 selection.data : 由于咱们要为 Enter 选择集的 group 指定好用于插入新元素的父节点.
一般咱们使用 D3 都会分别的处理:
可是, 对于 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, 阅读源代码是一个不错的方式. 这里也列出有一些其余人的演讲和文章, 方便进一步阅读: