ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

声明式UI+Canvas增量渲染:响应式与脏矩形实践

声明式UI+Canvas增量渲染:响应式与脏矩形实践

1. 先想清楚:为什么要把「声明式」和「Canvas」绑在一起

三年前接手一个数据密度极高的看板项目时,我第一次认真动了放弃 DOM 树的念头。那个页面单屏要摆三千多个图形节点,带实时数值刷新,滚动和缩放都得跟手。DOM 方案做到后期,瓶颈已经不在业务逻辑上,而是每次状态变化都在触发浏览器自己的排版和合成,开发者能优化的空间被压得很小。后来我把视线挪到了 Canvas 上:一块画布,几百行绘制代码,帧率立刻稳了下来。但紧接着新问题来了——命令式绘图写起来太累,每加一个交互元素都要手动维护「谁该擦掉、谁该重画」,改一个颜色要翻三个函数。

这就是我折腾声明式 UI 框架 Morphic的起点:保留声明式描述视图的顺手,同时把渲染交给 Canvas 拿回性能控制权。这里面的关键,一是响应式原理要做到细粒度,状态变了必须精确知道哪个节点脏了;二是Canvas 增量渲染要接得住这份精确,只擦改动的区域而不是每帧全量重刷。这两件事是一套的,响应式给不了精度,增量渲染就退化成碰运气。这篇文章适合两类人看:正在被海量节点渲染折磨、想了解自研渲染管线的前端工程师;以及已经会用主流声明式框架、想搞清楚「响应式」和「脏矩形」底层到底怎么咬合的人。哪怕你只是想读懂一个渲染框架的源码结构,这里的拆解也能直接对得上号。

1.1 命令式绘图最先崩掉的是可维护性,不是性能

很多人以为 Canvas 方案的核心难点是快不快,其实真正压垮项目的是「状态与画面不同步」。命令式写法里,绘制指令是顺序执行的,画面是这些指令的副产品,没有一个地方能回答「此刻的界面应该长什么样」。我见过一个典型事故:一个筛选条件变化后,某个图例的选中态没被擦掉,页面上同时出现了两个高亮块。排查花了两个小时,因为代码里没有任何一处声明「图例的选中态由这个变量决定」,只能靠人肉顺着条件分支一路追。

声明式的价值就在这里——视图是状态的函数,view = f(state)。框架保证了只要状态对,画面一定对。这个约束在 DOM 世界里由浏览器兜底,而在 Canvas 世界里,得由框架自己兜底。所以 Morphic 的第一条设计原则是:任何绘制都不能绕过状态。所有绘制指令都从视图描述树里推导出来,不允许业务代码直接ctx.fillRect。这条约束听起来很霸道,但它把「画面与状态不一致」这类 bug 从根上掐掉了。

配套的第二条原则是绘制必须可重入。同一棵树在任何时刻重绘一次,结果必须完全一致,不能有隐式累积。这个性质是做增量渲染的前提,因为增量渲染本质就是「只重绘局部」,如果重绘本身有副作用,局部重绘和全量重绘就会得到不同结果,画面就开始飘。

注意:如果你的项目里已经有大量直接操作画布的遗留代码,别急着一次性迁进声明式体系。我的做法是先划一块「新世界」区域用声明式写,旧区域用一层适配器包成「黑盒节点」,只暴露包围盒和重绘回调,等新体系稳定了再逐块吞掉。

1.2 三层结构:描述层、响应层、绘制层

Morphic 的内部结构我拆成三层,层与层之间单向依赖,不允许反向调用。这个约束是后期重构时最庆幸的一条,因为一旦让绘制层能读响应层的内部变量,整个体系会迅速变成一锅粥。

层级职责输入输出不该做的事
描述层把视图函数执行成节点树,做节点复用与差异比对状态快照、上次节点树变更集合(新增、更新、移除)不碰画布,不算坐标
响应层收集依赖、调度更新、把变更映射成脏区域变更集合、节点包围盒脏矩形列表不做绘制,不做像素处理
绘制层按脏矩形裁剪并执行绘制、管理缓存图层脏矩形列表、节点树像素不回读状态,不产生副作用

把职责这么切开,最直接的好处是每一层都能单独测。描述层只吐节点树,喂固定输入比对输出就行;响应层只吐矩形,纯数学;绘制层的验证靠像素快照对比。三层混在一起时,一个「画面不对」的问题可能来自任何一个环节,切开来之后,定位路径就变成一条直线。

另一个好处是替换成本低。后来我想给绘制层换一版文字渲染策略,只改了绘制层内部的三个函数,描述层和响应层一行没动。如果当初图省事把坐标计算塞在绘制里,这波改动就要牵连半棵树。

1.3 全量重绘看着简单,它的成本曲线很陡

我一开始就写的是全量重绘,理由很朴素:代码短,不会出错。跑起来在 30 个节点时毫无压力,60 帧稳如老狗。压力测试加到 800 个节点、每个节点带文字和圆角矩形,帧时间从 3ms 涨到 19ms,开始掉帧。这里的成本基本是线性的——绘制的开销跟图形数量成正比,跟变化量无关。

增量渲染的账完全不一样。假设单帧的绘制成本由两部分组成:脏区域的填充成本S × c(S 是脏面积,c 是单位面积成本),以及每个脏矩形固定的一次性开销N × k(N 是矩形数量,k 大约在 0.02ms 量级,主要是裁剪路径建立和状态切换)。全量重绘就是A × c,A 是全屏面积。所以:

  • 脏面积占比 5%、脏矩形 3 个时,增量成本约为0.05 × A × c + 0.06ms,对比全量的A × c,收益接近二十倍;
  • 脏面积占比 50%、脏矩形 20 个时,增量成本0.5 × A × c + 0.4ms,收益只剩不到一倍,还要多担一份正确性风险;
  • 脏面积占比 80% 时,增量已经亏了,多出来的矩形管理纯属浪费。

这就是为什么我在调度器里加了一条硬规则:脏面积覆盖率超过 0.6,或者脏矩形数量超过 12 个,直接退回全量重绘。这个阈值不是拍脑袋,是按上面的公式在几种典型分辨率下算出来的盈亏平衡点,再往保守方向挪一点。实测下来,这条规则把增量渲染的平均收益稳在 6 到 15 倍之间,同时避免了「动画帧大面积变化」时反而变慢的尴尬。

2. 响应式内核拆解:依赖怎么收集,更新怎么收敛

响应式听起来玄,拆开就是三件事:状态怎么被读、读的时候怎么记下来、状态变了怎么找到该重跑的东西。Morphic 用的是细粒度信号模型,不用整棵树的 diff 去推导变化。差别在哪?粗粒度方案里,状态一变,视图函数整体重跑,生成新树,再和旧树比,才知道哪儿变了;细粒度方案里,状态自己知道谁依赖它,直接通知,连重跑视图函数这一步都能省掉大半。

2.1 信号模型的最小实现

核心就两个函数:读的时候登记,写的时候通知。全局维护一个「当前正在执行的副作用」,读信号时把它塞进信号的订阅集合里。这套思路不新鲜,但细节很容易写歪,尤其是嵌套副作用和异常处理。

let activeEffect = null; const effectStack = []; function signal(initial) { let value = initial; const subs = new Set(); return { get value() { if (activeEffect) { subs.add(activeEffect); activeEffect.deps.push(subs); } return value; }, set value(next) { if (Object.is(next, value)) return; value = next; // 复制一份再遍历,防止通知过程中集合被改动 for (const e of [...subs]) schedule(e); } }; } function effect(fn) { const runner = () => { cleanup(runner); // 先清旧依赖,再做新收集 effectStack.push(runner); activeEffect = runner; try { return fn(); } finally { effectStack.pop(); activeEffect = effectStack[effectStack.length - 1] ?? null; } }; runner.deps = []; runner(); return runner; } function cleanup(runner) { for (const dep of runner.deps) dep.delete(runner); runner.deps.length = 0; }

几个地方值得单独说明。第一,cleanup必须在重新执行前跑,否则条件分支里的依赖会越积越多——一个if分支里读过的信号,分支切走后依然认为你是订阅者,之后那次信号变化会引发一次多余的更新。第二,遍历订阅集合前要复制,因为副作用执行过程中可能修改订阅关系,边遍历边删会漏掉元素。第三,用Object.is做等值判断,避免把NaN当成变化、把+0和-0当成同一个。

实操心得:调试期我在get里加过一个开关,打印「谁在什么调用栈下读了哪个信号」,配合调用栈采样,能在一分钟内定位到「为什么改这个值会重绘整个图例区」这类问题。这个日志开关后来一直留着,成了排查响应式问题的第一把刀。

2.2 依赖图和副作用调度是两件不同的事

很多人把「依赖收集」和「更新调度」混为一谈,觉得信号一变就该立刻执行副作用。原型阶段这么写没问题,一旦进入真实场景就会炸:一次用户操作往往连带改十几个状态,如果每个都立刻触发一次重绘,一帧内画十遍,性能全浪费在重复劳动上。

所以 Morphic 把这两件事分开:依赖收集在读取时同步完成,副作用执行交给调度器排队。

const queue = new Set(); let flushing = false; let pendingFrame = false; function schedule(runner) { if (runner.queued) return; runner.queued = true; queue.add(runner); if (flushing) return; flushing = true; queueMicrotask(flush); } function flush() { const batch = [...queue]; queue.clear(); flushing = false; for (const runner of batch) { runner.queued = false; try { runner(); } catch (err) { reportError(err); // 单个副作用失败不能拖垮整批 continue; } } if (queue.size) flush(); // 副作用里又触发新更新,补一轮 requestRender(); // 只标记脏,不在这里画 }

这里用微任务而不是宏任务,原因是微任务在同一个事件循环末尾执行,用户的一次点击引发的所有状态变更能在同一个批次里收敛完,不会跨帧。runner.queued标记解决的是同一个副作用在一次变更里被多个信号触发时的去重,没有这个标记,一个依赖三个信号的组件会跑三遍。

有一点得说清楚:flush 里不做绘制。绘制是另一条路径,由requestRender打上标记,等requestAnimationFrame回调进来时统一处理。这样做的目的是把「算」和「画」解耦,浏览器在 rAF 之前的这段时间可以安心处理输入事件和样式计算,不会因为长任务阻塞交互响应。

2.3 批处理边界怎么定,决定了体验的上限

批处理不是越晚越好。如果一帧内累积了过多变更,等到 rAF 才一次性处理,用户会觉得「点了没反应」。Morphic 的做法是给 flush 加了个时间闸:单批处理超过 4ms 就主动让出,剩余任务丢到下一个微任务,让浏览器有机会喘口气。实测在低端设备上,这个闸门能把长任务从 60ms 压到 8ms 以内,交互响应明显更跟手。

另外,渲染标记要能合并。同一个节点在同一帧里被标脏三次,最终只应该产生一个脏矩形。这个合并不是靠去重集合完成的,而是在脏矩形生成阶段做,因为有些变更虽然来自不同信号,但落到几何上就是同一块区域,在信号层去重反而拆散了本可以合并的矩形。

const dirtyNodes = new Set(); let renderScheduled = false; function requestRender(node) { if (node) dirtyNodes.add(node); if (renderScheduled) return; renderScheduled = true; requestAnimationFrame(() => { renderScheduled = false; const nodes = [...dirtyNodes]; dirtyNodes.clear(); renderFrame(nodes); }); }

这套机制里有个隐蔽的坑:如果某次变更发生在 rAF 回调执行期间,renderScheduled刚被置回 false,新的请求会正常排进下一帧,这没问题;但如果变更发生在 rAF 已经触发、回调尚未执行完的窗口里,就会漏掉——因为标记已经被清零。我最初的解法是给每次渲染打一个序号,变更时记录序号,渲染结束时比对序号是否推进,没推进就补一次。这个补丁上线后,残影类问题少了大概八成。

2.4 依赖泄漏是细粒度方案的主要长期成本

细粒度响应式在短期性能上占优,但长期运行下最怕依赖泄漏。场景很典型:一个列表项被移出屏幕,节点被回收,但它订阅的信号还握着它的副作用引用,每次信号变化都会把已经死掉的副作用捞出来跑一遍。跑一遍倒还好,问题是它可能又会去读别的信号,形成新的悬挂订阅,越积越多。

Morphic 的处理是给每个节点绑定一个dispose,节点销毁时把它的副作用从所有依赖集合里摘干净。但真正难的不是写这个函数,而是保证调用时机。我踩过的坑是:在一次批量更新中,列表数据整体替换,旧节点在新节点创建之后才被销毁,中间有个短暂窗口里新旧节点同时存在,脏矩形合并时把旧节点的包围盒也算进去了,画出一块不该出现的内容。

解决办法是把销毁提前到变更集合处理的第一阶段:先处理移除,再处理新增和更新。这个顺序调整只改了十几行,但把一类「闪一下」的问题彻底消掉了。写渲染框架久了会有个体会,这类问题的根因往往不在算法复杂度上,而在顺序假设上。

3. Canvas 增量渲染的落地细节

响应式解决了「知道哪里脏」,增量渲染解决「怎么只画脏的地方」。后者听起来就是「用 clip 裁一刀」,实际写下来有一堆数值层面的讲究,尤其在高分屏和抗锯齿这两件事上。

3.1 脏矩形从哪来:从节点包围盒到屏幕空间

脏矩形的源头是节点的包围盒。每个节点在布局阶段算出{x, y, w, h},这个盒子是绘制内容的保守估计,宁可大一点也不能小——小了就会留下残影。常见做法是在节点自身尺寸外扩 1 到 2 个像素,用来覆盖抗锯齿溢出和阴影模糊半径。

包围盒进入脏矩形列表之前要做两件事:一是裁剪到视口,完全在屏幕外的矩形直接丢掉,跨界的矩形缩到视口内;二是对齐到像素网格,按devicePixelRatio往上取整。第二点经常被忽略,导致脏矩形边缘差半个像素,擦除时把邻居的一列像素也带走了。

function toDirtyRect(node, dpr, viewport) { const pad = 2; const x0 = Math.floor((node.x - pad) * dpr) / dpr; const y0 = Math.floor((node.y - pad) * dpr) / dpr; const x1 = Math.ceil((node.x + node.w + pad) * dpr) / dpr; const y1 = Math.ceil((node.y + node.h + pad) * dpr) / dpr; const left = Math.max(x0, viewport.x); const top = Math.max(y0, viewport.y); const right = Math.min(x1, viewport.x + viewport.w); const bottom = Math.min(y1, viewport.y + viewport.h); if (right <= left || bottom <= top) return null; return { x: left, y: top, w: right - left, h: bottom - top }; }

pad取 2 不是玄学,是实测出来的:1 像素线宽在非整数坐标上抗锯齿会往外溢半个像素,圆角矩形的边缘再加半个,2 像素能覆盖绝大多数情况。如果你的场景里有大半径阴影,这个值要按阴影模糊半径除以 2 往上调。

3.2 合并策略:贪心合并的代价与阈值

脏矩形不合并的后果很直接:二十个小矩形各自建立裁剪路径,固定开销加起来比省下的填充时间还多。合并的策略我试过三种,最后留下来的是「贪心两两合并 + 面积增益阈值」。

const area = r => r.w * r.h; const unionRect = (a, b) => { const x = Math.min(a.x, b.x); const y = Math.min(a.y, b.y); return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y }; }; function mergeRects(rects, gain = 1.3) { const out = []; for (const r of rects) { let hit = -1; for (let i = 0; i < out.length; i++) { const merged = unionRect(out[i], r); // 合并后面积不超过两者之和的 1.3 倍,才认为划算 if (area(merged) <= (area(out[i]) + area(r)) * gain) { hit = i; break; } } if (hit >= 0) out[hit] = unionRect(out[hit], r); else out.push({ ...r }); } return out; }

gain = 1.3这个系数是权衡出来的。取 1.0 意味着只允许「合并后面积等于两者之和」的完美相邻矩形合并,实际几乎合不动;取 2.0 意味着可以白白多画一倍面积,画得多了反而亏。1.3 的经验含义是:多画的面积不超过 30%,换取少建立一次裁剪路径,是赚的。如果你的节点普遍很小、分布很散,这个值可以放宽到 1.5;如果节点都很大,收紧到 1.15 更合适。

注意:合并顺序会影响结果。按 x 坐标排序后再合并,横向相邻的矩形更容易合到一起,实测比随机顺序少产生 20% 到 30% 的矩形数量。这个优化只有两行代码,收益却很实在。

3.3 离屏缓存:把「不变的部分」冻起来

增量渲染解决帧内重复,离屏缓存解决帧间重复。有些节点的内容长期不变——背景网格、静态图例、文字标签——每帧重画纯属浪费。Morphic 给节点加了一个cacheable标记,命中条件的节点渲染进一张独立的离屏画布,主画布上只做一次drawImage。

function ensureLayer(node, dpr) { if (!node._layer) { const c = document.createElement('canvas'); node._layer = c; node._lctx = c.getContext('2d'); } const w = Math.ceil(node.w * dpr); const h = Math.ceil(node.h * dpr); if (node._layer.width !== w || node._layer.height !== h) { node._layer.width = w; // 改尺寸会清空画布,这是预期行为 node._layer.height = h; } const ctx = node._lctx; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, node.w, node.h); drawNodeContent(ctx, node); node._dirty = false; return node._layer; } function paintNode(ctx, node) { if (node.cacheable && !node._dirty) { const layer = node._layer; if (layer) { ctx.drawImage( layer, node.x, node.y, layer.width / ctx.__dpr, layer.height / ctx.__dpr ); return; } } if (node.cacheable) ensureLayer(node, ctx.__dpr); if (node.cacheable && node._layer) { ctx.drawImage(node._layer, node.x, node.y, node.w, node.h); return; } drawNodeContent(ctx, node); }

缓存要配上淘汰策略,否则一个长列表滚过去,缓存画布能把显存吃干净。我的做法是维护一个 LRU 队列,缓存节点数超过上限(默认 200 张,按典型图层尺寸估算约 60MB 到 90MB 显存占用)就淘汰最久未用的。上限值必须结合目标设备的实际情况调,低端设备建议压到 60 张以内。

还有一个细节:缓存图层的失效判定必须精确。内容变化、尺寸变化、缩放比例变化三者任一发生都要置_dirty。我漏判过缩放比例,结果用户缩放画布后,缓存图层按旧比例贴上去,文字糊成一团,排查了半天才想起这条路径。

3.4 高分屏下的像素对齐,是画面「锐利」与「发虚」的分水岭

Canvas 在高分屏上最常见的问题就是糊。根因是画布位图尺寸和 CSS 尺寸不一致,浏览器做了双线性缩放。标准做法是把位图尺寸乘以devicePixelRatio,再用setTransform把绘制坐标系按比例放大,业务代码仍然按 CSS 像素写坐标。

function resizeCanvas(canvas, cssW, cssH) { const dpr = Math.min(window.devicePixelRatio || 1, 2); const targetW = Math.round(cssW * dpr); const targetH = Math.round(cssH * dpr); if (canvas.width !== targetW || canvas.height !== targetH) { canvas.width = targetW; canvas.height = targetH; canvas.style.width = cssW + 'px'; canvas.style.height = cssH + 'px'; } const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.__dpr = dpr; return ctx; }

devicePixelRatio上限压到 2 是个刻意的取舍。三倍屏的设备上,把比例拉到 3 会让位图面积变成四倍多,填充成本直线上升,而肉眼在这个像素密度下几乎分不出 2x 和 3x 的差别——尤其是图形密集的看板场景。真要极致清晰,可以对文字单独走一遍高倍率缓存的路径,代价是显存。

坐标对齐是另一半。1 像素宽的线条如果落在整数坐标上,会被平均分到相邻两个像素,看起来是两像素宽的浅色线。老办法是加 0.5 偏移:

const crisp = v => Math.round(v) + 0.5;

但加了setTransform缩放之后这个偏移要换算,正确写法是先还原到设备像素再取整:

function crispLine(v, dpr) { const device = v * dpr; return (Math.round(device - 0.5) + 0.5) / dpr; }

矩形填充不需要这个偏移,因为填充是从边界向内铺的,落在整数坐标上正好对齐像素边界。区分开线宽和填充两套对齐规则,画面锐利度会有肉眼可见的提升。

4. 从零跑通一条最小渲染管线

原理讲清楚了,接下来是我实际从零搭起来的过程。这一套代码总共不到四百行,但把「信号 → 变更 → 脏矩形 → 绘制」这条链路完整串起来了,直接照抄就能跑。

4.1 模块划分与启动顺序

我的目录结构极简,五个文件:reactive.js(信号与调度)、vnode.js(节点树与差异比对)、layout.js(包围盒计算)、dirty.js(脏矩形)、renderer.js(绘制循环)。入口只有一个createApp,接收根容器、初始状态和视图函数。

启动顺序有讲究,顺序错了会出现「首帧空白」或「重复绘制」:

  1. 读取容器尺寸,初始化画布与缩放变换;
  2. 建立响应式根作用域,执行视图函数生成首棵节点树;
  3. 布局计算,得到所有节点的包围盒;
  4. 全量重绘一次(首帧没有增量可言);
  5. 绑定resize监听和 rAF 循环。

第 4 步容易被省掉,结果就是首屏要等第一次状态变化才出现内容。第 5 步的resize监听要做防抖,且防抖之后必须触发一次全量重绘——尺寸变了,所有缓存图层的坐标基准都失效了,增量更新的假设不成立。

export function createApp({ canvas, state, view }) { const ctx = resizeCanvas(canvas, canvas.clientWidth, canvas.clientHeight); const ctxState = setupReactiveRoot(state); let tree = view(ctxState); layout(tree, { x: 0, y: 0, w: canvas.clientWidth, h: canvas.clientHeight }); fullRepaint(ctx, tree); let resizeTimer = null; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const c = resizeCanvas(canvas, canvas.clientWidth, canvas.clientHeight); layout(tree, { x: 0, y: 0, w: canvas.clientWidth, h: canvas.clientHeight }); invalidateAllCaches(tree); fullRepaint(c, tree); }, 120); }); return { state: ctxState, getTree: () => tree }; }

4.2 核心循环:变更如何变成像素

渲染帧的处理分四步,每步都有明确的输入输出,任何一步都可以单独打点计时。

function renderFrame(dirtyNodes, ctx, root) { const t0 = performance.now(); // 第一步:把脏节点转换成脏矩形 const viewport = { x: 0, y: 0, w: ctx.canvas.width / ctx.__dpr, h: ctx.canvas.height / ctx.__dpr }; const raw = dirtyNodes .map(n => toDirtyRect(n, ctx.__dpr, viewport)) .filter(Boolean); const t1 = performance.now(); // 第二步:合并,并判断是否值得走增量 const merged = mergeRects(raw); const dirtyArea = merged.reduce((s, r) => s + r.w * r.h, 0); const fullArea = viewport.w * viewport.h; const useFull = merged.length > 12 || dirtyArea / fullArea > 0.6; const t2 = performance.now(); // 第三步:绘制 if (useFull) { fullRepaint(ctx, root); } else { for (const r of merged) { ctx.save(); ctx.beginPath(); ctx.rect(r.x, r.y, r.w, r.h); ctx.clip(); ctx.clearRect(r.x, r.y, r.w, r.h); paintSubtree(ctx, root, r); ctx.restore(); } } const t3 = performance.now(); sample({ convert: t1 - t0, merge: t2 - t1, paint: t3 - t2, rects: merged.length, full: useFull }); }

paintSubtree里有个容易写错的地方:遍历节点树时要用包围盒和脏矩形做相交测试,不相交直接剪枝,这是增量渲染真正的加速来源。如果老老实实遍历全树只靠clip裁剪,绘制量会少一点,但遍历开销一点没省,节点多的时候照样卡。

function paintSubtree(ctx, node, rect) { if (!intersects(node, rect)) return; // 剪枝,这一步不能少 if (node.cacheable) { paintNode(ctx, node); return; // 缓存节点内部不再递归 } for (const child of node.children || []) paintSubtree(ctx, child, rect); drawNodeContent(ctx, node); }

缓存的节点直接画完就返回,不往子节点递归,因为它整块内容都被贴图替代了。这个剪枝让缓存节点的子树遍历成本从 O(n) 变成 O(1),长列表里收益非常夸张——一个两百行的表格,滚动时每帧只需要处理两三个新进入视口的行。

4.3 帧预算怎么分配,超了怎么办

60 帧意味着 16.67ms 一帧,但这不是全部给绘制的。浏览器自己要处理输入、合成、垃圾回收,我的经验是留给渲染管线的安全预算在 10ms 到 11ms 左右。Morphic 内部把预算拆成三段:变更处理 2ms、脏矩形计算 3ms、绘制 6ms。

超过预算要降级,降级策略按「影响用户感知从低到高」排序:

降级级别触发条件具体动作用户感知
一级帧时间 > 12ms关闭非关键节点的阴影与渐变几乎无感
二级帧时间 > 16ms禁用离屏缓存重建,直接绘制轻微模糊
三级帧时间 > 24ms 连续 5 帧脏面积阈值从 0.6 降到 0.3,快速退回全量略掉帧
四级帧时间 > 33ms 连续 10 帧暂停非视口区域的更新,只保留可见区离线内容延迟刷新

这套降级不是永久性的,恢复条件比触发条件更严格——需要连续 30 帧低于 12ms 才能升一级回来。这个不对称设计是为了避免在临界点来回抖动,用户看到画面一会儿清一会儿糊比一直糊更难受。

4.4 怎么验证增量真的生效了

这一点很多人跳过,导致「以为自己写了增量,其实一直在全量」。我的验证方式有三层,从粗到细。

第一层是计数打点。在fullRepaint和增量绘制两个分支各加一个计数器,滚动页面十秒,看两个数字的比例。健康值应该是增量调用远多于全量,如果全量占比超过 30%,说明合并阈值或者脏矩形计算有问题。

第二层是面积比监控。记录每帧的脏面积和全屏面积的比值,画成曲线。这条曲线在静态页面上应该几乎贴着零,在动画场景里会有明显起伏。如果静态时曲线还在 0.5 以上,基本可以断定有节点被无故标脏——通常是某个副作用读了不该读的信号。

第三层是像素级快照对比。在两次渲染之间截取toDataURL,做差分。这个方式很重,只在回归测试里用,但它能抓住「脏矩形算小了导致残影」这类肉眼在高速滚动时看不出来的问题。

实操心得:我给脏矩形加了个可视化调试模式,按快捷键把每个脏矩形用半透明红色框画在最上层。这个功能开发期一直在用,一眼就能看出「这个矩形怎么比节点大那么多」或者「相邻两个矩形为什么没合并」。上线前记得关掉,它会显著拖慢帧率。

5. 实际踩坑与排查清单

下面这些坑都是我真实遇到过的,按出现频率排。每一个都花了我半小时以上,希望能帮后来人跳过。

5.1 残影:九成是脏矩形算小了

残影的表现是某个元素移动后,原位置留下淡淡的痕迹。原因几乎永远是脏矩形没覆盖到被擦除的区域。常见成因有这么几个:节点移动时只标了新旧位置中较小的那个;带阴影或描边的节点没加 padding;文字节点用了textBaseline带来的垂直偏移没算进包围盒;缓存的图层内容变化了但_dirty没置位。

排查顺序建议从外到内:先开脏矩形可视化,看那个位置有没有红框;有红框但仍有残影,说明框的位置错了,去查包围盒计算;完全没红框,说明节点压根没被标脏,回去查响应式依赖。

还有一种极其隐蔽的情况:父节点的坐标系是经过变换的(比如整体平移了一个偏移量),脏矩形是按父坐标系算的,但绘制时ctx已经带着变换,两者叠加后偏移了一倍。解决方式是在生成脏矩形时统一把节点包围盒换算到画布根坐标系,绝不使用局部坐标。

5.2 文字发虚:三种成因,对症下药

文字模糊是最常见的效果问题,成因完全不同,解法也不同。第一种是画布位图尺寸没乘devicePixelRatio,解法见 3.4。第二种是字体设置了非整数像素字号,浏览器会做亚像素排版,在高倍率下反而不如整数清晰,建议字号统一取整。第三种是文字画在离屏缓存里,缓存尺寸按 CSS 尺寸取整后又做了缩放贴图,引入了一次重采样。

第三种最少见也最难查,我的解决办法是缓存图层的位图尺寸一律Math.ceil而非Math.round,并在drawImage时严格使用layer.width / dpr作为目标尺寸,不做任何额外缩放。多占一点显存,换清晰度,很值。

同时提醒一句,ctx.textRendering这类新属性在不同环境下表现不一,别指望靠它一招解决模糊问题。真正稳的还是把位图尺寸和坐标系这两件事做对。

5.3 内存涨得快:缓存和订阅两头查

长跑之后内存曲线一路向上,我从两个方向查。第一是离屏缓存,用performance.memory(可用时)配合手动的图层计数,看缓存画布数量有没有持续增长。如果增长,说明 LRU 淘汰没生效,常见原因是淘汰时只清了引用没把 canvas 的宽高置零——一张画布的位图内存要等尺寸归零才真正释放。

第二是响应式订阅。给信号加一个计数,打印每个信号的订阅者数量。正常情况这个数字应该跟挂载的节点数同量级,如果某几个信号的订阅数持续攀升,说明有副作用没被清理,回去查dispose的调用路径。

还有一个隐蔽来源是requestAnimationFrame里的闭包。如果每帧都创建一个持有大对象的闭包,即使帧结束,只要还有引用没断,GC 就回收不掉。我的做法是渲染循环写成常驻函数,每帧只传小的参数对象,不创建新闭包。

5.4 问题速查表

现象最可能原因快速验证方式处理动作
元素移动后留残影脏矩形未覆盖旧位置开脏矩形可视化移动时同时标新旧两个盒子
整体画面发虚位图未乘 DPR查 canvas.width 与 clientWidth 比值走 resizeCanvas 统一处理
1px 线呈两像素浅色线条落在整数坐标放大截图看像素分布线条坐标加 0.5 偏移
首屏空白漏了初始全量重绘看首帧计数器启动流程补 fullRepaint
缩放后内容错位缓存图层未失效手动置_dirty试一次缩放时 invalidateAllCaches
静态页面仍在重绘副作用读了多余信号看每帧脏面积占比拆分组件,缩小依赖范围
滚动时掉帧全树遍历未剪枝看 paintSubtree 调用次数补相交测试提前返回
长时间运行内存上涨缓存未淘汰或订阅泄漏统计图层数与订阅数补 LRU 淘汰与 dispose
数组整体替换时闪一下新增先于移除处理调整变更集合处理顺序先移除,再新增与更新

最后分享一个我在这个项目里体会最深的东西。做渲染框架最难的不是写出能跑的第一版,而是建立一套能快速证伪的观测手段——脏矩形可视化、帧时间分段打点、订阅数统计、脏面积曲线,这四样东西加起来不到两百行代码,却把后面大半年的排查时间压缩到了原来的几分之一。很多时候问题本身并不复杂,复杂的是你不知道该往哪看。先把「看得见」这件事做好,剩下的都是体力活。至于 Morphic 这套东西后续怎么走,我目前在做的是把布局计算从主线程挪出去,让大批量节点变更不再阻塞输入响应,等跑稳了再把数据拿出来聊。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进