D3 数据连接实战:selection.data、join 与 enter/update/exit 三段式 DOM 同步机制详解
本篇基于 d3 官方文档 Joining data 展开,深入讲解 d3-selection 的数据连接(data join)机制:如何把任意数组数据绑定到已选中的 DOM 元素上、如何用 key 函数控制"数据—元素"的对应关系,以及 enter、update、exit 三个子选择如何配合完成 DOM 与数据的同步。读完本文,你将能够熟练运用 selection.data、selection.join、selection.enter、selection.exit、selection.datum 和 selection.merge 六个 API,掌握 d3 数据可视化中最核心的 General Update Pattern(通用更新模式),并能对照仓库内真实示例组件验证其用法。
一、为什么需要数据连接
d3 的核心工作方式是"数据驱动的 DOM 变换":通过 d3-selection 选中元素后,设置 attributes、styles 等内容。当数据是动态变化的,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 选择的新选择,同时在返回的选择上定义 enter 和 exit 子选择,供后续添加或删除元素。
关键行为:
- 数据粘性(sticky):数据被赋给元素后,会存储在该元素的
__data__属性上,因此重新选中同一元素时仍可用d访问其数据; - data 是"按组"(per group)指定的:若选择包含多个组(例如 d3.selectAll 之后又调用了 selection.selectAll),data 通常应写成函数,该函数按组依次求值,参数为组父级数据(d,可能为 undefined)、组索引(i)和选择父节点数组(nodes),
this为组父元素; - 默认按索引连接:若未提供 key 函数,则数据数组第 i 项绑定到第 i 个选中元素;
- key 函数控制对应关系:key 函数先对每个选中元素求值(参数 d、i、nodes,
this为当前 DOM 元素nodes[i],此时 d 是元素当前已绑定的数据,首次连接时为 null),再对每个新数据项求值(this为组父元素),两次返回的字符串相同即视为同一对象。若多个元素返回同一个 key,重复元素进入 exit 选择;若多个数据项返回同一个 key,重复数据进入 enter 选择; - 顺序规则:update 与 enter 选择按数据顺序返回,而 exit 选择保持连接前的选择顺序。指定 key 函数后,选择中元素的顺序可能不再与文档顺序一致,需要时用 selection.order 或 selection.sort 调整;
- 其他约束:不传 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 选择合并后的选择。它是显式通用更新模式的便捷替代,一次性替代了 enter、exit、selection.append、selection.remove 和 selection.order 的组合。
3.1 字符串简写与等价展开
svg.selectAll("circle")
.data(data)
.join("circle")
.attr("fill", "none")
.attr("stroke", "black");
enter 参数可以写成字符串简写,等价于用该元素名调用 selection.append;update 与 exit 为可选函数,默认分别为恒等函数和调用 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 中驱动过渡动画
在 enter、update、exit 函数内部创建 transitions 即可为三个阶段分别加动画。若 enter 和 update 函数返回的是过渡(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 是常量时所有元素共享同一数据项;是函数时按元素顺序逐个求值(参数 d、i、nodes,
this为当前 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 等设置视觉属性。掌握这套流程后,动态图表、流式数据更新与过渡动画都有了统一的实现基础。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00