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

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

译者注

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

译者: ssthouse

译文

在前一篇文章中, 我介绍了关于 D3 selection 的基础, 这些基础足以让你开始使用 D3 selection.

在这篇文章中, 我将介绍 d3-selection 的实现原理. 本文可能需要更长的时间来阅读, 但它能揭开 selection 的原理 并让你能真正掌握数据驱动文本的思想(D3的思想)

本文会介绍 selection 内部的工作原理而不是 selection 的设计动机, 所以你刚开始可能会对为什么使用 selection 这种模式感到疑惑.
但等你读到本文结尾时, 你自然会明白 selection 如此设计的原因.

D3 是一个 用于数据可视化的库, 所以本文也用可视化的方式, 结合着文字对selection原理进行讲解.

[图片上传失败...(image-96f3a4-1530241621119)]

我会用圆角矩形, 比如 thing 表示 JavsScript 的各种对象, 从 object ({foo:16}) 到 基础数据类型 ("hello"), 到数组 ([1,2,3]) 再到 DOM 元素. 不同种类的对象会用不同的颜色来区分.
对象之间的关系会用灰色的线来表示, 比如一个包含数字 42 的数组会表示成这样:

var array = [42]

[图片上传失败...(image-162abd-1530241621119)]

大部分情况下, 图像对应的代码会出现在图片的上方. 你可以访问这个网站, 并打开调试窗口对文中的代码进行试验, 这样能帮助你更好的理解本文.

现在, 让我们开始!

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')

[图片上传失败...(image-44237f-1530241621119)]

JavaScript 控制台, 尝试运行下面的命令并查看 selection[0] ==> group 和 元素 selectio[0][0]. 虽然 D3 支持这种通过数组下标访问元素的方式, 但是你很快就会意识到用 selection.node 会更好.

相似的, d3.selectAll 也会返回一个 group, 这个 group 中会有若干个元素:

d3.selectAll('h2')

[图片上传失败...(image-9a5354-1530241621119)]

d3.selectd3.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 的话, 我们调用下面的代码, 会得到:

d3.selectAll('tr')
  .selectAll('td')
  .selectAll('span')

[图片上传失败...(image-62d71a-1530241621120)]

每一个 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')

[图片上传失败...(image-1f7b1a-1530241621120)]

如果你调用下面的方法, 会为每一个 section 添加一个 p 元素, 你会得到一个有四个 p 元素的 group:

d3.selectAll('section').append('p')

[图片上传失败...(image-a13bcf-1530241621120)]

需要注意的是, 现在这个 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')

[图片上传失败...(image-475661-1530241621120)]

虽然在大部分情况下, 你完全可以忽略 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

[图片上传失败...(image-6c6c3e-1530241621120)]

使用 D3 的方式来达到同样的效果:

d3.select('body').datum(42)
  1. 从父节点中继承来数据, 比如: append, insert, select

[图片上传失败...(image-693939-1530241621120)]

如果我们现在向 body 中 插入一个 h1 元素, h1 元素就会自动继承 body 的数据:

d3.select('body')
  .datum(42)
  .append('h1')

[图片上传失败...(image-748392-1530241621120)]

  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 个数字的数组:

[图片上传失败...(image-561e6f-1530241621120)]

就像 selection.style 可以传入一个普通的 string (例: "red") 或者传入一个返回 string 的 function (例: function(d) => d.color ) 一样, selection.data 也可以接受这两种参数.

然而, 和其他 selection 的方法不同, selection.data 是为每一个 group 定义了数据, 而不是为每一个 DOM 元素定义数据: 对于 group 来说, 数据应该是一个数组或者是一个返回数组的 function. 因此, 一个有多个 group 的 selection 其对应的数据也应该是一个包含多个子数组的数组.

[图片上传失败...(image-b7fbec-1530241621120)]

上图中, 蓝色的线条表示 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 操作, 效果如图所示:

[图片上传失败...(image-69bc4e-1530241621120)]

下面的代码得到的绑定好数据的 selection:

d3.selectAll('div').data(numbers)

[图片上传失败...(image-dcad1e-1530241621120)]

如果你的数据和 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)

[图片上传失败...(image-87e47a-1530241621120)]

同样的, 现在 DOM 元素和数据完成了绑定.

d3.selectAll('div').data(letters, name)

[图片上传失败...(image-1b1ee5-1530241621120)]

当有多个 group 时, 上面的情况会变得更加复杂. 但是不用担心, 因为每一个 group 会独立的进行 join 操作. 因此, 你只需要关心如何在一个 group 中保持 key 值的唯一性即可.

[图片上传失败...(image-124ff2-1530241621120)]

上面的例子假设数据和 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

[图片上传失败...(image-b52ace-1530241621120)]

其中 A 和 E 是一直都存在的. 所以他们被划入了 Update 选择集, 并且顺序会切换为新数据集中的顺序, 如图:

var div = d3.selectAll('div').data(vowels, name)

[图片上传失败...(image-bc71d4-1530241621120)]

剩下的 B, C, D 因为在新的数据(YEAOI)中没有对应的数据, 所以被划入了 Exit 选择集. 注意, Exit 选择集中数据的顺序保持原有数据集中的顺序, 这个顺序会在我们需要加入移除动画时很有帮助.

div.exit()

[图片上传失败...(image-7cb305-1530241621120)]

最后, 新加入的三个字母: Y, O, I 因为没有对应的 DOM 元素, 所以被划分到了 Enter 选择集:

div.enter()

[图片上传失败...(image-7903d3-1530241621120)]

在这三种状态的选择集中, 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, 阅读源代码是一个不错的方式. 这里也列出有一些其他人的演讲和文章, 方便进一步阅读:

想继续了解 D3.js ?

这里是我的 D3.js数据可视化 的github 地址, 欢迎 start & fork :tada:

D3-blog

如果觉得不错的话, 不妨点击下面的链接关注一下 : )

github主页

知乎专栏

掘金

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容

  • [译] D3.js 嵌套选择集 (Nested Selection) 译者注: 原文: Mike Bostock ...
    ssthouse阅读 520评论 0 0
  • d3 (核心部分)选择集d3.select - 从当前文档中选择一系列元素。d3.selectAll - 从当前文...
    谢大见阅读 3,437评论 1 4
  • 对集合的操作 关于d3.attr 一个可以处理很多情况的函数,当只传入一个参数时,如果是string,则返回该属性...
    陈坚生阅读 2,511评论 0 2
  • 1. 基本数据导入与readr包应用 1.1 基本数据导入函数 readr包中的基本导入函数如下: read_cs...
    100gle阅读 1,998评论 0 3
  • “其实我最想做的还是研究,现在这份工作感觉就是谋生,我应该追求我的理想?还是应该怎么去选择?,有标准答案吗?” “...
    换行教育阅读 300评论 0 0