补最后 6 个组件的示例时发现一件事:其中 3 个是用 canvas 画的------水印和三种验证码。
顺手量了一下它们在 2 倍屏上的表现:位图和 CSS 尺寸是 1:1,也就是说一个位图像素被拉去铺满 4 个设备像素。这个组件库在 Retina 屏上一直是糊的。
位图和 CSS 尺寸是两回事
<canvas width="120" height="40"> 里,width / height 属性决定的是位图大小(120×40 个像素);而它在页面上占多大,取决于 CSS。默认两者相等,于是:
ini
位图 120×40 → CSS 120×40 → 在 dpr=2 的屏上占 240×80 设备像素
位图只有 120 个像素宽,却要铺满 240 个设备像素------每个像素被放大了一倍,文字边缘立刻发虚。
修法只有一句:位图乘 dpr,绘制仍用 CSS 坐标。
ts
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) | 修复后 | 比例 |
|---|---|---|---|
| ImageCaptcha | 120×40 / 120×40 | 240×80 / 120×40 | 2.0 |
| ClickCaptcha | 300×160 / 300×160 | 600×320 / 300×160 | 2.0 |
| SliderCaptcha 背景 | 300×160 / 300×160 | 600×320 / 300×160 | 2.0 |
修复后比例正好等于 dpr。CSS 尺寸没变,所以布局不受影响,只是位图更密。

为什么放大位图不会影响点击? 点击命中用的是 getBoundingClientRect() 给出的 CSS 坐标,跟位图分辨率无关。所以 ctx.scale(dpr, dpr) 之后,画的位置、点中的位置仍然一一对应------只改了「画得多细」,没改「画在哪」。这条对 ClickCaptcha 尤其关键,它的命中判定完全靠 CSS 坐标算距离。
顺带验证了刷新逻辑:点一下画布,update:code 带着新码出来,实测 M5A7FG → ND4235,刷新计数 0→1。

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

坑二:画出来的图还要管「铺贴尺寸」
水印比验证码多一层:它是画一张瓦片,再用 background-image 平铺。所以它有两个尺寸要管,少一个都会错位:
- 位图密度(
canvas.width = cssW × dpr); - 铺贴尺寸(
background-size: cssW px cssH px)。
只改第一个,瓦片会以「位图像素 = CSS 像素」铺开,于是水印变成原来的两倍大;只改第二个,位图密度不够,依旧糊。
ts
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 |
两件事值得记住:
- dpr=2 让 base64 体积翻倍(4→9KB),因为它编码的是位图字节数;耗时只涨 19%,因为 PNG 压缩和编码不是线性瓶颈。
- 每个 Watermark 实例都持有一份这个字符串。页面上放五个水印、每个 9KB,就是 45KB 常驻内存 + 五份 base64 在样式里------不算灾难,但要知道钱花在哪。
watch 监听的是 [text, rotate, gap, opacity, color] 五个 prop,任一变化都触发整轮重绘。如果这些值来自输入框(比如允许用户改水印文字),输入时每敲一个字符就重绘一次------真实项目里该在这里加一层防抖。
顺带量出来的:水印的 gap 不是间距
修 dpr 时我读了水印的瓦片计算,发现 gap 的语义和名字不太一样:
ts
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 | 公式预测 | 实测瓦片 |
|---|---|---|---|---|---|---|
| 内部资料 · 张三 | 95 | 120×80 | 22° | 21 | 257×129 | 257×129 |
| 机密文件 | 56 | 160×100 | 30° | 18 | 252×146 | 252×146 |
| 内部资料 | 56 | 200×120 | 22° | 14 | 284×162 | 284×162 |
三组预测与实测完全一致 ,误差 0。也就是说 gap 只是「额外留白」,不是「最终间距」:设 160 的 gap,实际周期是 252,多出来的 36px 全在补偿旋转包围盒。



坑三:canvas 里的东西,读屏看不见
三个验证码都是 canvas:字符画在画布里,DOM 里只有一个 <canvas>。这对读屏软件等于「一块空白」。
水印做对了一半------它把图层标了 aria-hidden="true",因为水印本来就是防截图、不是给读屏念的;但验证码是给用户操作的对象,它需要可访问名称说明「这里要做什么」,否则视障用户面对的就是一个没有标签的方块。
这部分我没在这次改动里补(要给三个验证码各加 aria-label 与操作说明),已记进待办。

两个容差:30px 与 10px
canvas 组件把「画」和「判定」都揽在自己身上,所以容差是个必须显式决定的数字。
ClickCaptcha 的命中断言(点选验证码):
ts
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; }
}
三个细节:
- 比的是平方距离 ,省掉每次循环的
Math.sqrt------字符最多二十来个,省得不多,但这是命中检测的惯例写法; - 半径 30px 比字符本身大一圈(字符约 24px),因为手指点不精确;
x = e.clientX - rect.left取的是 CSS 坐标,所以 dpr 改动完全不影响它------位图放大 2 倍,判定坐标系没变。
SliderCaptcha 的容差是另一个数字:TOLERANCE = 10,即拼图块与缺口的水平距离在 10px 内算通过。10px 对拖动来说其实挺紧------手指拖动时最终位置很难精确到 ±10px,这也解释了为什么滑块验证码总让人想再拖一次。


推演:换成 SVG 会不会更好
很自然的想法:不用 canvas,用 SVG,就没有 dpr 问题了。
分组件看:
| 组件 | 换 SVG | 结论 |
|---|---|---|
| 水印 | 可行,<pattern> 平铺文字 |
但字体度量要依赖 DOM 测量,且用户改字号要重排 |
| ImageCaptcha | 可行,<text> 直接画 |
反而更好:文字可选中、可被读屏读到(虽然验证码不该被读) |
| ClickCaptcha | 可行 | 同上 |
| SliderCaptcha | 不行,要从同一张图裁切像素 | 必须用位图 |
也就是说 SVG 能解决清晰度,但解决不了「裁切像素」的需求。SliderCaptcha 的拼图块就是「背景图的一个矩形区域」,这天然属于 canvas/位图。
所以现在的方案是对的:canvas 负责绘制、CSS 负责尺寸,两边都按 dpr 对齐。
水印的一个正确示范:不挡点击、不进读屏
水印的实现里有两行小设置,值得单独说:
css
.rn-watermark__layer {
position: absolute; inset: 0;
pointer-events: none; /* 不挡住下面内容的点击 */
z-index: var(--rn-z-index-overlay);
}
html
<div class="rn-watermark__layer" aria-hidden="true"></div>
pointer-events: none:水印铺满整个容器,如果不加这行,下面所有按钮、链接都点不动;aria-hidden="true":水印是防截图用的视觉标记,念给读屏没有意义,反而会打断阅读。
这两条对验证码恰好相反 :验证码必须能点(不能有 pointer-events: none),而且必须被读屏「感知到它存在」------至少要知道「这里有一道验证、需要完成」。所以 canvas 的可访问性没有统一答案,取决于这个 canvas 是装饰还是交互。
推演:能不能不用 canvas 画水印
一个常见的偷懒做法是 CSS 渐变平铺:
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 绘制,控制台一行就够:
js
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/...