首页
/ D3 数据连接实战:selection.data、join 与 enter/update/exit 三段式 DOM 同步机制详解

D3 数据连接实战:selection.data、join 与 enter/update/exit 三段式 DOM 同步机制详解

2026-09-06 12:36:34作者:邓越浪Henry

本篇基于 d3 官方文档 Joining data 展开,深入讲解 d3-selection 的数据连接(data join)机制:如何把任意数组数据绑定到已选中的 DOM 元素上、如何用 key 函数控制"数据—元素"的对应关系,以及 enter、update、exit 三个子选择如何配合完成 DOM 与数据的同步。读完本文,你将能够熟练运用 selection.dataselection.joinselection.enterselection.exitselection.datumselection.merge 六个 API,掌握 d3 数据可视化中最核心的 General Update Pattern(通用更新模式),并能对照仓库内真实示例组件验证其用法。

一、为什么需要数据连接

d3 的核心工作方式是"数据驱动的 DOM 变换":通过 d3-selection 选中元素后,设置 attributesstyles 等内容。当数据是动态变化的,DOM 中的元素数量往往与数据长度不一致——多了的元素要删掉,少了的元素要创建,一致的元素要更新。数据连接(data join)正是解决这一问题的机制:

  • update 选择:成功绑定到数据的现有元素;
  • enter 选择:数据中新增、但 DOM 中尚无对应元素的"占位符";
  • exit 选择:DOM 中残留、但新数据中已无对应数据项的多余元素。

在 d3 7 中,这套模式被 selection.join 收敛为一行调用,仓库中 package.json 声明的版本为 d3 7.9.0(d3-selection: ^3.0.0),selection.join 是该版本的标准做法。

二、selection.data(data, key):绑定数据并派生 enter/exit

selection.data(data, key) 将指定的数据数组绑定到选中的元素上,返回代表 update 选择的新选择,同时在返回的选择上定义 enterexit 子选择,供后续添加或删除元素。

关键行为:

  1. 数据粘性(sticky):数据被赋给元素后,会存储在该元素的 __data__ 属性上,因此重新选中同一元素时仍可用 d 访问其数据;
  2. data 是"按组"(per group)指定的:若选择包含多个组(例如 d3.selectAll 之后又调用了 selection.selectAll),data 通常应写成函数,该函数按组依次求值,参数为组父级数据(d,可能为 undefined)、组索引(i)和选择父节点数组(nodes),this 为组父元素;
  3. 默认按索引连接:若未提供 key 函数,则数据数组第 i 项绑定到第 i 个选中元素;
  4. key 函数控制对应关系:key 函数先对每个选中元素求值(参数 dinodesthis 为当前 DOM 元素 nodes[i],此时 d 是元素当前已绑定的数据,首次连接时为 null),再对每个新数据项求值(this 为组父元素),两次返回的字符串相同即视为同一对象。若多个元素返回同一个 key,重复元素进入 exit 选择;若多个数据项返回同一个 key,重复数据进入 enter 选择;
  5. 顺序规则:update 与 enter 选择按数据顺序返回,而 exit 选择保持连接前的选择顺序。指定 key 函数后,选择中元素的顺序可能不再与文档顺序一致,需要时用 selection.orderselection.sort 调整;
  6. 其他约束:不传 data 时,该方法返回选中元素当前绑定的数据数组;此方法不能用于清除已绑定数据,清除须用 selection.datum

2.1 嵌套组:从数字矩阵构建 HTML 表格

下面的经典示例展示了两层数据连接:外层按行数据连接 tr,内层在每一行内按单元格数据连接 td

const matrix = [
  [11975,  5871, 8916, 2868],
  [ 1951, 10048, 2060, 6171],
  [ 8010, 16145, 8090, 8045],
  [ 1013,   990,  940, 6907]
];

d3.select("body")
  .append("table")
  .selectAll("tr")
  .data(matrix)          // 第一层连接:每个组(body)绑一个二维数组
  .join("tr")
  .selectAll("td")      // 第二层连接:以每个 tr 为父组
  .data(d => d)          // 数据函数即恒等函数:每行返回对应的行数组
  .join("td")
    .text(d => d);

这里内层的 data 函数是恒等函数:对每个表格行,返回其对应的数据矩阵行。这一步正体现了"多组选择时 data 应写成函数"的规则——selection.selectAll("td") 产生了 4 个组(每个 tr 一个组),因此必须用 d => d 为每个组分别提供该行自己的数据。

2.2 key 函数:让元素"对号入座"

给定如下文档(6 个带 id 的 div):

<div id="Ford"></div>
<div id="Jarrah"></div>
<div id="Kwon"></div>
<div id="Locke"></div>
<div id="Reyes"></div>
<div id="Shephard"></div>

按 key 连接数据的写法:

const data = [
  {name: "Locke", number: 4},
  {name: "Reyes", number: 8},
  {name: "Ford", number: 15},
  {name: "Jarrah", number: 16},
  {name: "Shephard", number: 23},
  {name: "Kwon", number: 42}
];

d3.selectAll("div")
  .data(data, function(d) { return d ? d.name : this.id; })
    .text(d => d.number);

这个 key 函数的技巧在于:若数据项 d 存在就用 d.name,否则回退到元素自身的 id 属性。由于这些元素此前从未绑定过数据,key 函数在已选中元素上求值时 d 为 null(走 this.id 分支),而在新数据上求值时 d 非 null(走 d.name 分支)。最终 {name: "Locke"} 绑定到 <div id="Locke">,依此类推,实现按名称而非按位置匹配。

引入 key 函数的收益有两点:其一,数据重新排序或增删时,d3 能准确区分"移动""新增""删除",DOM 变动最小化,有利于性能;其二,元素与数据的稳定对应保证了"对象恒常性"(object constancy),是动画中同一数据项平滑移动的前提。

三、selection.join(enter, update, exit):一步完成增删改

selection.join(enter, update, exit) 对先前由 selection.data 绑定的数据做必要的追加、删除与重排,返回 enter 与 update 选择合并后的选择。它是显式通用更新模式的便捷替代,一次性替代了 enterexitselection.appendselection.removeselection.order 的组合。

3.1 字符串简写与等价展开

svg.selectAll("circle")
  .data(data)
  .join("circle")
    .attr("fill", "none")
    .attr("stroke", "black");

enter 参数可以写成字符串简写,等价于用该元素名调用 selection.appendupdateexit 为可选函数,默认分别为恒等函数和调用 selection.remove。上式展开即:

svg.selectAll("circle")
  .data(data)
  .join(
    enter => enter.append("circle"),
    update => update,
    exit => exit.remove()
  )
    .attr("fill", "none")
    .attr("stroke", "black");

传入三个分支函数后,后续的 .attr(...) 会同时作用于 enter 与 update 两部分元素(两者的选择被 merge 合并后返回)。

3.2 差异化处理 enter 与 update

通过为 enter、update 指定不同的函数,可以精细控制不同阶段的行为。例如入场元素绿色、更新元素蓝色:

svg.selectAll("circle")
  .data(data)
  .join(
    enter => enter.append("circle").attr("fill", "green"),
    update => update.attr("fill", "blue")
  )
    .attr("stroke", "black");

再结合给 selection.data 指定 key 函数,可以把 DOM 的改动压缩到最小,从而优化频繁更新的场景性能。

3.3 在 join 中驱动过渡动画

enterupdateexit 函数内部创建 transitions 即可为三个阶段分别加动画。若 enterupdate 函数返回的是过渡(transition),它们底层的选择会被合并后由 selection.join 返回;exit 函数的返回值则不被使用。

3.4 仓库内的真实用法

d3 官方文档站的示例组件大量采用这一写法。例如力导向图示例 ExampleDisjointForce.vue 中,边与节点分别连接:

svg.selectAll(".link")
  .data(links)
  .join("line")
    .attr("class", "link");

svg.selectAll(".node")
  .data(nodes)
  .join("circle")
    .attr("class", "node");

ExampleArcs.vue 则展示了多层嵌套 join 的完整形态——先用 .data([null]).join("svg") 为容器元素走了一遍"以 null 为数据保证单一实例"的连接模式,再对 pie(data) 的结果分别 .data(pie(data)).join("path") 绘制弧与标签组。这类"用 [null] 单元素数组连接容器"的写法是 selection.data/join 组合的一个实用技巧:把"确保存在一个容器"也统一纳入数据连接范式。

四、selection.enter():为缺失元素创建占位符

selection.enter() 返回 enter 选择:为每个"没有对应 DOM 元素"的数据项提供一个占位节点(对于非 selection.data 返回的选择,enter 选择为空)。占位符在概念上是指向父元素的指针,因此 enter 选择通常只是临时性地用于 append 元素,之后常与 update 选择 merge 起来,使后续修改同时作用于进入中和更新中的元素。

例如从数字数组创建 DIV:

const div = d3.select("body")
  .selectAll("div")
  .data([4, 8, 15, 16, 23, 42])
  .enter().append("div")
    .text(d => d);

若 body 初始为空,这段代码会按顺序创建 6 个新 DIV 并挂到 body 下,文本内容为对应数字(经字符串强制转换):

<div>4</div>
<div>8</div>
<div>15</div>
<div>16</div>
<div>23</div>
<div>42</div>

五、selection.exit():移除多余元素

selection.exit() 返回 exit 选择:对每个"找不到新数据"的现有 DOM 元素(对于非 selection.data 返回的选择,exit 选择为空)。exit 选择通常用于删除与旧数据对应的"多余"元素。

延续上一节的 6 个 DIV,用新的数据数组重新绑定,并显式指定 key 函数(恒等函数):

div = div.data([1, 2, 4, 8, 16, 32], d => d);

由于指定了 key 函数,且新数据中的 [4, 8, 16] 与文档中现有元素匹配,update 选择包含 3 个 DIV。保留它们不动,用 enter 选择为 [1, 2, 32] 追加新元素:

div.enter().append("div").text(d => d);

同样地,删除退出中的元素 [15, 23, 42]:

div.exit().remove();

此时 body 变为:

<div>1</div>
<div>2</div>
<div>4</div>
<div>8</div>
<div>16</div>
<div>32</div>

此处 DOM 元素顺序与数据顺序一致,是因为旧数据与新数据的顺序恰好相容。若新数据顺序不同(例如数据重新排序),需要调用 selection.order 按数据顺序重排 DOM 元素——这也是 selection.join 内部自动替你做的事之一。

六、selection.datum(value):单个数据的读写

selection.datum(value) 读写每个选中元素绑定的数据。与 selection.data 不同,它不计算连接,也不影响索引及 enter/exit 选择。

  • 若指定 value:把所有选中元素的绑定数据设为该值。value 是常量时所有元素共享同一数据项;是函数时按元素顺序逐个求值(参数 dinodesthis 为当前 DOM 元素),以函数返回值设置各元素的新数据;传 null 可删除绑定数据(这也是清除数据绑定的正确方式);
  • 若未指定 value:返回选择中第一个(非 null)元素的绑定数据,通常仅在选择恰好包含一个元素时才有意义。

一个典型应用是暴露 HTML5 自定义数据属性。给定:

<ul id="list">
  <li data-username="shawnbot">Shawn Allen</li>
  <li data-username="mbostock">Mike Bostock</li>
</ul>

可以把每个元素的 dataset(内置数据集属性)设为其绑定数据:

selection.datum(function() { return this.dataset; })

这样后续回调中 d.username 就能直接读到 data-username 的值。

七、selection.merge(other):按索引合并两个选择

selection.merge(other) 返回一个新选择,把当前选择与给定的 other 选择(或过渡)合并:

  • 返回选择与当前选择具有相同的组数与相同的父元素
  • 当前选择中缺失(null)的元素,用 other 选择中对应位置(若存在且非 null)的元素补齐;
  • other 选择有额外的组或父元素,则被忽略;
  • 这是按元素索引合并的:当两个选择在相同索引处都有非 null 元素时,取当前选择的元素,other 的选择在该索引的元素被忽略。因此它不适合拼接任意两个选择。

selection.join 内部正是用它来合并 data 绑定后的 enter 与 update 选择。你也可以显式使用 merge,但要注意:由于合并基于索引,应使用保持索引的操作(如 selection.select),而不要用 selection.filter(filter 会重排索引,导致错位合并)。示例:

const odd = selection.select(function(d, i) { return i & 1 ? this : null; });
const even = selection.select(function(d, i) { return i & 1 ? null : this; });
const merged = odd.merge(even);

这里用 select 函数版(返回 this 或 null)把选择拆成奇偶两个索引保持的选择,再 merge 回完整选择。若改用 filter,两个子选择的索引都会坍缩,合并结果就会错位。

八、小结:三个 API 的选择策略

场景 推荐 API 说明
常规增删改同步 selection.data(...).join(...) 字符串简写即可覆盖 90% 的场景
需差异化/动画化 enter、update、exit selection.join(enter, update, exit) 传函数 各阶段可分别 append、attr、transition
需要精细控制或教学理解机制 data + enter()/exit()/append/remove/order 显式通用更新模式,join 的展开形式
单元素读/写数据、清除绑定 selection.datum(value) 不参与连接,null 可清除
合并保持索引的两个子选择 selection.merge(other) 按索引补齐 null 位,勿与 filter 搭配

以上机制共同构成了 d3 数据可视化的骨架:select/selectAll 划定作用域(见 Selecting elements),data 完成数据与元素的匹配,join/enter/exit 完成 DOM 同步,再配合 Modifying elements 中的 attr/style/text 等设置视觉属性。掌握这套流程后,动态图表、流式数据更新与过渡动画都有了统一的实现基础。

登录后查看全文
热门项目推荐
相关项目推荐