D3 选集的工作原理

Any sufficiently advanced technology is indistinguishable from magic. – Arthur C. Clarke

我曾简要介绍过 D3 选集(Selections),不过只讲了些用来入门的细节。本文则更全面——较之介绍如何使用选集,我将诠释选集的实现原理。阅读本文可能需要花更多时间,但它可以解除所有关于数据驱动文档的魔法,帮助您掌握它。

也许本文的结构乍看起来有些乱,它优先描述了选集内部的工作方式而非其设计意图,因而您可能会想,为什么需要这样的机制呢?之所以采用这种行文结构,仅仅是因为简单:在解释各部分如何协调工作之前,先将各部分一一罗列并解释清楚。及至文末,设计意图便不言自明。

D3 是一个可视化库,所以本文采用了可视化的方式结合文本来解释问题。在后文的图中,左侧表示选集的结构,右侧表示数据的结构:

形如 的圆角矩形用来表示各种类型的 JavaScript 对象,包括字面量对象( {foo: 16} )、原生类型( “hello” )、数组( [1, 2, 3] )及 DOM 元素,不一而足。某些特殊对象类型会着色,包括 。对象间的关系用连接线()表示。包含数字 42 的数组示例如下:

1
var array = [42];

用来生成选集的代码会尽可能地放在图表上方。为了检测您对文本是否理解,最好的方法是打开浏览器的 JavaScript 控制台并交互式地创建选集,参与其中!

开始吧。

数组的子类

也许您曾听说过,“选集是由 DOM 元素构成的数组”。错。选集其实是数组的子类;该子类提供了操作选集中选中元素的方法,比如设置属性和样式。同时选集也继承了原生数组的一些方法,比如 array.forEach 和 array.map 。尽管如此,通常并不需要使用原生方法,因为 D3 提供了更便利的方式,比如 selection.each 。(另有一些原生方法被覆盖以将其行为适配于选集,即 selection.filter 和 selection.sort)。

元素分组

选集“并非由 DOM 元素构成的数组” 的另一个原因是,它是元素数组的数组:选集是一组数组,每个组(group)都是一个元素数组。例如 d3.select 返回的由所选元素构成的选集中,仅包含一个组:

1
var selection = d3.select("body");

尝试在 JavaScript 控制台中运行此命令,并通过 selection[0] 检查分组情况,通过 selection[0][0] 检查节点。D3 API 支持直接访问节点,使用 selection.node 即可,该方法十分常用,一会儿你就知道了。

同样地,d3.selectAll 返回一个选集,它带有一个组和任意数量的元素:

1
d3.selectAll("h2");

d3.select 和 d3.selectAll 返回的选集只包含一个组。获取包含多个组的选集的唯一方法是使用 selection.selectAll 。 例如,假设先选择表格的所有行,再选择每行中的单元格,则会为每行均获取一组同级单元格:

1
d3.selectAll("tr").selectAll("td");

通过调用 selectAll 方法,旧选集中的每个元素变成新选集中的一个组;每个组包含一个与旧元素匹配的后代元素。因此,假设每个 table 的单元格包含一个 span 元素,并且您第三次调用 selectAll,您将获得十六个组的选集:

1
d3.selectAll("tr").selectAll("td").selectAll("span");

每个组都有一个 parentNode 属性,它用来存储所有组内元素的共享父节点,该父节点在创建组时设置。因此,如果调用 d3.selectAll(“tr”)​.selectAll(“td”),返回的选集包含 td 元素构成的组,而它们的父元素是 tr 元素。 对于 d3.select 和 d3.selectAll 返回的选集,父元素则是 document 元素。

大多数情况下,您可以安全地忽略这些分组选集。调用函数 selection.attr 或 selection.style 时,D3 会自动为各个元素调用该函数;使用分组的主要问题在于:function(i)的第二个参数是组内索引,而不是选集的索引。

无分组的操作

只有 selectAll 的行为特殊一些,它会产生分组,而 select 则保留现有分组。select 方法之所以不同,是因为旧选集中的每个元素在新选集中只有一个元素与之对应。这样一来,select 可以将数据从父元素传播至子元素,而 selectAll 不会(因此需要数据连接(data-join))!

方法 append 和 insert 是在 select 之上的包装,它们也同样会保留分组并传播数据。示例包含了四个 section 的 document 元素:

1
d3.selectAll("section");

如果在每个 section 后附加一个 p 元素,新的选集同样只具有一个组,它包含四个元素:

1
d3.selectAll("section").append("p");

值得注意的是,该选集的 parentNode 仍然是 document 元素,因为没有调用 selection.selectAll 来对选集进行重新分组。

空元素

组内可以使用 null 来表示缺失的元素。D3 中的大多数操作都会忽略空值,例如,设置样式或修改属性时,它会跳过空元素。

使用 selection.select 方法查找元素时,如果给定的选择器匹配不到任何元素,则会产生空元素。select 方法保留分组结构并使用 null 来填充缺失。假设只有最后两个 section 包含 aside 元素:

1
d3.selectAll("section").select("aside");

与分组一样,您通常可以忽略 null 元素的存在,但请注意,D3 确实使用了它们以保持选集分组的结构不变,另外值得注意的是它们的组内索引。

绑定数据

数据并非选集的属性,而是元素的属性,这或许有些出人意料。这意味着在选集上绑定数据时,数据被存到了 DOM 而非选集中:数据被赋至每个元素的 __data__ 属性。如果元素缺少此属性则相关联的数据为 undefined。因此可以认为数据是持久态的,而选集是瞬时态的:你可以从 DOM 重新选择元素,它们将保留先前与之绑定的任何数据。

数据通过以下几种方式之一绑定到元素上:

  • 通过 selection.data 加入到组中。
  • 通过 selection.datum 分配给个别元素。
  • 通过 append、insert 或 select 从父元素传播至子元素。

示例如何实现数据绑定(事实上并不应该直接设置 __data__ 属性,而是应该使用 selection.datum 方法来实现):

1
document.body.__data__ = 42;

此例若采用 D3 惯用风格应该是先选中 body ,再调用 datum :

1
d3.select("body").datum(42);

如果将一个元素 append 到 body 上,那么子元素会自动从父元素继承数据:

1
d3.select("body").datum(42).append("h1");

至此,我们将引入数据绑定最后一个方法:神秘的 join(连接)操作!当务之急,我们需要先来回答一个更加实在的问题。

何谓数据?

D3 中的数据可以是任意值的数组。例如,数字构成的数组:

1
var numbers = [4, 5, 18, 23, 42];

又或对象构成的数组:

1
2
3
4
5
6
7
var letters = [
{name: "A", frequency: .08167},
{name: "B", frequency: .01492},
{name: "C", frequency: .02780},
{name: "D", frequency: .04253},
{name: "E", frequency: .12702}
];

甚或数组构成的数组:

1
2
3
4
5
6
var matrix = [
[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11],
[12, 13, 14, 15]
];

我们参照选集的可视化表示方式来表示数据,包含 5 个数字的普通数组形如:

就像 selection.style 一样——它既可以通过指定一个常量字符串来为每个选定的元素定义统一的样式属性(例如 “red”),也可以通过指定函数来动态计算每个元素的样式(function(d){return d.color; })—— selection.data 也可以接收常量值或函数。

然而,与选集其它方法不同的是:selection.data 是为各组定义数据而不是为各元素定义数据。在各个组中,使用数组或返回数组的函数来表示数据,因此选集中的分组具有相应的分组数据!

【本文译自 How Selections Work 如有侵权,即刻删除。另,鉴于翻译期间译者对 D3 的掌握水平有限,文中所有示例图使用 Snap.svg 绘制且未引入 D3 库,故不可以直接在本页面 JavaScript 控制台中调用 D3】

若是喜欢这篇文章,可以随便给个赏钱:)