在 Web 开发中,HTML <canvas>
元素为我们提供了强大的图形绘制能力。但很多开发者在使用 Canvas 时,会遇到一个令人困惑的问题:为什么我明明按照 CSS 设定的尺寸来绘制,最终结果却模糊、错位,或者显示不全? 尤其是在 Retina 或其他高分辨率(HiDPI)屏幕上,这个问题更是雪上加霜。
这背后隐藏的"秘密"就是 Canvas 元素的两个尺寸概念 ,以及不可忽视的设备像素比(Device Pixel Ratio, DPR) 。理解并掌握它们的完美同步,是实现像素级清晰 Canvas 绘制的关键。
Canvas 的两个"身份":内部分辨率 vs. 外部大小
要理解 Canvas 绘制的问题,首先要明白它有两个截然不同的尺寸属性:
-
Canvas 内部绘图表面尺寸 (The Drawing Surface Size)
- 定义方式 :通过
<canvas>
标签上的 HTML 属性width
和height
来设置,例如<canvas width="300" height="150"></canvas>
。 - 作用 :这定义了 Canvas 内部有多少个逻辑像素点 供你绘制。当你使用
ctx.fillRect(0,0,10,10)
这样的指令时,操作的就是这个内部的像素网格。你可以把它想象成一张图片本身的分辨率。 - 默认值 :如果不在 HTML 属性中指定,默认是
width="300"
和height="150"
。
- 定义方式 :通过
-
Canvas 元素在 DOM 中的渲染尺寸 (The Rendering Size)
- 定义方式 :通过 CSS 属性
width
和height
来设置,例如canvas { width: 100%; height: 200px; }
。 - 作用 :这决定了 Canvas 元素在网页布局中实际占据的物理空间大小 。这是你在浏览器中用肉眼看到的 Canvas 大小,也是通过
canvas.clientWidth
和canvas.clientHeight
获取到的值。 - 关系 :浏览器会将 Canvas 内部的绘图表面拉伸或压缩到这个 CSS 定义的渲染尺寸上。
- 定义方式 :通过 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 通常是
2
、3
,甚至1.5
(就像我们之前遇到的情况)。这意味着 1 个 CSS 像素实际上由多个(例如 2x2 或 1.5x1.5)物理像素组成,目的是为了让屏幕显示更细腻。
- 普通屏幕 (Standard-DPI):DPR 通常是
DPR 带来的挑战:如果你只是简单地让 Canvas 的内部尺寸等于其 CSS 渲染尺寸(例如都是 300x100),那么在 DPR 为 2 的屏幕上,这 300x100 个 Canvas 内部像素会被浏览器拉伸到 600x200 的物理像素区域上。结果就是,原本清晰的线条变得模糊,文字不再锐利,因为每个"逻辑像素"都被强制放大到了多个物理像素。
完美同步的奥秘:Canvas 适配 DPR 的策略
要彻底解决 Canvas 的模糊、错位和右侧留白问题,我们需要一个策略,让 Canvas 的内部绘图表面 与它在屏幕上实际显示的物理像素 1:1 对应,同时又保持我们用 CSS 像素单位进行绘图的便利性。
核心步骤如下:
-
获取 Canvas 的实际 CSS 渲染尺寸: 这是你的 Canvas 元素在浏览器布局中实际占据的空间大小,是用户肉眼看到的尺寸。
JavaScript
iniconst canvas = sliderCanvas.value; // 获取 Canvas DOM 元素 const cssWidth = canvas.clientWidth; // 例如:300px const cssHeight = canvas.clientHeight; // 例如:100px
提示:在 Vue 的
onMounted
钩子中获取clientWidth
/clientHeight
时,最好使用nextTick
,以确保 DOM 布局已完全稳定。同时,为了响应式布局,要监听window.resize
事件并重新执行绘制。 -
获取设备的像素比 (DPR) :
JavaScript
iniconst dpr = window.devicePixelRatio || 1; // 例如:1.5
-
动态调整 Canvas 的内部绘图表面尺寸 (HTML 属性) : 这是最关键的一步。我们将 Canvas 的 HTML
width
和height
属性设置为其 CSS 渲染尺寸乘以 DPR。JavaScript
inicanvas.width = cssWidth * dpr; // 例如:300 * 1.5 = 450 canvas.height = cssHeight * dpr; // 例如:100 * 1.5 = 150
现在,Canvas 内部有了 450x150 个逻辑像素,这些像素正好能够 1:1 地映射到 300x100 CSS 像素在高 DPR 屏幕上对应的物理像素区域。
-
缩放 Canvas 2D 绘图上下文 (Context) : 在调整了 Canvas 的内部尺寸后,
ctx
的坐标系也相应变大了dpr
倍。为了让我们在后续的绘图指令中仍然能够以直观的 CSS 像素单位 (例如,绘制一个 100px 宽的矩形)进行操作,我们需要将ctx
的坐标系反向缩放dpr
倍。JavaScript
iniconst ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);
现在,当你调用
ctx.fillRect(0, 0, 300, 100)
时,ctx
会自动将其内部绘制成300*dpr
x100*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 中的实际大小动态调整,确保内容始终正确填充。
我的文章如果对您有所启发,希望您能点赞支持一下哦