04-渲染管线与复用
渲染管线与扩展讨论 (renderCanvas)
概述
renderCanvas() 是整个排版系统的渲染入口,协调文本测量、算法执行和 Canvas 绑制。其设计遵循测量-计算-绑制三阶段模式,并特别关注高清屏幕适配。
完整渲染流程
flowchart TD
subgraph 初始化
A[document.fonts.ready] --> B[获取容器宽度]
B --> C[计算列宽<br/>colWidth = min容器 1024]
C --> D[计算内部宽度<br/>innerWidth = colWidth - PAD×2]
end
subgraph 文本测量
D --> E[创建临时Canvas<br/>测量字体]
E --> F[测量 NORMAL_SPACE_W<br/>空格宽度]
F --> G[测量 HYPHEN_WIDTH<br/>连字符宽度]
end
subgraph 文本预处理
G --> H[遍历每个段落PARAGRAPHS]
H --> I[split tokens<br/>保留空格]
I --> J[hyphenateWord<br/>断词处理]
J --> K[join软连字符]
K --> L[prepareWithSegments<br/>生成segments+widths]
H --> L
end
subgraph 算法求解
L --> M[optimalLayout<br/>计算最优断点]
M --> N[allLines<br/>所有段落行布局]
end
subgraph Canvas绘制
N --> O[setupCanvas<br/>高清适配]
O --> P[fillRect白底]
P --> Q[clip裁剪区域]
Q --> R{遍历每行}
R -->|非最后行<br/>且≥60%宽| S[两端对齐]
R -->|其他| T[左对齐]
S --> U[计算间距js]
T --> V[逐字绘制]
U --> V
V --> R
R -->|完成| W[ctx.restore<br/>恢复状态]
end
style H fill:#e3f2fd
style M fill:#fff3e0
style S fill:#c8e6c9
style W fill:#e8f5e9
代码逐段解析
阶段一:字体加载与宽度计算
// 第410-417行
const renderCanvas = useCallback(async () => {
const canvas = canvasRef.current;
if (!canvas) return;
await document.fonts.ready;
// 等待字体加载完成,确保测量准确
const containerWidth = canvas.parentElement!.clientWidth || 400;
// 获取父容器宽度兜底400
const colWidth = Math.min(containerWidth, 1024);
// 最大1024px,防止过宽
const innerWidth = colWidth - PAD * 2;
// 内边距各12px
为什么等待 document.fonts.ready?
Canvas 文本测量依赖字体加载完成,否则测量结果可能不准确(回退字体)。
阶段二:文本测量
// 第419-423行
const m = document.createElement("canvas").getContext("2d")!;
m.font = FONT;
// Georgia 15px
const NORMAL_SPACE_W = m.measureText(" ").width;
const HYPHEN_WIDTH = m.measureText("-").width;
// 测量关键字符宽度用于后续计算
为什么用独立Canvas测量?
临时创建一个离屏Canvas,不影响主Canvas状态。
阶段三:文本预处理流水线
// 第425-436行
const prepared = PARAGRAPHS.map((p) => {
// 1. 分词:保留空格
const tokens = p.split(/(\s+)/);
// 2. 断词处理
const hyphenated = tokens
.map((t) => {
if (/^\s+$/.test(t)) return t; // 空格直接返回
const parts = hyphenateWord(t);
// 返回拆分数组,如 ["un", "expected"]
return parts.length <= 1
? t // 不需要拆分
: parts.join(""); // 用软连字符连接
})
.join("");
// 重新拼接为字符串
// 3. 调用pretext库生成segments和widths
return prepareWithSegments(hyphenated, FONT);
});
预处理流程图:
flowchart LR
A["The relationship..."] --> B["The", " ", "relationship", "..."]
B --> C{每个token}
C -->|空格| D[保留原样]
C -->|单词| E[hyphenateWord]
E -->|拆分| F["re", "la", "tion", "ship"]
F --> G[join ""]
G --> H["relationship"]
H --> I[prepareWithSegments]
I --> J[segments + widths]
阶段四:算法求解
// 第438-441行
const allLines = prepared.map((p) =>
optimalLayout(p, innerWidth, NORMAL_SPACE_W, HYPHEN_WIDTH),
);
// 为每个段落计算最优行布局
返回值结构:
// lines数组,每个元素代表一行
{
segments: [ // 该行的文本片段
{ text: "The", width: 30, isSpace: false },
{ text: " ", width: 4, isSpace: true },
{ text: "relationship", width: 85, isSpace: false },
// ...
],
y: 120, // Y坐标
maxWidth: 400, // 最大宽度
isLast: false, // 是否最后一行
lineWidth: 200, // 实际内容宽度
}
阶段五:Canvas 高清适配
// 第270-280行(setupCanvas函数)
const setupCanvas = (canvas: HTMLCanvasElement, w: number, h: number) => {
const dpr = window.devicePixelRatio || 1;
// 获取设备像素比:普通屏幕=1, Retina=2, 3倍屏=3
canvas.width = w * dpr; // 设置物理像素宽度
canvas.height = h * dpr; // 设置物理像素高度
canvas.style.width = w + "px"; // CSS宽度不变
canvas.style.height = h + "px"; // CSS高度不变
const ctx = canvas.getContext("2d")!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 缩放上下文,使后续绘制按逻辑像素工作
return ctx;
};
高清适配原理:
flowchart TD
subgraph 普通屏幕 DPR=1
A[逻辑尺寸 400×300] --> B[物理尺寸 400×300]
A --> C[1像素=1像素]
end
subgraph Retina DPR=2
D[逻辑尺寸 400×300] --> E[物理尺寸 800×600]
D --> F[1像素=4像素<br/>更细腻]
end
style E fill:#fff3e0
阶段六:两行对齐绘制
// 第463-493行
allLines.forEach((para) => {
para.forEach((line) => {
line.y = curY;
curY += LINE_HEIGHT;
const justify = !line.isLast && line.lineWidth >= innerWidth * 0.6;
// 条件:非最后一行 且 行宽≥60%列宽 → 需要两端对齐
if (!justify) {
// 左对齐:简单逐字绘制
let x = PAD;
line.segments.forEach((s: any) => {
if (!s.isSpace) ctx.fillText(s.text, x, line.y);
x += s.width;
});
return;
}
// 两端对齐
let w = 0, sp = 0;
line.segments.forEach((s: any) =>
s.isSpace ? sp++ : (w += s.width)
);
// 计算总字宽w和空格数sp
const js = Math.max((innerWidth - w) / sp, NORMAL_SPACE_W * 0.75);
// js = 间距 = 可用空间/空格数
// 最少保持正常的75%,防止过紧
let x = PAD;
line.segments.forEach((s: any) => {
if (s.isSpace)
x += js; // 空格用计算出的间距
else {
ctx.fillStyle = "#222";
ctx.fillText(s.text, x, line.y);
x += s.width; // 文字用自身宽度
}
});
});
});
两行对齐图解:
flowchart LR
subgraph 原始状态
A[The] --> B[ ] --> C[relationship]
A1[_____60%宽度_____]
end
subgraph 对齐后
D[The] --> E[ ] --> F[relationship]
D1[___________100%宽度___________]
end
E -.->|js更宽| G[间距扩大]
A --> D[间距从4px变为约12px]
生命周期管理
// 第498-503行
useEffect(() => {
renderCanvas(); // 初始渲染
const resize = () => renderCanvas();
window.addEventListener("resize", resize);
// 监听窗口变化重新渲染
return () => window.removeEventListener("resize", resize);
// 清理:移除监听
}, [renderCanvas]);
// 依赖renderCanvas,其是useCallback包裹的稳定函数
为什么要监听 resize?
容器宽度可能因侧边栏折叠、窗口缩放等改变,需要重新计算布局。
扩展讨论:逻辑复用与延伸
1. 提取独立排版引擎
当前逻辑可以封装为通用库:
// typography-engine.ts
export interface TypographyOptions {
fontFamily: string;
fontSize: number;
lineHeight: number;
maxWidth: number;
hyphenExceptions?: Record<string, string[]>;
prefixes?: string[];
suffixes?: string[];
}
export class TypographyEngine {
constructor(private options: TypographyOptions) {}
// 断词
hyphenate(word: string): string[] { ... }
// 布局计算
layout(text: string): Line[] { ... }
// 渲染到Canvas
render(canvas: HTMLCanvasElement, text: string): void { ... }
// 渲染到DOM(SVG/HTML)
renderToDOM(container: HTMLElement, text: string): void { ... }
}
2. 支持 RTL 语言
扩展方向:
// 检测文本方向
const detectDirection = (text: string): 'ltr' | 'rtl' => {
const rtl = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
return rtl.test(text) ? 'rtl' : 'ltr';
};
// RTL布局镜像
const getX = (logicalX: number, lineWidth: number, maxWidth: number) => {
return isRTL ? maxWidth - logicalX - lineWidth : logicalX;
};
3. 导出为 PDF/SVG
// 使用canvas.toDataURL导出图片
const exportAsImage = (canvas: HTMLCanvasElement, format: 'png' | 'jpeg') => {
return canvas.toDataURL(`image/${format}`);
};
// 渲染到SVG(用于矢量导出)
const renderToSVG = (lines: Line[], options: TypographyOptions) => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
// 添加text元素...
return new XMLSerializer().serializeToString(svg);
};
4. 响应式排版策略
const getOptimalColumns = (containerWidth: number): number => {
if (containerWidth < 400) return 1;
if (containerWidth < 600) return Math.random() > 0.5 ? 1 : 2; // 窄双栏
if (containerWidth < 900) return 2;
return 3; // 三栏排版
};
5. 与 React 组件集成
// 封装为React组件
const Typography: React.FC<TypographyProps> = ({
text,
fontSize = 15,
lineHeight = 24,
maxWidth = 800,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const engine = new TypographyEngine({
fontFamily: 'Georgia, serif',
fontSize,
lineHeight,
maxWidth,
});
engine.render(canvasRef.current!, text);
}, [text, fontSize, lineHeight, maxWidth]);
return <canvas ref={canvasRef} />;
};
6. 性能优化:Web Worker
将计算密集型算法移至 Worker:
// typography.worker.ts
self.onmessage = ({ data }) => {
const { segments, widths, maxWidth, normalSpace } = data;
const result = optimalLayout(segments, widths, maxWidth, normalSpace);
self.postMessage(result);
};
// 主线程
const worker = new Worker('typography.worker.ts');
worker.postMessage({ segments, widths, maxWidth, normalSpace });
worker.onmessage = ({ data }) => {
// 接收计算结果并渲染
};
设计亮点总结
| 特性 | 实现方式 | 价值 |
|---|---|---|
| 三阶段分离 | 测量→算法→渲染 | 职责清晰,易测试 |
| useCallback 稳定渲染 | 避免不必要重绘 | 性能优化 |
| Resize 节流 | 每次resize都重算 | 布局始终正确 |
| 高清适配 | devicePixelRatio | Retina清晰 |
| 两行对齐算法 | 等比拉伸间距 | 专业排版效果 |