聊天宽表格:从「必须写死高度」到离屏 Canvas 预布局
针对小程序 Skyline 聊天宽表格需要显式高度、流式内容高度又不确定的问题,使用离屏 Canvas 测量文本,预计算列宽、折行与表格高度,减少上屏后测量回填带来的闪烁与布局抖动。

最近在做小程序 Agent 的 Markdown 表格渲染。
列少的时候,表格可以直接等分宽度,让内容自然折行。
但列数多了以后,每一格都会被挤得很窄。一句话折成很多行,数字和单位也容易拆开,阅读体验很差。
所以目前把 4 列及以上的表格作为宽表,放进横向 scroll-view。
问题也从这里开始:
Skyline 下,横向
scroll-view的高度不会被子节点自然撑开。不给明确高度,整段表格就看不见。
而聊天里的表格来自流式输出。
内容还没收完,高度就已经要用上了。
一开始的问题:表格还没生成完,高度怎么给?
普通静态表格可以先知道内容,再安排布局。
流式 Markdown 不一样。
模型可能先输出表头,再输出分隔行,接着逐行补充数据。甚至同一个单元格里的文字,也会分几次到达。
表格高度取决于:
- 每列分到多少宽度。
- 每个单元格折成几行。
- 同一行里,哪个单元格最高。
- 字号、行高和上下 padding。
这些条件在流式过程中都可能变化。
直接写一个固定高度,很快就会遇到两个问题:
写大了,表格下面空出一截;写小了,最后几行被裁掉。
等整张表收完再处理也不合适。用户看到的中间状态,要么没有表格,要么表格突然出现并撑开一大块空间。
所以真正要解决的是:
每次内容更新时,在表格上屏之前,就得到一个足够准确的高度。
第一反应:先渲染,再量真实高度
最直觉的做法,是让内容先排出来,再用:
createSelectorQuery()
.select(...)
.boundingClientRect(...)拿到实际高度,最后通过 setData 写回 scroll-view。
它的优点很明确:
量到的是真机已经排好的盒子,通常比自己估算更接近实际布局。
但放进流式聊天里,就会形成一条反复执行的链路:
内容更新 → 渲染布局 → 查询高度 → 回写高度 → 再次更新布局。
一个回答可能有几十次内容更新,这条链路也会跟着重复。
高度不断回写,容易带来跳动;适老化切换字号后,还可能需要重新测量。
另外,聊天页有抽屉缩放效果。祖先节点带 transform 时,测量结果还需要区分变换后的视觉尺寸和原始布局尺寸,不能直接拿来当布局高度。
这条路可以做,但会把表格高度计算绑在实际渲染之后。
对于持续更新的 Markdown,我更希望把这一步提前。
换个方向:不上屏,能不能先算出高度?
后来参考了 @chenglou/pretext 的文字预布局思路,以及微信小店 foldLine 在小程序里的实践。
核心思路是:
先测量文字宽度,再根据可用宽度模拟折行,最后用行数推导高度。
例如,一个单元格的可用宽度已经确定,就可以逐个累加字形宽度。
当前行放不下时,换到下一行。
得到折行数以后,再结合行高和 padding,算出单元格高度。
同一数据行里,取最高的那个单元格,就能得到这一行的高度。
把所有数据行加起来,就是整张表需要的高度。
这里的 Canvas 只负责 measureText,不负责把表格画成图片。最终上屏的仍然是原来的表格节点。
小程序里需要自己接一层
这次没有直接引入 pretext。
它依赖的部分 Web 环境能力,不能直接照搬到小程序里。因此,小程序侧单独实现了一套测量逻辑:
- 用
wx.createOffscreenCanvas创建 1×1 离屏 Canvas。 - 用 2D context 的
measureText测量字宽。 - 自行处理字素拆分。
- 区分常规文字和粗体。
- 把测量结果从 px 换算成 em。
这样,文字测量可以在逻辑线程完成,不需要先创建一份可见表格来试高度。
先算列宽,再算折行
这里有一个顺序不能反过来:
列宽确定后,折行数才有意义。
当前的列宽规则是:
取该列最长单元格的自然宽度,再限制在
4em ~ 10em之间。
下限避免列太窄,上限避免某个长文本单元格把整列撑得过宽。
确定各列宽度后,再按对应列宽计算每个单元格的折行数。
例如:
| 数据行 | 第一格 | 第二格 | 第三格 | 本行占用 |
|---|---|---|---|---|
| 第一行 | 1 行 | 3 行 | 2 行 | 3 行 |
| 第二行 | 2 行 | 1 行 | 1 行 | 2 行 |
这两行合计占用 5 份文字行高,再加上各数据行的上下 padding。
折行时还处理了显式换行 \n,以及部分中文全角标点的行尾挤压,减少标点单独掉到下一行的情况。
把布局结果挂到 Markdown 节点上
现在的接入位置,在 Markdown 解析和节点整理阶段。
Skyline 下,compactTowxmlNodes 会调用表格预布局逻辑,把结果挂到对应节点的 tableLayout 上。
数据结构如下:
type TableLayout = {
wide: boolean;
cols: number;
rows: number;
// 每个数据行的最大折行数之和
lines: number;
// 各列宽度之和,单位 em
totalEm: number;
// 每列宽度,单位 em
colEms: Array<number | "">;
};WXS 拿到这些数字后,只负责拼出列宽和高度样式。
表格高度可以概括成:
总高度
=
累计文字行数 × 行高
+
数据行数 × 每行上下 padding正常走预计算结果时,WXS 不再重新扫描整张表的文字。
职责也就清楚了:
JS 负责测量和折行,WXS 负责把布局数字交给样式。
流式更新时,高度也会更新,但不用再回头量
每次流式内容变化,解析阶段都会重新计算当前表格的布局。
这不意味着表格高度完全不变。
新增数据行、单元格文字变长,或者列宽调整,都可能让高度发生变化。
区别在于:
内容和对应高度一起进入渲染,不需要先展示一次,再查询高度、回写一次。
省掉的是测量回写带来的第二轮更新。
所以这里更准确的说法是:减少额外的布局往返和闪动,而不是保证流式过程中表格完全不发生位移。
为什么把结果存成 em?
另一个需要兼顾的场景是适老化字号。
如果布局结果全部存成 px,字号一变,之前算好的列宽和高度就可能失效。
当前测量时固定使用:
14px PingFang SC测量结果再除以 14,换算成 em。
上屏时,实际尺寸由字号变量决定,例如:
calc(var(--cd-fs-28) * N)这样,适老化切换字号时,列宽和文字尺寸可以同比例变化,行高也跟随对应变量更新。
在字体、字重以及宽度约束保持一致的前提下,不需要为了换字号重新解析 Markdown 或重新测量文字。
固定 14px 只是统一测量基准,不代表页面实际使用 14px。
Canvas 拿不到时,表格仍然需要显示
离屏 Canvas 也可能创建失败。
例如开发者工具偶发异常,或者代码运行在非微信环境。
这种情况下,WXS 仍然保留原来的估算逻辑:
- 中文按接近一个完整字宽计算。
- ASCII 按较小的宽度系数计算。
它能保证表格继续显示,但西文、数字和粗体混排时更容易产生偏差,也可能低估高度。
另外,少于 4 列的窄表仍然走等分折行,不进入横向滚动,也不设置固定高度。
三种方案,分别付出了什么?
| 方案 | 高度精度 | 更新成本 | 适老化 |
|---|---|---|---|
| 上屏后测量,再回写 | 最接近实际布局,但需处理 transform 等影响 | 需要布局、查询和回写 | 通常需要重新测量 |
| WXS 启发式估宽 | 实现简单,混排和粗体容易偏 | 每次更新扫描表格文字 | 使用 em 时可跟随字号 |
| 离屏 Canvas 预计算 | 更接近实际字宽,仍有排版近似 | 逻辑线程扫描,渲染侧读取数字 | 同比例缩放时无需重新测量 |
这次选择离屏 Canvas,是为了在可接受的精度下,减少上屏测量和高度回写。
相比原来的启发式估宽,它也能让西文、数字和粗体的宽度更接近实际表现,降低末行被裁掉的概率。
缓存主要放在字形层
测量过程中,有两类对象可以复用。
| 对象 | 生命周期 | 作用 |
|---|---|---|
| 离屏 Canvas / 2D context | 模块单例 | 避免每张表重复创建 |
| 字形宽度 Map | 当前 JS 上下文 | 避免同一字形重复测量 |
| em 布局结果 | 随 Markdown 节点保存 | 用于当前节点渲染 |
字形缓存区分常规和粗体,例如:
n:字 → 常规字宽
b:字 → 粗体字宽这样表头加粗时,不会误用正文的测量结果。
缓存热起来以后,大部分字宽查询都只是 Map 读取,后续计算主要是累加和折行判断。
这里没有对整张表的布局做跨消息缓存。
流式内容一直在变化,拿整段 Markdown 作为缓存键,复用机会有限,还会增加失效管理的复杂度。
真正需要关注的开销,不只是 measureText
从当前实现看,还有几处比重复创建 Canvas 更值得关注的工作。
没有表格,也会遍历并复制节点
walkTables 在遍历时会浅拷贝节点:
{ ...node, children }因此,即使消息里没有表格,也可能多付出一次整树遍历和对象分配。
流式更新越频繁,这部分累计开销越明显。
同一个单元格会拆两次文字
自然宽度测量时拆一次,计算折行时又拆一次。
同一次布局计算中,这份字素数组可以复用。
窄表也会先测列宽
当前实现里,窄表虽然跳过了横滚折行计算,但前面的列宽测量仍然会执行。
如果能更早确定列数,就可以提前跳过不需要的测量。
因此,流式长文和多张宽表场景下,需要关注的是:
每次更新都重新遍历节点、分配对象、扫描表格,累计下来有多少成本。
目前没有正式 benchmark,不能直接给出耗时结论。后续优化也应该先测这些环节,而不是只盯着 Canvas API。
缓存不失效,不代表排版完全准确
在同一测量字体、字号和字重下,字形宽度可以复用。
适老化只是按比例改变字号时,em 结果也不需要重新计算。
但预布局和实际渲染之间,仍然存在误差来源:
- 测量使用的
PingFang SC与真机最终字体可能不同。 letter-spacing目前按近似值计入。- 自行实现的字素拆分,对复杂 emoji、ZWJ 序列的处理可能与系统不同。
- 逐字累加无法完全复现浏览器的字形组合和断行规则。
所以这套方案是针对当前表格内容的预估布局,仍然需要通过真机样本验证边界。
一个更值得防的故障:首次失败后不再重试
当前 Canvas 初始化只尝试一次:
undefined
→ 还没尝试
context
→ 创建成功
null
→ 创建失败,后续不再尝试如果第一次解析时,Canvas 能力还没准备好,或者创建过程偶发抛错,后续宽表就会一直走 WXS 兜底,直到 JS 上下文重建。
这不是字形缓存变脏,而是初始化失败被长期保留了。
相比给布局结果继续加缓存,允许有限次数重试,或者在运行环境准备好后再初始化,更值得优先处理。
后续可以继续收敛的地方
目前还有几项优化没有做:
- Canvas 初始化失败后,增加有限次重试。
- 没有表格的子树,避免不必要的节点复制。
- 同一次测量中,复用单元格文本和字素数组。
- 更早判断窄表,跳过不需要的列宽测量。
这些优化都围绕同一件事:减少每次流式更新时重复做的工作。
这次最关键的调整,则是把表格高度计算提前到了解析阶段。
让表格带着当前内容对应的布局结果一起上屏,横向 scroll-view 就不必再等渲染完成后,回头寻找自己的高度。