课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从一个五岁孩子也能理解的游戏开始
用积木搭一个小舞台:蓝纸是背景,三个玩偶是演员,透明胶片上的黄色圆圈表示“谁被选中了”。让一个孩子当舞台经理,另一个当售票员。如果每当一位观众眨眼,售票员就大喊:“所有演员,把整场演出再演一遍!”,会发生什么?所有人都会忙得无暇售票。正确的做法是,只有当背景、演员或黄色圆圈确实发生变化时,才点亮对应的信号灯。经理看到信号灯后,会在下一次幕布升起前处理一次。
先预测一下:如果“黄色圆圈信号灯”在同一秒内亮起十次,幕布应该升起十次,还是只升起一次?经理下班回家后,信号灯还能把他们叫回来吗?当一个动画玩具车持续移动时,信号灯应该如何保持点亮?
- React 售票大厅工具栏与检查器
- 点亮脏信号灯记录发生变化的图层
- 升起下一次幕布合并重复通知
- 对称地收工取消信号灯、监听器和观察器
本章唯一的核心事实是:React 管理产品 UI;Canvas 引擎管理高频交互和绘制。二者之间的边界必须明确。
将玩具游戏映射到 Canvas 工程
| 舞台游戏概念 | 工程概念 | 负责人 |
|---|---|---|
| 售票大厅 | React / Next.js UI | 工具栏、检查器、对话框 |
| 舞台内核 | 命令式 Canvas 引擎 | 文档、输入、相机、渲染器 |
| 红、黄、蓝信号灯 | 脏标记 / 变更集 | 渲染调度器 |
| 下一次幕布 | requestAnimationFrame() | 浏览器帧调度 |
| 背景纸、演员、透明圆圈 | 分层 Canvas / 渲染通道 | 渲染器 |
| 动画秒表 | 动画时钟 | 调度器/模拟系统 |
| 售票牌上的“已选择 2 个” | 低频派生状态 | 外部存储 → React |
| 经理开始/结束工作 | 设置 / 清理 | Effect 生命周期 |
这个类比也有局限。浏览器并不保证 requestAnimationFrame 恰好每隔 16.67ms 运行一次;后台标签页通常会暂停它或降低其运行频率。React 严格模式的开发检查并不意味着“两个经理会永远同时工作”。它会额外执行一次设置→清理→设置,以验证对称性。分层 Canvas 既可以指多个物理 <canvas> 元素,也可以指一个 Canvas 中的多个渲染通道。前者可以减少局部重绘,但会增加合成、尺寸同步和内存成本。
先消除这些误导性的直觉
- “每次 React 渲染时都重建引擎,因为这样最具声明性。” 引擎拥有监听器、观察器和 rAF。反复构造会丢失文档并泄漏资源。
- “在每次
pointermove时调用setState,让检查器保持实时更新。” 240Hz 的输入流不应驱动整个产品 UI。引擎在内部更新瞬态状态,只发布低频且具有产品价值的快照。 - “永久运行的 rAF 循环最简单。” 在静态编辑器中,这会浪费电力。默认采用基于失效的渲染,仅在确实存在动画时切换为连续渲染。
- “严格模式的双重初始化是 React 的错误;删除 StrictMode。” 它揭示的是不对称的清理。应修复资源所有权。
- “从后台返回后,用一个巨大的
dt补完所有动画。” 这会导致跳变或模拟螺旋。应限制可变时间步长,也要限制固定时间步长的追赶步数。 - “客户端组件可以接收任意服务器对象。” 跨越服务器/客户端边界的 Props 必须可序列化。Context、函数、Map 和引擎实例都无法跨越这条边界。
生产环境工具包
前置契约
前置条件是第 5 章中的 RuntimeDocument/Session 分离。构造引擎时,它会接收一个现有的 Canvas、一个 Overlay 根节点以及一个只读文档存储。销毁操作具有幂等性。React 只接收一个初始的、可进行 JSON 序列化的 WireDocument,并在挂载后创建引擎。引擎发布一个稳定的 DerivedSnapshot={selectionCount,canUndo,saveStatus};它不会在每一帧都将指针坐标或整个文档发送给 React。
形式化知识:调度器
requestAnimationFrame(callback) 会在浏览器下一次重绘前安排一个回调,并提供一个来自共享高精度时间基准的时间戳。回调只执行一次;连续渲染必须再次发出请求。基于失效的渲染会在状态变脏时请求一帧,并合并重复的失效请求。脏标记可以使用位掩码表示背景、场景和 Overlay。变更集携带发生变化的记录,之后可以让分层 Canvas 或缓存只更新必要的部分。
渲染通道沿用第 3 章中的顺序。多个物理 Canvas 必须共享宿主尺寸和相机。默认情况下,静态调度器没有待处理的帧。动画开始时,continuous=true;动画停止后,如果没有任何内容变脏,就不再发出新请求。防止重复循环的核心是只存储一个待处理的 rAF ID:如果它非空,就不要再发出另一个请求。
动画时钟不会“每帧加 1”。可变时间步长会将一个受上限约束的 dt 传给视觉动画。固定时间步长使用累加器,以固定步长更新确定性模拟,然后为绘制进行插值。后台标签页暂停后,dt 可能会变得极大。应限制最大增量和最大追赶步数,还可以选择利用可见性变化来重新设定时间基准。时间策略属于引擎,而不属于 React 状态。
形式化知识:React / Next.js
使用 Next.js App Router 时,应使用 'use client' 标记使用 DOM/Canvas 的入口文件。ref 提供 Canvas 和 Overlay DOM 节点。对于必须在浏览器绘制前完成的宿主/引擎设置,适合使用 useLayoutEffect;普通网络订阅或遥测可以使用 useEffect。二者都要求设置和清理保持对称。在开发期间,严格模式会额外运行一次 Effect 设置和清理。正确的结果是:任何时刻最多只有一个处于活动状态的引擎/循环。
外部存储通过 useSyncExternalStore(subscribe,getSnapshot,getServerSnapshot) 向 React 暴露低频且稳定的快照。当数据没有变化时,getSnapshot 必须返回同一个对象,以避免无限渲染。DOM Overlay 可以使用普通的绝对定位子元素,也可以由 createPortal 将文本编辑器放置在 Overlay 根节点内。视觉位置来自引擎低频或按需执行的投影,而焦点和输入仍由 DOM 管理。
命令式引擎的生命周期是:在 Effect 挂载时创建 → 绑定文档订阅/指针/尺寸调整 → 执行首次失效。清理过程会取消 rAF、取消订阅、移除监听器并销毁宿主。React 重新渲染并不等于挂载,也不得重建文档。在 SSR 期间,模块顶层不得访问 window、document、devicePixelRatio 或 getContext。
证据与兼容性
- MDN:requestAnimationFrame()
- MDN:页面可见性 API
- React:useEffect
- React:useLayoutEffect
- React:StrictMode
- React:useSyncExternalStore
- React:createPortal
- React:
'use client'
以上来源已于 2026-08-29 核查。后台 rAF 策略由浏览器定义,因此测试不得依赖固定频率。React 的开发环境检查与生产构建有所不同,但两者都必须保证清理操作对称。
本章的工程增量
**起点:**一个 React 组件直接绘制,并在每次指针移动时写入状态。**终点:**独立的调度器/引擎可在 Node 中测试;Shell 只负责 DOM 宿主和低频订阅;任意时刻都只有一个活动循环。
canvas-lab/src/lab/ch06/
render-scheduler.ts
canvas-engine.ts
CanvasEditorShell.tsx
render-scheduler.test.ts
CanvasEditorShell.test.tsx
完整的调度器将失效标记、连续模式、固定/可变时钟和幂等销毁统一表达如下:
export const Dirty = { Background: 1, Scene: 2, Overlay: 4, All: 7 } as const;
export type Frame = {
now: number;
variableDt: number;
dirty: number;
fixedSteps: number;
alpha: number;
};
export type FramePlatform = {
request(callback: FrameRequestCallback): number;
cancel(id: number): void;
};
const browserFrames: FramePlatform = {
request: (callback) => window.requestAnimationFrame(callback),
cancel: (id) => window.cancelAnimationFrame(id),
};
export class RenderScheduler {
private pending: number | null = null;
private dirty = 0;
private continuous = false;
private disposed = false;
private lastNow: number | null = null;
private accumulator = 0;
private readonly fixedStep = 1000 / 60;
constructor(
private readonly render: (frame: Frame) => void,
private readonly platform: FramePlatform = browserFrames,
) {}
invalidate(mask: number): void {
if (this.disposed) return;
this.dirty |= mask;
this.ensureFrame();
}
setContinuous(value: boolean): void {
if (this.disposed || this.continuous === value) return;
this.continuous = value;
if (value) this.ensureFrame();
}
rebaseClock(): void {
this.lastNow = null;
this.accumulator = 0;
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
if (this.pending !== null) this.platform.cancel(this.pending);
this.pending = null;
this.dirty = 0;
this.continuous = false;
this.rebaseClock();
}
private ensureFrame(): void {
if (this.pending !== null || this.disposed) return;
this.pending = this.platform.request((now) => this.onFrame(now));
}
private onFrame(now: number): void {
this.pending = null;
if (this.disposed) return;
const rawDt = this.lastNow === null ? 0 : now - this.lastNow;
const variableDt = Math.max(0, Math.min(100, rawDt));
this.lastNow = now;
this.accumulator = Math.min(250, this.accumulator + variableDt);
let fixedSteps = 0;
while (this.accumulator >= this.fixedStep && fixedSteps < 5) {
this.accumulator -= this.fixedStep;
fixedSteps += 1;
}
if (fixedSteps === 5 && this.accumulator >= this.fixedStep) {
this.accumulator %= this.fixedStep;
}
const dirty = this.dirty;
this.dirty = 0;
this.render({ now, variableDt, dirty, fixedSteps, alpha: this.accumulator / this.fixedStep });
if (this.continuous || this.dirty !== 0) this.ensureFrame();
}
}
执行五个固定步长后,丢弃所有完整步长的积压,只保留小于一个步长的余量。否则,“每帧最多执行五个追赶步长”仍可能把后台积欠带入之后的许多帧,形成越来越慢的追赶螺旋。如果业务逻辑不能丢弃步长,就应暂停并与权威时间重新同步,而不是无休止地追赶。
引擎拥有高频资源。变更集映射到各图层,并且仅在值发生变化时替换快照对象:
import { Dirty, RenderScheduler, type FramePlatform } from './render-scheduler';
export type DocumentChangeSet = Readonly<{
backgroundChanged: boolean;
changedShapeIds: readonly string[];
}>;
export type DocumentStore = {
getSnapshot(): unknown;
subscribe(listener: (change: DocumentChangeSet) => void): () => void;
};
export type DerivedSnapshot = Readonly<{
selectionCount: number;
canUndo: boolean;
saveStatus: 'saved' | 'dirty';
}>;
export class CanvasEngine {
private scheduler: RenderScheduler;
private snapshot: DerivedSnapshot = { selectionCount: 0, canUndo: false, saveStatus: 'saved' };
private listeners = new Set<() => void>();
private abort = new AbortController();
private unsubscribeDocument: () => void;
private destroyed = false;
constructor(
private readonly canvas: HTMLCanvasElement,
documentStore: DocumentStore,
private readonly renderPasses: (dirty: number) => void,
frames?: FramePlatform,
) {
this.scheduler = new RenderScheduler((frame) => this.renderPasses(frame.dirty), frames);
this.unsubscribeDocument = documentStore.subscribe(this.onDocumentChange);
canvas.addEventListener('pointermove', this.onPointerMove, { signal: this.abort.signal });
document.addEventListener('visibilitychange', this.onVisibility, { signal: this.abort.signal });
this.scheduler.invalidate(Dirty.All);
}
private onPointerMove = (_event: PointerEvent): void => {
if (!this.destroyed) this.scheduler.invalidate(Dirty.Overlay);
};
private onVisibility = (): void => {
if (document.visibilityState === 'visible') {
this.scheduler.rebaseClock();
this.scheduler.invalidate(Dirty.All);
}
};
private onDocumentChange = (changeSet: DocumentChangeSet): void => {
this.publish({ ...this.snapshot, canUndo: true, saveStatus: 'dirty' });
let dirty = Dirty.Overlay;
if (changeSet.backgroundChanged) dirty |= Dirty.Background;
if (changeSet.changedShapeIds.length > 0) dirty |= Dirty.Scene;
this.scheduler.invalidate(dirty);
};
setSelection(ids: ReadonlySet<string>): void {
this.publish({ ...this.snapshot, selectionCount: ids.size });
this.scheduler.invalidate(Dirty.Overlay);
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): DerivedSnapshot => this.snapshot;
private publish(next: DerivedSnapshot): void {
if (
next.selectionCount === this.snapshot.selectionCount &&
next.canUndo === this.snapshot.canUndo &&
next.saveStatus === this.snapshot.saveStatus
)
return;
this.snapshot = next;
for (const listener of this.listeners) listener();
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.unsubscribeDocument();
this.abort.abort();
this.scheduler.dispose();
this.listeners.clear();
}
}
此处,DocumentStore.subscribe 是第 5 章命令边界中的只读订阅端口。引擎不会直接修改记录。变更集不只是一个“已更改”布尔值:背景变化和形状 ID 变化会映射到不同的脏图层,从而确保后续的分层 Canvas、缓存和局部失效安全可靠。生产环境中的存储还应使其取消订阅函数具有幂等性;引擎的 destroy() 保证该函数只会被调用一次。
Shell 是客户端边界;initialDocument 是 JSON,而不是运行时 Map。以下代码展示了全部四个必需图层和 Portal:
'use client';
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { CanvasEngine, type DerivedSnapshot, type DocumentStore } from './canvas-engine';
type WireDocument = { records: readonly { id: string; type: string }[] };
const empty: DerivedSnapshot = { selectionCount: 0, canUndo: false, saveStatus: 'saved' };
const subscribeEmpty = (): (() => void) => () => undefined;
const getEmpty = (): DerivedSnapshot => empty;
const staticStore = (document: WireDocument): DocumentStore => ({
getSnapshot: () => document,
subscribe: () => () => undefined,
});
const defaultCreateEngine = (canvas: HTMLCanvasElement, document: WireDocument): CanvasEngine =>
new CanvasEngine(canvas, staticStore(document), () => undefined);
export function CanvasEditorShell({
initialDocument,
createEngine = defaultCreateEngine,
}: {
initialDocument: WireDocument;
createEngine?: (canvas: HTMLCanvasElement, document: WireDocument) => CanvasEngine;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const [engine, setEngine] = useState<CanvasEngine | null>(null);
useLayoutEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const next = createEngine(canvas, initialDocument);
setEngine(next);
return () => {
next.destroy();
};
}, [createEngine, initialDocument]);
const snapshot = useSyncExternalStore(
engine?.subscribe ?? subscribeEmpty,
engine?.getSnapshot ?? getEmpty,
getEmpty,
);
return (
<section className="canvas-editor-shell">
<div className="canvas-host">
<canvas ref={canvasRef}>Business-process canvas</canvas>
</div>
<div ref={overlayRef} className="dom-overlay-layer" />
<nav className="toolbar" aria-label="Canvas tools">
<button type="button">Select</button>
<button type="button">Rectangle</button>
</nav>
<aside className="inspector" aria-live="polite">
{snapshot.selectionCount} selected; {snapshot.saveStatus}
</aside>
{overlayRef.current
? createPortal(<textarea aria-label="Text being edited" />, overlayRef.current)
: null}
</section>
);
}
defaultCreateEngine 必须定义在组件外部。如果在参数列表中编写默认箭头函数,每次渲染都会创建一个新引用。创建引擎后的 setEngine 会触发下一次渲染;变化后的依赖项继而销毁并重建引擎,形成循环。在实际应用中,自定义的 createEngine 和 initialDocument 引用也必须保持稳定。服务器组件只传递普通 JSON。如果父组件每次都创建新对象,请通过文档 ID 加载存储;不要为了忽略真实的数据更新而抑制 Effect 依赖项。清理过程不会在卸载期间写入 React 状态。它只销毁本次设置实际创建的实例。
使用伪帧的调度器测试证明:十次失效只会产生一次请求,销毁操作会取消该请求:
import { describe, expect, it, vi } from 'vitest';
import { Dirty, RenderScheduler, type FramePlatform } from './render-scheduler';
describe('RenderScheduler', () => {
it('coalesces dirty changes and owns one active request', () => {
let callback: FrameRequestCallback | null = null;
const cancel = vi.fn();
const platform: FramePlatform = {
request: vi.fn((cb) => {
callback = cb;
return 41;
}),
cancel,
};
const render = vi.fn();
const scheduler = new RenderScheduler(render, platform);
for (let i = 0; i < 10; i += 1) scheduler.invalidate(Dirty.Overlay);
expect(platform.request).toHaveBeenCalledTimes(1);
(callback as FrameRequestCallback)(100);
expect(render).toHaveBeenCalledWith(expect.objectContaining({ dirty: Dirty.Overlay }));
expect(platform.request).toHaveBeenCalledTimes(1);
scheduler.invalidate(Dirty.Scene);
scheduler.dispose();
scheduler.dispose();
expect(cancel).toHaveBeenCalledTimes(1);
});
it('clamps a background-tab time jump', () => {
const callbacks: FrameRequestCallback[] = [];
const platform: FramePlatform = {
request: (cb) => {
callbacks.push(cb);
return callbacks.length;
},
cancel: () => undefined,
};
const render = vi.fn();
const scheduler = new RenderScheduler(render, platform);
scheduler.setContinuous(true);
callbacks.shift()!(10);
callbacks.shift()!(10_000);
expect(render.mock.calls[1][0].variableDt).toBe(100);
expect(render.mock.calls[1][0].fixedSteps).toBeLessThanOrEqual(5);
expect(render.mock.calls[1][0].alpha).toBeLessThan(1);
scheduler.dispose();
});
});
运行 npx vitest run src/lab/ch06/render-scheduler.test.ts;预期得到 2 passed。使用 React Testing Library,在 <StrictMode> 内挂载并卸载 Shell 两次,断言 create count === destroy count,且同时活动数量的最大值为 1。在 React Profiler 下拖动五秒。Canvas 应以高频率更新,而 Shell 的渲染仅随 selectionCount/saveStatus 变化。
回到舞台:售票员只显示“已选择多少张”和“是否可以撤销”。演员每移动一毫米,他们都不会重新打印所有门票。舞台经理会把十条同色通知合并到下一次开幕时处理,并在工作结束时归还每一把钥匙。
故意破坏它
| 注入故障 | 症状 | 证据 | 修复 | 回归测试 | 恢复 |
|---|---|---|---|---|---|
| 严格模式设置后不清理 | 开发环境中出现双重监听器/双重循环 | 活动计数器达到 2 | Effect 返回幂等的 destroy | 严格挂载周期的最大活动数=1 | 移除残留实例 |
| 挂载/卸载两次 | 第二次事件触发两遍 | 监听器/渲染调用翻倍 | AbortController 由引擎独占 | 两个周期后 create=destroy | 再次挂载并验证功能 |
| 未释放 ResizeObserver | 离开页面后回调仍继续执行 | 已分离的节点仍被保留 | Host.destroy 执行 disconnect | 堆/监视器计数归零 | 手动销毁残留的 Host |
| 重复的指针监听器 | 一次移动触发多次失效 | 重复的事件跟踪记录 | 通过 signal 统一移除 | 一个事件只进入一次 | 中止旧控制器 |
| 重复的 rAF 循环 | 空闲时 CPU 占用高且帧数翻倍 | 待处理 ID/性能跟踪记录 | 单待处理请求守卫 | 十次失效只产生一次请求 | 取消多余的 ID |
在 SSR 模块顶层读取 window | 服务器构建报告 window is not defined | SSR 堆栈跟踪 | 'use client' + 在 Effect 内访问 | 服务器渲染/导入测试 | 移除模块副作用 |
| 指针移动写入 React 状态 | 页面每秒渲染 120–240 次 | React Profiler | 将高频瞬态状态保留在引擎中 | 五秒拖动的渲染预算 | 删除错误的状态桥接 |
后台产生巨大的 dt | 返回后瞬移/冻结 | dt≈数万毫秒 | 限幅、固定步长上限、重设基准 | 10→10000 时间测试 | 重新启动动画时钟 |
每次只启用一个故障开关,并记录活动引擎、待处理的 rAF、监听器、观察器和 React 提交次数。修复后,运行单元测试、严格生命周期测试和浏览器 Profiler。最后,主动执行卸载。在恢复完成前,每个计数都必须归零。
用证据验收
| 门槛 | 自动/手动 | 证据 |
|---|---|---|
| 始终只有一个活动循环 | 自动 | 待处理 rAF 单元测试和活动计数器 |
| 引擎无需 React 即可测试 | 自动 | 调度器采用纯 Node 测试;引擎使用 jsdom,且不导入 React |
| React 重新渲染不会重建文档 | 自动/手动 | Profiler + 引擎创建次数不随检查器渲染而增长 |
| 高频交互不会导致页面重新渲染 | 手动 | 指针跟踪频率很高,而 React 提交次数保持较低 |
| 生命周期对称 | 自动 | 严格设置/清理,观察器/监听器/rAF 均为 1:1 |
| 后台恢复受控 | 自动 | dt 限幅、固定步长上限、可见性基准重设测试 |
- 我实现了失效标记、脏标记、变更集和固定渲染阶段。
- 仅在动画期间启用连续模式;静态时不存在 rAF。
- 固定/可变时间步长和后台标签页策略都有确定性测试。
-
<CanvasEditorShell>包含 Host、DOM 覆盖层、工具栏和检查器。 -
'use client'、ref、Effect/LayoutEffect、严格模式、外部存储、Portal 和序列化边界各自都有明确职责。 - 引擎在挂载时创建,在卸载时销毁,并且只向 React 发布低频派生状态。
给五岁孩子讲明白
请不要使用“React”“引擎”“rAF”“脏标记”或“Effect”这些词来回答:为什么不能因为每位观众眨了一下眼睛,就让所有演员重新表演一遍?如何确保经理回家后,不会有第二位经理仍在偷偷开灯?
展开一份不含术语的精彩答案
眨眼不会改变背景、演员或黄色圆圈,所以重复演出只会浪费能量。当某样东西发生变化时,就点亮对应的灯。即使它在短短一瞬间亮了十次,也可以等到下一次开幕时一起处理。演员持续奔跑时,这盏灯可以暂时保持点亮;他们停下后就把灯关掉。开始工作时,经理会拿到一组钥匙:计时器、尺子和门铃。工作结束时,每件物品都必须归还。检查时,让经理开始工作,然后立即停止,再重新开始。如果每一刻都只有一个人拿着钥匙,就不会有第二位经理暗中继续工作。