Canvas 文本渲染进阶 — 自动换行、省略号、多行排版,写出排版引擎
Day 33 学的 fillText 是"一行流":给坐标、画一行。但真实产品里的文本全是"排版问题":节点标签要在固定宽度里自动换行,太长要截断加省略号,多行要控制行高,仪表盘可能还要竖排。更要命的是 measureText 有真实开销——拖拽节点时每帧排版几十个标签,不缓存测量结果必卡。今天写一个完整的文字排版引擎
drawWrappedText,它将直接进入第 49 天编辑器的节点渲染,也是你未来一切"画布内富文本"的地基。
目录
- 一、为什么 Canvas 文本这么"原始"
- 二、measureText:唯一的测量工具
- 三、自动换行算法
- 四、省略号与截断
- 五、多行排版与行高
- 六、性能:测量缓存
- 七、实战:节点标签排版引擎
- 八、常见坑点与最佳实践
- 九、自测挑战
- 十、总结与知识图谱
一、为什么 Canvas 文本这么"原始"
1.1 浏览器里有现成的文字排版,但 Canvas 不用
// HTML/CSS 世界:浏览器自动排版
<div style="width: 120px;">一段很长的设备描述文字</div>
// 自动换行 ✅ 溢出处理(ellipsis) ✅ 行高 ✅ —— 全免费
// Canvas 世界:只有"把字符串画在锚点上"的原语
ctx.fillText("一段很长的设备描述文字", 0, 0);
// 超出宽度?照样画出去,被裁掉都不知道 ❌
// 因为 Canvas 是"立即模式"(Immediate Mode):
// 它只执行绘图指令,不维护 DOM 那样的文档结构 → 没法自动排版
1.2 排版引擎要解决的四件事
① 测量:这段文字画出来多宽?(measureText)
② 断行:在哪里断才能既不超宽又尽量填满?(换行算法)
③ 截断:放不下时砍哪里、怎么加省略号?
④ 垂直:多行之间隔多少(行高)、整体在容器里怎么对齐?
二、measureText:唯一的测量工具
2.1 基本用法
// ⚠ 先设 font,再测量(测量结果依赖当前字体!)
ctx.font = "14px sans-serif";
const m = ctx.measureText("设备温度过高警告");
m.width; // 文字宽度(像素)—— 90% 场景只用它
m.actualBoundingBoxAscent; // 锚点之上的实际高度(字形真实边界,不含行距)
m.actualBoundingBoxDescent; // 锚点之下的实际高度
m.fontBoundingBoxAscent; // 字体设计上的高度(含行距预留)
m.fontBoundingBoxDescent;
2.2 ascent/descent:精准垂直居中的钥匙
/**
* 让文字在高度 h 的容器里垂直居中
*
* 错误做法(新手 99% 这么写):
* y = 容器顶 + h/2,textBaseline = "alphabetic"(默认基线)
* → 文字偏下(基线在"字形的底部附近",不是中心)
*
* 正确做法 A:textBaseline = "middle"(够用,但不同字体有细微偏差)
* 正确做法 B:用 actualBoundingBox 精确补偿(像素级居中)
*/
function drawCentered(ctx: CanvasRenderingContext2D, text: string, cx: number, cy: number): void {
const m = ctx.measureText(text);
// 字形真实顶 = 基线 - ascent;真实中心 = 基线 - (ascent - descent)/2
const baseline = cy + (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2;
ctx.textAlign = "center"; // 水平居中交给 textAlign
ctx.fillText(text, cx, baseline);
}
2.3 测量的性能真相
// measureText 不是纯 JS 计算——要走字体光栅化管线,开销不小:
// 单次 ≈ 0.005~0.02ms,看着小,但是:
// ❌ 反面模式:拖拽节点时每帧、每个节点、每个字符重新测量
for (const node of nodes) { // 50 个节点
for (const ch of node.label) { // 平均 10 字符
ctx.measureText(ch); // 500 次/帧 → 5~10ms 全喂给测量了!
}
}
// ✅ 正解:缓存(第六节展开)
三、自动换行算法
3.1 问题定义
输入:文本 "水泵电机温度超过阈值请检修"(15 字)
约束:容器宽 90px,字体 14px(一个汉字 ≈ 14px 宽)
输出:分行的数组:
["水泵电机温度超", ← 7 字 × 14 = 98px?超了 → 6 字
"过阈值请检修"]
3.2 逐字断行(中文场景)
中文没有空格分词,最小断行单位就是单字——算法反而简单:
/**
* 中文逐字换行:贪心策略——每行尽量塞,塞不下就断
* @returns 分行后的字符串数组
*/
function wrapCJK(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const lines: string[] = [];
let current = ""; // 当前正在组装的行
for (const ch of text) {
const candidate = current + ch;
if (ctx.measureText(candidate).width <= maxWidth) {
current = candidate; // 还塞得下 → 继续攒
} else {
lines.push(current); // 塞不下了 → 当前行入队
current = ch; // 新行从当前字符开始
}
}
if (current) lines.push(current); // 别忘了最后一行
return lines;
}
3.3 逐词断行(英文场景)
英文单词不能从中间劈开(“pump” 拆成 “pu/mp” 没法读)——最小单位换成词:
/**
* 英文按空格换行:贪心 + 单词不拆分
* 超长单词(比如 URL)单独占一行硬拆(兜底)
*/
function wrapLatin(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const lines: string[] = [];
let current = "";
for (const word of text.split(/\s+/)) {
// 情况 1:单词本身就超宽 → 硬拆字符(兜底,如超长 URL)
if (ctx.measureText(word).width > maxWidth) {
if (current) { lines.push(current); current = ""; }
lines.push(...hardBreakWord(ctx, word, maxWidth));
continue;
}
// 情况 2:常规——试放"当前行 + 空格 + 该词"
const candidate = current ? `${current} ${word}` : word;
if (ctx.measureText(candidate).width <= maxWidth) {
current = candidate;
} else {
lines.push(current);
current = word;
}
}
if (current) lines.push(current);
return lines;
}
/** 超长单词的硬拆(逐字符贪心) */
function hardBreakWord(ctx: CanvasRenderingContext2D, word: string, maxWidth: number): string[] {
const parts: string[] = [];
let cur = "";
for (const ch of word) {
if (ctx.measureText(cur + ch).width <= maxWidth) cur += ch;
else { parts.push(cur); cur = ch; }
}
if (cur) parts.push(cur);
return parts;
}
3.4 中英混排:逐字 + 空格不拆
/**
* 混排策略(工程上最实用的简化):
* 按字符遍历,但"遇到空格后的连续拉丁串"视为不可拆单元
* (更精细的 Unicode 断行算法属于 ICU 库的领域,工业场景这个简化足够)
*/
function* tokens(text: string): Generator<string> {
let buf = "";
for (const ch of text) {
if (/[A-Za-z0-9]/.test(ch)) {
buf += ch; // 拉丁字母/数字:攒进 buffer(不拆)
} else {
if (buf) { yield buf; buf = ""; }
yield ch; // 中文/标点:单字 yield
}
}
if (buf) yield buf;
}
// 然后把 3.2 的 for (const ch of text) 换成 for (const tk of tokens(text)) 即可
四、省略号与截断
4.1 单行省略号(CSS ellipsis 的 Canvas 版)
/**
* 单行截断:超宽时砍尾 + "..."
* 难点:"..." 自己也占宽度!砍的时候要给它留位置
*/
function ellipsize(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
if (ctx.measureText(text).width <= maxWidth) return text; // 不超宽,原样返回
const ELLIPSIS = "…"; // 用单字符省略号(比 "..." 窄)
const ellW = ctx.measureText(ELLIPSIS).width;
const limit = maxWidth - ellW; // 文字本体只能用这么多
// 二分查找"最多能保留几个字"(比逐字删快:log n 次测量)
let lo = 0, hi = text.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (ctx.measureText(text.slice(0, mid)).width <= limit) lo = mid;
else hi = mid - 1;
}
return text.slice(0, lo) + ELLIPSIS;
}
4.2 多行省略号(N 行后截断)
/**
* 多行截断:先换行,超过 maxLines 的部分砍掉,末行加省略号
* 典型场景:节点标签最多 2 行
*/
function wrapWithEllipsis(ctx: CanvasRenderingContext2D, text: string, maxWidth: number, maxLines: number): string[] {
const lines = wrapCJK(ctx, text, maxWidth); // 第三节的换行
if (lines.length <= maxLines) return lines;
const kept = lines.slice(0, maxLines); // 留前 N 行
kept[maxLines - 1] = ellipsize(ctx, kept[maxLines - 1], maxWidth); // 末行再加省略号
// ⚠ 末行本来是满的,加 "…" 可能又超宽 → ellipsize 会再砍一次,最终一定 ≤ maxWidth ✅
return kept;
}
五、多行排版与行高
5.1 行高的行业惯例
// 行高(lineHeight)不是随便定的,CSS 世界的事实标准:
const LINE_HEIGHT_FACTOR = 1.4; // 14px 字体 → 20px 行高(1.4 倍)
// 太挤(1.0):上下行的字形贴在一起,可读性差
// 太松(2.0):浪费纵向空间,节点变得傻大
// 1.4 ~ 1.6:阅读舒适区(Material Design 用 1.5)
5.2 多行绘制的锚点推进
/**
* 绘制多行文本:从锚点开始,每行向下推进 lineHeight
* @param x 左边界(textAlign 建议 "left")
* @param y 第一行的基线
*/
function drawLines(ctx: CanvasRenderingContext2D, lines: string[], x: number, y: number, lineHeight: number): void {
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
lines.forEach((line, i) => {
ctx.fillText(line, x, y + i * lineHeight);
});
}
5.3 整块文本的垂直对齐
/**
* 把 lines 这块文本在 [top, top+boxHeight] 里垂直居中
* 关键:整块高度 = (行数-1)×lineHeight + 一行的字高
*/
function drawLinesCenteredV(ctx: CanvasRenderingContext2D, lines: string[], x: number, top: number, boxHeight: number, lineHeight: number): void {
const firstBaseline = top + (boxHeight - (lines.length - 1) * lineHeight) / 2;
// 再用 actualBoundingBox 微调第一行(第二节的知识),此处从简用 middle:
ctx.textBaseline = "middle";
lines.forEach((line, i) => {
ctx.fillText(line, x, firstBaseline + i * lineHeight);
});
}
六、性能:测量缓存
6.1 缓存的键与值
/**
* 排版缓存:同样的 (文字, 字体, 宽度) → 同样的排版结果
* 键的设计:font + maxWidth + text 三元组
*/
interface CacheEntry {
lines: string[]; // 分行结果
totalHeight: number; // 整块高度(布局要用)
}
const layoutCache = new Map<string, CacheEntry>();
function cachedLayout(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): CacheEntry {
const key = `${ctx.font}|${maxWidth}|${text}`;
let entry = layoutCache.get(key);
if (!entry) {
const lines = wrapWithEllipsis(ctx, text, maxWidth, 2);
entry = {
lines,
totalHeight: lines.length * 14 * LINE_HEIGHT_FACTOR, // 14 是字号
};
layoutCache.set(key, entry);
}
return entry;
}
6.2 缓存淘汰
// Map 只进不出 → 内存无限膨胀(长跑的大屏必炸)
// 简单有效的策略:LRU 近似——超上限清空重来(排版很快,重建成本低)
const MAX_CACHE = 2000;
if (layoutCache.size > MAX_CACHE) layoutCache.clear();
// 更精细的:真 LRU(Map 的插入序特性可做)
// 工业大屏场景节点标签有限且重复率高,"超限清空"足够
6.3 缓存命中率为什么高
节点标签的特点:
- 文字内容:低频变化(用户编辑时才变)
- 字体:几乎不变
- 容器宽度:节点尺寸定了就不变
→ 三元组高度稳定 → 50 个节点的画面,缓存命中率轻松 > 95%
→ 拖拽时的每帧渲染:查缓存(O(1) Map.get)而非重新排版
七、实战:节点标签排版引擎
今天的实战目标:把今天的所有函数组装成一个 drawNodeLabel 模块——固定宽度容器内的自动换行 + 2 行截断 + 垂直居中 + 测量缓存,外加一个演示页面(拖拽节点验证缓存生效时依然流畅)。
// day43-label-engine.ts —— 节点标签排版引擎
import { bootCanvas } from "../day29/canvas-boot.js";
const { ctx, cssWidth, cssHeight } = bootCanvas(document.querySelector("#board")!);
const FONT = "14px 'Microsoft YaHei', sans-serif";
const LINE_HEIGHT = 14 * 1.4;
// (把第二~六节的 wrapCJK / ellipsize / wrapWithEllipsis / cachedLayout 拷贝进本文件)
/**
* 节点标签渲染:把 text 排版进 (x, y, w, h) 的盒子里
* - 水平:居中
* - 垂直:居中
* - 超出:2 行 + 省略号
*/
function drawNodeLabel(text: string, x: number, y: number, w: number, h: number): void {
ctx.font = FONT;
const layout = cachedLayout(ctx, text, w - 16); // 左右各留 8px 内边距
ctx.fillStyle = "#e6f1ff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const firstMiddle = y + (h - layout.totalHeight) / 2 + LINE_HEIGHT / 2; // 第一行的"middle 线"
layout.lines.forEach((line, i) => {
ctx.fillText(line, x + w / 2, firstMiddle + i * LINE_HEIGHT);
});
}
// ===== 演示:三个不同长度的标签 + 拖拽验证性能 =====
interface Node { x: number; y: number; w: number; h: number; label: string }
const nodes: Node[] = [
{ x: 60, y: 60, w: 140, h: 56, label: "水泵" },
{ x: 260, y: 60, w: 140, h: 56, label: "冷却塔电机温度传感" },
{ x: 460, y: 60, w: 140, h: 56, label: "三号车间空压机冷却循环水泵出口压力监测点" },
];
function render(): void {
ctx.clearRect(0, 0, cssWidth, cssHeight);
for (const n of nodes) {
ctx.fillStyle = "#1a2740";
ctx.fillRect(n.x, n.y, n.w, n.h);
ctx.strokeStyle = "#00ff88";
ctx.strokeRect(n.x, n.y, n.w, n.h);
drawNodeLabel(n.label, n.x, n.y, n.w, n.h);
}
ctx.fillStyle = "#8fa3c0";
ctx.font = "12px sans-serif";
ctx.fillText(`排版缓存条目: ${layoutCache.size}(拖节点试试流畅度)`, 12, cssHeight - 12);
}
render();
// 拖拽(Day 34/38 的老套路)
let dragIdx = -1, offX = 0, offY = 0;
canvas.addEventListener("mousedown", (e) => {
const r = canvas.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
dragIdx = nodes.findIndex((n) => px >= n.x && px <= n.x + n.w && py >= n.y && py <= n.y + n.h);
if (dragIdx >= 0) { offX = px - nodes[dragIdx].x; offY = py - nodes[dragIdx].y; }
});
window.addEventListener("mousemove", (e) => {
if (dragIdx < 0) return;
const r = canvas.getBoundingClientRect();
nodes[dragIdx].x = e.clientX - r.left - offX;
nodes[dragIdx].y = e.clientY - r.top - offY;
render();
});
window.addEventListener("mouseup", () => { dragIdx = -1; });
观察点:拖拽过程中 layoutCache.size 稳定不涨(同样的标签反复渲染全命中缓存)——这就是"排版一次、绘制万次"。
八、常见坑点与最佳实践
| # | 坑 | 症状 | 解法 |
|---|---|---|---|
| 1 | 测量前忘了设 font | 换字体后排版全错(用旧字体量的宽) | measureText 前必设 ctx.font |
| 2 | textBaseline 默认 alphabetic 当 middle 用 | 文字偏下 | 显式设置;或用 actualBoundingBox 精算 |
| 3 | 省略号忘了给自己留宽度 | 加 “…” 后反而超宽 | 先量省略号宽,文字可用宽 = maxWidth - ellW |
| 4 | 英文单词被逐字劈开 | “pump” 变 “pu/mp” | 逐词断行 + 超长词兜底硬拆 |
| 5 | 每帧重新排版 | 拖拽多节点时掉帧 | layoutCache(font+width+text 为键) |
| 6 | 缓存无限膨胀 | 大屏长跑内存泄漏 | 超限清空(近似 LRU) |
| 7 | textAlign 状态泄漏 | 后面的图形文字错位 | 排版函数里 save/restore 或每次显式设置 |
| 8 | 行高用字号本体 | 中文上下行贴脸 | lineHeight = 字号 × 1.4~1.6 |
| 9 | 高分屏字体发虚 | 字糊成一团 | 检查 DPR 适配是否生效(Day 29) |
九、自测挑战
- 手算题:14px 中文字体,容器宽 100px,文字"智能制造执行系统数据采集模块"(15 字,每字约 14px)。写出 wrapCJK 的分行结果。
- 实现题:给 ellipsize 增加
position: "start" | "middle" | "end"参数——省略号在头部(“…集模块”)、中部(“智能执…集模块”)、尾部(“智能制造执…”)。写出中部版的关键代码。 - 改错题:下面的垂直居中代码为什么偏下?修复它。
ctx.textBaseline = "alphabetic";
ctx.fillText(text, cx, boxTop + boxHeight / 2);
- 设计题:节点的标签支持"标题 + 描述"两段式(标题粗体 14px 不换行截断,描述常规 12px 最多 2 行)。设计缓存 key 并写出 drawNodeLabel 的升级版。
- 思考题:为什么缓存策略用"超限清空"而不是逐条过期?(提示:排版计算的成本量级 vs 缓存重建的成本量级;标签内容的访问模式。)
- 进阶题:实现竖排文字(每字一行,从上到下)。思考:中文竖排简单在哪?英文竖排(旋转 90°)怎么和中文混排?
十、总结与知识图谱
Day 43 文本渲染进阶
├── 认知
│ └── Canvas 立即模式 → 排版自己写
├── 测量 measureText
│ ├── width / ascent / descent
│ └── 精确垂直居中(actualBoundingBox)
├── 换行算法
│ ├── 中文逐字贪心
│ ├── 英文逐词 + 超长词硬拆
│ └── 混排 tokens 生成器
├── 截断
│ ├── 单行省略号(二分查找 + 给 "…" 留宽)
│ └── 多行 N 行截断(末行再 ellipsize)
├── 排版
│ ├── 行高 1.4 倍惯例
│ └── 整块垂直居中的基线推导
├── 性能
│ └── layoutCache(font|width|text 键)+ 超限清空
└── 实战
└── drawNodeLabel 引擎(BOSS 战节点渲染直用)
明天预告(Day 44):裁剪 clip——把路径变成"模板",之后画的东西只在模板内可见。扇形进度环(仪表盘的兄弟)、圆形头像、雷达图,这些"非矩形可视区域"全靠它。还记得 Day 36 说过的"裁剪区域也被 save/restore 管理"吗?明天正式回收这个伏笔。