JEPA4Japan · 教程

第 6 章:灯亮时才重绘——渲染调度与 React 边界

4,870字 14分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

通过失效标记驱动渲染,并确保 React Strict Mode 下始终只有一个活动引擎、一组观察器与监听器,以及一个 rAF 循环。

课程进度 课程大纲 已发布 18/18 课

从一个五岁孩子也能理解的游戏开始

用积木搭一个小舞台:蓝纸是背景,三个玩偶是演员,透明胶片上的黄色圆圈表示“谁被选中了”。让一个孩子当舞台经理,另一个当售票员。如果每当一位观众眨眼,售票员就大喊:“所有演员,把整场演出再演一遍!”,会发生什么?所有人都会忙得无暇售票。正确的做法是,只有当背景、演员或黄色圆圈确实发生变化时,才点亮对应的信号灯。经理看到信号灯后,会在下一次幕布升起前处理一次。

先预测一下:如果“黄色圆圈信号灯”在同一秒内亮起十次,幕布应该升起十次,还是只升起一次?经理下班回家后,信号灯还能把他们叫回来吗?当一个动画玩具车持续移动时,信号灯应该如何保持点亮?

  1. React 售票大厅工具栏与检查器
  2. 点亮脏信号灯记录发生变化的图层
  3. 升起下一次幕布合并重复通知
  4. 对称地收工取消信号灯、监听器和观察器
产品 UI 负责售票处;引擎负责高频运转的舞台。只有信号灯亮起时才重绘。

本章唯一的核心事实是: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。

证据与兼容性

以上来源已于 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 变化。

回到舞台:售票员只显示“已选择多少张”和“是否可以撤销”。演员每移动一毫米,他们都不会重新打印所有门票。舞台经理会把十条同色通知合并到下一次开幕时处理,并在工作结束时归还每一把钥匙。

故意破坏它

注入故障症状证据修复回归测试恢复
严格模式设置后不清理开发环境中出现双重监听器/双重循环活动计数器达到 2Effect 返回幂等的 destroy严格挂载周期的最大活动数=1移除残留实例
挂载/卸载两次第二次事件触发两遍监听器/渲染调用翻倍AbortController 由引擎独占两个周期后 create=destroy再次挂载并验证功能
未释放 ResizeObserver离开页面后回调仍继续执行已分离的节点仍被保留Host.destroy 执行 disconnect堆/监视器计数归零手动销毁残留的 Host
重复的指针监听器一次移动触发多次失效重复的事件跟踪记录通过 signal 统一移除一个事件只进入一次中止旧控制器
重复的 rAF 循环空闲时 CPU 占用高且帧数翻倍待处理 ID/性能跟踪记录单待处理请求守卫十次失效只产生一次请求取消多余的 ID
在 SSR 模块顶层读取 window服务器构建报告 window is not definedSSR 堆栈跟踪'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”这些词来回答:为什么不能因为每位观众眨了一下眼睛,就让所有演员重新表演一遍?如何确保经理回家后,不会有第二位经理仍在偷偷开灯?

展开一份不含术语的精彩答案

眨眼不会改变背景、演员或黄色圆圈,所以重复演出只会浪费能量。当某样东西发生变化时,就点亮对应的灯。即使它在短短一瞬间亮了十次,也可以等到下一次开幕时一起处理。演员持续奔跑时,这盏灯可以暂时保持点亮;他们停下后就把灯关掉。开始工作时,经理会拿到一组钥匙:计时器、尺子和门铃。工作结束时,每件物品都必须归还。检查时,让经理开始工作,然后立即停止,再重新开始。如果每一刻都只有一个人拿着钥匙,就不会有第二位经理暗中继续工作。