avatar

聊天宽表格:从「必须写死高度」到离屏 Canvas 预布局

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

聊天宽表格:从「必须写死高度」到离屏 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 就不必再等渲染完成后,回头寻找自己的高度。

© 2026 Hailin. All rights reserved.