从字节到屏幕:小程序 Markdown 流式渲染全链路优化
大模型回答要实时渲染进微信小程序,这条链路比 Web 长出一截。本文按"文本拼接 → 格式解析 → 视图渲染"三层拆解,逐层给出具体方案:打字机动态追赶、stable/tail 增量解析、island 缓存与增量 setData、动画对账与渐进收敛。
一、引言
流式输出是大模型交互的标准体验——文字一个字一个字蹦出来,用户不用干等整段生成完毕。这个体验在 Web 上已经成熟,但搬到小程序上,事情就没那么简单了。
Web 端有 ReadableStream 有 EventSource,DOM 和 JS 在同一个线程里,改节点就是改内存。小程序端呢?网络层只给 ArrayBuffer,每次视图更新都要跨线程 setData,Markdown 在流式过程中语法还不完整,半截 ** 和没闭合的链接会让节点反复跳变,旧文字跟着每一帧重新播一遍动画。
一句话总结:小程序做流式渲染,本质上是要在双线程架构下、用最昂贵的更新通道、去渲染一份每帧都在变的不完整文档。这三个约束叠加在一起,没有一个是可以绕过去的。
一条 AI 回复从模型生成到用户看见,中间至少经过这些环节:
服务端持续写入 SSE 响应
↓
HTTP Chunked 传输
↓
小程序 onChunkReceived(ArrayBuffer)
↓
UTF-8 解码 + SSE 拆包
↓
业务帧归一化 → 统一 Markdown 字符串
↓
打字机调度:控速、追赶、按帧提交
↓
Markdown 增量解析:稳定块 + 活跃尾部
↓
视图层增量 setData + 动画对账后面按实现顺序分三层来讲:文本拼接层解决"收到的东西怎么变成可展示的文字"、格式解析层解决"不完整的 Markdown 怎么解析才不会闪"、视图渲染层解决"怎么让小程序的 setData 只传变化的部分"。每一层都先说问题在哪,再说怎么解。
二、数据接收:从网络字节到业务事件
SSE 和 Chunked 是两件事
SSE 和 Chunked 经常一起出现,顺嘴就说成"后端返回一个 SSE Chunk"。但两者根本不在一个层。
SSE 是事件协议,约定每条事件一行 data: {json},事件之间空行分隔:
data: {"answer":{"data":"你好","status":"<default>"}}
data: {"answer":{"data":",有什么可以帮你?","status":"<finish>"}}
Chunked 是 HTTP 响应体的传输编码,服务端不知道总长度时用来边写边发:
<chunk-size,十六进制>\r\n
<chunk-data>\r\n
...
0\r\n
\r\n两层叠在一起:
HTTP Chunked:负责一段字节怎样传输
└── SSE:负责这些字节怎样组成业务事件一次 chunk 可能只有半条 SSE,也可能粘着多条;服务端一次 write() 不等于客户端一次回调。
不同客户端对这两层的严格程度不一样。Node 这类框架会在没有 Content-Length 时自动补合法的 Chunked 编码,业务侧只管写内层 SSE 就行;换成不做自动处理的服务端框架,如果网关只是纯转发,就会出现 Header 声明了 Chunked 但 Body 还是裸 SSE 的"半成品响应"。容错性强的客户端能接住,严格遵循协议的直接读不到内容。
解决办法是让发送端显式编码,不依赖框架自动处理:
std::string chunk_data = "data: " + resp_json + "\n\n";
char hex_size[32];
int n = snprintf(hex_size, sizeof(hex_size), "%zx\r\n", chunk_data.length());
std::string chunk;
chunk.append(hex_size, n);
chunk.append(chunk_data);
chunk.append("\r\n");
WantWrite(chunk);流结束时再写终止块 0\r\n\r\n。注意 chunk-size 必须是十六进制。
拿到的是字节,不是消息
wx.request 开启 Chunked 接收后,回调拿到的是 ArrayBuffer:
const requestTask = wx.request({
url: '/api/ask',
method: 'POST',
enableChunked: true,
timeout: 60_000,
});
requestTask.onChunkReceived((res) => {
// res.data: ArrayBuffer — 不是字符串,更不是 JSON
});一次回调可能有半条事件,也可能多条粘在一起。所以必须分两步:先解码,再缓冲。
SSE parser:留住半条消息
解析器维护一个 buffer,每次追加字符流,只有遇到 \n\n 分隔符才消费完整事件:
buffer += chunk;
let idx = buffer.indexOf('\n\n');
while (idx !== -1) {
const rawEvent = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
consumeRawEvent(rawEvent);
idx = buffer.indexOf('\n\n');
}半条事件留在 buffer 里等下一次推送。关键不是 JSON.parse,而是承认底层一定会拆包和粘包。
UTF-8 跨包:半个字符的问题
中文字符三个字节,emoji 四个字节。如果刚好被拆到两个 ArrayBuffer,单独解码会出乱码导致 JSON 解析失败。解决办法是让解码器也变流式:尾部不完整的字节留到下一个 chunk 一起解码。这个问题本地网络不易复现,弱网或不同分块策略下才会偶发。
归一化:统一成一种输入格式
SSE 事件里不一定只有正文,还有卡片、推荐列表等结构化数据。网络层统一转成 Markdown 字符串:
TEXT → 原样文本
CARD → ```card 围栏
SUGGEST → ```suggestions 围栏打字机和 Markdown 只处理字符串,业务组件通过自定义围栏识别。这一层把网络协议和渲染协议隔开。
三、文本拼接层:打字机是调度器,不是定时器
协议解析完成后,直接追加文本到页面能跑,但网络节奏原样暴露给用户——一会儿半秒没动,一会儿突然冒一大段。固定间隔打字机(比如每 30ms 一个字)能盖住抖动,却带来新问题:网络已送来 200 字,30ms 一字还要让用户多等 6 秒才能看到早就到达的内容。
核心矛盾就一个:展示速度不该写死,该由"还差多少没展示"动态决定。
积压量决定速度
Typewriter 根据队列长度动态算目标 CPS(字符/秒):队列越长越快,封顶 maxCps;队列快空也不低于 minCps,免得用户以为卡了:
function computeTargetCps(): number {
if (queue.length === 0) return minCps;
// 按积压量比例加速,夹在 [minCps, maxCps] 之间
const desired = (queue.length * 1000) / targetLatency;
return Math.max(minCps, Math.min(maxCps, desired));
}当前接入用 minCps: 30、maxCps: 120、targetLatency: 80。这三个值不是手速参数,是产品约束——反馈下限、追赶上限、允许落后网络的时长。队列本身就是背压信号:积压增加说明展示落后,自动加速;积压减少说明追上了,速度回落。不用猜网络状态。
速率预算落到离散帧
小程序没有稳定的 requestAnimationFrame,用 ~16ms 的 setTimeout 模拟帧循环。但 30 CPS 一帧只有 0.48 个字,取整会让低速档永远没输出;强行每帧一字又拉到 60+ CPS。
解法是跨帧预算:
runtime.budget += (cps * dt) / 1000;
let emitCount = Math.floor(runtime.budget);
runtime.budget -= emitCount;预算累计到 1 才消费一个字,小数留下一帧。dt 限制在 100ms 以内——切后台恢复后丢弃不可见期间的时间债务,避免一口气喷出大量内容。
逐字消费,按帧批量提交
这是最关键的性能取舍。每个字一次 commit 在小程序里代价太高:setData 要序列化、跨线程传、触发节点更新,content 变了 Markdown 组件还要走一轮渲染。一个字一次 commit,打字机会变成性能放大器。
做法是把视觉粒度和提交粒度拆开:队列内部仍按字符消费(方便算预算、识别围栏),同一帧内连续字符合并成一个 batch,只执行一次 onChar:
let batch = '';
const flushBatch = () => {
if (!batch) return;
onChar(batchMsgId, batch);
batch = '';
};队列短时一帧约一个字,体验接近逐字;积压时一帧自然输出一小批,但只触发一次 commit。batch 在切换消息、遇到围栏、标点停顿时会被强制切开。空格制表符跟前后文字一起输出但不消耗预算——用户不会因为空格获得额外反馈。
围栏块整块输出
结构化卡片不适合逐字展示:
```card
{"id":"123"}
```逐字会先露出半截反引号和 JSON。打字机维护围栏状态机 idle → accumulating → body:确认连续三个反引号后缓存整个围栏内容,直到结束围栏才整块输出。单反引号和双反引号仍按行内代码处理。围栏输出后可执行 afterEmitFence 等异步准备完成后再继续正文,保证阅读顺序一致。
结束、停止、清空是三种动作
- 正常结束:先
seal()封口缓冲保持自然节奏,队列排空后onDrain完成状态; - 用户停止:走
flush()立即输出残留快速收尾; - 内容作废:走
stop()直接清空。
seal → 等待 drain → done (平滑结束)
flush → 快速收尾 (用户主动停)
stop → 清空状态 (切换会话/作废)四、格式解析层:直出节点树与 stable/tail 分层
打字机控制住节奏之后,问题全部集中在"文本怎么变成可渲染结构"这一层。这一层必须同时扛住两个矛盾:
- 语法不完整:流式过程中
message.content尾部往往还不是合法 Markdown(半截**、没闭合的链接)。每帧强行全量解析,节点会在文本、强调、链接之间反复跳变,肉眼看到的就是闪。 - 成本随长度线性增长:每帧都"完整 content → 完整 parse → 完整 nodes",答案越长,每帧重复处理的量越大。
这是 **还没闭合的强调
[一个还没写完的链接](https://不先定架构,这两个矛盾靠"把 parser 写快一点"是解不开的。所以这一层的核心策略不是"解析得快",而是**"不该解析的就不解析、不该暴露的就先补好"**。下面先讲清架构策略、它要解决什么,再落具体方案。
架构策略:不绕 HTML 中间层,直出小程序节点树
很多 Markdown 渲染方案走的是"先编译成 HTML 字符串,再交给视图层"。这在 Web 上行得通——浏览器有 innerHTML、有增量 reflow。但小程序里这条路径走不通,原因有两层:
- 小程序视图层没有
innerHTML,不能把一段 HTML 丢进去让渲染引擎自己解析,只能靠自定义组件递归渲染一份结构化节点树; - 即便能渲染,HTML 字符串是匿名的——每个 block、每段文字都没有稳定身份。后面想做增量 diff、想追踪"这段文字上一帧是不是已经出现过",都没有抓手。
所以这个架构的第一个策略决定是:让解析器直接输出小程序节点树(一份 nodes 对象树),不生成任何 HTML 中间产物。
Markdown 文本
↓ parser(直出节点树)
nodes 对象树 → 自定义组件递归渲染 → 视图层
↑ 每个 block / 文本叶子都有稳定身份这个决定换来三件东西,正好对应后面所有优化:
- 身份可追踪:每个文本叶子有稳定节点路径,动画对账才能唯一定位"这一段是不是新出现的";
- 增量可落地:节点树能按顶层 block 拆分——稳定部分固化、变化部分替换,而不是整树重传;
- 渲染可控:block 级动画标记和逐字 segment 都能挂在节点上,生命周期由我们自己管,不交给浏览器黑盒。
要让这套架构真正成立,得先回答两件事:为什么在小程序上这层比 Web 更难,以及上面那两个矛盾具体怎么解。
为什么小程序上这层问题更难
Web 端做增量渲染至少有几件事是白拿的:DOM 和 JS 同线程同内存、浏览器引擎自己算增量 reflow、insertAdjacentHTML 可以只插入一段而不动已有 DOM。
小程序完全不同:
Web: JS ──(同线程/同内存)── DOM
小程序: 逻辑层(JS) ──setData── 视图层(渲染引擎)
↑ 唯一通道,必须序列化几个具体差距:
- 没有增量 DOM API:Markdown 落地成
nodes对象树靠组件递归渲染,想改内容只能重传整份nodes,做不到像insertAdjacentHTML只插一小段。 - 序列化成本线性增长:树越大
setData开销越高,而且流式场景每帧都要付一次,不会因"只加了几个字"而变便宜。 - 列表 diff 代价不对称:
wx:for按 key diff,如果某帧节点数剧烈变化(N 个片段骤变成 1 个),被当成大范围增删而不是内容更新,代价更高且容易"跳一下"。
小程序把 Web 端隐藏在浏览器内部的渲染开销,变成了开发者必须自己管理的跨线程通信成本。这就是后面所有优化存在的根因。
stable + tail:稳定块缓存解决解析成本
策略一句话:别指望每帧拥有最终结构,把"已经确定的过去"和"仍不确定的现在"分开处理。
渲染器把当前 Markdown 分成两部分:
[ stable blocks ][ active tail ]
已经确定 仍可能被后文改变
后续永久复用 每帧允许修复和重解析这么做直接解决前面说的"成本随长度线性增长":新的标题、分隔线、围栏等独立 block 出现时,前面的内容提交到 stableFlat,后续无论再来多少 chunk,这部分都不再重新 parse。稳定区越往后越大,每帧通常只处理最后一段——解析耗时不再随答案长度增长,而是收敛到尾部一小段。
稳定块缓存还带来第二个好处:已提交的结构不可回退。这恰恰是流式渲染稳定感的关键,但前提是提交边界必须语法安全,不能"见空行就 commit"。列表续行、引用 lazy continuation、表格、Setext 标题这些跨行语法,后续行到达后会反过来修改前面已"稳定"的结构:
function shouldCommitBefore(prevType, currentType, prevBlank): boolean {
if (prevType === null) return false;
// 独立 block 出现,前文可提交
if (currentType === 'heading' || currentType === 'hr' || currentType === 'fence') {
return true;
}
// 相同类型无空行,仍是同一 block
if (currentType === prevType && !prevBlank) return false;
// list / blockquote 的续行不提前提交
if (currentType === 'paragraph' && !prevBlank
&& (prevType === 'list' || prevType === 'blockquote')) {
return false;
}
return true;
}列表续行如果被当成新段落,流式阶段会拆成多个 <ul>,结束又合回去,整块列表突然跳一下;含 | 的行也要延迟到确认表格后再提交。稳定块缓存的收益能不能拿到,全看这条边界判得准不准。
尾部补全:remend 解决不完整语法暴露
stable/tail 解决了"解析多少",但 tail 本身还是不完整的——半截 ** 直接渲染给用户,比闪烁更难看。
策略是:tail 可以不完整,但渲染副本要先补成可渲染状态,且不污染真实内容。解析前用 remend 生成临时渲染副本:
const tail = this.renderedText.slice(this.committedLen);
const fixedTail = tail && needsRemend(tail)
? remend(tail, STREAM_FIX_OPTIONS)
: tail;模型输出 **正在生成,副本临时补成闭合强调;下一帧继续基于原始文本,补出来的字符不写回 content。它不伪造模型输出,只给当前帧一个更稳定的视觉解释。纯文本 tail 无 *、[、反引号、$ 等触发字符时直接跳过修复——既省一次遍历,也避免无意义处理。
三层快路径:为常见路径优化
stable/tail 之外,还有一个现实:流式回答的绝大多数 chunk 其实只是普通文字追加。如果每帧都跑完整 parser,哪怕只解析 tail,也是浪费。
策略是为常见路径优化,为罕见路径兜底,渲染器做了三层回避完整解析:
- stable 不解析:已确认的顶层块留在
stableFlat,后续只复用; - 纯文本 tail 直出:tail 无 Markdown 触发字符且不像列表或分隔线时,直接构造段落节点;
- 尾部追加复用:新 tail 是旧 tail 前缀延续且新增部分为普通文本时,只更新最后一个文本叶子的
value。
第三层有个陷阱:最后一个叶子带粗体/斜体/删除线/行内代码样式时,不能直接拼新字,否则暂时继承错误样式,必须新建兄弟节点或回退完整解析。绝大多数帧都很"无聊",必须让无聊的帧足够便宜;真正需要完整解析的复杂帧占比很低,把它们的成本控在可接受范围就行。
五、视图渲染层:island 固化与增量 setData
前面几层把"要解析多少"压下来了,但还有一个更根本的成本没动:小程序的渲染成本本质上是 setData 的成本——只要每帧把完整 nodes 树序列化、跨线程传出去,稳定内容就还在反复参与那条最贵的通信。
所以这层的架构策略同样一句话:已确定的内容,既不在 JS 层重新解析,也不在视图层重新接收。围绕这条,下面讲四件事:怎么让稳定块不参与重绘、怎么让动画不重播、怎么让收尾不闪、以及最终怎么只传变化的部分。
架构落点:island 固化 + 区间 patch
整体渲染形态是——已稳定的顶层 block 固化为 island 常驻,每帧只有 tail 区整体替换:
[ island ][ island ][ island ][ tail 每帧整体替换 ]
k=1 k=2 k=3每个 island 只增不减;setData 不碰 island,只 patch tail 及刚结算的区间。这个形态同时承接了"重绘重播"和"setData 范围"两个问题,下面拆开讲。
island 缓存:稳定块不再随 tail 重绘
问题:tail 每帧整体替换时,如果稳定区的块还带着块级淡入标记(opacity 0.3→1),重绘会触发动画重播;tail 自身的块级淡入也每帧重播,肉眼可见的"闪"。
方案是把已稳定的顶层 block 固化为 island——它们脱离每帧重绘循环,只增不减。tail 区的块级淡入标记在流式帧中被清除(clearBlockAnimateDeep),只保留逐字动画:块级淡入在 tail 区纯属多余,逐字动画已经提供了足够的出现反馈。
还有个更细的跨出口场景:当一个块从 tail 提交到 stable 时,它会跨渲染出口重新挂载 DOM。这时只清块级淡入,但保留逐字 animationSegments(含 animation-delay)。如果某段逐字动画还没播完,保留它能让重挂载后的元素接续播放、视觉连续;反之清成 undefined,这行会从 segmented 分支突变到 plain 分支,从动画中途跳到静止,就是"闪一下"。island 固化解决的是"历史块被反复重绘",保留 animationSegments 解决的是"重挂载时的视觉断裂"。
动画对账:只让新字动
问题:如果只给整个 Markdown 容器做淡入,流式更新会让旧文字反复播放。上一帧显示"今天北京",下一帧追加"天气",整段文本若被重新视为新内容,旧文字也跟着重新淡入。
方案是让动画不看当前节点树,而看文字出现的历史。TextAnimationReconciler 按稳定节点路径记录每个文本叶子的旧值与 animation segment,每帧先找公共前缀:
上一帧:今天北京
这一帧:今天北京天气很好
复用: 今天北京 ← 沿用原来的 bornAt
新动画: 天气很好 ← 生成新 segment公共前缀沿用原 bornAt,只有新增后缀产生新 segment——动画属于"新出现的文字",而不是"本帧重新构造的节点"。按节点路径而非文本内容对账:同一段文字可能出现在不同节点(比如列表两项恰好相同),路径 root/0:p/1:text 才能唯一定位。链接闭合也有个细节:前一帧是普通 text,补齐 ]() 后 parser 把其中一段变成 a 节点,对账器尝试从前一个兄弟文本继承对应 segment(inheritFromSibling),处理"同段文字跨节点迁移"的场景。
渐进收敛:不能 N → 1 骤变
问题:逐字动画让每个批次对应一个 segment,一句话 N 次批次就有 N 个 <span>。动画结束后想收敛回一个文本节点,但某一帧直接 N→1,wx:for 子树从 N 项骤降为 1 项,触发明显重排和闪烁——正是前面说的列表 diff 代价不对称。
方案是渐进合并:已播放结束的连续 segment 逐步合并进第一个稳定段,最后自然变成一个段,而不是瞬间跳跃:
function coalesceSettledSegments(segments, now, duration): AnimTextSegment[] {
if (segments.length <= 1) return segments;
let settledCount = 0;
let settledValue = '';
for (const seg of segments) {
if (now - seg.bornAt >= duration) {
settledValue += seg.value;
settledCount++;
} else {
break;
}
}
if (settledCount <= 1) return segments;
const out = [{ k: segments[0].k, value: settledValue, bornAt: segments[0].bornAt }];
for (let i = settledCount; i < segments.length; i++) out.push(segments[i]);
return out;
}这段优化对解析耗时帮助不大,却直接决定页面稳不稳:已经发生过的动画不能因下一帧更新重播,已经结束的动画不能因收敛而闪一下。
终帧对账,不是重渲染
问题:流式结束后需要完整 parse 一次修正最终结构(Setext 标题、复杂列表、表格只有全文已知时才能确定)。但直接 reset() 会把节点树视为新内容,version 变化导致整树重挂载,历史 block 重打动画标记,用户看到的就是生成完成时整篇突然闪一下。
方案是终帧走 finalizeStream():完整解析修正结构,但不增加 version,也不重标记动画——它是一次对账,不是一次新渲染:
if (!streaming) {
// 最终文本是上一帧的前缀延续 → 增量收尾
if (text && this.previousMarkdown && text.startsWith(this.previousMarkdown)) {
return this.finalizeStream(text, enableAnimation);
}
this.reset(); // 真正的新内容才全文重建
}流式阶段允许 tail 暂时不确定,结束时做一次无闪的结构修正——"最终正确"和"过程稳定"不冲突。
增量 setData:只传变化的节点
问题回到了根因:前面所有优化都在减少"要解析多少",但只要每帧仍 setData({ nodes }),完整节点树还是会被序列化传往视图层,稳定内容仍在反复参与那条最贵的跨线程通信。
方案是让渲染控制器除了返回 nodes,还输出三个边界,把"这一帧到底改了哪些"显式交付给视图层:
version:非前缀更新或显式 reset 时变化;dirtyStart:本帧新提交或变化节点的起点;settledStart:结构和动画都已稳定的边界。
正常增量帧只 patch settledStart 之后的部分:
const patch: Record<string, unknown> = { animation };
const rebuildFrom = Math.min(settledStart, prevLen);
for (let i = rebuildFrom; i < nodes.length; i++) {
patch[`nodes[${i}]`] = nodes[i];
}
this.setData(patch);从 settledStart 而非 dirtyStart 开始,是因为 [settledStart, dirtyStart) 区间的节点刚完成动画结算(segment 已清除或合并),需要再提交一次才能永久进入稳定区。这一点正是 island 固化与增量 patch 的衔接处:island 保证历史块不重绘,区间 patch 保证历史块不重传。
到这里稳定块缓存才真正闭环:JS 层不再解析历史块,视图层也不再接收历史块。每一层优化的最终指向都是同一个——让 setData 只传这一帧真正变化的东西。
六、整条链路回顾
| 层 | 输入 | 输出 | 关键约束 |
|---|---|---|---|
| HTTP Chunked | 服务端持续写入 | ArrayBuffer | Header 与 body 编码一致 |
| SSE parser | 解码后字符流 | 完整业务事件 | 网络 chunk ≠ 事件边界 |
| 业务协议 | 文本/卡片/状态 | 统一 Markdown 字符串 | 明确 finish/clear/error/stop |
| Typewriter | 已收到文本 | 按帧展示批次 | 动态追赶,不逐字 commit |
| Markdown renderer | 累计 content | stable + tail 节点 | 确定内容不重复解析 |
| 视图层 | 节点变化范围 | 局部 setData | 稳定节点不重复传递 |
| 动画对账 | 新旧文本叶子 | animation segments | 只给新增内容播动画 |
iOS 收不到内容 → 查 Chunked 编码;收到字节没事件 → 查 UTF-8 和 SSE 拆包;输出忽快忽慢 → 查打字机队列和背压;输出越长越卡 → 查 stable/tail 和 setData 范围;内容正确但一直闪 → 查节点身份和动画历史。分层之后,笼统的"流式有问题"能明确落在某一层。
Last updated on