课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从一个五岁孩子也能理解的游戏开始
本章唯一的真理:文本渲染和文本编辑是两个完全不同的系统。
在一张纸上写下“Hello 👨👩👧👦”,拍一张照片,然后把原纸和照片都交给一个孩子。两者都显示了这些文字。请孩子在“e”和“l”之间放置插入符,选择“Hello”,使用日语输入法将读音转换为汉字,并让阅读器朗读文本。
先预测会发生什么。这些任务中,哪些是照片能够完成的?如果程序在每次 keydown 时都向字符串末尾追加一个字符,那么在中文拼音组字期间会发生什么?按下退格键删除家庭 Emoji 时,应该移除其中一个人,还是整个家庭符号?
- 正常查看照片速度快,并随图片一起移动
- 切换到原纸进行编辑插入符、选择、语言输入
- 精确对齐原纸位置、缩放、旋转
- 完成后拍摄新照片提交或取消
Canvas fillText() 就像把字母拍进一张图片。textarea 或 contenteditable 元素就像浏览器能够理解的原纸。未处于编辑状态时,渲染器绘制文本形状。进入编辑状态后,由 DOM 覆盖层接管。提交时,文档会更新,渲染器会重新绘制。取消时,覆盖层会被丢弃,原始值保持不变。
将这些玩具映射到 Canvas
| 玩具世界 | 文本系统 | 负责内容 |
|---|---|---|
| 文本的照片 | Canvas 预览 | 随场景快速渲染显示 |
| 可书写的原纸 | textarea / contenteditable | 插入符、选择、剪贴板、IME、拼写检查 |
| 活字盒 | FontFace / document.fonts | 字体加载和就绪通知 |
| 字母尺 | TextMetrics / DOM 测量 | 宽度、基线、实际边界和换行 |
| 一个“可见字符” | 字素簇 | 对 Emoji 和组合字符执行用户操作的单位 |
| 从右向左书写的书 | Bidi / RTL | 文本算法处理视觉顺序和逻辑顺序 |
| 输入法候选卡 | 组字事件 | 组字期间的值不是最终的文档值 |
| 覆盖在纸上的透明胶带 | DOM 覆盖层变换 | 与形状/相机对齐,并补偿缩放 |
| 写完或撕掉 | 提交/取消 | 一个历史事务或零次写入 |
这个类比有其局限。DOM 覆盖层和 Canvas 并不使用同一个布局引擎。即使字体字符串完全相同,换行、字体回退、微调和基线也可能相差若干像素。字素是用户感知的单位,不一定是语言学意义上的字符或单词。contenteditable 提供了丰富的浏览器行为,但它不是安全的富文本数据模型;粘贴的 HTML 仍然需要清理和规范化。
先消除错误直觉
- “Canvas 能绘制文本,所以我们也能绘制插入符。” 你仍然必须重建选择、IME、Bidi、剪贴板、虚拟键盘、辅助技术和平台惯例。其成本远不止绘制一个插入符。
- “在
keydown时追加event.key,可以获得最大的控制力。” 组字会产生中间按键,一个 Emoji 可能包含多个码点,而且选择位置不一定在末尾。 - “
text.length就是字符数。” UTF-16 代码单元、Unicode 码点和字素簇各不相同。一个家庭 Emoji 的长度远大于 1。 - “字体 URL 加载完成后,就可以测量文本了。” 字体可能尚未解码,而且回退字体可能已经参与了布局。应等待 FontFaceSet,并在字体变化后使缓存失效。
- “Canvas
measureText会自动换行。” 它只测量所提供的行内文本。段落布局或 DOM 测量必须负责换行。 - “将 textarea 放在形状的 x/y 坐标处就能对齐。” 此时仍缺少父级、世界和相机矩阵、旋转、变换原点、内边距、行高以及缩放补偿。
- “将富文本存储为
innerHTML。” 这会把不受信任的 HTML、浏览器私有标记和业务模型混在一起。
生产级工具包
前置条件契约
第 4 章提供形状从局部→世界→屏幕的矩阵。第 6 章提供带有对称清理机制的 DOMOverlayLayer Portal。第 9 章包含 editing 状态以及提交/取消。第 10 章防止选择和调整大小在文本编辑期间抢夺输入。文档中的文本形状存储文本、宽度、样式令牌和方向,而不是 DOM 节点。
正式知识
字体加载不仅仅是下载文件。FontFace 表示一种可加载字体,而 document.fonts 是一个 FontFaceSet。在布局或测量之前,可以调用 await document.fonts.load('16px "Canvas Sans"', sample),并在 loadingdone 之后使缓存失效。失败时,应使用明确的字体回退链并记录失败,同时不要阻塞编辑。当回退字体切换为目标字体时,文本度量、换行、形状高度和连接线锚点都可能发生变化。应重新计算派生布局,而不是悄然更改文档内容。
Canvas measureText() 返回 TextMetrics。除宽度之外,一些浏览器还会公开实际边界框、字体边界框以及与基线相关的值。textBaseline 选择一条绘制锚线;它不是 CSS 行框。基础文本渲染会显式设置字体、方向、textAlign 和基线,然后对每一行已布局的文本调用 fillText()。Canvas 没有段落换行 API。简单的文本形状可以按确定的宽度断行。对于生产级富文本,更适合在隐藏 DOM 或成熟的布局引擎中进行权威测量,并将测量版本纳入缓存键。
断行并不是在空格处执行 split。CJK 文字不要求使用空格,而且标点禁则、软连字符和长单词都需要相应规则。换行需要区分硬换行 \n 和软换行。Intl.Segmenter(...,{granularity:'grapheme'}) 可以避免拆分大多数字素簇。如果目标浏览器矩阵不支持它,应随应用打包一个经过 Unicode 测试夹具验证的分词库;Array.from(string) 拆分的是码点,不能作为字素处理的回退方案。要实现完整的 Unicode 断行,还需要针对产品所支持语言的断行库或 DOM 布局。应将 Emoji、组合标记、肤色修饰符和 ZWJ 序列作为用户感知的单位进行测试。
Bidi 算法决定 LTR 和 RTL 混合文本的视觉排列方式。文档存储逻辑字符串,不会将字符重新排列为屏幕顺序。形状具有 direction: auto|ltr|rtl;覆盖层使用 dir 和 CSS direction,而 Canvas 则设置上下文的 direction。绝不要手动反转阿拉伯文。字体回退可能按字素发生,因此测量必须使用与渲染相同的 CSS 字体简写属性。
IME 使用 compositionstart/update/end 与 beforeinput/input 协同工作。组字期间 DOM 值可能发生变化,但每个中间步骤都不得分别创建历史记录。compositionend 也不是唯一可能的提交时机;编辑事务结束时,提交操作使用覆盖层的当前值。keydown 只处理 Escape 和显式快捷键等控制意图,并在组字期间检查 event.isComposing。将插入符、选择、局部撤销栈和剪贴板交给原生控件。需要明确文本形状撤销与控件内部撤销之间的边界:例如,编辑期间 Ctrl/Cmd+Z 由控件处理,只有退出编辑后才交给文档历史记录处理。
textarea 适合纯文本:它稳定,并原生支持选择、IME 和移动端键盘。contenteditable 适合富文本,但需要规范化模型、粘贴内容清理、选择映射以及浏览器矩阵测试。应根据产品和隐私要求选择是否启用拼写检查、自动纠正和自动补全;绝不要将用户字符串发送到自己的遥测系统。屏幕阅读器需要可访问名称、编辑状态和焦点。在编辑状态之外,应提供 DOM 检查器或回退语义,而不是只暴露“Canvas”。
当文本区域覆盖层打开时,冻结原始值,创建 DOM,设置 value/dir/lang/spellcheck,应用完整的屏幕矩阵,聚焦,并恢复选区。每当相机或父级变换发生变化时,都要更新矩阵;仅在创建时对齐是不够的。支持缩放补偿的编辑器可以按屏幕缩放比例增大 DOM 字体大小,也可以使用矩阵变换其外层元素。无论采用哪种方式,DOM 的换行宽度都必须与 Canvas 布局的世界宽度相对应。应使用 CSS 矩阵应用旋转,而不能只旋转位置。提交时读取控件值,验证最大长度和富文本模式,然后发出一条 UpdateText 命令。除非文档模式明确规定且产品已向用户说明,否则不要在此处暗中应用 NFC/NFKC 规范化,因为规范化会改变实际的码点序列。取消不发出任何命令。退出时移除监听器和节点,然后将焦点映射回 Canvas 或检查器。
证据与兼容性(已于 2026-08-29 验证)
- MDN CSS 字体加载 API、
FontFace和Document.fonts定义了加载和就绪边界。 - MDN TextMetrics、
measureText()和textBaseline记录的是 Canvas 基础图元测量,而不是段落编辑。 - MDN
Intl.Segmenter提供字素、单词和句子分段;它不是完整的富文本布局引擎。 - MDN CompositionEvent、
InputEvent.isComposing和 UI Events 的组合事件章节 定义了 IME 生命周期。绝不要根据 keydown 构建文本。 - MDN
<textarea>、contenteditable、dir和spellcheck是原生编辑、双向文本和拼写检查的基础。 - WHATWG HTML 的编辑 API 和
textarea元素 定义了标准边界。各项功能和移动键盘仍需在真实浏览器中验证。
本章的工程增量
**起点:**文本标签仅使用 Canvas fillText(),没有插入光标、IME 或剪贴板。**终点:**非编辑状态下使用 Canvas 预览;编辑期间由 DOM 覆盖层精确覆盖文本形状。提交只更新文档一次,而取消执行零次写入。
添加以下文件:
src/engine/text/TextLayout.ts:字体缓存、字素安全换行和基线;src/engine/text/FontManager.ts:FontFaceSet 就绪状态和失效处理;src/ui/overlays/TextEditorOverlay.ts:textarea 生命周期和矩阵;src/engine/shapes/TextShapeRenderer.ts:根据布局行绘制预览;src/engine/text/__tests__/layout.test.ts:中日韩文字、Emoji、RTL 和回退字体;tests/browser/text-ime.spec.ts:组合输入、选区、剪贴板、旋转和缩放。
先实现一个完整的纯函数,以便测试基础布局。它遵循硬换行、字素和最大宽度约束,并且不会使用 split('') 拆散 Emoji:
export type Measure = (text: string) => number;
export type TextLine = Readonly<{ text: string; width: number }>;
export function wrapGraphemes(
value: string,
maxWidth: number,
measure: Measure,
locale = 'und',
): readonly TextLine[] {
if (!(maxWidth > 0)) throw new Error('TEXT_WIDTH_MUST_BE_POSITIVE');
const segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
const output: TextLine[] = [];
for (const paragraph of value.split('\n')) {
const graphemes = Array.from(segmenter.segment(paragraph), (part) => part.segment);
if (graphemes.length === 0) {
output.push({ text: '', width: 0 });
continue;
}
let line = '',
width = 0;
for (const grapheme of graphemes) {
const candidate = line + grapheme;
const candidateWidth = measure(candidate);
if (line && candidateWidth > maxWidth) {
output.push({ text: line, width });
line = grapheme;
width = measure(grapheme);
} else {
line = candidate;
width = candidateWidth;
}
}
output.push({ text: line, width });
}
return output;
}
export async function layoutCanvasText(
context: CanvasRenderingContext2D,
value: string,
cssFont: string,
width: number,
locale: string,
onFontFailure: (reason: unknown) => void = () => undefined,
): Promise<readonly TextLine[]> {
try {
const faces = await document.fonts.load(cssFont, value || 'M');
if (faces.length === 0) onFontFailure(new Error(`no face matched ${cssFont}`));
} catch (cause) {
onFontFailure(cause);
}
context.save();
try {
context.font = cssFont;
return wrapGraphemes(value, width, (text) => context.measureText(text).width, locale);
} finally {
context.restore();
}
}
下面这个完整的纯文本覆盖层不会在 keydown 时监听普通字符,不会在组合输入期间写入文档,并且会将形状→屏幕矩阵直接传给 CSS:
export type TextShape = Readonly<{
id: string;
text: string;
width: number;
minHeight: number;
font: string;
fontSize: number;
lineHeight: number;
color: string;
direction: 'auto' | 'ltr' | 'rtl';
language: string;
}>;
export type Matrix2D = Readonly<{
a: number;
b: number;
c: number;
d: number;
e: number;
f: number;
}>;
export interface TextEditPort {
commit(id: string, before: string, after: string): void;
focusCanvas(): void;
}
export class TextEditorOverlay {
private readonly textarea: HTMLTextAreaElement;
private composing = false;
private blurPending = false;
private closed = false;
constructor(
host: HTMLElement,
private readonly shape: TextShape,
screenMatrix: Matrix2D,
private readonly port: TextEditPort,
) {
const area = document.createElement('textarea');
this.textarea = area;
area.value = shape.text;
area.dir = shape.direction;
area.lang = shape.language;
area.spellcheck = true;
area.setAttribute('aria-label', 'Edit canvas text');
area.style.position = 'absolute';
area.style.left = '0';
area.style.top = '0';
area.style.width = `${shape.width}px`;
area.style.minHeight = `${shape.minHeight}px`;
area.style.margin = '0';
area.style.padding = '0';
area.style.border = '1px solid currentColor';
area.style.background = 'Canvas';
area.style.color = shape.color;
area.style.font = shape.font;
area.style.lineHeight = String(shape.lineHeight);
area.style.resize = 'none';
area.style.transformOrigin = '0 0';
this.updateScreenMatrix(screenMatrix);
area.addEventListener('compositionstart', this.onCompositionStart);
area.addEventListener('compositionend', this.onCompositionEnd);
area.addEventListener('keydown', this.onKeyDown);
area.addEventListener('blur', this.onBlur);
host.append(area);
area.focus({ preventScroll: true });
area.setSelectionRange(area.value.length, area.value.length);
}
private onCompositionStart = () => {
this.composing = true;
};
private onCompositionEnd = () => {
this.composing = false;
if (this.blurPending) this.commit();
};
private onKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || this.composing) return;
if (event.key === 'Escape') {
event.preventDefault();
this.cancel();
}
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
this.commit();
}
};
private onBlur = () => {
if (this.closed) return;
if (this.composing) {
this.blurPending = true;
return;
}
this.commit();
};
updateScreenMatrix(matrix: Matrix2D) {
if (this.closed) return;
this.textarea.style.transform = `matrix(${matrix.a},${matrix.b},${matrix.c},${matrix.d},${matrix.e},${matrix.f})`;
}
commit() {
if (this.closed) return;
if (this.composing) {
this.blurPending = true;
return;
}
this.blurPending = false;
const after = this.textarea.value;
if (new TextEncoder().encode(after).byteLength > 100_000) {
this.textarea.setCustomValidity('Text is too long');
this.textarea.reportValidity();
this.textarea.focus({ preventScroll: true });
return;
}
this.textarea.setCustomValidity('');
this.closed = true;
this.cleanup();
if (after !== this.shape.text) this.port.commit(this.shape.id, this.shape.text, after);
this.port.focusCanvas();
}
cancel() {
if (this.closed) return;
this.closed = true;
this.cleanup();
this.port.focusCanvas();
}
private cleanup() {
this.textarea.removeEventListener('compositionstart', this.onCompositionStart);
this.textarea.removeEventListener('compositionend', this.onCompositionEnd);
this.textarea.removeEventListener('keydown', this.onKeyDown);
this.textarea.removeEventListener('blur', this.onBlur);
this.textarea.remove();
}
}
Canvas 预览使用相同的 font/lineHeight 和缓存行,并明确指定基线。direction:auto 不能直接赋给 Canvas direction,因为后者只接受 ltr/rtl/inherit。权威的 DOM 测量和布局会先为段落解析出一个 resolvedDirection,然后预览复用该值。fontSize 也必须是经过独立验证的数值。如果对 "600 16px Canvas Sans" 调用 parseFloat(),就会把字体粗细 600 误认为字体大小:
export function renderTextPreview(
context: CanvasRenderingContext2D,
shape: TextShape,
lines: readonly TextLine[],
resolvedDirection: 'ltr' | 'rtl',
) {
context.save();
try {
context.font = shape.font;
context.fillStyle = shape.color;
context.textBaseline = 'alphabetic';
context.direction = resolvedDirection;
const fontSize = shape.fontSize;
if (!(fontSize > 0) || !Number.isFinite(fontSize)) throw new Error('INVALID_FONT_SIZE');
lines.forEach((line, index) =>
context.fillText(line.text, 0, fontSize + index * fontSize * shape.lineHeight),
);
} finally {
context.restore();
}
}
单元测试证明,字素绝不会在 ZWJ 序列内部断开。真实浏览器测试为 IME 提供最终证据:
import { describe, expect, it } from 'vitest';
import { wrapGraphemes } from '../TextLayout';
describe('text layout', () => {
it('treats a family emoji as one wrapping unit', () => {
const family = '👨👩👧👦';
const lines = wrapGraphemes(
`A${family}B`,
2,
(text) =>
Array.from(new Intl.Segmenter('und', { granularity: 'grapheme' }).segment(text)).length,
);
expect(lines.map((line) => line.text)).toEqual([`A${family}`, 'B']);
expect(lines.flatMap((line) => [...line.text]).join('')).toBe(`A${family}B`);
});
it('preserves hard line breaks, including empty lines', () => {
expect(wrapGraphemes('あ\n\nい', 10, (text) => text.length).map((line) => line.text)).toEqual([
'あ',
'',
'い',
]);
});
});
运行 pnpm vitest run src/engine/text。中日韩文字、Emoji、空行、宽度边界和字体缓存测试都应通过。运行 pnpm playwright test tests/browser/text-ime.spec.ts --project=chromium。组合输入期间,历史记录修订号应保持不变;结束组合输入并提交后,它应恰好增加 1。然后在 Safari/WebKit 和 Firefox 中运行剪贴板、选区和 RTL 测试。在移动设备上,手动验证虚拟键盘不会将覆盖层永久推到视口之外。
回到照片游戏。渲染器显示新照片,DOM 控件是暂时覆盖在照片上的原稿纸,FontManager 是装有印刷字模的盒子,而组合输入是尚未选定的候选卡片。只有当孩子说“完成”时,我们才拍摄新照片并更新账本。取消只会移除原稿纸。
故意破坏它
| 注入项 | 症状 | 证据 | 修复方法 | 回归测试 | 恢复方法 |
|---|---|---|---|---|---|
| 中文拼音组合输入 | 候选内容重复;每次按键产生一次撤销记录 | 组合/input/历史记录跟踪 | 由原生控件管理中间值;退出时只提交一次 | Chromium IME 事件序列 | 取消覆盖层;文档保持不变 |
| 日文转换 | 按 Enter 过早退出编辑 | isComposing=true keydown | 组合输入期间忽略提交快捷键 | WebKit/Chromium 固定测试用例 | 恢复选区 |
| 家庭 Emoji | 退格后留下破损符号,或换行将其拆开 | 字素分段 | Intl.Segmenter 或 DOM 布局 | ZWJ/肤色/旗帜固定测试用例 | 重建布局缓存 |
| 阿拉伯语 RTL | 字符顺序颠倒且插入光标位置错误 | 逻辑值和视觉截图 | 不要反转字符串;设置 dir/direction | RTL 混合数字测试 | 恢复逻辑文本 |
| 字体延迟加载 | 文本跳动且连接线锚点错误 | fonts.status 和加载前后度量 | 在 loadingdone 时使缓存失效 | 延迟字体路由 | 清除布局缓存 |
| 目标字体替换回退字体 | 行数发生变化,但边界未更新 | 行数和度量修订号 | 重新计算派生高度和锚点 | 字体交换测试 | 执行一次确定性重绘 |
| 400% 缩放 + 旋转 | 覆盖层与文本分离 | 比较 CSS 和形状的屏幕矩阵 | 使用完整矩阵和 transform-origin | 在 0/37/90° 下进行多档缩放截图 | 重建覆盖层 |
| 粘贴富 HTML | 脚本或私有样式进入模型 | 剪贴板 MIME/模式日志 | 纯文本策略,或使用清理器并配合类型化富文本模型 | 恶意 HTML 固定测试用例 | 以原子方式拒绝粘贴 |
| 移动端虚拟键盘 | 输入框被遮挡或焦点丢失 | VisualViewport/焦点跟踪 | 可见区域滚动和焦点策略 | 真实 iOS/Android 设备 | 保留编辑会话并重新定位 |
用证据通过验收
| 待证明的主张 | 自动化证据 | 必需的手动/设备证据 |
|---|---|---|
| 基于 keydown 的构建不会破坏 IME | 事件重放和一条命令 | 真实的中文/日文输入法 |
| 字素不会被拆分 | Segmenter 固定测试用例 | 使用系统 Emoji 键盘执行删除 |
| RTL/双向文本保留逻辑值 | 值快照 | 屏幕阅读器和插入光标方向 |
| 字体变化后可以恢复 | 延迟加载和缓存失效 | 不同操作系统上的回退字体 |
| 覆盖层正确对齐 | 跨缩放和旋转的截图差异比较 | 400% 缩放时的选区 |
| 提交/取消边界 | 修订号为 1/0 的断言 | 失焦、Escape 和虚拟键盘 |
- FontFace、
document.fonts、回退字体、加载失败和重新测量都有明确协议。 - 文本度量、断行/换行、基线和 Canvas 预览使用显式布局数据。
- 字素、Emoji、双向文本、RTL、IME、组合输入、插入光标和选区由正确的层负责。
- textarea/contenteditable 的选择有 ADR;富文本、拼写检查和粘贴都有安全策略。
- DOM 测量和覆盖层在缩放、旋转及父级变换发生变化后仍保持对齐。
- 提交是一条命令;取消对文档执行零次写入。
- 普通文本不是根据
keydown手动拼装的,因此 IME、选区、剪贴板、组合输入和屏幕阅读器行为保持完好。
给五岁孩子解释
回答时不要说“DOM”、“Canvas”、“输入法”、“字素”或“双向文本”:
- 为什么你能在照片中看到文字,却不能在两个字母之间放一条小竖线?
- 当孩子仍在挑选候选字符时,为什么不能在每次按键时都更新共享账本?
- 为什么全家福由许多细小部分组成,却通常需要作为一个整体行动?
- 新问题:装有印刷字模的盒子晚到了一分钟,一行文字变成了两行。必须重新测量什么?