Canvas 里的字为什么总是发糊:devicePixelRatio 与三层缩放

举报
yd_222021590 发表于 2026/11/10 21:01:55 2026/11/10
【摘要】 同一段文字 DOM 锐、画进 Canvas 就虚:问题不在字体,而在位图尺寸与 CSS 尺寸的账没对上。

Canvas 里的字为什么总是发糊:devicePixelRatio 与三层缩放

同一段文字,用 DOM 渲染是锐的,画进 Canvas 就发虚;在普通屏上还能忍,换到高分屏或者把窗口拖到另一块显示器,边缘直接糊成一片。

多数人第一反应是调字体、换 font-weight、开 image-rendering,全都无效。因为问题不在字体,而在你告诉 Canvas 的尺寸,和它真正拥有的像素数量对不上。

一、两层像素:CSS 像素与物理像素

<canvas width="600" height="400"> 里的 600×400 是位图尺寸,也就是"我真的有 600×400 个像素可以画"。

而 CSS 里的 style="width:600px" 是布局尺寸,单位是 CSS 像素。在 devicePixelRatio = 2 的屏幕上,1 个 CSS 像素要占 2×2 个物理像素。

如果你只设了 CSS 尺寸,位图尺寸会沿用默认值(常见是 300×150),浏览器于是把这张小位图拉伸到 600×400 的显示区域。文字边缘被双线性插值抹平,看起来就是"糊"。

所以第一步永远是:位图 = CSS 尺寸 × dpr。

const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();

canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);

const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后继续用 CSS 像素思考

setTransform 那一行是关键:它把坐标系放大 dpr 倍,于是你后面写 fillText('hi', 10, 20) 仍然按 CSS 像素算,不用到处乘 dpr。

二、三个容易叠在一起的缩放

真正让事情变复杂的是:这层缩放不止一个来源,而且会互相叠加。

第一层是 dpr,必须补上。第二层是 CSS 的 transform: scale() 或页面的 zoom,这层不要再手动补偿——浏览器已经会把它算进 getBoundingClientRect()。第三层是导出:toDataURL()、getImageData()、drawImage() 收的都是位图坐标,不是 CSS 坐标。

常见事故:代码里写 ctx.scale(dpr, dpr),布局里又给 canvas 加了 transform: scale(2),结果 2×2 的缩放叠成 4 倍,元素位置全错,而"糊"反而变成了"内容被裁掉一半"。排查时先固定 transform: none,确认那层缩放只出现一次。

三、改尺寸会清空一切

canvas.width = ... 不只是改个数字,它会把画布重新分配并清空,同时重置变换矩阵、fillStyle、lineWidth、字体、裁剪路径——所有状态归零。

所以"响应式重绘"不能只写 resize 监听,顺序必须是:改尺寸 → 重设 transform → 重设样式 → 重画内容。反过来先画后改尺寸,等于白画。

function resize() {
  const rect = canvas.getBoundingClientRect();
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.round(rect.width * dpr);
  canvas.height = Math.round(rect.height * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.font = '16px system-ui';
  draw();
}

四、还有一个不常被提到的变化源

把窗口从笔记本屏拖到外接屏时,devicePixelRatio 会从 2 变成 1(或反过来),但不会触发 resize 事件。画布尺寸没变,dpr 变了,于是又开始糊。

可靠做法是监听 dpr 的变化本身:

let mq;
function watchDpr() {
  if (mq) mq.removeEventListener('change', onDprChange);
  mq = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
  mq.addEventListener('change', onDprChange, { once: true });
}
function onDprChange() {
  resize();
  watchDpr(); // 重新挂一次,因为 dpr 已经是新值
}

五、最后两个细节

半像素对齐:在 dpr 缩放之后画 1px 的线或小字号文字,落点常常压在像素缝隙上,颜色被摊到两个像素里,看着又虚又浅。整数坐标、或干脆把线宽设成 ≥2 再画,比反复调 lineJoin 有效得多。

文字仍糊但图形清晰:那多半是字体还没加载完就画了。首次绘制发生在 web 字体就绪之前,Canvas 会用回退字体画一遍且不会自动重画,需要在字体加载完成后主动重绘一次。

检查清单

  1. canvas.width/height 是否等于 CSS 尺寸 × dpr
  2. 是否用 setTransform 把坐标系也放大,且只放大一次
  3. CSS 里有没有额外的 transform: scale 或 zoom
  4. 改尺寸之后,变换与样式是否重设了
  5. 是否监听了 dpr 变化(换屏、系统缩放调整)
  6. 字体加载完成后有没有重绘

按这六条过一遍,Canvas 的文字基本就和 DOM 一样锐了。绝大多数"糊"都不是渲染引擎的问题,而是尺寸账算错了一位。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。