Expand document collections with Pretext tutorial and simplify library navigation
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m47s
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m47s
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# 从浏览器排版到 Pretext:高性能文本布局源码教程
|
||||
|
||||
这份教程回答一个问题:**Pretext 为什么能很快地算出一段文字的换行与高度?**
|
||||
|
||||
读完后,你应该能解释它的性能来源,沿着函数调用读懂核心源码,并知道如何在自己的界面中使用它。只需要了解 JavaScript 函数、数组和基本 DOM;字体、字素、回流等概念会逐步解释。
|
||||
|
||||
分析基于上游提交 [`0d102a5cd933611e86a7e60bda7b4baa5995c6b0`](https://github.com/chenglou/pretext/tree/0d102a5cd933611e86a7e60bda7b4baa5995c6b0),研究日期为 2026 年 10 月 28 日。本文描述这个版本,不把早期实现或未来版本的行为混在一起。源码链接指向本次研究的上游版本;实验脚本和记录随文保存在本站。
|
||||
|
||||
## 阅读路线
|
||||
|
||||
1. [第一章:为什么算文字高度也会慢](/docs/pretext/01-chapter)
|
||||
2. [第二章:先看懂 prepare 与 layout 的分工](/docs/pretext/02-chapter)
|
||||
3. [第三章:分析阶段如何把文字变成可布局的分段](/docs/pretext/03-chapter)
|
||||
4. [第四章:测量缓存如何减少浏览器工作](/docs/pretext/04-chapter)
|
||||
5. [第五章:换行算法为什么能变成数值循环](/docs/pretext/05-chapter)
|
||||
6. [第六章:为什么只求高度比拿到每行文字便宜](/docs/pretext/06-chapter)
|
||||
7. [第七章:如何避免长文本拖垮算法](/docs/pretext/07-chapter)
|
||||
8. [第八章:准确性怎样影响性能设计](/docs/pretext/08-chapter)
|
||||
9. [第九章:把它接进界面和虚拟列表](/docs/pretext/09-chapter)
|
||||
10. [第十章:亲自验证性能机制](/docs/pretext/10-chapter)
|
||||
11. [第十一章:按什么顺序读源码](/docs/pretext/11-chapter)
|
||||
12. [第十二章:把这些设计迁移到自己的代码](/docs/pretext/12-chapter)
|
||||
|
||||
第一次阅读建议顺着读到第六章,再看第九章。第四、七、八章适合对照源码细读;第十章提供实验与复现方法。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 第一章:为什么算文字高度也会慢
|
||||
|
||||
### 1.1 字符数不能直接决定高度
|
||||
|
||||
假设一段话放在宽 320px 的聊天气泡中。它会占几行,至少取决于文字内容、字体、字号、可用宽度、字间距和换行规则。
|
||||
|
||||
同样长度的 `iiii` 和 `WWWW` 宽度可能差很多。中文通常能在许多字之间断行,英文更常在空格处换行;标点又有自己的限制。所以“字符数乘平均字宽”只能估计,无法可靠地预测真正的行数。
|
||||
|
||||
传统方案是让浏览器先排一遍,再读取结果:
|
||||
|
||||
```js
|
||||
element.textContent = text
|
||||
element.style.width = '320px'
|
||||
const height = element.getBoundingClientRect().height
|
||||
```
|
||||
|
||||
写入文字或宽度后,之前的排版结果可能失效。此时立刻读取几何信息,浏览器必须先保证这个结果是最新的;如果有待处理的样式或布局工作,就可能同步完成这些工作。
|
||||
|
||||
这就是这里关心的“回流”或“布局”:确定元素的尺寸与位置。**一次几何读取不一定总触发昂贵回流,关键在于读取时是否存在需要更新的布局。**
|
||||
|
||||
### 1.2 写与读交替,会放大成本
|
||||
|
||||
```js
|
||||
for (const row of rows) {
|
||||
row.element.style.width = width + 'px'
|
||||
heights.push(row.element.getBoundingClientRect().height)
|
||||
}
|
||||
```
|
||||
|
||||
这个循环可能不断要求浏览器“写一点、立即排好、读出来”。把写操作与读操作分批,通常能改善它。但虚拟列表还有另一个问题:大多数行根本没有挂载,无法直接读取那些行的 DOM 高度。
|
||||
|
||||
Pretext 的切入点是:**如果能在应用代码中算出文本行数,就能在创建那些 DOM 之前知道高度。** 浏览器最终仍然负责把字画出来。
|
||||
|
||||
### 1.3 区分布局和绘制
|
||||
|
||||
把一段文字显示到屏幕上,可以粗略拆成:
|
||||
|
||||
```text
|
||||
文字与样式 → 确定字形和宽度 → 决定换行与位置 → 绘制文字
|
||||
```
|
||||
|
||||
Pretext 借助 Canvas 的字体测量,自己完成一部分换行与布局计算。它不是一个自动让任意页面绘制都变快的开关,也不承担完整的浏览器 CSS 排版。
|
||||
|
||||
本章检查:如果宽度变化,文字本身的字体宽度一定要重新测量吗?通常不需要。变的是“每行能装多少”,这就是下一章的设计基础。
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# 第二章:先看懂 prepare 与 layout 的分工
|
||||
|
||||
### 2.1 同一份输入,有不同的变化频率
|
||||
|
||||
聊天消息的文字和字体可能很久不变,但容器宽度会在拖动窗口时连续变化。把所有工作绑在一个函数里,每次 resize 都会重复做许多相同的事情。
|
||||
|
||||
Pretext 将它拆成两步:
|
||||
|
||||
```js
|
||||
import { prepare, layout } from '@chenglou/pretext'
|
||||
|
||||
const prepared = prepare('Hello 世界,今天一起读源码。', '16px Arial')
|
||||
|
||||
const narrow = layout(prepared, 200, 24)
|
||||
const wide = layout(prepared, 400, 24)
|
||||
```
|
||||
|
||||
这里 `200`、`400` 是内容宽度,`24` 是调用者指定的每行高度,单位都是 CSS 像素。
|
||||
|
||||
| 阶段 | 输入 | 主要工作 | 什么时候重做 |
|
||||
| --- | --- | --- | --- |
|
||||
| `prepare()` | 文字、字体、选项及语言环境 | 分析断行机会、测量、记录修正数据 | 相关输入变化 |
|
||||
| `layout()` | prepared、宽度、行高 | 根据已知宽度决定换行并计数 | 容器宽度或行高变化 |
|
||||
|
||||
prepared 可以理解为“已经整理好的排版材料”。它与容器宽度无关,但并不包含任意宽度下现成的布局答案。
|
||||
|
||||
### 2.2 沿源码走一遍
|
||||
|
||||
打开 [`src/layout.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/layout.ts),定位 `prepareInternal()`:
|
||||
|
||||
```text
|
||||
读取引擎规则与语言
|
||||
→ analyzeText(...)
|
||||
→ measureAnalysis(...)
|
||||
→ prepared handle
|
||||
```
|
||||
|
||||
再定位 `layout()`,其核心逻辑是:
|
||||
|
||||
```js
|
||||
const lineCount = countPreparedLines(internal, normalizedWidth)
|
||||
return { lineCount, height: lineCount * lineHeight }
|
||||
```
|
||||
|
||||
这是省略类型与内部转换的源码摘义。它没有逐行调用 Canvas,也没有先创建 DOM 再读取高度。
|
||||
|
||||
### 2.3 一段文字被布局多次,省下多少工作
|
||||
|
||||
用 `P` 表示准备成本,用 `L` 表示一次布局成本,一共布局 `k` 次:
|
||||
|
||||
```text
|
||||
复用 prepared:P + k × L
|
||||
每次重新 prepare:k × (P + L)
|
||||
```
|
||||
|
||||
两种写法相差 `(k - 1) × P`。这解释了为什么使用方式会直接决定收益:在 resize 回调中重新 prepare,会让原本可以省掉的分析工作再次发生。
|
||||
|
||||
另外,重复 prepare 可能命中测量缓存,但仍要分析文本、组装新的 handle。它依然比直接复用已有 handle 做得更多。
|
||||
|
||||
本章检查:`layout()` 是 O(1) 吗?不是。它通常还要扫描分段。快来自减少每一步要做的工作,而不是完全不看文本。
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 第三章:分析阶段如何把文字变成可布局的分段
|
||||
|
||||
### 3.1 三个容易混淆的单位
|
||||
|
||||
| 名称 | 含义 | 例子 |
|
||||
| --- | --- | --- |
|
||||
| UTF-16 码元 | JavaScript 字符串索引使用的存储单位 | 一些 emoji 的 `length` 大于 1 |
|
||||
| 字素簇,简称字素 | 用户通常感知的一个文字单位 | 字母加组合音标、由多个码点组成的 emoji |
|
||||
| 分段 segment | Pretext 为测量与换行建立的单位 | 一个词、一段文本、空格或特殊控制段 |
|
||||
|
||||
分段并不总等于单词。字素也不总等于一个 JavaScript 字符。理解这两个区别,后面的超长单词断行和 emoji 处理才容易读懂。
|
||||
|
||||
### 3.2 先找哪里允许断行
|
||||
|
||||
对 `hello world`,可以先想象成:
|
||||
|
||||
```text
|
||||
['hello', ' ', 'world']
|
||||
```
|
||||
|
||||
这个示意足以解释普通英文,但真实的中文标点、日文、泰文、软连字符和不可断空格需要更多规则。
|
||||
|
||||
[`analysis.ts` 的 `analyzeText()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/analysis.ts)先处理空白与源文本,再按引擎选择扫描器,最后形成分段及标志。其中主要的引擎对应关系是:
|
||||
|
||||
| 浏览器引擎 | 主要实现入口 |
|
||||
| --- | --- |
|
||||
| Blink,例如 Chrome | `line-breaks.ts` 中的 `getBlinkLineBreaks()` |
|
||||
| WebKit,例如 Safari | `line-breaks.ts` 中的 `getWebKitLineBreaks()` |
|
||||
| Gecko,例如 Firefox | `gecko-line-breaks.ts` 中的 `getGeckoLineBreaks()` |
|
||||
|
||||
选择由 `measurement.ts` 的 `getEngineProfile()` 完成,并缓存 profile。未知引擎采用 Blink 的模型。
|
||||
|
||||
### 3.3 表驱动扫描是什么意思
|
||||
|
||||
可以把扫描器想象成一个不断读取字符的机器:
|
||||
|
||||
```text
|
||||
当前状态 + 当前字符类别 → 新状态 + 是否出现边界
|
||||
```
|
||||
|
||||
字符属于哪一类、某种状态遇到某一类应该怎么走,都放在表里。运行时主要是读字符、查类别、查转换表、更新状态。
|
||||
|
||||
规则数据来自对应引擎及其 Unicode/ICU 数据,保存在 [`src/generated/`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/generated)。数据经过压缩;页面按需解开自己的引擎所用的表。这样能把复杂规则变成较直接的扫描,但也增加了包体积与初始化成本。这是有代价的工程选择。
|
||||
|
||||
[`graphemes.ts` 的 `findGraphemeEnds()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/graphemes.ts)也采用类似方式寻找字素边界。这个版本不是对所有文字都调用 `Intl.Segmenter`:词典分词主要留给泰语、老挝语、高棉语、缅甸语等特定文字的运行片段。
|
||||
|
||||
### 3.4 为什么空白处理要先做
|
||||
|
||||
`white-space: normal` 下,连续普通空格通常被折叠;`pre-wrap` 则保留空格、换行与制表符的相关行为。若测量的是原始字符串,绘制时浏览器却折叠了它,预测自然会错。
|
||||
|
||||
所以分析阶段必须建立与目标 CSS 相符的文本视图。这个版本对孤立 `\r` 有明确限制:`pre-wrap` 下绘制前也应把它规范成 `\n`,详见根目录 README。
|
||||
|
||||
本章检查:为什么不能直接 `text.split(' ')`?因为断行机会远不止空格,而且许多语言没有用空格分隔每一个词。
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 第四章:测量缓存如何减少浏览器工作
|
||||
|
||||
### 4.1 Canvas 提供的是字体测量能力
|
||||
|
||||
浏览器已经知道如何选择字体、塑造字形、计算宽度。Pretext 通过:
|
||||
|
||||
```js
|
||||
context.font = '16px Arial'
|
||||
const width = context.measureText('hello').width
|
||||
```
|
||||
|
||||
获得字体事实。它优先创建 `OffscreenCanvas`;没有该能力时,创建不挂载到文档的普通 Canvas。
|
||||
|
||||
`OffscreenCanvas` 这个名字不表示测量会自动进入另一个线程。代码在哪里调用,工作就在哪里执行;想在 worker 中使用,需要应用自己安排,还有第八章的准确性限制。
|
||||
|
||||
### 4.2 两层复用各解决什么问题
|
||||
|
||||
第一层是应用保留 prepared。第二层在库内部,以字体等测量条件组织缓存,再按分段文本查找:
|
||||
|
||||
```text
|
||||
字体 A 的缓存
|
||||
'hello' → 测量结果
|
||||
'world' → 测量结果
|
||||
|
||||
字体 B 的缓存
|
||||
'hello' → 另一份测量结果
|
||||
```
|
||||
|
||||
在 [`measurement.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/measurement.ts) 搜索 `getSegmentMetrics()`,会看到这一核心表达式:
|
||||
|
||||
```js
|
||||
measurement.metrics.get(seg) ?? addMetrics(...)
|
||||
```
|
||||
|
||||
命中就读取结果;未命中才调用 Canvas 并存入缓存。相同词语出现在不同消息里,也有机会复用,不要求整段消息完全相同。
|
||||
|
||||
实际缓存不只有一个数字。`SegmentMetrics` 还会保留拆词适配、emoji 统计或其他修正所需的信息。语言变化和字距造成的 shaping 模式变化也受到专门处理;不能把它简单描述成全局 `(text, font)` 永久不变的表。
|
||||
|
||||
### 4.3 命中缓存后,连字体设置都尽量省
|
||||
|
||||
`getFontMeasurement()` 先找到字体对应的数据;真正测量时才通过 `getContext()` 设置 Canvas 的字体和测量模式。
|
||||
|
||||
为什么把两者拆开?富文本可能反复查询许多 item 的字体,但这些 item 的文字全都命中缓存。既然无需测量,也就不必为了查询而触碰 Canvas。
|
||||
|
||||
源码同时照顾了字体后加载等浏览器行为,因此不是无条件地“字体字符串相同就永不赋值”。读这一段时应一起看注释,而不要只把它当成普通 setter 缓存。
|
||||
|
||||
### 4.4 prepared 与全局测量缓存的寿命不同
|
||||
|
||||
全局缓存为了复用测量,prepared 为了一段具体文本的后续布局。二者不应混为一谈。
|
||||
|
||||
`MAX_CACHED_SEGMENTS = 1 << 18`,即 262,144。所有字体缓存的分段数超过边界后,下一次准备开始时会清理测量缓存。它不是每次插入都逐出一个条目的 LRU,因此单次准备期间可能超过阈值。
|
||||
|
||||
已经创建的 handle 保留自己的布局数据,不会因为这次缓存清理突然失效。不过,如果实际字体或语言变了,旧 handle 也不会自动更新,仍需要重新准备。
|
||||
|
||||
本章检查:清理全局缓存会自动把旧 prepared 换成新字体宽度吗?不会。缓存清理和重新创建 handle 是两步。
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 第五章:换行算法为什么能变成数值循环
|
||||
|
||||
### 5.1 用一组虚构宽度手算
|
||||
|
||||
下面只是教学数据,不代表某种真实字体:
|
||||
|
||||
```text
|
||||
分段:hello 空格 world 空格 again
|
||||
宽度: 40 4 45 4 42
|
||||
可用宽度:90
|
||||
```
|
||||
|
||||
从左到右累计:
|
||||
|
||||
| 操作 | 当前宽度 | 决定 |
|
||||
| --- | ---: | --- |
|
||||
| 放入 hello | 40 | 留在第一行 |
|
||||
| 放入空格 | 44 | 继续 |
|
||||
| 放入 world | 89 | 继续 |
|
||||
| 尝试后面的空格 | 93 | 第一行结束,行尾空格按规则处理 |
|
||||
| 放入 again | 42 | 第二行 |
|
||||
|
||||
最终两行;若行高是 24px,高度就是 48px。
|
||||
|
||||
### 5.2 核心思路简单,实现处理边界
|
||||
|
||||
[`line-break.ts` 的 `countPreparedLines()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-break.ts)为普通文本专门实现数值循环。以下是教学版,不是可替代源码的完整算法:
|
||||
|
||||
```js
|
||||
for (const segment of segments) {
|
||||
if (currentWidth + segment.width <= availableWidth) {
|
||||
currentWidth += segment.width
|
||||
} else {
|
||||
finishCurrentLine()
|
||||
startNextLineWith(segment)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
真实代码还处理行首空白、行尾修正、零宽空格、放不下一整个分段时的字素断行。关键是这些判断都基于提前准备的数据,不再重新问 Canvas 这个词有多宽。
|
||||
|
||||
### 5.3 为什么需要简单路径和完整路径
|
||||
|
||||
准备阶段设置两个相关标志:
|
||||
|
||||
- `simpleLineWalkFastPath`:适合简单逐行遍历。
|
||||
- `simpleLineCountFastPath`:适合简单行数计算;某些边界仍可能回退处理。
|
||||
|
||||
普通文本、普通空格等可以走简单路径。保留空白、制表符和软连字符等内容需要更完整的规则,进入 `walkPreparedComplexLines()` 等路径。
|
||||
|
||||
源码在 `countPreparedLines()` 前的注释记录:完整 walker 每个分段的成本约为简单路径的 3~5 倍。**这是项目记录的局部测量依据,不是所有设备上的固定倍数。** 分开实现的目的,是让常见输入少支付复杂状态管理的成本。
|
||||
|
||||
### 5.4 高度计算为何不经过“每行对象”
|
||||
|
||||
仅求高度时,简单计数循环使用局部数值变量,直接累加行数,没有每行游标对象和逐行回调。`layout()` 最后仍会返回一个结果对象,所以准确说法是“避免每行分配”,而不是“零分配”。
|
||||
|
||||
这也是为什么一个看似更通用、更加抽象的 walker,未必适合最高频的行数路径。
|
||||
|
||||
本章检查:为什么文本支持更多规则,不一定让普通英文同样变慢?因为准备阶段能判断适用路径,复杂处理不必全部进入普通文本的热循环。
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# 第六章:为什么只求高度比拿到每行文字便宜
|
||||
|
||||
### 6.1 计算结果越详细,需要的工作越多
|
||||
|
||||
假设一个段落有 50 行。高度只需要一个行数;返回每行文字则还要确定范围、切字符串、拼接内容并创建结果对象。这些临时对象最终还需要垃圾回收。
|
||||
|
||||
因此选择 API 时应先问:调用者实际要什么?
|
||||
|
||||
| 需求 | 准备方式 | 布局 API |
|
||||
| --- | --- | --- |
|
||||
| 高度、行数 | `prepare()` | `layout()` |
|
||||
| 行数、最宽行 | `prepare()` | `measureLineStats()` |
|
||||
| 每行范围与宽度 | `prepare()` | `walkLineRanges()` |
|
||||
| 每行文字 | `prepareWithSegments()` | `layoutWithLines()` |
|
||||
| 逐行取文字,可按需停止 | `prepareWithSegments()` | `layoutNextLine()` |
|
||||
|
||||
普通 `prepare()` 的最终 handle 不保留分段字符串;准备过程中仍会使用文字,不能因此说它完全没有字符串处理。
|
||||
|
||||
### 6.2 范围与文字分开生成
|
||||
|
||||
一行可以先表示为:
|
||||
|
||||
```text
|
||||
从第几个 segment 的第几个字素开始
|
||||
到第几个 segment 的第几个字素结束
|
||||
这一行有多宽
|
||||
```
|
||||
|
||||
这是 range。需要显示那一行时,再把 range 变成实际字符串,这一步叫 materialize,即生成具体内容。
|
||||
|
||||
`walkLineRanges()` 不生成行字符串,但公共回调接收的 range 及其游标仍然会分配对象。它比“每行完整文字”做得少,也不应被描述成零分配。
|
||||
|
||||
[`line-text.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-text.ts)还用 WeakMap 按 handle 缓存所需分段的字素结束位置。只有行边界切进一个分段内部、确实需要定位字素时,才会补上相关数据。
|
||||
|
||||
### 6.3 流式逐行适合什么情境
|
||||
|
||||
如果只显示前三行摘要,逐行 API 可以在取到所需行数时停止;如果每行可用宽度不同,也可以逐次传入不同宽度。
|
||||
|
||||
```js
|
||||
let cursor
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const line = layoutNextLine(preparedWithSegments, 320, cursor)
|
||||
if (!line) break
|
||||
console.log(line.text)
|
||||
cursor = line.end
|
||||
}
|
||||
```
|
||||
|
||||
这个片段假设已经导入 `layoutNextLine` 并准备好 `preparedWithSegments`。它演示逐行读取,不包含省略号处理。高度用途直接选 `layout()` 会更合适。
|
||||
|
||||
本章检查:虚拟列表只想知道 10,000 条消息各有多高,是否需要为每条创建所有行字符串?不需要。
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 第七章:如何避免长文本拖垮算法
|
||||
|
||||
### 7.1 一次调用的成本也取决于字符串长度
|
||||
|
||||
假设为了知道单词的每个位置能否断开,依次测量:
|
||||
|
||||
```text
|
||||
a
|
||||
ab
|
||||
abc
|
||||
abcd
|
||||
……
|
||||
```
|
||||
|
||||
虽然只调用了 n 次 Canvas,总共提交的字符量却是:
|
||||
|
||||
```text
|
||||
1 + 2 + … + n = n(n + 1) / 2
|
||||
```
|
||||
|
||||
因此只数 API 调用次数还不够,也要数测量请求总共处理了多少文字。
|
||||
|
||||
### 7.2 96 个字素的边界
|
||||
|
||||
[`measurement.ts` 的 `getSegmentFit()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/measurement.ts)按引擎与输入情况使用字素求和、相邻组合、前缀等测量模型。
|
||||
|
||||
前缀测量限定在 `MAX_PREFIX_FIT_GRAPHEMES = 96` 以内。超过这一规模,相关路径改用相邻字素组合,限制提交给 Canvas 的文字量随长度增长的放大程度。
|
||||
|
||||
这并不等于严格证明浏览器内部 shaping 总是线性的。它限制的是库自己构造测量请求所造成的成本;浏览器字体引擎还有自己的工作。
|
||||
|
||||
### 7.3 逐行继续时,不从头重扫
|
||||
|
||||
逐行 API 将上一行结束的游标传给下一行,遍历从那里继续。若每取下一行都从全文开头重新定位,行数多时会累积出大量重复工作。
|
||||
|
||||
[`RESEARCH.md` 的 Keeping Work Bounded](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/RESEARCH.md#keeping-work-bounded)记录了许多实际陷阱,包括增长字符串的反复处理、从每行起点重扫到文本结尾、重复搜索断行片段。阅读性能代码时,除了看一次循环的内容,也要问:这个循环会被重复启动多少次?
|
||||
|
||||
本章检查:每个前缀只测一次,能否保证总成本是 O(n)?不能,因为每次测量的输入长度在增长。
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# 第八章:准确性怎样影响性能设计
|
||||
|
||||
### 8.1 字符宽度不总能独立相加
|
||||
|
||||
字偶距 kerning 会让相邻字形距离发生变化;连字可能把多个字母组成一个字形;阿拉伯文字母还会随上下文改变形态。
|
||||
|
||||
所以 `width('A') + width('V')` 未必等于 `width('AV')`。一个词整体测得的宽度,也未必直接告诉你从词中间截断后每一行的正确宽度。
|
||||
|
||||
Pretext 的策略是测量完整分段,同时提前记录紧急断词所需的 advances,以及行首、行尾的修正信息。复杂性主要支付在准备阶段,布局阶段读取结果。
|
||||
|
||||
### 8.2 相同 Unicode 文本,各浏览器也可能不同
|
||||
|
||||
浏览器引擎在空白、标点、行边界与拆词等方面存在差异。Pretext 的 engine profile 选择对应扫描规则、拟合模式与容差,而不是仅靠一套理想化 Unicode 换行实现预测所有浏览器。
|
||||
|
||||
这份模型有已知缺口,尤其在特殊字体设置、窄到不常见的内容宽度、某些复杂文字与平台 bug 上。它的目标是匹配支持范围内的浏览器行为;“算法能给出一个结果”并不能证明所有浏览器都一定这样画。
|
||||
|
||||
### 8.3 “无 DOM”有需要说明的例外
|
||||
|
||||
`layout()` 本身不做 DOM 测量。准备阶段则可能:
|
||||
|
||||
- 读取 `<html lang>`,除非调用者通过 `setLocale()` 给出了语言。
|
||||
- 在需要修正 emoji 宽度时,按字体使用 DOM span 探测。
|
||||
- 缺少 OffscreenCanvas 时,创建不挂载的 Canvas 元素。
|
||||
|
||||
因此应理解成它避免了“每个文本块依赖 DOM 测量”的架构,而不是声称整个库绝对不会访问 DOM。worker 中缺少 emoji 探测条件,准确性也可能不同。
|
||||
|
||||
### 8.4 重新准备的条件
|
||||
|
||||
| 变化 | 通常怎样处理 |
|
||||
| --- | --- |
|
||||
| 只有宽度变化 | 复用 handle,重新 `layout()` |
|
||||
| 只有调用者指定行高变化 | 复用 handle,传入新行高 |
|
||||
| 文字、字体、字号、字距、whiteSpace、wordBreak 变化 | 重新 `prepare()` |
|
||||
| 页面语言变化 | 重新准备;旧 handle 保留旧规则与宽度 |
|
||||
| 先前测过后,目标字体才加载完成 | 清理缓存并重新准备,避免继续用回退字体数据 |
|
||||
| macOS emoji 相关 DPR 变化 | 按 README 要求清理并重新准备含 emoji 文本 |
|
||||
|
||||
最终行为受字体、语言和 CSS 的共同约束。根目录 [README 的 Caveats](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/README.md#caveats)、[PLATFORM_BUGS.md](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/PLATFORM_BUGS.md)和 [ENGINE_FOLLOWUPS.md](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/ENGINE_FOLLOWUPS.md)是理解边界的重要入口。
|
||||
|
||||
本章检查:`height = lineCount × lineHeight` 是否能概括带任意大小图片的富文本?不能,图片或不同字体可能把某一行撑高,需要调用者处理行高模型。
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# 第九章:把它接进界面和虚拟列表
|
||||
|
||||
### 9.1 一个完整的浏览器模块例子
|
||||
|
||||
先在你的前端项目中安装:
|
||||
|
||||
```sh
|
||||
npm install @chenglou/pretext
|
||||
```
|
||||
|
||||
下面是供 Vite 等支持 npm 模块导入的环境使用的 JavaScript 模块。直接打开一个 HTML 文件并不能解析这个包名。
|
||||
|
||||
```js
|
||||
import { prepare, layout } from '@chenglou/pretext'
|
||||
|
||||
const text = 'Hello 世界,今天一起读源码。'.repeat(8)
|
||||
const font = '16px "Helvetica Neue", "PingFang SC", Arial, sans-serif'
|
||||
const lineHeight = 24
|
||||
|
||||
document.documentElement.lang = 'zh-CN'
|
||||
await document.fonts.load(font, text)
|
||||
|
||||
const prepared = prepare(text, font)
|
||||
const block = document.createElement('div')
|
||||
block.textContent = text
|
||||
Object.assign(block.style, {
|
||||
font,
|
||||
lineHeight: lineHeight + 'px',
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'normal',
|
||||
whiteSpace: 'normal',
|
||||
lineBreak: 'auto',
|
||||
letterSpacing: '0px',
|
||||
wordSpacing: '0px',
|
||||
fontKerning: 'auto',
|
||||
textWrap: 'wrap',
|
||||
hyphens: 'manual',
|
||||
textRendering: 'auto',
|
||||
boxSizing: 'content-box',
|
||||
padding: '0',
|
||||
border: '0',
|
||||
})
|
||||
document.body.appendChild(block)
|
||||
|
||||
function setContentWidth(width) {
|
||||
const predicted = layout(prepared, width, lineHeight)
|
||||
block.style.width = width + 'px'
|
||||
return predicted
|
||||
}
|
||||
|
||||
const predicted = setContentWidth(320)
|
||||
console.log('预测高度:', predicted.height)
|
||||
console.log('实际高度:', block.getBoundingClientRect().height)
|
||||
|
||||
// 模拟容器变宽:复用相同的 prepared。
|
||||
setContentWidth(480)
|
||||
```
|
||||
|
||||
这个例子最后的 DOM 高度读取是开发期核对。生产中如果每次布局都再读一次 DOM,仍会承担那次读取可能触发的工作。示例也假设没有其他 CSS 规则覆盖这些值,所用字体在目标设备上可用;跨平台项目应配置自己的字体。
|
||||
|
||||
### 9.2 让模型与显示使用相同输入
|
||||
|
||||
`layout(prepared, 320, 24)` 中的 320 是内容宽度。如果外框总宽 320px、左右各有 12px padding,再加边框,真正给文字使用的宽度就小于 320px。
|
||||
|
||||
可以由应用维护一个尺寸模型:先算出内容宽度,把同一个数同时传给 Pretext 和渲染层。这样可以减少“CSS 用一个值,JS 猜另一个值”的误差。
|
||||
|
||||
准备前等待具体文字所需字体加载很重要。只有 `document.fonts.ready` 不一定覆盖尚未使用、由 unicode-range 分片加载的字体;因此例子把 text 也传给 `document.fonts.load()`。
|
||||
|
||||
### 9.3 虚拟列表从哪里获得收益
|
||||
|
||||
假设有 10,000 条消息,屏幕上只显示 20 条:
|
||||
|
||||
```text
|
||||
每条消息保留 prepared
|
||||
→ 按当前内容宽度求各条高度
|
||||
→ 累计高度,确定每条消息顶部的位置
|
||||
→ 按滚动位置找可见范围
|
||||
→ 只挂载可见消息与少量缓冲区
|
||||
```
|
||||
|
||||
Pretext 解决的是“没挂载的文字怎样求高度”。累计位置、查找可见范围、滚动锚定、图片高度、选择与无障碍行为,仍由应用或虚拟列表库负责。
|
||||
|
||||
窗口变宽时,每条消息通常仍需要重新布局。因此大量消息的整表 resize 成本不会凭空消失。准备与布局分离,让这次更新的单位成本更低。
|
||||
|
||||
### 9.4 编辑与富文本如何处理
|
||||
|
||||
长文本编辑时,可以按段落保留 prepared,只重新准备被编辑影响的段落;有跨段结构变化时,应用还要更新相应块。
|
||||
|
||||
混合字体、提及标签和行内图片使用 `@chenglou/pretext/rich-inline`。这个版本把富文本作为一个段落分析,保留 item 与分段的映射,然后复用布局基础设施,而不是把每个 span 完全独立换行。
|
||||
|
||||
富文本还要考虑垂直尺寸:字体大小、上升部与下降部、行内盒子高度都可能影响真实行高。不要直接把所有富文本的行数乘一个固定值,当成任意 CSS 的精确高度。可继续读项目自己的 [Markdown 聊天案例说明](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/pages/demos/markdown-chat.md)。
|
||||
|
||||
本章检查:Pretext 是否已经替你完成虚拟列表?没有,但它提供了此前很难在挂载前获得的文本几何信息。
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# 第十章:亲自验证性能机制
|
||||
|
||||
### 10.1 先把问题变成可观察的证据
|
||||
|
||||
本教程关心四件事:
|
||||
|
||||
1. 首次准备是否调用 Canvas?
|
||||
2. 相同文本再次准备是否复用测量?
|
||||
3. 改变宽度做布局时是否继续调用 Canvas?
|
||||
4. 这些样本的预测高度是否与 DOM 相符?
|
||||
|
||||
下载脚本后,将它放进研究版本的 Pretext 仓库 `tutorials/zh-CN/verify.cjs`(按需新建目录),因为它以该位置查找 `../../src`。
|
||||
|
||||
随文的 [`verify.cjs`](/materials/pretext/verify.cjs)用真实 Chromium Canvas,在 `measureText()` 外包一层计数器,同时统计提交字符串的 UTF-16 码元总数。然后测试英文、中文、保留空白的文本。
|
||||
|
||||
实验不使用伪造的平均字宽。源码通过 Node 的 TypeScript 转换功能转换为浏览器模块;没有修改库实现。
|
||||
|
||||
### 10.2 第一次研究保存的结果
|
||||
|
||||
原始记录在 [`results.chrome156.json`](/materials/pretext/results.chrome156.json)。环境为本机 macOS、Headless Chrome 156、DPR 1,设置 `<html lang="en">`,使用记录脚本中的命名字体及其回退链。三段文本都重复了 12 次,刻意让缓存效果可观察。
|
||||
|
||||
| 样本 | 字符串 length | 首次 Canvas 次数 | 首次提交码元数 | 再次准备 Canvas 次数 | 变宽布局 Canvas 次数 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| 英文 | 1,428 | 62 | 529 | 0 | 0 |
|
||||
| 中文 | 408 | 53 | 67 | 0 | 0 |
|
||||
| 保留空白、tab、软连字符 | 888 | 50 | 497 | 0 | 0 |
|
||||
|
||||
每段分别在 80、121、200、319、320、321、480、800px 下求高度,24 组结果与 DOM 高度一致。这只能证明这些样本的高度一致,不能证明所有语言与断行位置都完全一致。
|
||||
|
||||
### 10.3 怎样理解计时数据
|
||||
|
||||
预先准备后,每轮做 3,000 次宽度切换,热身后取 5 轮中位数。原始记录为:
|
||||
|
||||
| 样本 | Pretext 布局总时间 | DOM 改宽后立即测高总时间 |
|
||||
| --- | ---: | ---: |
|
||||
| 英文 | 1.7ms | 86.0ms |
|
||||
| 中文 | 1.5ms | 57.8ms |
|
||||
| 保留空白等 | 6.3ms | 93.9ms |
|
||||
|
||||
这些数字展示“用已测宽度计算”与“改变 DOM 宽度后同步求几何信息”的成本差异。它们不包含 Pretext 首次准备成本,也不包含完整页面绘制。
|
||||
|
||||
这不是严谨的产品性能排名:样本很少且重复,固定先测 Pretext 再测 DOM,没有记录硬件与电源条件,短样本受计时分辨率影响,也没有与批量 DOM 测量对照。结果不应被宣传成“任意页面快几十倍”。更可靠的机制证据是调用次数和这组样本的结果核对。
|
||||
|
||||
### 10.4 复现方法
|
||||
|
||||
要求 Node 23.10+ 或支持 `module.stripTypeScriptTypes(..., { mode: 'transform' })` 的较新 Node、Playwright,以及可用 Chromium。
|
||||
|
||||
为避免改动本仓库的依赖,在单独目录安装实验依赖:
|
||||
|
||||
```sh
|
||||
mkdir -p /tmp/pretext-study-deps
|
||||
npm install --prefix /tmp/pretext-study-deps playwright
|
||||
/tmp/pretext-study-deps/node_modules/.bin/playwright install chromium
|
||||
```
|
||||
|
||||
在本仓库根目录运行:
|
||||
|
||||
```sh
|
||||
PLAYWRIGHT_MODULE=/tmp/pretext-study-deps/node_modules/playwright \
|
||||
node tutorials/zh-CN/verify.cjs
|
||||
```
|
||||
|
||||
默认打印 JSON,不覆盖已归档结果。若要保存本次输出:
|
||||
|
||||
```sh
|
||||
PLAYWRIGHT_MODULE=/tmp/pretext-study-deps/node_modules/playwright \
|
||||
node tutorials/zh-CN/verify.cjs --output /tmp/pretext-study-result.json
|
||||
```
|
||||
|
||||
也可以通过 `CHROME_EXECUTABLE` 指定已安装 Chrome 的可执行文件路径;两种方式选一种即可。脚本只监听本机回环地址,结束后关闭服务器与浏览器。
|
||||
|
||||
脚本会检查 24 组高度、重复准备和布局的 Canvas 调用次数,以及行文本 API 与行数 API 的行数一致性。失败时返回非零状态。它不设置速度阈值,因为这种微型计时不适合承担性能回归门禁。
|
||||
|
||||
项目正式验证还要看 [`harness/README.md`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/harness/README.md) 与 [`harness/bench/README.md`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/harness/bench/README.md):区分新文本、已见文本、resize、生成行文本与最坏情况;同时处理浏览器自身缓存、JIT、GC 和跨轮噪声。本教程没有运行完整的固定浏览器 harness,也没有把 headless 结果当成其正式准确性基线。
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 第十一章:按什么顺序读源码
|
||||
|
||||
不要从庞大的规则数据开始。先建立主流程,再读会影响这个流程的细节。
|
||||
|
||||
| 顺序 | 文件与函数 | 阅读时要回答的问题 |
|
||||
| --- | --- | --- |
|
||||
| 1 | [`layout.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/layout.ts):`prepareInternal`、`prepare`、`layout` | 两个阶段分别接收什么、返回什么? |
|
||||
| 2 | [`line-break.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-break.ts):`PreparedLineBreakData` | 布局真正需要哪些提前准备的数据? |
|
||||
| 3 | 同文件:`countPreparedLines` | 普通文本怎样被一个循环计数? |
|
||||
| 4 | [`analysis.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/analysis.ts):`analyzeText` | 空白、语言与引擎怎样影响分段? |
|
||||
| 5 | [`prepare.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/prepare.ts):`measureAnalysis` | 宽度、适配数据与快路径标志在哪里建立? |
|
||||
| 6 | [`measurement.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/measurement.ts):`getFontMeasurement`、`getSegmentMetrics`、`getContext` | 命中缓存时到底跳过哪些浏览器工作? |
|
||||
| 7 | 同文件:`getSegmentFit`、`measureFitAdvances` | 一个放不下的词怎样预先准备拆分信息? |
|
||||
| 8 | [`line-text.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-text.ts) 与 `layoutWithLines` | 哪些成本只在要文字时出现? |
|
||||
| 9 | `stepPreparedSimpleLineGeometry`、`walkPreparedComplexLines` | 按行输出和复杂规则怎样衔接? |
|
||||
| 10 | [`line-breaks.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-breaks.ts)、[`gecko-line-breaks.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/gecko-line-breaks.ts)、[`graphemes.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/graphemes.ts) | 扫描器怎样使用表和状态? |
|
||||
| 11 | [`rich-inline.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/rich-inline.ts) | item 与整段文本的映射怎样建立? |
|
||||
| 12 | [`RESEARCH.md`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/RESEARCH.md) 的 Keeping Work Bounded、Evaluation Traps 与 Decisions Log | 哪些看似更简单的实现被测过,又为何没保留? |
|
||||
|
||||
做一个小练习:对 `hello world` 写下你认为需要的 prepared 数据,然后依次改变宽度、字体和 whiteSpace,判断哪些数据能保留。能解释这些变化,就已经掌握主设计,不需要先记住所有 Unicode 规则。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# 第十二章:把这些设计迁移到自己的代码
|
||||
|
||||
### 12.1 先按变化频率拆工作
|
||||
|
||||
找出输入中很少变的部分与高频变化的部分。这里文字和字体较稳定,宽度频繁变化,于是准备与布局分离。类似思路也适用于图表几何、搜索索引和格式化数据。
|
||||
|
||||
预计算需要内存,也会增加第一次处理的成本。只有被复用、能减少更贵的工作时,它才值得。
|
||||
|
||||
### 12.2 让 API 允许调用者少做事
|
||||
|
||||
如果只要计数,就提供计数路径;只要范围,就不强迫构造字符串;只要可见几行,就允许提前停止。
|
||||
|
||||
这比在一个总会生成全部结果的 API 内微调循环,通常更能减少实际工作。
|
||||
|
||||
### 12.3 给常见输入一条清晰的快路径
|
||||
|
||||
先通过准备阶段建立可验证的前提,再让热循环使用这些前提。完整路径仍负责其余情况。快路径的正确性应来自明确条件,而不是凭输入“看起来简单”猜测。
|
||||
|
||||
### 12.4 衡量总工作量
|
||||
|
||||
除了总耗时,还观察测量次数、提交字符量、扫描范围、对象分配和重复启动次数。它们能解释为什么快,也能暴露短样本看不到的二次增长。
|
||||
|
||||
### 12.5 把结论说到证据能支持的位置
|
||||
|
||||
这次源码研究与实验支持的是:Pretext 用准备阶段的测量和规则数据,使后续宽度变化主要成为数值布局;缓存、专用计数循环和按需生成结果进一步减少工作。完整页面是否获益、获益多少,还取决于首次准备量、复用频率、字体与 CSS 是否匹配,以及应用剩余的 DOM 和绘制成本。
|
||||
|
||||
读完后可以用三个问题检验理解:哪些工作被移到准备阶段?哪些结果被跨调用复用?调用者只要高度时,哪些工作根本没有发生?把这三个问题顺着源码回答出来,就能解释 Pretext 的主要性能来源。
|
||||
Reference in New Issue
Block a user