跳到主要内容

DPR与backing-store

1. 三套尺寸必须显式对齐

CSS 布局尺寸 getBoundingClientRect() / 样式宽高
× DPR
backing store canvas.width / canvas.height
× CTM.a(若 scale(dpr))
用户空间 你传入 fill/lineTo 的坐标

工程约定(强烈建议写进渲染器,不要每个调用点自己乘):

  • 用户空间单位 = CSS 像素
  • 创建 context 后 setTransform(dpr, 0, 0, dpr, 0, 0),或每帧绘制前这么设
  • canvas.width = cssWidth * dpr(取整,见下)
export interface SurfaceMetrics {
cssWidth: number;
cssHeight: number;
dpr: number;
backingWidth: number;
backingHeight: number;
}

export function allocateSurface(
canvas: HTMLCanvasElement,
cssWidth: number,
cssHeight: number,
dpr: number,
): SurfaceMetrics {
const backingWidth = Math.max(1, Math.round(cssWidth * dpr));
const backingHeight = Math.max(1, Math.round(cssHeight * dpr));
if (canvas.width !== backingWidth) canvas.width = backingWidth;
if (canvas.height !== backingHeight) canvas.height = backingHeight;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
return { cssWidth, cssHeight, dpr, backingWidth, backingHeight };
}

canvas.width 会清空 backing store 并重置 context 状态(含 CTM、clip、font)。resize 之后必须重设 transform 和样式,并标记全场景 dirty。

不要用 CSS transform: scale 放大 canvas 来「提高清晰度」——那是放大已糊的位图。清晰度只来自 backing store 足够密。

2. DPR 会变,而且经常不是整数

window.devicePixelRatio 在以下情况会变:

  • 拖到另一台显示器
  • OS 显示缩放
  • 浏览器缩放(Ctrl/Cmd ±)
  • 部分移动浏览器的 pinch(还要看 visualViewport.scale

非整 DPR(1.251.52.75)下 cssWidth * dpr 常为分数。backing store 必须是整数,所以要 Math.round。取整后 CSS 像素和设备像素不再严格 1:N,1px 线会落在半像素上发糊。对策:

  • 描边坐标 + 0.5 只对 奇数线宽 + 单位阵用户空间 成立;有 DPR scale 后,应对齐到 backing store 像素中心
  • 或把用户空间也改成 backing 像素(命中公式要一起改)。两套里选一套,不要混。
export function snapHairline(
x: number,
y: number,
dpr: number,
lineWidthCss: number,
): { x: number; y: number } {
const widthBacking = lineWidthCss * dpr;
const offset = (Math.round(widthBacking) % 2 === 1) ? 0.5 : 0;
return {
x: (Math.round(x * dpr) + offset) / dpr,
y: (Math.round(y * dpr) + offset) / dpr,
};
}

3. 用 ResizeObserver,不要只听 window.resize

canvas 放在 flex/grid 里时,窗口没变、容器变了。window.resize 不够。

优先 device-pixel-content-box:直接拿到 设备像素 尺寸,避免自己乘 DPR 再 round 的二次误差。

export function observeSurface(
canvas: HTMLCanvasElement,
onMetrics: (metrics: SurfaceMetrics) => void,
): () => void {
const apply = (cssWidth: number, cssHeight: number, dpr: number): void => {
onMetrics(allocateSurface(canvas, cssWidth, cssHeight, dpr));
};

const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const dprBox = entry.devicePixelContentBoxSize?.[0];
if (dprBox) {
const dpr = window.devicePixelRatio || 1;
const cssWidth = dprBox.inlineSize / dpr;
const cssHeight = dprBox.blockSize / dpr;
const backingWidth = Math.max(1, dprBox.inlineSize);
const backingHeight = Math.max(1, dprBox.blockSize);
if (canvas.width !== backingWidth) canvas.width = backingWidth;
if (canvas.height !== backingHeight) canvas.height = backingHeight;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
onMetrics({ cssWidth, cssHeight, dpr, backingWidth, backingHeight });
return;
}
const box = entry.contentBoxSize?.[0];
const cssWidth = box?.inlineSize ?? entry.contentRect.width;
const cssHeight = box?.blockSize ?? entry.contentRect.height;
apply(cssWidth, cssHeight, window.devicePixelRatio || 1);
});

try {
observer.observe(canvas, { box: "device-pixel-content-box" });
} catch {
observer.observe(canvas);
}

// resolution 查询绑的是「当时的 dpr」。变一次之后必须换新的 MediaQueryList,
// 否则从 2 再变到 1.5 不会再通知。
let dprQuery: MediaQueryList | undefined;
const onDpr = (): void => {
const rect = canvas.getBoundingClientRect();
apply(rect.width, rect.height, window.devicePixelRatio || 1);
bindDprQuery();
};
const bindDprQuery = (): void => {
dprQuery?.removeEventListener("change", onDpr);
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
dprQuery.addEventListener("change", onDpr);
};
bindDprQuery();

return () => {
observer.disconnect();
dprQuery?.removeEventListener("change", onDpr);
};
}

Safari 对 device-pixel-content-box 的支持晚于 Chromium。observe 抛错就回退 content-box。DPR 变化用 matchMedia('(resolution: …dppx)') 比只听 resize 更准——拖显示器时窗口尺寸可能不变。

visualViewport.scale !== 1 时(移动端 pinch),getBoundingClientRect 与视觉视口会分叉。命中必须用事件的 clientX/Y 相对 canvas 的 rect,不要自己再乘一遍 visualViewport.scale

4. 内存与上限

backing store 字节约 width * height * 4(忽略预乘与对齐)。DPR=3 的 1920×1080 视口 ≈ 5760×3240×4 ≈ 74MB 一张。再叠离屏层、ImageBitmap 缓存,移动端会直接杀进程。

策略:

  • DPR 封顶:Math.min(window.devicePixelRatio, 2) 往往肉眼可接受。
  • 离屏层按 CSS 像素 × 封顶 DPR 分配,不要按「显示器理论最大值」。
  • 不可见 canvas width = 0 或从 DOM 拿掉,释放 backing store。

5. 失败形态

症状原因
Retina 发糊canvas.width 等于 CSS 宽度,没乘 DPR
命中全偏指针按 CSS 算,path 按 backing 存,或反过来
resize 后样式丢失canvas.width 重置了 CTM/font/clip
1px 线发灰半像素,未按 backing 对齐
移动端 OOM多层 × 3x DPR 未封顶

权威资料

核对日期:2026-08-26