JEPA4Japan · 教程

第 2 章:一张会遗忘的像素纸

3,610字 10分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

理解位图、CSS 像素、物理像素和 DPR,再构建一个既不重复订阅也不会丢失状态的 CanvasHost。

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

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

拿一张方格纸,把第三行第四格涂成蓝色。在旁边贴一张便签,写上“这是 shape-7”。让朋友只拿走方格纸,你自己保留便签。然后问:“这个蓝色方块叫什么?是谁画的?能把它向右移动两格吗?”你的朋友只能看见颜色,无法回答它的名字。现在,把方格纸换成网格更密的一张。原来的蓝色方块也随之消失,所以你必须根据便签上的记录重新画出来。

先预测:如果把一张 10×10 的画纸拉伸到两倍大,你会得到更多方格,还是只会让原有方格变胖?如果直接换成一张新的 20×20 画纸,旧图会自动搬过去吗?亲手试试看。

  1. 记住对象shape-7 在哪里
  2. 准备方格纸屏幕有多密
  3. 按照记录上色变成位图
  4. 换纸并重画不要依赖旧颜色
应用程序记住对象;Canvas 只保留每个方格的颜色。

本章唯一的核心事实是:Canvas 存储位图,应用程序存储对象。 Canvas 2D 采用即时模式:一次调用会改变像素,浏览器不会替你保留“矩形 shape-7”这个对象。

把玩具翻译成 Canvas 工程

玩具世界中的对象或动作浏览器概念结果
外框大小CSS 尺寸它在页面布局中占据多少 CSS 像素
纸上的方格数量canvas.width/height背景位图的像素尺寸
网格密度devicePixelRatio一个 CSS 像素周围可能对应多个物理像素
更换方格纸设置 width / height清空位图并重置上下文状态
便签上的形状记录文档数据以确定方式重建场景
测量外框的助手ResizeObserver容器改变时通知 Host
一盒绘图工具CanvasRenderingContext2D包含变换、透明度和样式等有状态工具

类比也有边界。CSS 像素并不是尺寸固定的物理方格,DPR 也不只由硬件决定;浏览器缩放和窗口跨显示器移动,都可能改变有效比率。ResizeObserver 不是一把同步直尺:浏览器会调度回调批次和布局周期。更换真实纸张不会“重置画笔颜色”,但设置 Canvas 尺寸属性会同时重置上下文状态,因此必须显式重建状态。

先消灭具有误导性的直觉

  • “CSS width: 800px 会给我 800 个清晰像素。” CSS 只改变显示尺寸。把默认的 300×150 位图放大,依然会模糊、拉伸。
  • “DPR 只需在首次加载时读取。” 浏览器缩放或跨显示器移动之后,DPR 可能改变,因此 Host 每次有效测量都必须重新计算。
  • “把相同值赋给 canvas.width 不会有影响。” 规范要求尺寸赋值重置 Canvas。即使执行 canvas.width = canvas.width,也会清空内容并恢复默认上下文状态。
  • “调整尺寸后只要恢复相机就够了。” 变换、裁剪、透明度、lineDash、字体等状态都可能消失。可靠方法是在尺寸变化后执行统一初始化,并完整重绘。
  • “Observer 越多,响应越及时。” 重复的 Observer/监听器会造成重复测量、循环和泄漏。一台 Host 只拥有一套资源。

生产工程背包

前置契约

前提是第 1 章建立的边界:产品 UI 留在 DOM,动态场景使用 Canvas 2D。此时 Canvas Lab 只有一个不保存像素的 redraw(viewport) 回调。相机属于应用数据,绝不能被藏进上下文当前的 Transform。容器可能被隐藏,宽度或高度可能为零。销毁后,任何回调都不得再次绘制。

正式知识

<canvas> 是 DOM 元素;getContext('2d', settings) 会绑定渲染上下文。fillRect() 等 Canvas 2D 调用会立即修改输出位图,并不会生成一个可以查询的矩形节点。CSS 尺寸决定布局大小,背景位图尺寸决定有多少像素可用。常见计算方式是 round(cssSize × effectiveDpr),随后执行 ctx.setTransform(dpr, 0, 0, dpr, 0, 0),这样后续绘制仍可使用 CSS 像素作为单位。

物理像素是设备上的显示单元;CSS 像素是浏览器的逻辑单元。devicePixelRatio 是一个比率,不是永久不变的设备标签。像素尺寸必须设置上限,否则大尺寸高 DPR 显示器会分配惊人的内存。如果宽或高为 0,不要创建零尺寸位图并假装操作已经完成。应进入暂停状态,等待下一次非零测量。

按照规范,给尺寸赋值会初始化新位图并重置上下文状态。即使新旧值相同,也不能把赋值视为没有副作用。只有计算得到的像素尺寸确实改变时才赋值。一旦改变,就遵循固定顺序:设置 CSS 尺寸 → 设置背景尺寸 → 初始化上下文 → 根据模型完整重绘。

上下文创建选项只是请求,并非保证。alpha: false 表示不需要透明背景;desynchronized 是降低延迟的提示;willReadFrequently 适合频繁使用 getImageData() 的场景,也可能改变加速策略;colorSpace 可以请求 srgb 或受支持的广色域;unorm8/float16 等 colorType 更需要谨慎检测。应通过 getContextAttributes?.() 报告实际属性;输入设置不能证明能力存在。getContext() 也可能返回 null。

使用 ResizeObserver 观察容器,不要每帧轮询。有些环境提供 devicePixelContentBoxSize,但仍应保留 contentRect × DPR 的降级路径。如果回调只是因为自己改变了 Canvas 尺寸就再次改变被观察容器,便可能形成 Resize 循环。Host 的 CSS 应让 Canvas 跟随容器;位图属性不得参与布局。

证据与兼容性

以上来源核查于 2026-08-29。请对 colorType、device-pixel-content-box 和上下文属性报告进行特性检测。不支持时,应降级到 sRGB、CSS 尺寸 × DPR 与“未知”,而不是阻止基本绘制。

本章的工程增量

**起点:**第 1 章使用固定尺寸直接调用 renderCanvas。**终点:**一个只创建一个 Observer、支持 DPR/零尺寸/销毁、每次重置后都确定性重绘,并能输出能力报告的 CanvasHost。

canvas-lab/
  src/lab/ch02/canvas-host.ts
  src/lab/ch02/canvas-host.test.ts
  src/lab/ch02/demo.html

接口契约:redraw 接收 CSS 视口和实际 DPR。Host 不存储文档,只通知渲染器。下面是完整的关键实现:

export type Viewport = { cssWidth: number; cssHeight: number; dpr: number };
export type CapabilityReport = {
  requested: CanvasRenderingContext2DSettings;
  actual: Record<string, unknown> | null;
};
export type HostPlatform = {
  dpr(): number;
  observe(element: Element, callback: () => void): () => void;
  watchDpr(callback: () => void): () => void;
};

const browserPlatform: HostPlatform = {
  dpr: () => window.devicePixelRatio || 1,
  observe(element, callback) {
    const observer = new ResizeObserver(callback);
    observer.observe(element);
    return () => observer.disconnect();
  },
  watchDpr(callback) {
    let media: MediaQueryList | null = null;
    const onChange = (): void => {
      arm();
      callback();
    };
    const arm = (): void => {
      media?.removeEventListener('change', onChange);
      media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
      media.addEventListener('change', onChange);
    };
    arm();
    return () => media?.removeEventListener('change', onChange);
  },
};

export class CanvasHost {
  readonly context: CanvasRenderingContext2D;
  readonly capabilities: CapabilityReport;
  private stopObserving: (() => void) | null = null;
  private stopWatchingDpr: (() => void) | null = null;
  private destroyed = false;
  private last: Viewport | null = null;

  constructor(
    private readonly container: HTMLElement,
    private readonly canvas: HTMLCanvasElement,
    private readonly redraw: (ctx: CanvasRenderingContext2D, viewport: Viewport) => void,
    private readonly platform: HostPlatform = browserPlatform,
  ) {
    const requested: CanvasRenderingContext2DSettings = {
      alpha: false,
      desynchronized: false,
      willReadFrequently: false,
      colorSpace: 'srgb',
      colorType: 'unorm8',
    };
    const context = canvas.getContext('2d', requested);
    if (!context) throw new Error('Canvas 2D context is unavailable');
    this.context = context;
    const readable = context as CanvasRenderingContext2D & {
      getContextAttributes?: () => Record<string, unknown>;
    };
    this.capabilities = {
      requested,
      actual: readable.getContextAttributes?.() ?? null,
    };
    this.canvas.style.display = 'block';
    this.canvas.style.width = '100%';
    this.canvas.style.height = '100%';
    this.stopObserving = this.platform.observe(this.container, () => this.measure());
    this.stopWatchingDpr = this.platform.watchDpr(() => this.measure());
    this.measure();
  }

  measure(): void {
    if (this.destroyed) return;
    const rect = this.container.getBoundingClientRect();
    const cssWidth = Math.max(0, rect.width);
    const cssHeight = Math.max(0, rect.height);
    if (cssWidth === 0 || cssHeight === 0) {
      this.last = null;
      return;
    }
    const dpr = Math.min(3, Math.max(1, this.platform.dpr()));
    const pixelWidth = Math.max(1, Math.round(cssWidth * dpr));
    const pixelHeight = Math.max(1, Math.round(cssHeight * dpr));
    const bitmapChanged = this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight;
    const viewport: Viewport = { cssWidth, cssHeight, dpr };
    const viewportChanged =
      !this.last ||
      this.last.cssWidth !== cssWidth ||
      this.last.cssHeight !== cssHeight ||
      this.last.dpr !== dpr;
    if (!bitmapChanged && !viewportChanged) return;
    if (bitmapChanged) {
      this.canvas.width = pixelWidth;
      this.canvas.height = pixelHeight;
    }
    this.initializeContext(viewport);
    this.last = viewport;
    this.redraw(this.context, viewport);
  }

  private initializeContext(viewport: Viewport): void {
    this.context.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0);
    this.context.globalAlpha = 1;
    this.context.globalCompositeOperation = 'source-over';
    this.context.lineCap = 'butt';
    this.context.lineJoin = 'miter';
    this.context.setLineDash([]);
    this.context.fillStyle = '#ffffff';
    this.context.fillRect(0, 0, viewport.cssWidth, viewport.cssHeight);
  }

  destroy(): void {
    if (this.destroyed) return;
    this.destroyed = true;
    this.stopObserving?.();
    this.stopWatchingDpr?.();
    this.stopObserving = null;
    this.stopWatchingDpr = null;
    this.last = null;
  }
}

完整的使用页面保留了真正、可访问的标题:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <style>
      #stage {
        width: 80vw;
        height: 50vh;
      }
      canvas {
        display: block;
      }
    </style>
  </head>
  <body>
    <h1>Canvas Lab</h1>
    <div id="stage"><canvas id="scene">Two business cards</canvas></div>
    <script type="module">
      import { CanvasHost } from './canvas-host.ts';
      const host = new CanvasHost(stage, scene, (ctx, view) => {
        ctx.fillStyle = '#2563eb';
        ctx.fillRect(24, 24, 120, 64);
        ctx.fillStyle = '#fff';
        ctx.fillText(`${view.dpr}x`, 34, 60);
      });
      window.addEventListener('pagehide', () => host.destroy(), { once: true });
    </script>
  </body>
</html>

测试不猜测浏览器的调度时机,而是注入平台,并断言资源所有权与重绘行为:

import { describe, expect, it, vi } from 'vitest';
import { CanvasHost, type HostPlatform } from './canvas-host';

describe('CanvasHost', () => {
  it('resizes for both element and DPR changes, then releases both subscriptions once', () => {
    const container = document.createElement('div');
    vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
      width: 200,
      height: 100,
    } as DOMRect);
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d')!;
    const setTransform = vi.spyOn(ctx, 'setTransform');
    const redraw = vi.fn();
    const disconnect = vi.fn();
    const unwatchDpr = vi.fn();
    let notifyResize = () => undefined;
    let notifyDpr = () => undefined;
    let dpr = 2;
    const platform: HostPlatform = {
      dpr: () => dpr,
      observe: (_element, callback) => {
        notifyResize = callback;
        return disconnect;
      },
      watchDpr: (callback) => {
        notifyDpr = callback;
        return unwatchDpr;
      },
    };
    const host = new CanvasHost(container, canvas, redraw, platform);
    expect([canvas.width, canvas.height]).toEqual([400, 200]);
    expect(setTransform).toHaveBeenLastCalledWith(2, 0, 0, 2, 0, 0);
    expect(redraw).toHaveBeenCalledTimes(1);
    notifyResize();
    expect(redraw).toHaveBeenCalledTimes(1);
    dpr = 1.5;
    notifyDpr();
    expect([canvas.width, canvas.height]).toEqual([300, 150]);
    expect(setTransform).toHaveBeenLastCalledWith(1.5, 0, 0, 1.5, 0, 0);
    expect(redraw).toHaveBeenCalledTimes(2);
    host.destroy();
    host.destroy();
    expect(disconnect).toHaveBeenCalledTimes(1);
    expect(unwatchDpr).toHaveBeenCalledTimes(1);
  });

  it('waits through a zero-sized hidden container', () => {
    const container = document.createElement('div');
    vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
      width: 0,
      height: 0,
    } as DOMRect);
    const redraw = vi.fn();
    const platform: HostPlatform = {
      dpr: () => 2,
      observe: () => () => undefined,
      watchDpr: () => () => undefined,
    };
    new CanvasHost(container, document.createElement('canvas'), redraw, platform);
    expect(redraw).not.toHaveBeenCalled();
  });
});

ResizeObserver 只会告诉你元素盒子是否改变。DPR 可能在盒子尺寸不变时独立变化,所以应像 MDN 示例那样,为当前分辨率重新设置 matchMedia 监听器。运行 npx vitest run src/lab/ch02/canvas-host.test.ts --environment jsdom,预期结果为 2 passed。运行 npm run dev,然后改变窗口大小和浏览器缩放。控制台中的能力报告应保持稳定,形状边缘不得拉伸。回到方格纸游戏:Host 根据外框尺寸和密度换纸,并重新准备画笔;真正的图画仍根据便签记录重绘。

故意让它出错

注入的故障症状证据修复回归测试恢复方式
浏览器从 100% 缩放到 200%旧实现模糊或尺寸错误记录 CSS/位图/DPR 元组与截图每次测量都重新计算 DPRPlaywright 改变 DPR 后像素尺寸匹配恢复缩放
从普通显示器拖到高 DPI 显示器仍使用旧密度devicePixelRatio 已变,位图未变Resize/媒体变化触发 measure模拟 DPR 1→2拖回并再次验证
先 display:none,再显示首次测量为 0,随后永久空白redraw 调用次数为 0零尺寸时暂停,在下一次回调恢复0→300×200 时调用一次恢复显示
尺寸变化后跳过初始化相机/透明度/lineDash 消失getTransform() 为单位矩阵且截图错误初始化状态,再根据模型重绘监视顺序:resize→setTransform→render删除故障分支
回调总是写入相同尺寸不断清空、闪烁和重复渲染相同测量仍增加 redraw 次数赋值前比较像素/视口重复通知不会重绘恢复守卫
React 每次渲染都创建 Host出现多个 Observer 与循环构造与断开次数不对称生命周期中只拥有一个 Host两次挂载/卸载都为 1:1销毁多余实例

还要手动测试极小宽度、大尺寸高 DPR 显示器,以及缺少能力报告的情况。恢复不是“刷新后看起来正常”,而是:移除故障注入,Observer 数量回到零,测试重新通过,下一次 Resize 仍能绘制。

用证据验收

属性自动/手动证据
不模糊手动CSS 尺寸 × DPR 与位图尺寸日志、跨显示器截图
不拉伸自动比较 CSS 与位图宽高比的断言
不丢失 Transform/相机自动Resize 后的 getTransform/相机渲染测试
不创建多个 Observer自动构造与 disconnect 计数,加重复挂载测试
重绘可重复自动清空/Resize 后,同一文档得到相同像素快照
可以优雅降级自动/手动无属性报告、DPR=1 与零尺寸路径测试
  • 我能区分 CSS 尺寸、背景尺寸、CSS 像素和物理像素。
  • 我知道改变 width/height 会清空位图并重置上下文状态,即使赋值与原值相同也一样。
  • Host 统一处理 Resize、DPR、初始化、能力报告和确定性重绘。
  • alpha/desynchronized/willReadFrequently/colorSpace/colorType 被记录为请求,实际能力经过测量。
  • 隐藏→显示、缩放、跨显示器移动、零尺寸与 Resize 循环都有证据。
  • destroy() 具有幂等性,且不会泄漏 Observer。

给五岁孩子讲明白

不要使用“像素”“DPR”“上下文”或“观察器”这些词,回答:为什么拉伸方格纸不等于换成网格更密的纸?为什么换纸后必须根据记录重画?

展开一个不用术语的好答案

拉伸旧纸只会让原有方格变胖,因此边缘会变得模糊。方格更多的纸可以画出更细的边缘。但新纸一开始是空的:旧纸上的蓝色不会自己跳过去,甚至上一支画笔的颜色也不能再相信。因此,我们会保存一份清单,记录有哪些东西以及它们在哪里。只要纸的尺寸或密度发生变化,就重新准备画笔,再按照清单画出全部内容。便签和方格纸各自有各自的工作。