你也许能画出 0.5px,也许不能(feat. DPR)

profile image

为什么 0.5px 边框在某些设备能显示、在另一些却不行?理解 DPR(设备像素比)就明白了。

本帖由 Jetbrains's Coding Agent Junie junie logo翻译。如有任何翻译错误,请告知我们!

做前端开发时,设计稿里经常会出现 0.5px 边框。但即便你写了 border-width: 0.5px,打开开发者工具往往还是显示为 1px。难道浏览器画不出 0.5px 吗?奇怪的是,在某些环境里又能精确渲染 0.5px。

关键差异在于设备像素比 —— DPR。

什么是 DPR(Device Pixel Ratio)?

DPR 表示渲染 1 个逻辑像素所使用的物理像素数量,本质上是一个缩放比:

它描述了“屏幕上展示 1 个逻辑像素需要多少个物理像素”。

text
DPR = 物理像素数 ÷ 逻辑像素数

物理像素(Physical Pixel)

物理像素是构成显示屏的真实硬件像素,是发光的最小单位。

例如,若 iPhone 16 Pro 的屏幕分辨率为 1206 x 2622,就表示横向有 1206 个、纵向有 2622 个物理像素。

逻辑像素(Logical/CSS Pixel)

逻辑像素是我们在 CSS 中使用的抽象单位。我们在代码里写 width: 100px 时,这个 px 指的就是逻辑像素。

逻辑像素的目的是在不同物理分辨率的设备之间提供一致的视觉尺寸。得益于此,开发者无需关心设备的物理分辨率差异,也能用同一套 CSS 适配多种设备。

继续上面的例子,iPhone 16 Pro 的逻辑分辨率是 402 × 874。把物理像素(1206 x 2622)除以逻辑像素(402 × 874),正好等于 3,也就是说该设备的 DPR 为 3。

不同 DPR 下的像素映射

理解了 DPR 后,我们看看在不同 DPR 下,1 个逻辑像素如何映射到物理像素:

  • DPR 1(常见显示器):CSS 1px = 物理 1px
    • 1 个逻辑像素用 1 个物理像素渲染
  • DPR 2(MacBook 视网膜屏):CSS 1px = 物理 2x2px(共 4 个)
    • 1 个逻辑像素用 4 个物理像素渲染
  • DPR 3(iPhone Pro 等):CSS 1px = 物理 3x3px(共 9 个)
    • 1 个逻辑像素用 9 个物理像素渲染

dpr-example.png

那 0.5px 什么时候能画出来?

DPR 1(常见显示器)

在普通显示器上,1 个逻辑像素用 1 个物理像素表示。那要如何画 0.5px 呢?物理上不存在“半个像素”,像素是最小不可分单位。

浏览器会检测到这一点,然后进行四舍五入或抗锯齿(变模糊)处理。所以你看到的不是极细的 0.5px,而可能是 1px 的粗线或一条模糊的线。

text
0.5px(逻辑) × DPR 1 = 0.5px(物理,不可能)→ 四舍五入为 1px

DPR 2(MacBook 视网膜)

在 DPR 为 2 的设备上情况就不同了。1 个逻辑像素可以用 2×2 的物理像素网格表示。

text
0.5px(逻辑) × DPR 2 = 1px(物理,可行!)

把 0.5 个逻辑像素乘以 2 的 DPR,正好得到 1 个物理像素。也就是说,它可以被一个完整的物理像素表示。因此在 DPR ≥ 2 的设备上,0.5px 能被正确渲染。

浏览器缩放与 DPR

有趣的是,改变浏览器缩放比例也会改变 window.devicePixelRatio(不同浏览器可能略有差异)。

javascript
// 在一台 DPR 1 的设备上
// 100% 缩放
console.log(window.devicePixelRatio); // 1
// 200% 缩放
console.log(window.devicePixelRatio); // 2
// 50% 缩放
console.log(window.devicePixelRatio); // 0.5

Chromium 浏览器在缩放时改变 DPR 的行为,之前也有相关讨论:

Update window.devicePixelRatio on browser zoom (content upscaling)

浏览器缩放的工作原理

浏览器缩放会改变 CSS 像素本身的物理尺寸。比如在 200% 时,每个 CSS 像素占据的物理面积变为原来的 2 倍。

重要的是,元素的 CSS 像素数值并不会改变。一个被定义为 width: 128px 的元素,在 200% 缩放时仍然是 128 个 CSS 像素。改变的是每个 CSS 像素的物理占用面积。

  • 100%:CSS 1px = 物理 1px(基准)
  • 200%:CSS 1px = 物理 2x2px(每个 CSS 像素放大为 4 倍面积)
  • 50%:CSS 1px = 物理 0.5x0.5px(每个 CSS 像素面积缩小为 1/4)

0.5px 受缩放的影响如何?

text
100%(DPR 1):0.5px × 1 = 0.5px(物理)→ 四舍五入为 1px ✗
200%(DPR 2):0.5px × 2 = 1px(物理)→ 可以渲染 ✓

也就是说,放大到 200% 后,有效 DPR 变为 2,从而可以呈现 0.5px。

相反地,在视网膜屏(DPR 2)上缩小到 50% 时,有效 DPR 会降到 1。此时 0.5 个逻辑像素又变成 0.5 个物理像素(无法表示),于是你会像在普通显示器上一样失去清晰的 0.5px 渲染。

DPR 与图片渲染

DPR 不仅影响边框等 UI 元素,也对图片渲染很重要。

高密度屏与图片清晰度

假设在一台 DPR 2 的视网膜屏上以 300x300px 的大小展示一张图片。浏览器会用 600x600px 的物理区域来表示这 300 个 CSS 像素。

如果图片文件本身只有 300x300px 会怎样?浏览器必须把它放大到 600x600 的物理区域,于是就会变模糊。

text
CSS 300px × DPR 2 = 物理 600px
但图片文件是 300px → 需要放大 → 模糊

为避免这种情况,应按不同 DPR 提供匹配尺寸的图片资源。

  • DPR 1:300x300px 足够
  • DPR 2:提供 600x600px(2×)
  • DPR 3:提供 900x900px(3×)

srcset 与浏览器的自动选择

用户使用的设备 DPR 各不相同。如果总是提供最大图,低密度屏用户会下载过大的文件;如果只提供小图,高密度屏上就会显得模糊。

HTML 的 srcset 能解决这个问题。

html
<img
  srcset="
    photo-320w.jpg,
    photo-480w.jpg 1.5x,
    photo-640w.jpg 2x
  "
  src="photo-640w.jpg"
  alt="描述"
  style="width: 320px"
/>

在上面的代码中:

  • 图片通过 CSS 以 320px 显示
  • srcset 定义了多个分辨率

浏览器会根据设备的 DPR 自动选择最合适的图片:

  • DPR 1 设备:加载 photo-320w.jpg(1x 可省略)
  • DPR 1.5 设备:加载 photo-480w.jpg
  • DPR 2+ 设备:加载 photo-640w.jpg

在高密度屏(DPR ≥ 2)上,会加载更大的图片文件以获得更清晰的画质。

Next.js 的 Image 组件如何工作

Next.js 的 <Image> 组件会自动替你处理上述原理。

typescript
<Image
  src="/photo.jpg"
  width={300}
  height={300}
  alt="描述"
/>

Next.js 会在内部生成多种尺寸并自动构建 srcset。开发者无需手动准备多个版本,也不必亲自书写 srcset

小结

我最初只是好奇:“为什么 0.5px 画不出来?”理解了 DPR 之后,不仅明白了这个问题,也理解了为什么同样的 CSS 会在不同设备上呈现不同效果、为什么高密度屏上的图片看起来反而模糊、以及 srcset 存在的意义。


参考