D3.js 与图形语法

以 D3 v7 为基准讲清 data join 的 enter/update/exit 心智模型、比例尺与轴的构建、形状生成器、力导向与层级布局、过渡动画,并对照 Vega-Lite 的图形语法说明声明式与命令式的边界与组合策略。

引言

D3 常被误认为「又一个图表库」。它不是——D3 不提供现成的柱状图或折线图组件,它提供的是一组把数据绑定到 DOM 的底层原语:数据连接、比例尺、形状生成器、布局算法、过渡。理解了这些原语,你可以造出任何图形,包括那些在 ECharts 里根本不存在的可视化形式。

这带来一个选型上的清晰边界:能用现成图表类型表达的需求,不该用 D3。用 D3 写一个标准柱状图要 60 行代码,ECharts 只需 10 行配置,且前者的可维护性更差。D3 的价值在于三类场景:需要发明新图形(如自定义的桑基变体)、需要数据驱动的布局算法(力导向、树、打包)、以及需要与既有 DOM 深度集成(如把图表元素混进表单)。

本文按「数据连接 → 比例尺 → 形状 → 布局 → 过渡 → 声明式对照」的顺序展开,全部代码基于 D3 v7(ESM 模块化版本)。第 9 节会对照 Vega-Lite 的图形语法,说明为什么声明式在标准图表上更优,以及两者如何组合使用。

目录

  1. D3 的定位与版本演进
  2. 数据连接与 enter/update/exit
  3. 比例尺 scale 体系
  4. 坐标轴与多坐标系
  5. 形状生成器与路径数据
  6. 力导向布局与网络图
  7. 层级布局:树、矩形树与打包
  8. 过渡动画与插值器
  9. 图形语法与声明式的边界
  10. Canvas 与 WebGL 混合渲染
  11. 大数据量下的性能策略
  12. 构建可复用的 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
库选择D3ECharts/G2自定义图形、布局算法标准图表类型
范式命令式声明式(Vega-Lite)需要超出规格的能力规格可表达
渲染SVGCanvas元素 <2000、需 CSS 控制元素 >2000、高频重绘
数据连接按索引按 key数据只增不改序会增删或重排
曲线curveMonotoneXcurveBasis需要保真只需平滑观感
力导向实时 tick预计算后静态节点 <500、需交互节点多、只需终态
组件化闭包模式框架组件框架无关复用已在 React/Vue 内

常见坑清单

  1. 数据连接不给 key——重排序时元素属性「跳变」而非平滑移动;会增删重排的数据必须传 key 函数。
  2. Y 轴 range 不反转——SVG 原点在左上,数值大的画到了底部;range 用 [height, 0]。
  3. 自己算 band 宽度——忽略 padding 导致柱子重叠;用 scaleBand().bandwidth()。
  4. 曲线用 curveBasis 做保真图——平滑会偏离数据点,产生虚假极值;默认用 curveMonotoneX。
  5. 不处理空值——缺失数据被连成 0,出现虚假深谷;用 .defined() 断开。
  6. 过渡前不 interrupt——新旧过渡叠加,属性抖动;更新前先 interrupt()。
  7. SVG 渲染上万元素——DOM 节点过多导致布局与合成卡顿;改 Canvas 并批量绘制。
  8. 循环内切换 fillStyle——每点一次状态切换,性能骤降;按颜色分组批量绘制。
  9. 力导向结果当确定性——每次运行布局不同,无法复现;固定种子或缓存坐标。
  10. hierarchy 忘了 sum()——父节点 value 为 undefined,布局计算出 NaN;建树后必须 sum。

小结

D3 的价值不在「画图」,而在「把数据映射到视觉通道」这套原语。数据连接的 enter/update/exit 三分法、比例尺的域到范围映射、形状生成器把数据转路径、布局算法把关系转坐标——理解了这四组原语,任何图形都可以从零构建。

选型上的判断很清晰:标准图表用声明式库,定制图形用 D3。Vega-Lite 的图形语法证明了「数据、映射、几何、变换、坐标系、分面」六要素能覆盖绝大多数图表需求,而 BI 平台正是建立在这个洞察之上。当六要素中有一项无法表达时,才轮到 D3 出场。

下一步可以对照 AntV G2 声明式图表体系 看另一种声明式实现(图形语法 + 交互语法),或者回到 数据可视化原理与图表选型 重新审视选型决策的整体框架;网络图与地理图的进阶实现见 地理关系与网络图可视化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「数据可视化」更多文章

  1. WebGL 与三维数据可视化
  2. 数据叙事与图表沟通
  3. 嵌入式分析与白标集成