四个 canvas 组件,在 2 倍屏上一直是糊的

0 阅读9分钟

补最后 6 个组件的示例时发现一件事:其中 3 个是用 canvas 画的——水印和三种验证码。

顺手量了一下它们在 2 倍屏上的表现:位图和 CSS 尺寸是 1:1,也就是说一个位图像素被拉去铺满 4 个设备像素。这个组件库在 Retina 屏上一直是糊的。

位图和 CSS 尺寸是两回事

<canvas width="120" height="40"> 里,width / height 属性决定的是位图大小(120×40 个像素);而它在页面上占多大,取决于 CSS。默认两者相等,于是:

位图 120×40  →  CSS 120×40  →  在 dpr=2 的屏上占 240×80 设备像素

位图只有 120 个像素宽,却要铺满 240 个设备像素——每个像素被放大了一倍,文字边缘立刻发虚。

修法只有一句:位图乘 dpr,绘制仍用 CSS 坐标。

const dpr = Math.min(3, Math.max(1, window.devicePixelRatio || 1));
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
ctx.scale(dpr, dpr);   // 之后所有绘制照旧用 CSS 坐标

ctx.scale 把坐标系统放大,于是 fillRect(0, 0, 120, 40) 依然画在「CSS 的 120×40」区域,只是落到了 240×80 的位图上。

实测:修复前后

用 CDP 把视口设成 dpr=2,量每个 canvas 的位图尺寸和 CSS 尺寸:

组件修复前(位图 / CSS)修复后比例
ImageCaptcha120×40 / 120×40240×80 / 120×402.0
ClickCaptcha300×160 / 300×160600×320 / 300×1602.0
SliderCaptcha 背景300×160 / 300×160600×320 / 300×1602.0

修复后比例正好等于 dpr。CSS 尺寸没变,所以布局不受影响,只是位图更密。

ImageCaptcha 六位验证码:位图按 dpr 放大后,字符边缘不再发虚

为什么放大位图不会影响点击? 点击命中用的是 getBoundingClientRect() 给出的 CSS 坐标,跟位图分辨率无关。所以 ctx.scale(dpr, dpr) 之后,画的位置、点中的位置仍然一一对应——只改了「画得多细」,没改「画在哪」。这条对 ClickCaptcha 尤其关键,它的命中判定完全靠 CSS 坐标算距离。

顺带验证了刷新逻辑:点一下画布,update:code 带着新码出来,实测 M5A7FG → ND4235,刷新计数 0→1。

点图刷新后:验证码从 M5A7FG 变成 ND4235,计数 +1

禁用态的边界也顺带确认了——disabled 时点击不换码:

禁用态:点击画布不刷新,也不发 update:code

坑二:画出来的图还要管「铺贴尺寸」

水印比验证码多一层:它是画一张瓦片,再用 background-image 平铺。所以它有两个尺寸要管,少一个都会错位:

  1. 位图密度(canvas.width = cssW × dpr);
  2. 铺贴尺寸(background-size: cssW px cssH px)。

只改第一个,瓦片会以「位图像素 = CSS 像素」铺开,于是水印变成原来的两倍大;只改第二个,位图密度不够,依旧糊。

const layerStyle = computed(() => imageUrl.value ? {
  backgroundImage: `url("${imageUrl.value}")`,
  backgroundSize: `${tileSize.value[0]}px ${tileSize.value[1]}px`,
} : undefined);

实测 dpr=2:瓦片位图 568×324,background-size 284×162——位图是显示的 2 倍,正是想要的密度。

代价:每次重绘要导出一张 base64

水印的瓦片是 canvas.toDataURL() 出来的——不是「一直挂着」的位图,而是每次参数变化都重新画一遍、再导出一张 base64 塞进 background-image。

我量了这个动作的成本(同一台机器、Chrome):

瓦片尺寸toDataURL 耗时base64 体积
284×162(dpr=1)3.66 ms约 4 KB
568×324(dpr=2)4.37 ms约 9 KB

两件事值得记住:

  1. dpr=2 让 base64 体积翻倍(4→9KB),因为它编码的是位图字节数;耗时只涨 19%,因为 PNG 压缩和编码不是线性瓶颈。
  2. 每个 Watermark 实例都持有一份这个字符串。页面上放五个水印、每个 9KB,就是 45KB 常驻内存 + 五份 base64 在样式里——不算灾难,但要知道钱花在哪。

watch 监听的是 [text, rotate, gap, opacity, color] 五个 prop,任一变化都触发整轮重绘。如果这些值来自输入框(比如允许用户改水印文字),输入时每敲一个字符就重绘一次——真实项目里该在这里加一层防抖。

顺带量出来的:水印的 gap 不是间距

修 dpr 时我读了水印的瓦片计算,发现 gap 的语义和名字不太一样:

const extra = Math.ceil((textWidth + fontSize) * Math.sin(rad) * 0.5);
canvas.width  = textWidth + gapX + extra * 2;
canvas.height = fontSize * 2 + gapY + extra;

gap 直接加到瓦片尺寸上,而旋转会把文字包围盒撑大,所以要额外补 extra。结果是:实际平铺周期 = 文字宽 + gap + 2×extra,比用户设的 gap 大。

三个示例全部实测对照(文字宽用 canvas 量,公式算出的周期 vs 瓦片真实位图尺寸):

文字文字宽gap旋转extra公式预测实测瓦片
内部资料 · 张三95120×8022°21257×129257×129
机密文件56160×10030°18252×146252×146
内部资料56200×12022°14284×162284×162

三组预测与实测完全一致,误差 0。也就是说 gap 只是「额外留白」,不是「最终间距」:设 160 的 gap,实际周期是 252,多出来的 36px 全在补偿旋转包围盒。

基础水印:长文本 + 默认间距,斜向平铺

自定义样式:红色、-30°、gap 160×100——实际周期 252×146

低透明度 + 大间距:0.06 透明度、200×120 间距,实际周期 284×162

坑三:canvas 里的东西,读屏看不见

三个验证码都是 canvas:字符画在画布里,DOM 里只有一个 <canvas>。这对读屏软件等于「一块空白」。

水印做对了一半——它把图层标了 aria-hidden="true",因为水印本来就是防截图、不是给读屏念的;但验证码是给用户操作的对象,它需要可访问名称说明「这里要做什么」,否则视障用户面对的就是一个没有标签的方块。

这部分我没在这次改动里补(要给三个验证码各加 aria-label 与操作说明),已记进待办。

ClickCaptcha 放进表单:标签 + 画布 + 状态回显,但画布本身没有可访问名称

两个容差:30px 与 10px

canvas 组件把「画」和「判定」都揽在自己身上,所以容差是个必须显式决定的数字。

ClickCaptcha 的命中断言(点选验证码):

let best = 30 * 30;              // 半径 30px 的平方
for (const p of placed.value) {
  const d = (p.x - x) ** 2 + (p.y - y) ** 2;
  if (d < best) { best = d; hit = p; }
}

三个细节:

  1. 比的是平方距离,省掉每次循环的 Math.sqrt——字符最多二十来个,省得不多,但这是命中检测的惯例写法;
  2. 半径 30px 比字符本身大一圈(字符约 24px),因为手指点不精确;
  3. x = e.clientX - rect.left 取的是 CSS 坐标,所以 dpr 改动完全不影响它——位图放大 2 倍,判定坐标系没变。

SliderCaptcha 的容差是另一个数字:TOLERANCE = 10,即拼图块与缺口的水平距离在 10px 内算通过。10px 对拖动来说其实挺紧——手指拖动时最终位置很难精确到 ±10px,这也解释了为什么滑块验证码总让人想再拖一次。

暗色主题下的 ClickCaptcha 与 ImageCaptcha

SliderCaptcha:拼图块与缺口从同一张背景图裁切,禁用态不可拖动

推演:换成 SVG 会不会更好

很自然的想法:不用 canvas,用 SVG,就没有 dpr 问题了。

分组件看:

组件换 SVG结论
水印可行,<pattern> 平铺文字但字体度量要依赖 DOM 测量,且用户改字号要重排
ImageCaptcha可行,<text> 直接画反而更好:文字可选中、可被读屏读到(虽然验证码不该被读)
ClickCaptcha可行同上
SliderCaptcha不行,要从同一张图裁切像素必须用位图

也就是说 SVG 能解决清晰度,但解决不了「裁切像素」的需求。SliderCaptcha 的拼图块就是「背景图的一个矩形区域」,这天然属于 canvas/位图。

所以现在的方案是对的:canvas 负责绘制、CSS 负责尺寸,两边都按 dpr 对齐。

水印的一个正确示范:不挡点击、不进读屏

水印的实现里有两行小设置,值得单独说:

.rn-watermark__layer {
  position: absolute; inset: 0;
  pointer-events: none;   /* 不挡住下面内容的点击 */
  z-index: var(--rn-z-index-overlay);
}
<div class="rn-watermark__layer" aria-hidden="true"></div>
  • pointer-events: none:水印铺满整个容器,如果不加这行,下面所有按钮、链接都点不动;
  • aria-hidden="true":水印是防截图用的视觉标记,念给读屏没有意义,反而会打断阅读。

这两条对验证码恰好相反:验证码必须能点(不能有 pointer-events: none),而且必须被读屏「感知到它存在」——至少要知道「这里有一道验证、需要完成」。所以 canvas 的可访问性没有统一答案,取决于这个 canvas 是装饰还是交互。

推演:能不能不用 canvas 画水印

一个常见的偷懒做法是 CSS 渐变平铺:

background-image: repeating-linear-gradient(45deg, rgba(0,0,0,.06) 0 2px, transparent 2px 80px);

它确实能得到斜条纹、零 JS、零 base64。但只能画线条,画不了文字——水印的核心价值是「能追溯到谁」,需要用户名或部门名,这就必须走文本绘制。

所以这类组件的技术选型没什么余地:要文字 → 必须 canvas(或 SVG)→ 就必须自己处理 dpr。

另一个选择是 SVG <pattern>:清晰度天生正确(矢量),但文字定位要依赖 DOM 测量(getBBox),且 Safari 对 pattern 里的 <text> 有历史渲染差异。相比之下 canvas 的 measureText 行为统一、代码更短——代价就是本文这一整套 dpr 处理。

一段能自查的代码

任何 canvas 组件,判断它有没有按 dpr 绘制,控制台一行就够:

const c = document.querySelector("canvas");
({
  dpr: devicePixelRatio,
  bitmap: c.width,
  css: c.getBoundingClientRect().width,
  ratio: c.width / c.getBoundingClientRect().width,
});
// ratio === dpr  → 正确
// ratio === 1    → 高分屏上被放大 dpr 倍

我这次就是先用这段确认了 ratio: 1,才去改代码的。改完再跑一遍,ratio 变成 2,才算修好。

顺带说下为什么 dpr 上限取 3:dpr 4 以上的设备(部分安卓机)如果照实放大,瓦片位图面积是 1x 的 16 倍,toDataURL 的 base64 会涨到 30KB 以上,而肉眼看不出差别。3 是个「够清晰、不失控」的经验值——不是物理上限,是成本上限。

结论

canvas 组件很容易「在开发机上看起来没问题」——因为大多数笔记本是 2x 屏,但浏览器窗口缩放、外接显示器、截图工具都可能让 dpr 变成 1 或 1.5,问题只在某些设备上暴露。

这次一共修了四个组件(水印 + 三个验证码),全部用同一套模式:位图 × dpr、坐标不动、铺贴尺寸显式声明。验证方式也不是看截图,而是量 canvas.width / getBoundingClientRect().width 是否等于 devicePixelRatio。

顺带把 P1 的示例补齐做完了:36 个组件的示例缺口清零,全部 860 个用例通过。

文档页整体:水印的三个示例(基础 / 自定义 / 低透明度)


在线示例:ui.ruanno.top/components/… 源码:gitee.com/ruanno-top/…