引言
D3 常被误认为「又一个图表库」。它不是——D3 不提供现成的柱状图或折线图组件,它提供的是一组把数据绑定到 DOM 的底层原语:数据连接、比例尺、形状生成器、布局算法、过渡。理解了这些原语,你可以造出任何图形,包括那些在 ECharts 里根本不存在的可视化形式。
这带来一个选型上的清晰边界:能用现成图表类型表达的需求,不该用 D3。用 D3 写一个标准柱状图要 60 行代码,ECharts 只需 10 行配置,且前者的可维护性更差。D3 的价值在于三类场景:需要发明新图形(如自定义的桑基变体)、需要数据驱动的布局算法(力导向、树、打包)、以及需要与既有 DOM 深度集成(如把图表元素混进表单)。
本文按「数据连接 → 比例尺 → 形状 → 布局 → 过渡 → 声明式对照」的顺序展开,全部代码基于 D3 v7(ESM 模块化版本)。第 9 节会对照 Vega-Lite 的图形语法,说明为什么声明式在标准图表上更优,以及两者如何组合使用。
目录
- D3 的定位与版本演进
- 数据连接与 enter/update/exit
- 比例尺 scale 体系
- 坐标轴与多坐标系
- 形状生成器与路径数据
- 力导向布局与网络图
- 层级布局:树、矩形树与打包
- 过渡动画与插值器
- 图形语法与声明式的边界
- Canvas 与 WebGL 混合渲染
- 大数据量下的性能策略
- 构建可复用的 D3 组件
1. D3 的定位与版本演进
D3(Data-Driven Documents)的核心命题是:把数据绑定到 DOM 元素,让文档随数据变化。它的能力分五层:选择集(selection)、数据连接(data join)、比例尺(scale)、形状与布局(shape/layout)、过渡(transition)。上面几层完全独立,可以只用其中一层——比如只用 d3-scale 和 d3-array 做数据处理,渲染交给 React。
v4 是一个分水岭:它把 D3 拆成三十多个独立 npm 包(d3-selection、d3-scale、d3-shape 等),并引入了 flat namespace——事件、数据连接的回调签名从 d.foo 改为 (d, i, nodes) 三元组。v5 加入 Promise 化的 d3.csv,v6 把事件系统换成原生 DOM 事件(event 作为回调的第一个参数而非全局 d3.event),v7 全面 ESM 化。
# 只装需要的子包,而不是整包 280KB
npm i d3-selection d3-scale d3-shape d3-axis d3-array
# 或者装整包(开发方便,生产建议按需)
npm i d3
import { select } from 'd3-selection';
import { scaleLinear } from 'd3-scale';
import { line, curveMonotoneX } from 'd3-shape';
按需引入能把体积从 280KB 压到几十 KB,这对只用到两三个原语的项目是必要的。
2. 数据连接与 enter/update/exit
数据连接是 D3 最核心也最容易误解的机制。它的目标是把一个数据数组与一组 DOM 元素对应起来,并区分三种情况:新数据没有对应元素(enter)、数据与元素一一对应(update)、元素没有对应数据(exit)。
const data = [30, 86, 168, 281, 303];
select('svg')
.selectAll('rect') // 选中的是「将要存在」的元素
.data(data) // 绑定数据,按索引或 key 匹配
.join(
(enter) => enter.append('rect') // 新增:创建元素
.attr('fill', '#2f6fed')
.attr('height', 0)
.call((e) => e.transition().attr('height', (d) => d)),
(update) => update.attr('fill', '#2f6fed'), // 更新:只改属性
(exit) => exit.call((e) => e.transition().attr('height', 0).remove()),
);
join() 是 v5.8 引入的语法糖,替代了老式的 enter().append().merge() 三段式。它的三个回调分别对应 enter、update、exit,返回的选择集是 merge 后的结果。
key 函数决定了匹配方式。默认按索引匹配:数据 [A, B] 变成 [B, A] 时,D3 认为两个元素都只是「值变了」,会做属性过渡而非移动元素。这往往不是想要的效果——如果元素有身份(如用户 ID),必须提供 key:
.data(rows, (d) => d.id) // 按 id 匹配,重排序时元素会跟随移动
这个区别在动画里非常明显:按索引匹配时重排序会看到颜色在元素间「跳」,按 key 匹配时会看到元素平滑移动到新位置。凡是会增删或重排的数据,都必须给 key。
3. 比例尺 scale 体系
比例尺把数据域(domain)映射到视觉范围(range)。D3 的比例尺家族覆盖了所有常见映射:
| 比例尺 | 数据域 | 输出 | 典型用途 |
|---|---|---|---|
scaleLinear | 连续 | 连续 | 数值 → 位置 |
scaleLog | 连续(正) | 连续 | 跨数量级 → 位置 |
scaleSqrt | 连续 | 连续 | 数值 → 面积(气泡图) |
scaleTime | 时间 | 连续 | 时间 → 位置 |
scaleBand | 离散 | 离散区间 | 类别 → 条形位置 |
scalePoint | 离散 | 离散点 | 类别 → 散点位置 |
scaleOrdinal | 离散 | 离散 | 类别 → 颜色 |
scaleSequential | 连续 | 连续色 | 数值 → 连续色阶 |
scaleQuantize | 连续 | 离散 | 数值 → 分档色 |
import { scaleLinear, scaleBand, scaleSequential } from 'd3-scale';
import { interpolateViridis } from 'd3-scale-chromatic';
const x = scaleBand().domain(data.map((d) => d.name)).range([0, width])
.paddingInner(0.2).paddingOuter(0.1); // 组内/组外间距(比例)
const y = scaleLinear().domain([0, maxValue]).nice() // nice() 扩到整洁边界
.range([height, 0]); // Y 轴反转,SVG 原点在左上
const color = scaleSequential(interpolateViridis).domain([0, maxValue]);
两个易错点。其一,Y 轴的 range 必须反转([height, 0]),因为 SVG 的 Y 轴向下增长,不反转的话数值大的会画在底部。其二,scaleBand 的 bandwidth() 给出每段的宽度,不要自己算 width / n——padding 会破坏这个简单除法。
scaleSqrt 用于气泡图是必要的:它让面积与数值成正比,而不是半径。这与感知原理中「面积编码要用平方映射」的结论一致,细节见 视觉编码与感知原理
。
4. 坐标轴与多坐标系
d3-axis 把比例尺渲染成带刻度和标签的轴。它渲染的是真实 DOM(g.tick 与 line、text),可以被 CSS 完全控制。
import { axisBottom, axisLeft } from 'd3-axis';
const xAxis = axisBottom(x).ticks(6) // 建议刻度数,D3 选「整洁」的值
.tickFormat((d) => `${d}月`).tickSizeOuter(0);
const yAxis = axisLeft(y).ticks(5, '~s') // SI 前缀:12k, 1.2M
.tickSize(-width); // 负值让刻度线变成网格线
svg.append('g').attr('transform', `translate(0,${height})`).call(xAxis);
svg.append('g').call(yAxis)
.selectAll('.tick line').attr('stroke', '#eee'); // 网格线调淡
.ticks(5, '~s') 的第二个参数是格式说明符,~s 表示 SI 前缀且去掉尾随零。用 tickSize(-width) 把刻度线变成横贯全图的网格线是常用技巧——比单独画网格线省事,且与刻度位置天然对齐。
多坐标系(如共享 X 轴的两个子图)用多个 <g> 容器加 transform 平移实现。共享 Y 轴范围是关键,否则两个子图的量级不可比。如果确实需要不同的 Y 范围(如双指标),把它们画成上下两个独立子图,而不是共用一张图加双 Y 轴——双 Y 轴会制造虚假相关性。
5. 形状生成器与路径数据
d3-shape 把数据数组转成 SVG 路径的 d 属性。它不画图,只生成路径字符串。
import { line, area, curveMonotoneX, arc, pie } from 'd3-shape';
const lineGen = line().x((d) => x(d.date)).y((d) => y(d.value))
.curve(curveMonotoneX) // 单调三次插值,不产生虚假极值
.defined((d) => d.value != null); // 空值处断开
const areaGen = area().x((d) => x(d.date)).y0(height).y1((d) => y(d.value))
.curve(curveMonotoneX);
// 饼图:pie 生成角度,arc 生成路径
const pieGen = pie().value((d) => d.value).sort(null);
const arcGen = arc().innerRadius(60).outerRadius(100);
svg.selectAll('path.slice').data(pieGen(data)).join('path')
.attr('class', 'slice').attr('d', arcGen)
.attr('fill', (d) => color(d.data.name));
曲线插值器的选择影响很大。curveLinear 是直线连接,curveMonotoneX 保持单调性(不会在数据点之间产生超出范围的过冲),curveBasis 平滑但会偏离数据点,curveStep 是阶梯线(适合离散状态)。默认应该用 curveMonotoneX——它在平滑与保真之间取得平衡,而 curveBasis 的「好看」是以失真为代价的。
.defined() 处理空值很关键。没有它,缺失数据会被当作 0 连接起来,产生虚假的深谷。
6. 力导向布局与网络图
力导向是 D3 最著名的布局,它用物理模拟(电荷斥力 + 弹簧引力 + 向心力)让节点自动排布。它适合展示关系结构而非精确数值。
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide } from 'd3-force';
const simulation = forceSimulation(nodes)
.force('link', forceLink(links).id((d) => d.id).distance(60).strength(0.5))
.force('charge', forceManyBody().strength(-200)) // 负值表示斥力
.force('center', forceCenter(width / 2, height / 2))
.force('collide', forceCollide().radius((d) => d.r + 2)) // 防重叠
.alphaDecay(0.0228) // 默认衰减,越小越慢越稳
.on('tick', ticked);
function ticked() {
link.attr('x1', (d) => d.source.x).attr('y1', (d) => d.source.y)
.attr('x2', (d) => d.target.x).attr('y2', (d) => d.target.y);
node.attr('cx', (d) => d.x).attr('cy', (d) => d.y);
}
三个调参要点。forceManyBody().strength() 的负值越大斥力越强,图越松散;forceLink().distance() 是弹簧的自然长度;alphaDecay 控制冷却速度,默认 0.0228 约 300 次迭代后停止,值越小收敛越慢但布局越稳定。
力导向的结果是不确定的——每次运行都不同。这对「可复现的图表」是个问题,解决办法是固定随机种子(d3-force 内部用 Math.random,需要 monkey patch)或预计算一次坐标后缓存。另一个常见需求是「固定某些节点位置」(如把核心节点钉在中心),通过设置节点的 fx/fy 实现。
性能上,forceManyBody 默认用 Barnes-Hut 四叉树近似,复杂度 O(n log n)。节点超过约 2000 个后应关闭实时 tick 动画,改为「跑 300 次迭代后一次性绘制」。这个取舍与 大屏渲染性能与优化
中的思路一致。
7. 层级布局:树、矩形树与打包
d3-hierarchy 处理嵌套数据。先用 d3.stratify 或 d3.hierarchy 把扁平/嵌套数据转成树,再交给布局。
import { hierarchy, tree, treemap, pack, partition } from 'd3-hierarchy';
const root = hierarchy(nestedData).sum((d) => d.value) // 自底向上汇总
.sort((a, b) => b.value - a.value);
tree().size([width, height])(root); // 树状图:层级关系
treemap().size([width, height]).padding(2).round(true)(root); // 矩形树图:层级 + 占比
pack().size([width, height]).padding(3)(root); // 打包圆:层级 + 量级
// 访问结果:root.descendants() 与 root.links(),坐标为 d.x/d.y/d.r 或 d.x0/d.y0/d.x1/d.y1
sum() 是必须的一步——没有它,父节点的 value 是 undefined,布局无法计算。sort() 决定子节点排列顺序,对矩形树图的可读性影响很大(按大小排序能让读者快速定位最大的块)。
旭日图(sunburst)用 partition() 生成:它把树映射成同心圆环,每一层的角度范围与数值成正比。相比矩形树图,旭日图的层级嵌套感更强,但外圈的扇区角度小、标签难放。选择依据是层级深度:2 到 3 层用矩形树图,3 层以上且需要强调「从中心向外展开」用旭日图。
8. 过渡动画与插值器
d3-transition 让属性变化在时间上平滑。它基于 requestAnimationFrame,自动处理插值。
import { transition } from 'd3-transition';
import { interpolateNumber, interpolateRgb, interpolateString } from 'd3-interpolate';
import { easeCubicOut } from 'd3-ease';
select('rect')
.transition()
.duration(300)
.ease(easeCubicOut)
.delay((d, i) => i * 20) // 错开,形成瀑布效果
.attr('height', (d) => height - y(d.value))
.attr('fill', (d) => color(d.value));
D3 会自动选择插值器:数字用 interpolateNumber,颜色字符串用 interpolateRgb,带单位的字符串(如 "12px")用 interpolateString。这意味着你可以直接对 attr('d', ...) 做过渡——路径字符串会被逐段插值。前提是前后路径的命令结构一致(同样数量的点、同样的命令序列),否则会出现诡异的形变。
三条动画规则。时长:200 到 500 毫秒。错开:多个元素同时动画会显得呆板,用 delay((d, i) => i * 20) 形成波浪。可中断:新数据到来时要先 interrupt() 掉进行中的过渡,否则会叠加冲突。
// 新数据到达时中断旧过渡
select('rect').interrupt().transition().duration(300).attr('width', (d) => x(d.value));
9. 图形语法与声明式的边界
D3 是命令式的:你告诉它每一步做什么。图形语法(Grammar of Graphics)是声明式的:你声明「数据、映射、几何对象、统计变换、坐标系、分面」六个要素,渲染由框架决定。
Vega-Lite 是图形语法最完整的实现,配置本身就是 JSON:
{
"data": { "url": "sales.csv" },
"mark": "bar",
"encoding": {
"x": { "field": "region", "type": "nominal", "sort": "-y" },
"y": { "field": "sales", "type": "quantitative", "aggregate": "sum" },
"color": { "field": "category", "type": "nominal" }
}
}
这 8 行声明了一个按销售额排序、按类别着色的分组柱状图,包含聚合。用 D3 实现同样的效果需要约 60 行命令式代码。声明式的优势在于:规格可序列化、可复现、可被工具生成——这正是 BI 平台把图表定义存进数据库的基础,Metabase 与 Superset 的图表定义本质上都是这种规格。
那么 D3 的边界在哪?当六个要素中任何一个需要超出规格定义的能力时。比如:自定义的布局算法(力导向的变体)、非标准几何对象(河流图、和弦图的定制)、与 DOM 的深度交互(可拖拽编辑的图表)。判断标准是:如果 Vega-Lite 能表达,就不要用 D3。
实践中两者可以组合:用 Vega-Lite(或 ECharts)处理 90% 的标准图表,把剩下的定制图表用 D3 写成独立组件。ECharts 的 custom 系列提供了类似的混合能力——在标准坐标系里插入自定义渲染逻辑。关于声明式体系的进一步展开,见 AntV G2 声明式图表体系
。
10. Canvas 与 WebGL 混合渲染
D3 默认操作 SVG DOM,元素超过约 2000 个后性能明显下降——每个元素都是 DOM 节点,浏览器要维护样式、布局与合成。此时应该把渲染层换成 Canvas,只保留 D3 的比例尺与数据处理。
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 用 D3 的比例尺,但用 Canvas 绘制
function draw(data) {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#2f6fed';
for (const d of data) {
// 圆形散点:arc 比 rect 慢,用 rect 做近似可提速数倍
ctx.fillRect(x(d.x) - 2, y(d.y) - 2, 4, 4);
}
}
Canvas 的关键性能技巧:避免逐点设置样式(按颜色分组批量绘制)、用 fillRect 替代 arc(小圆点用矩形近似几乎看不出差别但快得多)、离屏 Canvas 缓存静态层。超过十万点时应该上 WebGL(用 regl 或 deck.gl),或者干脆改用分箱聚合的热力图——点太多时散点本身就失去了可读性。
// 按颜色批量绘制,避免在循环里切换 fillStyle
const byColor = groupBy(data, (d) => d.category);
for (const [color, items] of Object.entries(byColor)) {
ctx.fillStyle = colorMap[color];
ctx.beginPath();
for (const d of items) ctx.rect(x(d.x) - 2, y(d.y) - 2, 4, 4);
ctx.fill();
}
11. 大数据量下的性能策略
除了切换渲染后端,还有四类结构性优化。
降采样:用 LTTB 或分箱把点数降到屏幕像素量级。D3 本身不提供降采样,需要手写或用 d3-array 的 bin()。
虚拟化视窗:只渲染当前可见范围的数据,配合 dataZoom 式的交互。这要求数据已按 X 轴排序,可以用二分查找定位视窗。
分帧渲染:把绘制拆成多个 requestAnimationFrame,避免长任务阻塞。d3-timer 提供的 interval 可以做节流。
Web Worker 计算布局:力导向、聚类这类计算密集的布局放到 Worker 里,主线程只负责绘制。d3-force 是纯计算,很容易搬进 Worker,通过 postMessage 回传坐标。
// worker.js:Worker 里跑力导向,主线程只画
importScripts('/vendor/d3-force.min.js');
self.onmessage = ({ data }) => {
const sim = d3.forceSimulation(data.nodes)
.force('link', d3.forceLink(data.links).id((d) => d.id))
.force('charge', d3.forceManyBody()).stop();
for (let i = 0; i < 300; i++) sim.tick(); // 同步跑完,不逐帧渲染
self.postMessage({ nodes: data.nodes });
};
在 Worker 里同步跑完迭代再一次性回传,比在主线程里逐帧 tick 快得多,也不会让页面卡顿。
12. 构建可复用的 D3 组件
D3 没有组件概念,但可以用闭包 + 可链式调用的配置函数封装出组件。这是 Mike Bostock 提出的 reusable chart 模式:
function barChart() {
let width = 600, height = 320, value = (d) => d.value;
const margin = { top: 20, right: 20, bottom: 30, left: 50 };
function chart(selection) {
selection.each(function (data) {
const innerW = width - margin.left - margin.right;
const innerH = height - margin.top - margin.bottom;
const x = scaleBand().domain(data.map((d, i) => i)).range([0, innerW]).padding(0.2);
const y = scaleLinear().domain([0, max(data, value)]).nice().range([innerH, 0]);
const g = select(this).selectAll('g.plot').data([data])
.join('g').attr('class', 'plot')
.attr('transform', `translate(${margin.left},${margin.top})`);
g.selectAll('rect').data(data).join('rect')
.attr('x', (d, i) => x(i)).attr('y', (d) => y(value(d)))
.attr('width', x.bandwidth()).attr('height', (d) => innerH - y(value(d)));
});
}
// 链式配置:chart.width(800) 既做 setter 也返回自身
chart.width = (v) => (v == null ? width : ((width = v), chart));
chart.value = (v) => (v == null ? value : ((value = v), chart));
return chart;
}
select('#container').datum(salesData).call(barChart().width(720).value((d) => d.amount));
这个模式的关键是 chart() 接受一个选择集并就地渲染,配置方法既做 setter 又做 getter。它与 React 的 ref + useEffect 模式可以结合:把 D3 组件当作纯粹的渲染函数,数据与配置通过 props 传入,effect 里调用 .call(chart)。
权衡取舍
| 决策点 | 选项 A | 选项 B | 何时选 A | 何时选 B |
|---|---|---|---|---|
| 库选择 | D3 | ECharts/G2 | 自定义图形、布局算法 | 标准图表类型 |
| 范式 | 命令式 | 声明式(Vega-Lite) | 需要超出规格的能力 | 规格可表达 |
| 渲染 | SVG | Canvas | 元素 <2000、需 CSS 控制 | 元素 >2000、高频重绘 |
| 数据连接 | 按索引 | 按 key | 数据只增不改序 | 会增删或重排 |
| 曲线 | curveMonotoneX | curveBasis | 需要保真 | 只需平滑观感 |
| 力导向 | 实时 tick | 预计算后静态 | 节点 <500、需交互 | 节点多、只需终态 |
| 组件化 | 闭包模式 | 框架组件 | 框架无关复用 | 已在 React/Vue 内 |
常见坑清单
- 数据连接不给 key——重排序时元素属性「跳变」而非平滑移动;会增删重排的数据必须传 key 函数。
- Y 轴 range 不反转——SVG 原点在左上,数值大的画到了底部;range 用
[height, 0]。 - 自己算 band 宽度——忽略 padding 导致柱子重叠;用
scaleBand().bandwidth()。 - 曲线用 curveBasis 做保真图——平滑会偏离数据点,产生虚假极值;默认用 curveMonotoneX。
- 不处理空值——缺失数据被连成 0,出现虚假深谷;用
.defined()断开。 - 过渡前不 interrupt——新旧过渡叠加,属性抖动;更新前先
interrupt()。 - SVG 渲染上万元素——DOM 节点过多导致布局与合成卡顿;改 Canvas 并批量绘制。
- 循环内切换 fillStyle——每点一次状态切换,性能骤降;按颜色分组批量绘制。
- 力导向结果当确定性——每次运行布局不同,无法复现;固定种子或缓存坐标。
- hierarchy 忘了 sum()——父节点 value 为 undefined,布局计算出 NaN;建树后必须 sum。
小结
D3 的价值不在「画图」,而在「把数据映射到视觉通道」这套原语。数据连接的 enter/update/exit 三分法、比例尺的域到范围映射、形状生成器把数据转路径、布局算法把关系转坐标——理解了这四组原语,任何图形都可以从零构建。
选型上的判断很清晰:标准图表用声明式库,定制图形用 D3。Vega-Lite 的图形语法证明了「数据、映射、几何、变换、坐标系、分面」六要素能覆盖绝大多数图表需求,而 BI 平台正是建立在这个洞察之上。当六要素中有一项无法表达时,才轮到 D3 出场。
下一步可以对照 AntV G2 声明式图表体系 看另一种声明式实现(图形语法 + 交互语法),或者回到 数据可视化原理与图表选型 重新审视选型决策的整体框架;网络图与地理图的进阶实现见 地理关系与网络图可视化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。