前端如何把文字渲染到屏幕上
当我们在 <p>Hello world</p>
里写下区区几个字符时,浏览器、操作系统、GPU 在背后悄悄完成了 10 余个步骤,才让你我在屏幕上看到清晰或模糊、锐利或发虚的文字。本文把这条「文字渲染流水线」拆解成 10 个关键环节,并给出前端代码可以干预的“旋钮”。
1. 拿到原始字符串
- 来源:HTML、CSS
content
、JSfillText()
。 - 内存形态:一串 Unicode 码点(Code Points)。
2. 码点 → 字形索引
- 浏览器根据
font-family
链查找字体文件。 - 字体内部的 cmap 表把 Unicode 映射到 Glyph ID。
- 找不到就回退下一款字体,最终可能 fallback 到「豆腐框」。
3. 字形塑形(Shaping)
引擎:Chrome/Edge 用 HarfBuzz,Safari 用 CoreText。
处理内容:
- 连字(fi → fi)
- 阿拉伯/印度文上下文变形
- 字距 kerning、track、ligature
输出:一组带坐标的“已定位字形”。
4. 断行与排版
- 依据 CSS
writing-mode
、white-space
、line-break
、word-break
等规则拆成行盒(line boxes)。 - 计算基线、对齐、缩进。
- 得到最终每个字形在页面上的绝对 (x, y)。
5. 生成绘制指令
图形库:Skia(Chromium)、CoreGraphics(Safari)、D2D(Edge)。
记录:
“在 (x, y) 处用颜色 #333 绘制 glyph 12345”
此时仍为矢量命令,尚未光栅化。
6. 字体光栅化(Rasterization)
- 把矢量轮廓(TrueType/OTF 的 Bézier 曲线)转为位图。
- 灰度或 RGBA mask,再做 hinting / grid-fitting。
- 位图缓存在 GPU 纹理 atlas,供后续帧复用。
7. 合成层(Layerization)
- 文字、背景、边框拆成多个 Compositing Layers。
- 文字层常因
will-change
、opacity、transform 变成独立纹理。 - 每层对应一张或若干张四通道纹理。
8. 混合与着色(Blending & Shading)
- GPU 片段着色器把字形纹理与背景纹理按 Porter-Duff 合成。
- 支持子像素抗锯齿(ClearType、macOS CoreText gamma 校正)。
9. 输出到帧缓冲
- 合成器把最终像素写入窗口帧缓冲(双/三缓冲)。
- 操作系统在下一帧 V-Sync 时送到显示器。
10. 屏幕发光 → 你看到了文字
前端可控“旋钮”
大部分环节浏览器已封装,但 1~4 与 7 仍有大量可调参数:
需求 | 代码示例 |
---|---|
指定字体 | font-family: "Inter", "PingFang SC", sans-serif; |
关闭连字 | font-feature-settings: "liga" 0; |
抗锯齿 | -webkit-font-smoothing: antialiased; |
图层提升 | will-change: opacity; |
Canvas 自绘 | ctx.fillText('Hello', 20, 50); |
WebGL 全链路 | 上传字形 bitmap,自行写 shader |
Demo
下面给出 4 段可运行的最小 DEMO,分别演示「字体回退」「连字开关」「子像素抗锯齿差异」和「Canvas 自绘字形」这四个最容易被前端干预的环节。把代码直接粘进本地 index.html
即可看到效果。
1. 字体回退(Font Fallback)
<style>
.fb { font-size: 60px; }
.fb-a { font-family: "SomeFakeFont", "PingFang SC", serif; } /* 会回退 */
.fb-b { font-family: "SomeFakeFont", "Noto Sans SC", serif; } /* 也会回退,但字形不同 */
</style>
<div class="fb fb-a">你好 A</div>
<div class="fb fb-b">你好 B</div>
观察:两段文字字形不同,说明浏览器确实按顺序回退到不同字体。
2. 连字(Ligature)开关
<style>
.lig { font: 48px/1 "Fira Code", monospace; }
.on { font-feature-settings: "liga" 1; }
.off { font-feature-settings: "liga" 0; }
</style>
<p class="lig on">!= == =></p>
<p class="lig off">!= == =></p>
观察:第一行出现连字符号(≠、⩵、⇒),第二行保持原样。
3. 子像素抗锯齿差异
(macOS 需手动关闭「字体平滑」才能肉眼区分;Windows 用 ClearType 差异明显)
<style>
body { background:#fff; color:#000; }
.aa { font: 36px/1 "Inter", sans-serif; margin:20px; }
.aa-gray { -webkit-font-smoothing: antialiased; } /* 灰度抗锯齿 */
.aa-subpx { -webkit-font-smoothing: subpixel-antialiased; } /* 子像素 */
</style>
<p class="aa aa-gray">Grayscale AA</p>
<p class="aa aa-subpx">Subpixel AA</p>
放大 500 % 观察边缘颜色:子像素抗锯齿会出现红/绿/蓝边。
4. Canvas 自绘字形(跳过浏览器排版)
<canvas id="c" width="400" height="100"></canvas>
<script>
const ctx = c.getContext('2d');
ctx.font = '48px Inter';
ctx.fillStyle = '#222';
ctx.fillText('Canvas 文字', 20, 60);
// 自行画下划线,说明完全自己排版
ctx.strokeStyle = '#ff4757';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(20, 70);
ctx.lineTo(ctx.measureText('Canvas 文字').width + 20, 70);
ctx.stroke();
</script>
观察:下划线位置由我们手动计算,浏览器不参与行盒模型。
结论
前端“渲染文字”= 把 Unicode 字符串 → 找字体 → 变字形 → 排成行 → 画成位图 → 合成图层 → GPU 画到屏幕。
理解这条流水线后,再遇到「为什么 12 px 汉字发虚」「为什么自定义字体闪烁」等问题,就能快速定位到是哪个环节掉链子了。祝你调试愉快,文字常清!