Canvas 绘制模糊?那是你没搞懂 DPR!

在 Web 开发中,HTML <canvas> 元素为我们提供了强大的图形绘制能力。但很多开发者在使用 Canvas 时,会遇到一个令人困惑的问题:为什么我明明按照 CSS 设定的尺寸来绘制,最终结果却模糊、错位,或者显示不全? 尤其是在 Retina 或其他高分辨率(HiDPI)屏幕上,这个问题更是雪上加霜。

这背后隐藏的"秘密"就是 Canvas 元素的两个尺寸概念 ,以及不可忽视的设备像素比(Device Pixel Ratio, DPR) 。理解并掌握它们的完美同步,是实现像素级清晰 Canvas 绘制的关键。


Canvas 的两个"身份":内部分辨率 vs. 外部大小

要理解 Canvas 绘制的问题,首先要明白它有两个截然不同的尺寸属性:

  1. Canvas 内部绘图表面尺寸 (The Drawing Surface Size)

    • 定义方式 :通过 <canvas> 标签上的 HTML 属性 widthheight 来设置,例如 <canvas width="300" height="150"></canvas>
    • 作用 :这定义了 Canvas 内部有多少个逻辑像素点 供你绘制。当你使用 ctx.fillRect(0,0,10,10) 这样的指令时,操作的就是这个内部的像素网格。你可以把它想象成一张图片本身的分辨率
    • 默认值 :如果不在 HTML 属性中指定,默认是 width="300"height="150"
  2. Canvas 元素在 DOM 中的渲染尺寸 (The Rendering Size)

    • 定义方式 :通过 CSS 属性 widthheight 来设置,例如 canvas { width: 100%; height: 200px; }
    • 作用 :这决定了 Canvas 元素在网页布局中实际占据的物理空间大小 。这是你在浏览器中用肉眼看到的 Canvas 大小,也是通过 canvas.clientWidthcanvas.clientHeight 获取到的值。
    • 关系 :浏览器会将 Canvas 内部的绘图表面拉伸或压缩到这个 CSS 定义的渲染尺寸上。

问题之源 :当 Canvas 的渲染尺寸 (由 CSS 决定)与它的内部绘图表面尺寸(由 HTML 属性决定)不一致时,就会发生内容拉伸。例如,你内部只有 300x150 像素,却要显示在 600x300 的空间里,内容自然会变模糊。鼠标事件的坐标(基于 CSS 渲染尺寸)也可能因此与内部绘图坐标产生偏差。


设备像素比 (DPR) 的隐形影响

仅仅同步 Canvas 的内部尺寸和渲染尺寸还不足以解决所有问题,尤其是在高 DPI 屏幕(如手机、Retina MacBook)上。这时,设备像素比 (Device Pixel Ratio, DPR) 登场了。

  • DPR 的定义window.devicePixelRatio 返回的值,表示一个 CSS 像素 对应多少个 物理像素

    • 普通屏幕 (Standard-DPI):DPR 通常是 1 (1个CSS像素 = 1个物理像素)。
    • 高 DPI 屏幕 (HiDPI):DPR 通常是 23,甚至 1.5(就像我们之前遇到的情况)。这意味着 1 个 CSS 像素实际上由多个(例如 2x2 或 1.5x1.5)物理像素组成,目的是为了让屏幕显示更细腻。

DPR 带来的挑战:如果你只是简单地让 Canvas 的内部尺寸等于其 CSS 渲染尺寸(例如都是 300x100),那么在 DPR 为 2 的屏幕上,这 300x100 个 Canvas 内部像素会被浏览器拉伸到 600x200 的物理像素区域上。结果就是,原本清晰的线条变得模糊,文字不再锐利,因为每个"逻辑像素"都被强制放大到了多个物理像素。


完美同步的奥秘:Canvas 适配 DPR 的策略

要彻底解决 Canvas 的模糊、错位和右侧留白问题,我们需要一个策略,让 Canvas 的内部绘图表面 与它在屏幕上实际显示的物理像素 1:1 对应,同时又保持我们用 CSS 像素单位进行绘图的便利性。

核心步骤如下:

  1. 获取 Canvas 的实际 CSS 渲染尺寸: 这是你的 Canvas 元素在浏览器布局中实际占据的空间大小,是用户肉眼看到的尺寸。

    JavaScript

    ini 复制代码
    const canvas = sliderCanvas.value; // 获取 Canvas DOM 元素
    const cssWidth = canvas.clientWidth;  // 例如:300px
    const cssHeight = canvas.clientHeight; // 例如:100px

    提示:在 Vue 的 onMounted 钩子中获取 clientWidth/clientHeight 时,最好使用 nextTick,以确保 DOM 布局已完全稳定。同时,为了响应式布局,要监听 window.resize 事件并重新执行绘制。

  2. 获取设备的像素比 (DPR)

    JavaScript

    ini 复制代码
    const dpr = window.devicePixelRatio || 1; // 例如:1.5
  3. 动态调整 Canvas 的内部绘图表面尺寸 (HTML 属性) : 这是最关键的一步。我们将 Canvas 的 HTML widthheight 属性设置为其 CSS 渲染尺寸乘以 DPR。

    JavaScript

    ini 复制代码
    canvas.width = cssWidth * dpr;   // 例如:300 * 1.5 = 450
    canvas.height = cssHeight * dpr; // 例如:100 * 1.5 = 150

    现在,Canvas 内部有了 450x150 个逻辑像素,这些像素正好能够 1:1 地映射到 300x100 CSS 像素在高 DPR 屏幕上对应的物理像素区域。

  4. 缩放 Canvas 2D 绘图上下文 (Context) : 在调整了 Canvas 的内部尺寸后,ctx 的坐标系也相应变大了 dpr 倍。为了让我们在后续的绘图指令中仍然能够以直观的 CSS 像素单位 (例如,绘制一个 100px 宽的矩形)进行操作,我们需要将 ctx 的坐标系反向缩放 dpr 倍。

    JavaScript

    ini 复制代码
    const ctx = canvas.getContext('2d');
    ctx.scale(dpr, dpr);

    现在,当你调用 ctx.fillRect(0, 0, 300, 100) 时,ctx 会自动将其内部绘制成 300*dpr x 100*dpr 的物理像素,完美匹配 Canvas 的内部物理分辨率。


完整的代码示例(核心部分)

将这些原理应用到 Vue Canvas 组件中,核心代码会是这样的:

JavaScript

ini 复制代码
// ... (Vue script setup 部分) ...

const sliderCanvas = ref(null);
let ctx = null;
let dpr = 1; // 定义设备像素比

const drawSlider = () => {
  const canvas = sliderCanvas.value;
  if (!ctx || !canvas) return;

  // 1. 获取 Canvas 的 CSS 渲染尺寸
  const cssWidth = canvas.clientWidth; 
  const cssHeight = canvas.clientHeight; 

  // 2. 根据 DPR 调整 Canvas 内部绘图表面尺寸
  canvas.width = cssWidth * dpr;
  canvas.height = cssHeight * dpr;

  // 3. 缩放绘图上下文,使后续操作基于 CSS 像素单位
  ctx.scale(dpr, dpr);

  // 清除 Canvas,注意尺寸使用 CSS 尺寸,因为 ctx 已被 scale
  ctx.clearRect(0, 0, cssWidth, cssHeight); 

  // 4. 所有绘图操作都使用 CSS 尺寸进行计算和绘制
  // 例如:绘制轨道背景
  ctx.fillStyle = props.trackBgColor;
  ctx.fillRect(0, trackY, cssWidth, trackHeight); // 宽度使用 cssWidth

  // ... 绘制手柄,计算手柄位置等,都使用 cssWidth/cssHeight ...
  // 例如:手柄 X 坐标 = currentMinPercent.value * cssWidth;
};

onMounted(() => {
  ctx = sliderCanvas.value.getContext('2d');
  dpr = window.devicePixelRatio || 1; // 获取 DPR

  // 确保在 DOM 布局完成后再进行首次绘制
  nextTick(() => {
    drawSlider(); 
  });

  // 监听窗口大小变化,以实现响应式 Canvas 绘制
  window.addEventListener('resize', drawSlider);
});

onUnmounted(() => {
  ctx = null;
  window.removeEventListener('resize', drawSlider);
});

总结

通过理解 Canvas 的内部绘图表面尺寸外部渲染尺寸 以及设备像素比 (DPR) 之间的关系,并采取相应的同步和缩放策略,我们能够:

  • 消除模糊:在所有屏幕上都能绘制出锐利、清晰的图形。
  • 解决错位:鼠标事件的坐标与 Canvas 上的绘制内容完美对齐。
  • 实现响应式:Canvas 能够根据其在 DOM 中的实际大小动态调整,确保内容始终正确填充。

我的文章如果对您有所启发,希望您能点赞支持一下哦

相关推荐
RadiumAg2 分钟前
记一道有趣的面试题
前端·javascript
yangzhi_emo6 分钟前
ES6笔记2
开发语言·前端·javascript
yanlele22 分钟前
我用爬虫抓取了 25 年 5 月掘金热门面试文章
前端·javascript·面试
中微子2 小时前
React状态管理最佳实践
前端
烛阴2 小时前
void 0 的奥秘:解锁 JavaScript 中 undefined 的正确打开方式
前端·javascript
中微子2 小时前
JavaScript 事件与 React 合成事件完全指南:从入门到精通
前端
Hexene...2 小时前
【前端Vue】如何实现echarts图表根据父元素宽度自适应大小
前端·vue.js·echarts
天天扭码3 小时前
《很全面的前端面试题》——HTML篇
前端·面试·html
xw53 小时前
我犯了错,我于是为我的uni-app项目引入环境标志
前端·uni-app
!win !3 小时前
被老板怼后,我为uni-app项目引入环境标志
前端·小程序·uni-app