“我的电脑上拖起来不卡”不是性能结论。无限画布要用可复现数据证明:查询有界、对象有界、资源有界、长任务受控,而且公开发布没有泄漏私有实现。
学习目标
- 建立从 Document 到 Renderer、资源和帧时间的指标链;
- 设计规模、密度、媒体和交互四类基准场景;
- 区分平均值、分位数、长任务与内存峰值;
- 做故障注入验证恢复与降级;
- 理解业务无关开源的泄漏、来源和包内容门禁。
1. 最少要观察哪些数字
| 指标 | 回答的问题 |
|---|---|
documentNodeCount |
整幅作品有多大 |
spatialCandidateCount |
本次查询缩小到多少候选 |
workingSetSize |
当前要求活跃多少节点 |
renderedObjectCount |
实际创建多少 handle |
decodedBytes / objects |
解码媒体占多少预算 |
activePlayers |
有多少真实视频播放器 |
hydrateQueue / inFlight |
资源准备是否形成积压 |
frameTime p50/p95/p99 |
交互是否稳定而非偶尔流畅 |
longTasks |
主线程是否出现 >50ms 阻塞 |
historyBytes |
Undo 是否随着操作失控 |
四个最核心不变量:
Document 可以远大于 Working Set
Rendered Objects 稳定后接近 Working Set
Decoded Bytes 永不突破硬预算
一次用户/Agent 意图只产生一个原子 History Transaction
2. 不要只测一个节点分布
至少覆盖:
均匀分布:检验大 World 平移
局部高密度:检验视口内大量节点
长距离跳转:检验 enter/exit 峰值和取消
媒体画布:4K 图片、共享资产、视频 poster/player
编辑压力:连续拖动、缩放、Undo/Redo
Agent 批量:一次创建/排列大量节点
RBush 在均匀分布很快,不代表高度重叠的节点也一样;Working Set 有界也不代表某个超密集区域能保持同样对象数。
3. 一个可复现的容量基准
Viewport:1440 × 900,DPR 2
Document:1,200 / 12,000 / 50,000
Node distribution:固定 seed
Camera path:固定 30 秒平移 + 缩放 + 远区跳转
Warm-up:一次完整路径
Measure:重复 5 次,记录 p50/p95/p99
输出至少包含:
document / candidates / working set / handles
hydrates / releases / cancelled hydrates
decoded bytes peak / active players peak
frame time percentiles / long tasks / dropped frames
固定随机 seed 和 Camera path,才能比较不同提交、设备和 Renderer。
4. 性能预算来自产品,而不是框架口号
示例门槛:
decodedBytes <= 64 MiB
renderedObjects <= expected viewport density + pinned cap
activePlayers <= 2
inFlightHydrates <= 4
Camera interaction p95 <= 16.7ms(60Hz 目标)
no unbounded growth after 10 round trips
门槛应按目标设备分档。低端集显、远程桌面和高 DPR 需要不同预算。不要用开发机平均帧率替代目标环境。
5. 内存验收要看“回来没有”
一次峰值并不足以判定泄漏。执行往返:
初始视口
→ 远区 A
→ 远区 B
→ 回初始
→ 等待 exit grace + GC 观察窗口
→ 重复 10 次
检查 handle、lease、decoded objects、player 和 event listener 是否回到稳定区间。JS heap 可能因 GC 时机波动,但业务计数不应阶梯式增长。
6. 故障注入
需要主动制造:
- 图片解码迟到、失败或被 Abort;
- IndexedDB 配额不足、事务失败;
- Renderer destroy 后异步任务返回;
- WebGL/GPU context loss(若有 GPU Adapter);
- Agent 超时、非法 JSON、超量 Command、revision conflict;
- 页面刷新时 Document/History revision 不一致。
目标不是所有请求都成功,而是失败后 Document 真相、预算和可恢复性仍然正确。
7. 为什么 GPU 不是第一答案
无限画布是 Document/Camera/Working Set 的容量模型,不是某个 GPU 后端。换 WebGL/WebGPU 可能提高可见矢量的吞吐,却不会自动解决:
全量 Document 扫描
全量场景对象
图片解码内存
视频播放器数量
全量 History 快照
只有基准证明“可见内容绘制”是主要瓶颈时,才值得新增 GPU Adapter;Context Loss 后仍必须能从 Document 和 AssetStore rehydrate。
8. 开源门禁:不泄漏业务代码
guard:public
检查私有绝对路径、内部域名、人员信息、凭据、token、跨仓 import
guard:origin
对候选源码与私有参考树做归一化 token/结构比较
发现高相似片段时要求人工解释或重写
guard:package
检查最终 npm tarball,而不只检查 Git 工作区
release:check
缺少必要私有参考根时失败关闭
课程可以讲公开的概念和算法,但不能复制业务节点、内部 API、品牌资源、真实数据、私有路径和凭据。抽象后的命名必须是 CanvasNode、AssetStore、RendererPort 等通用概念。
9. 为什么还要检查最终 tarball
Gitignore 不等于 npm ignore,构建产物也可能意外包含 source map、测试 fixture 或本地路径。发布前:
npm pack --dry-run
→ 查看文件白名单、大小、bin/exports
→ 解包扫描凭据、邮箱、绝对路径、内部域名
→ 在临时项目分别验证 ESM/CJS 与子路径 exports
API Key 和 npm token 只能来自本地凭据环境,不能提交到公开仓库、Docker image 或示例 .env。
10. 公开课完整演示顺序
- Camera Demo:证明 World 不随视图移动;
- 1,200 节点 Demo:Document 远大于 Working Set;
- 快速平移:观察 enter/stay/exit 与 grace;
- Resource Demo:lease 阻止突破硬预算;
- IndexedDB 刷新:恢复 Document/History,但只 hydrate 当前区域;
- Agent Transaction:批量修改、冲突拒绝、整体 Undo;
- Renderer Switch:Canvas2D/Konva 共用同一 Core;
- 展示指标面板和门禁报告,而不是只展示动画。
这条演示链分别证明坐标稳定、容量有界、生命周期正确、资源有界、恢复正确、写入可控、框架可替换和发布安全。
11. 发布验收清单
[ ] Document 规模至少是 Working Set 的一个数量级
[ ] handles/decoded/player 在往返后回到稳定区间
[ ] 硬预算无法被活跃 lease 突破
[ ] History/Journal 有步数和字节预算
[ ] 失败注入不破坏 Document 真相
[ ] ESM/CJS/子路径 exports 在临时项目通过
[ ] 最终 tarball 无凭据、私有路径和业务标识
[ ] Docker 以非 root、只读文件系统和资源限制运行
[ ] 文档链接、双语构建和 source_hash 门禁通过
常见错误
- 只看 FPS 平均值;
- 只测初始视口,不测长距离往返;
- 只看 JS heap,不看业务对象计数;
- 用 GPU 替代 Working Set 与资源释放;
- 把本地自动化通过说成真实设备发布验收;
- 只扫描源码,不扫描 dist/tarball/Docker context;
- 因为“概念相同”就复制私有业务实现。
课后实验
写一个固定 seed 的 30 秒 Camera 路径,输出 JSON 基准报告。分别将 overscan、exit grace、decoded budget 改为三组值,比较 p95 帧时间、hydrate 次数、峰值 bytes 和往返后的稳定对象数。最后解释哪个参数只是把成本从一处移动到另一处。