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 × cS 是脏面积c 是单位面积成本以及每个脏矩形固定的一次性开销N × kN 是矩形数量k 大约在 0.02ms 量级主要是裁剪路径建立和状态切换。全量重绘就是A × cA 是全屏面积。所以脏面积占比 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接收根容器、初始状态和视图函数。启动顺序有讲究顺序错了会出现「首帧空白」或「重复绘制」读取容器尺寸初始化画布与缩放变换建立响应式根作用域执行视图函数生成首棵节点树布局计算得到所有节点的包围盒全量重绘一次首帧没有增量可言绑定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 这套东西后续怎么走我目前在做的是把布局计算从主线程挪出去让大批量节点变更不再阻塞输入响应等跑稳了再把数据拿出来聊。
阅读完成 · 觉得有帮助?