做 D3.js 数据可视化的朋友,绕不开一个看似简单却特别容易翻车的环节——文本排版,尤其是长文本的分行与分段。SVG 里的 text 元素不像 HTML 里的 div 那样会自动换行,也不会自动帮你处理段间距,你给它多长的字符串,它就一行画到天边。早几年我第一次在力导向图节点里塞备注文字时,就因为这个吃了大亏。本文会从 SVG 文本排版的基本模型讲起,一步步拆解如何用 tspan、宽度测量和断行策略,实现真正可用的自动分行与分段方案,并附上我封装好的可复用函数和踩坑记录,适合正在做图表 tooltip、节点标签、示意图说明文字的读者参考。
1. 为什么 SVG 文本排版这么反直觉
刚接触 D3 的时候,很多人会把 SVG 里的 text 想象成一个文本框,以为设置好坐标和宽度后,文字就会像网页排版一样自动包裹、自动分段。实际运行起来才发现,它完全是另外一套逻辑。
1.1 先认清 SVG text 的排版模型
SVG 的 text 元素本质上是一条“文本基线”。你指定一个 (x, y) 坐标,这个坐标是文本基线的起点,后续所有字符都沿着这条基线向右延伸。没有块级布局,没有自动换行,没有 line-height 属性,字符超出画布也不会有任何提示或截断。
这和 HTML 的排版模型完全是两回事。在 HTML 里,文本放进一个固定宽度的 div,浏览器会先按换行机会拆分文本,再按行盒(line box)依次排布,行高、断词、溢出不换行都是底层排版引擎自动处理的。而 SVG 是绘图模型,它的最小单位是形状和路径,字符只是一个复杂一点的形状组合,绘制完当前字符后,文本光标就移动到下一个字符的位置,仅此而已。
所以,当我们给 D3 生成的 text 元素绑定一个长字符串,最直接的结果就是:整段文字变成一条线,一直延伸出去,直到超出 viewBox 被裁掉。要想让它多行显示,唯一办法是使用 tspan 子元素,手动把文字拆成多行,并明确指定每一行相对上一行的偏移量。这也是分行与分段所有技巧的技术起点。
还有一种常见误解是“加 white-space: normal 或 word-break 就能自动换行”。这些 CSS 属性在 SVG text 上基本不生效,尤其是喂给 innerHTML 字符串时,SVG 不会有任何 DOM 重排。你把 \n 塞进 SVG text 的 textContent 里,它也会原样显示成一个空格或小方块,绝不会变成新行。
1.2 手动排版前先想清楚的几个问题
在写代码之前,我建议先想清楚三个问题:文本会不会动态变化?最大宽度是多少?字体资源能否保障稳定加载?这三个答案直接决定你用哪种方案。
文本是否动态变化,影响的是你需不需要写一个通用的 wrap 工具函数,还是直接在数据绑定阶段手动插入几个 tspan。如果只是图表里一个固定不变的标题,手动写两个 tspan 完全够用;如果是 tooltip 或标注框,文本是动态拼接的,就必须做自动化分行。
最大宽度怎么定,影响的是断行依据。在 D3 图表里,宽度通常有三个来源:图表的固定容器宽度、节点的半径范围、或 tooltip 预留的矩形框宽度。你需要把可用宽度算出来,再传给排版函数。这个宽度建议留 8-12px 的余量,否则测量误差容易导致最后 1-2 个字符被挤到下一行。
字体资源是否加载完成,是最隐蔽的一个坑。SVG text 的测量是依赖当前字体的度量数据的,如果异步字体还没加载完就测量并换行,等字体切换后文字宽度变化,所有行都会错位。后面我会专门讲字体加载的排查方法。
2. 分行:把长文本拆成多行的完整套路
分行的核心其实就两步:第一,决定哪些字符属于同一行;第二,用 tspan 把这些行依次渲染出来。听起来简单,但每一步都有不少细节。
2.1 基础操作:用 tspan 手工分行
最朴素的分行方式,是在 D3 的链式调用里用 selectAll + data + enter 生成多个 tspan。每个 tspan 控制一行的文字内容、起点 x 坐标和相对上一行的位移 dy。
先看一个最简版本:
const lines = ['第一行文本', '第二行文本', '第三行文本']; svg.append('text') .attr('x', 20) .attr('y', 40) .style('font-size', '14px') .style('font-family', 'sans-serif') .selectAll('tspan') .data(lines) .join('tspan') .attr('x', 20) // 每行都从 x=20 开始,保证左对齐 .attr('dy', (d, i) => i === 0 ? 0 : '1.4em') // 第一行不偏移,后续每行向下 1.4em .text((d) => d);这个例子里有几个关键点值得说透。
第一个是 x 属性。tspan 如果不设置 x,它会默认继承父级 text 的起始位置,但这只是相对于整条基线的一次性定位。一旦中间某一行因为文字被二义性字符占位或测量偏差导致长度变化,后续行 x 并不会自动校正。所以我每行都显式指定 x,让每一行的起点绝对定位到同一列,左对齐效果才稳定。
第二个是 dy 的 em 单位。dy 表示当前字符相对前一个 tspan 基线位置的垂直偏移。第一行用 0,因为它的位置已经由 text 元素的 y 决定了;从第二行开始,dy 的值表示“在上一行的基础上再往下移多少”。用 em 单位的好处是它可以跟随字体大小等比缩放,比如字号从 14px 变成 20px,1.4em 对应的实际像素值也会变成 28px,行距比例始终一致。如果你用固定 px 值,哪怕字体大小调整了,行距还是老样子,视觉上会很拥挤或过宽。
第三个是 join 的用法。D3 v6 之后推荐直接用 selection.join('tspan'),它会根据数据长度自动处理新增、更新和删除。以前常见的 enter().append('tspan') 写法不是不能用,但要记得在更新数据时清理旧的 tspan,否则会出现文字残留,尤其是 tooltip 场景里特别容易遇到。
手工分行最大的缺点是:你必须自己预先把长文本拆成多行数组。如果文本来自接口返回的一句完整描述,或者用户输入的一段注释,你根本不知道哪个词该断在哪一行,这时就需要做“按宽度自动换行”。
2.2 按宽度自动换行:测量是关键
自动换行的原理,本质上是用一个测量函数模拟文本渲染过程,逐字累加宽度,当累加宽度超过最大宽度时,把当前这串文字作为一行推入行数组,然后从下一个字符开始重新累加。
这个方案里最关键的地方,是你用什么方式测量文本宽度。我在实际项目里用过两种方式,各有优劣。
第一种是用浏览器内置的 canvas 2D context 的 measureText。先创建一个 canvas 上下文,设置好和 SVG text 一致的 font-family、font-size、font-weight,然后调用 measureText(text).width 得到像素宽度。这个方案的好处是速度快、不依赖 DOM,适合在批量计算时反复调用;坏处是你得确保 canvas 的字体风格和真正渲染时完全一致,尤其是 font-family 的列表顺序和 font-weight,稍微差一点,测量结果就偏了。
function createTextMeasurer(fontSize, fontFamily, fontWeight = 'normal') { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`; return (text) => ctx.measureText(text).width; }第二种是直接把临时字符串放到一个 SVG text 里,渲染后调用 getComputedTextLength() 获取真实渲染宽度。这种方式最接近最终渲染结果,但缺点是:你需要在 DOM 里创建临时元素,测量完再移除;而且如果字体还没加载完,测出来的宽度同样是错的。
我通常的做法是:初始化时创建一个离屏 text 测量器,缓存 font 上下文,然后所有测量都走同一个 context,保证一致性。性能上完全够用,即使一次性计算几千行的文本宽度,canvas measureText 也是纳秒级到微秒级的开销,不会成为瓶颈。
拿到测量函数后,自动换行的核心逻辑就可以这样写:
function wrapLine(text, maxWidth, measurer) { const chars = Array.from(text); const lines = []; let currentLine = ''; for (const char of chars) { const testLine = currentLine + char; if (measurer(testLine) > maxWidth && currentLine) { lines.push(currentLine); currentLine = char; } else { currentLine = testLine; } } if (currentLine) lines.push(currentLine); return lines; }Array.from 而不是 text.split(''),是因为后者会把宽字节字符和 emoji 拆成两半。Array.from 能正确处理码元序列,中文、emoji、特殊符号都能按单个可见字符处理。
这段逻辑里有一个容易忽略的边界条件:如果单个字符本身就超过 maxWidth,不能把空字符串推入行数组,也不能陷入死循环。上面的条件&& currentLine保证了第一行没内容时不会切分,而是把超宽字符硬塞进当前行,这样虽然会溢出,但不会把流程搞崩。真实项目里我还会单独判断:如果 measurer(char) 已经超宽,就动态调大 maxWidth 或降低字号,而不是硬切。
2.3 中文、英文混排的断行策略
按字符硬切是最简单的方案,但它有一个明显的缺点:英文单词会被拦腰截断。比如 “visualization” 被从中间切开,看起来非常不专业。中文是方块字,每个字都可以作为断行点,按字符切没问题;但英文和数字应该按自然语言习惯,在空格处回退,尽量保证单词完整。
一个比较好的折中策略是:遇到空格时,把当前行先“暂存”,继续试探后面一个单词,如果加入这个单词后会超宽,才把之前的暂存内容作为一行输出,同时从当前单词重新开始累积。这就是常见的“last space fallback”算法。
简化版可以这样做:先按空格把文本拆成单词数组,然后逐个单词尝试组合进当前行,超宽就断行。这个策略对纯英文和数字特别友好,对中文的效果也还凑合,因为中文文本里往往夹杂着标点和数字,这些位置天然提供了断行机会。
function wrapSmartLine(text, maxWidth, measurer) { const words = text.split(/(\s+)/); // 保留空格,用于行内拼接 const lines = []; let currentLine = ''; for (const word of words) { const testLine = currentLine + word; if (measurer(testLine) > maxWidth && currentLine.trim()) { lines.push(currentLine.trim()); currentLine = word.trimStart(); } else { currentLine = testLine; } } if (currentLine.trim()) lines.push(currentLine.trim()); return lines; }实际项目里遇到中英文混排,我一般先尝试空格拆分,如果整段文本里空格很少(比如连续中文),再退回逐字拆分。判断规则是统计文本中空格数量占总字符数的比例,低于 5% 就按字符拆,否则按单词拆。
还要注意一点:断行宽度不能卡得太死。measureText 返回的宽度和实际渲染时的抗锯齿差异,以及 Safari 和 Chrome 的字体渲染偏差,可能导致一两个字在空中浮动。所以我在传给 wrap 函数的 maxWidth 上,一般会先减去 4px 左右的预留量,实测下来在各种浏览器里都更稳。
3. 分段:多段文本的布局与视觉控制
分段和分行是两件事。分行解决的是“一行放不下,拆成多行”的问题;分段解决的是“多段文字之间该留多大空隙,怎么组织层级”的问题。很多人做完分行就结束了,结果多段文本之间完全没有段间距,所有段落挤成一团,阅读体验很差。
3.1 多段落的三种组织方式
在 D3 中处理多段文本,常见有三种组织方式,各有适用场景。
第一种是“一个 text + 多个 tspan”。所有行都在同一个 text 元素下,通过 tspan 的 dy 控制行距和段距。这种方式结构最紧凑,适合 tooltip、节点标签、图例说明这种短文本。缺点是想单独给某一段设置不同样式(比如第一段加粗、第二段缩小),需要给每个 tspan 额外设置 class 或 style,数据驱动时稍微麻烦一些。
第二种是“多个 text 元素”。每个段落一个 text,通过 getBBox() 获取前一段的实际高度,然后累加 y 坐标。这种方式适合段落之间有比较大样式差异的场景,你可以给每个 text 独立设置字号、颜色、字体粗细,段落排版更像是 HTML 里的多个 p 标签。缺点是计算坐标时需要动态测量,而且段落一旦增多,DOM 节点数也会线性增长,对性能有一定影响。
第三种是“foreignObject + HTML 标签”。在 SVG 里嵌入一个 foreignObject,然后内部放 div、p 标签,完全交给浏览器排版引擎处理。这种方式能让 HTML 的自动换行、段落间距、首行缩进全部生效,代码最简单,遇到超长文本时排版效果也最好。但它在图片导出、部分旧浏览器、SVG 转 canvas 的场景下经常出问题,所以如果你有导出图表或打印需求,要谨慎使用。
我在实际项目里的选择逻辑很简单:文本内容短、样式单一,用第一种;内容多、段落样式独立,用第二种;文本量很大且不涉及导出,用 foreignObject。前两种是本文的重点,因为它们是真正的 SVG 原生方案,兼容性和可控性都更好。
3.2 段间距、行高和基线控制
段间距的设计,核心是一个比例问题:段落之间的空隙要比行与行之间的空隙更明显,但也不能大到看起来完全断开。
行高通常控制在 1.3 到 1.6 之间。用 em 做单位,1.4em 的视觉密度比较舒服,这也是很多设计系统的默认行距。段间距则建议取行高的 1.5 到 2 倍。也就是说,如果行高是 1.4em,段间距设为 1.4 * 1.8 = 2.52em 左右,视觉上既有区分度,又不至于割裂。
在第一种组织方式里,段间距的实现办法是:给新段落的第一行一个更大的 dy。具体做法是在构建行数据时,标记每一行是否是新段落的开始行,渲染时做判断。
function buildLines(text, maxWidth, measurer) { const paragraphs = text.split(/\n+/); const flow = []; paragraphs.forEach((para, pi) => { const lines = wrapSmartLine(para, maxWidth, measurer); lines.forEach((line, li) => { flow.push({ text: line, isNewParagraph: li === 0 && pi > 0 }); }); }); return flow; }渲染时只需判断 isNewParagraph:
selection .selectAll('tspan') .data(flow) .join('tspan') .attr('x', x) .attr('dy', (d, i) => { if (i === 0) return 0; return d.isNewParagraph ? '2.52em' : '1.4em'; }) .text((d) => d.text);这里用到的文本输入约定是:传入的长文本里,用两个换行符 \n\n 表示分段,用一个换行符 \n 表示普通换行。这个约定在用户输入和接口返回里都很好处理,也符合 Markdown 的习惯。
还有一点容易被忽略的是 vertical-align 与基线调整。SVG text 的 y 坐标是基线位置,不是文本块的顶部。如果你想让一整块文本的顶部对齐某个矩形框,需要算出第一行文字的实际高度,偏移量大约等于 fontSize * 0.35。这个数值和字体有关,不同字体在 ascent 和 descent 上差别明显。我的做法是先用一个临时 span 渲染“M”字符,测量它的实际边界高度,再决定偏移量,而不是直接用经验值。
4. 实战:封装一个可复用的 SVG 文本排版函数
前面讲了原理和零散技巧,但一个工具函数只有在被反复调用时才能体现价值。我自己维护了一个封装好的 textWrap 函数,可以通过 D3 的 selection 原型链直接调用,类似 D3 内置的 text()、attr() 一样顺手。
4.1 需求明确与函数设计
在设计这个函数之前,我先把需求列清楚:支持分行和分段;支持自定义最大宽度、行高、段间距、字号、字体;支持中英文混排的断行策略;链式调用后返回原 selection,方便继续追加样式或事件。还有一个隐藏需求:多次调用时不能残留旧 tspan,这关系到 tooltip 内容更新的场景。
基于这些需求,函数签名设计为 options 对象,而不是一堆散落的参数。这样调用方可以只覆盖需要调整的项,默认值覆盖常见场景:
{ maxWidth: 200, lineHeight: 1.4, paragraphSpacing: 1.8, fontSize: 14, fontFamily: 'sans-serif', fontWeight: 'normal' }这里的 paragraphSpacing 是一个倍数,实际段间距为 lineHeight * paragraphSpacing,即 1.4 * 1.8 = 2.52em。这个设计比单独传一个绝对 em 值更灵活,因为行高调整后,段距会成比例变化。
4.2 核心代码实现与逐段说明
完整实现如下,我拆成几个小模块分别说明。
第一块是测量器,负责创建并缓存 canvas context:
function createTextMeasurer(fontSize, fontFamily, fontWeight = 'normal') { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`; return { measure: (text) => ctx.measureText(text).width, updateFont: (newSize, newFamily, newWeight) => { ctx.font = `${newWeight || fontWeight} ${newSize}px ${newFamily}`; } }; }updateFont 的存在是为了应对动态更新场景,比如字号变化后重新测量,不需要重复创建 canvas。
第二块是断行逻辑,整合了空格回退和逐字降级两条路径:
function wrapSmartLine(text, maxWidth, measurer) { const words = text.split(/(\s+)/); const lines = []; let currentLine = ''; for (const word of words) { const testLine = currentLine + word; if (measurer.measure(testLine) > maxWidth && currentLine.trim()) { lines.push(currentLine.trim()); currentLine = word.trimStart(); } else { currentLine = testLine; } } if (currentLine.trim()) lines.push(currentLine.trim()); // 如果整段连一个空格都没有,说明是连续中文或长词,退回逐字切割 if (lines.length <= 1 && words.length <= 1) { return fallbackCharWrap(text, maxWidth, measurer); } return lines; } function fallbackCharWrap(text, maxWidth, measurer) { const chars = Array.from(text); const lines = []; let currentLine = ''; for (const ch of chars) { const testLine = currentLine + ch; if (measurer.measure(testLine) > maxWidth && currentLine) { lines.push(currentLine); currentLine = ch; } else { currentLine = testLine; } } if (currentLine) lines.push(currentLine); return lines; }第三块是主函数。这里用 selection 原型挂载,让 D3 链式调用更自然:
d3.selection.prototype.textWrap = function(text, options = {}) { const opts = { maxWidth: 200, lineHeight: 1.4, paragraphSpacing: 1.8, fontSize: 14, fontFamily: 'sans-serif', fontWeight: 'normal', x: 0, // 文本起点 x,默认取当前属性或 0 y: 0, // 文本起点 y,默认取当前属性或 0 ...options }; const x = opts.x !== 0 ? opts.x : (this.attr('x') || 0); const y = opts.y !== 0 ? opts.y : (this.attr('y') || 0); const measurer = createTextMeasurer(opts.fontSize, opts.fontFamily, opts.fontWeight); const paragraphs = String(text).split(/\n+/); const flow = []; paragraphs.forEach((para, pi) => { const lines = wrapSmartLine(para, opts.maxWidth - 4, measurer); lines.forEach((line, li) => { flow.push({ text: line, isNewParagraph: li === 0 && pi > 0 }); }); }); this.attr('x', x) .attr('y', y) .style('font-size', opts.fontSize + 'px') .style('font-family', opts.fontFamily) .style('font-weight', opts.fontWeight) .selectAll('tspan') .data(flow) .join('tspan') .attr('x', x) .attr('dy', (d, i) => { if (i === 0) return 0; return d.isNewParagraph ? (opts.lineHeight * opts.paragraphSpacing) + 'em' : opts.lineHeight + 'em'; }) .text((d) => d.text); return this; };这里有一点要注意:创建测量器时传入了 fontWeight,但在主函数里如果用户在 options 里覆盖了 fontWeight,需要同步更新测量器的字体上下文。我在简化版本里直接按 opts 创建,实际使用中建议把 createTextMeasurer 调用放到动态更新里,或者暴露一个 setFont 方法,否则粗体和正常体混排时测量会不准。
4.3 实际调用与视觉验证
现在调用起来就很干净了。比如,做一个图表 tooltip,内容包含标题和说明文字:
const tooltip = svg.append('g') .attr('class', 'tooltip') .attr('transform', 'translate(80, 120)'); // 外部矩形框 tooltip.append('rect') .attr('width', 260) .attr('height', 110) .attr('rx', 6) .attr('fill', 'rgba(255,255,255,0.95)') .attr('stroke', '#ccc'); // 多段文本,用 \n\n 模拟段落 tooltip.append('text') .call(g => g.textWrap('销售趋势分析:本月整体增长 12%,主要来自华东区域贡献。\n\n建议重点追踪移动端转化漏斗,当前漏斗各环节流失率偏高。', { x: 12, y: 24, maxWidth: 236, fontSize: 13, lineHeight: 1.5, paragraphSpacing: 1.7 }));渲染出来的效果是:第一段两行,第二段两行,段落间自动留出约 2.55em 的空隙。外部矩形框的宽度 260,内部文本最大宽度 236,两侧各留 12px 的 padding,视觉上和设计稿对齐。
视觉验证方面,我建议加两个辅助手段:一是给 tspan 临时加 outline,查看每行实际边界和基线位置;二是用 getBBox() 对比最后一行 tspan 的 y 坐标,确认整体高度没有超出外部容器。如果发现最后一行被裁切,优先增大矩形的 height,而不是强行减小行高,因为行高太小会让多行文本阅读困难。
5. 常见问题与排查技巧实录
代码写完只是第一步,真正让人头大的是各种环境下出现的奇怪现象。下面这些坑我基本都踩过一遍,列出来给大家做个参考。
5.1 字体加载导致测量误差
最隐蔽的坑之一,就是字体异步加载导致的行切分位置错误。现象是第一次渲染时文字换行点正常,等 Web Font 加载完成后,文字变得比测量时宽或窄,出现多出一个字、少了一个字的情况。
这是因为 measureText 在字体尚未加载时,会优先用 fallback 字体(通常是系统默认字体)计算宽度。如果 Web Font 的实际字形更宽,测量结果偏窄,那么一些本应换行的位置就不会换行,导致行宽度溢出。
解决办法是:在排版前先确保目标字体已加载完成。可以用 document.fonts.ready 等待所有字体加载,再执行 textWrap。对于单字体场景,也可以用 FontFaceSet.load 单独加载目标字体:
await document.fonts.load(`${fontWeight} ${fontSize}px ${fontFamily}`); svg.append('text').call(g => g.textWrap(longText, options));还有一个更稳妥的做法:在文本可见之前,用一个隐藏的 SVG text 渲染一行目标文字,强制浏览器触发字体加载和逐字体度量。不过实测下来,document.fonts.ready 已经能满足绝大多数场景,配合 loading 态展示,体验不错。
5.2 tspan 样式继承与基线偏移问题
tspan 并不继承 text 的所有样式属性,或者说在不同浏览器里的继承表现不一致。最典型的问题是 fill 颜色在某些浏览器里不继承,或者 font-weight 设置为 bold 后,子 tspan 的 font-family 重新回落到了默认字体。
我的建议是:涉及样式控制的地方,不要依赖继承,给 text 和 tspan 统一设置 class,然后通过 CSS 规则定义样式。这样既能保证一致性,又便于主题切换。
基线偏移问题也很常见。SVG text 的 y 是基线位置,不同字体在同一字号下的基线位置差异很大,尤其在混排不同字体时,第一行的视觉位置可能忽高忽低。对于多行文本,我会额外在 text 元素上设置一个 dominant-baseline,比如 central 或 hanging,让整体文本块相对 y 坐标居中或顶端对齐。不过要小心,每个浏览器的 dominant-baseline 支持程度不一样,Safari 对很多取值的解析都有问题。稳妥方案还是手动计算垂直偏移,公式为baselineOffset = fontSize * 0.35,把它加到 y 上,视觉上接近顶部对齐。
5.3 缩放、响应式与高性能注意事项
图表的 viewBox 会随着容器尺寸变化而整体缩放,这意味着 SVG 里的所有文字会跟着缩放。如果文字字号本身是 14px,viewBox 缩放到 0.8 倍,实际显示就变成 11.2px,可读性会下降。针对这个问题,有两种处理思路。
第一种是给文字单独的 g 分组,缩放这个分组时逆补偿 scale,让文字保持恒定物理大小。第二种是在 resize 时重新计算 viewBox 或重新执行 textWrap。前者适合图表中固定的标注文字,后者适合内容自适应的 tooltip。
还有个性能问题:每次调用 textWrap 都会创建新的 canvas context。在数据量大、频繁刷新的场景下,比如鼠标悬停显示大量节点的 tooltip,反复创建 context 会造成额外开销。我的做法是模块级缓存 measure 工具,按${fontWeight}_${fontSize}_${fontFamily}生成 key,命中的直接复用。
另外,selectAll('tspan').data(flow).join('tspan') 这种模式很依赖 data 的 key。如果你的行数组是按内容生成的,而没有指定 key 函数,D3 会按下标对应更新,那么文本内容变化的行不会正确移除,导致 tooltip 里出现旧文字残留。保险起见,在 .data(flow, d => d.text) 中把文本内容作为 key,这样更新时能准确匹配老行和新行。
5.4 常见问题速查表
我把平时排查频率最高的问题整理成了表格,方便快速定位:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 中文正常但英文单词被切断 | 没有按空格回退断词 | 用空格分词后再组装,单词内部不截断 |
| 文字测量偏窄或偏宽 | 字体未加载完成,或测量字体与渲染字体不一致 | document.fonts.ready 后测量;统一 font-family 列表 |
| 第二行 x 位置错乱 | 没有给每个 tspan 显式设置 x | 每个 tspan 都 set x,不要只依赖继承 |
| 行间距忽大忽小 | dy 用了固定 px,字号变化后没有同步 | 行高用 em 单位,与字号联动 |
| 段落间没有空隙 | 数据中没有记录段落首行标记 | 用 isNewParagraph 标记并放大首行 dy |
| tooltip 更新后文字残留 | data join 没有指定 key 函数 | .data(flow, d => d.text) |
| 文本整体被矩形框底部裁切 | 忽略了段间距和最后一行基线 | 用 getBBox() 测量整体高度,预留底部 padding |
| 某些浏览器 fill 不生效 | 样式继承在 tspan 上不一致 | 用 CSS class 统一设置,不要依赖继承 |
这个表不覆盖所有情况,但已经能解决绝大多数 D3 文本排版问题。真的遇到表格里没有的情况,我一般会先打印 tspan 的 getBBox() 和 getComputedTextLength(),用数据说话,而不是靠猜。
我个人在实际操作中的体会是:文本排版在 SVG 可视化里从来不是锦上添花,而是实打实的用户体验组件。很多图表本身画得不错,最后栽在一行长到离谱的标签文字上,观感瞬间掉一个档次。所以我不太建议每次用到 text 时都临场写分行逻辑,而是像我这样维护一个通用的 textWrap 函数,遇到新项目直接复用。如果你手头也经常做 D3 图表,可以把这套函数沉淀到自己的工具库里,再根据项目需要补充断词、首行缩进、对齐模式这些定制能力。最后再分享一个小技巧:调试排版问题时,在 CSS 里给 tspan 加一个 outline 样式,你能一眼看出每行文字的实际边界和基线位置,比盯着数字猜快得多。