3
头图

前言

最近遇到一个这样的问题,在一些机型上的loading转圈动画看起来有点抖,转起来像个椭圆,心想会不会是这个icon宽高不同造成的,但看了一眼代码里面宽高写的是一样,按理来说这个loading应该是一个正圆,旋转起来不应该抖才是的。

比如这样:

<div class="w-20px h-20px border-rd-50% loading"></div>

宽高相等的一个正圆,旋转起来看着怪怪的。事实上这是由于rem单位转换导致出现的小数像素(亚像素)问题

可以看到0.2rem计算过后的值为19.72px,这样就出现了亚像素,但是它宽高依然还是相等的,旋转起来也不应该出现抖动的现象🤔

这应该跟浏览器的渲染有关系,计算出来的像素为小数,那么对于小数像素浏览器是如何进行渲染的?

CSS值的处理过程

CSS值的定义到最终渲染实际上会经过一系列的步骤,这一过程在W3C Recommendation中有介绍,整个过程一共分为6步:

  • 声明值:应用于元素的每个属性都会为它提供一个声明值,当然也可能存在多个,比如在多个样式表中重复声明
  • 级联值:这一步其实就是在计算样式属性的权重,从而得到一个权重最高的值
  • 指定值:它一般等于级联值或者默认值,继承属性用的继承值 inherit,非继承属性将用初始值 initial,也可以显式的设置 initial/inherit/unset 等关键字,从而保证每个元素上的每个属性都存在一个值
  • 计算值:这一步是为CSS计算得出的值,转为需要使用的像素值(色彩值等等),注意这里最终得到的是绝对单位,比如rem在这一步就会转换成px
  • 使用值:获取计算值并完成所有剩余计算的结果,使其成为文档格式化中使用的绝对理论值。
  • 实际值:使用值原则上可以直接使用,但用户代理可能无法在给定环境中使用该值。例如,用户代理可能只能渲染具有整数像素宽度的边框,因此可能必须近似于已使用的宽度。此外,元素的字体大小可能需要根据字体的可用性或font-size-adjust属性的值进行调整。实际值是进行任何此类调整后的已使用值。
属性声明值级联值指定值计算值使用值实际值
font-sizefont-size: 1.2em1.2em1.2em14.1px14.1px14px
widthwidth: 80%80%80%80%354.2px354px

亚像素

像素是成像面的基本单位也是最小单位,通常被称为图像的物理分辨率。如果成像系统要显示的对象尺寸小于物理分辨率时,成像系统是无法正常辨识出来的。亚像素是一种抽象概念,用于以逻辑像素的分数表示渲染对象的位置或大小,主要用于布局和命中测试。当前实现将值表示为 1/64 像素的倍数。这使我们能够使用整数数学并避免浮点不精确。尽管布局计算是使用 LayoutUnits 完成的,但绘制时的值仍与整数像素值对齐,以与设备像素对齐。

在使用em, rem这样的相对单位时, 浏览器计算出来的px很可能不是整数,进而在一些显示设备上出现亚像素渲染问题,比如:圆形变椭圆、图片显示不全有切割、元素之间有缝隙等

浏览器如何计算亚像素

比如,我们在页面上写了一条0.3px的线,那么浏览器的计算值是多少?

.line {
  width: 100px;
  height: 0.3px;
  margin-top: 30px;
  background-color: black;
}

那么这个值最终是怎么算出来的呢?文档上好像没有特别说明浏览器的亚像素计算方式,估计是各浏览器的实现都有所不同。

以Google浏览器为例来验证亚像素的计算方式:

比如上图的0.3px,得到的计算值为0.296875,而Google浏览器亚像素表示为1/64 的像素

0.3*64 = 19.2  // 得到了0.3px对应的亚像素

Math.floor(0.3*64) * 64   // 再将得到的亚像素向下取整后再转为像素,刚好等于0.296875

但是我发现如果都按这种计算方式,有些亚像素算出来的值是有偏差的。

比如0.9px,浏览器的计算值为0.898438px

这种就不能以向下取整再转像素,而是要把小数位取到0.5再转像素

0.9*64 // 57.6

// 转为 57.5

57.5 / 64  // 再转为小数,得到的值为0.8984375

所以结论就是:(Google)

  • 小数位像素先转为亚像素后得到的值不超过0.5的向下取整后,再转为像素
  • 小数位像素先转为亚像素后得到的值超过0.5的将小数位取到0.5,再转为像素

亚像素与像素对齐方案

对于亚像素与像素的对齐webkit内核会有两种对齐方案:

上图中,灰色格子代表物理像素,蓝色区域表示亚像素计算值,黑色区域表示最终 亚像素 -> 像素 的对齐结果。

enclosingRect

x: floor(x)
y: floor(y)
maxX: ceil(x + width)
maxY: ceil(y + height)
width: ceil(x + width) - floor(x)
height: ceil(y + height) - floor(y)

这种方式采用的是向上取整的方式来与物理像素对齐,保证能完全覆盖渲染的物理像素,这个方案只在少部分地方用到,如渲染svg,为了保证盒子能完整包裹矢量图。

这种方式可能会导致盒模型溢出的风险。

pixelSnappedIntRect

x: round(x)
y: round(y)
maxX: round(x + width)
maxY: round(y + height)
width: round(x + width) - round(x)
height: round(y + height) - round(y)

这种方式则是采用四舍五入的方式来对齐离自己最近的一个物理像素,但整体上来看并不是简单的四舍五入,而是需要考虑相邻元素之间的占位与补充。

这种方式的好处是能够保证最终渲染的物理大小不超过原来的大小,使得在屏幕等分出现小数的情况也不会溢出到下一行。

浏览器是如何渲染亚像素的

上面我们了解了浏览器是如何计算出亚像素的,但是亚像素只会出现在浏览器的计算值中,但浏览器绘制时的值仍需要与整数像素值对齐,以保证与设备像素对齐,当与设备像素对齐时,边缘将与最近的像素对齐,然后相应地调整大小。这可确保底部/右侧边缘和总宽度/高度最多相差一个。

获取元素宽高的一些方法:

  • getComputedStyle: 返回一个对象,该对象在应用活动样式表并解析这些值可能包含的任何基本计算后报告元素的所有 CSS 属性的值。(计算值)
  • offsetWidth/offsetHeight:返回一个元素的布局宽高,但需要注意的是这个属性将会 round(四舍五入) 为一个整数。
  • getBoundingClientRect:这个值按理来说应该对应的是使用值,但也不能当成实际值

从开发者角度我们好像并不能直接通过JS去获取到元素的真实渲染宽度,也就是上面CSS值处理过程中提到的实际值

那怎么去验证浏览器是怎样去渲染亚像素的呢?这个时候可以上设计工具了:PS、figma等等都可以...

<div class="outer_box">
    <div ref="innerBox" class="inner_box" v-for="index in 5" :key="index">
        {{ list[index-1]?.computedWidth }} - {{ list[index-1]?.offsetWidth }}
    </div>
</div>
nextTick(() => {
    innerBox.value?.forEach((item: HTMLElement) => {
        list.value.push({
            computedWidth: getComputedStyle(item).width,
            offsetWidth: item.offsetWidth,
        })
    })
})

可以得到这样一个内容:

把它导入figma中进行测量:

得到第一个矩形的宽度为82px

前两个总宽度为165px,所以可以得出第二个矩形的宽度为83px

依此类推,我们可以测量出这五个矩形的真实渲染宽度分别为:82px、83px、82px、83px、82px

82+83+82+83+82 = 412 总宽度正好等于屏幕宽度412px。

那么浏览器是按什么规律来这样渲染的呢?

  • 第一个矩形原本宽度为82.3984px,按四舍五入取整,实际渲染宽度为82px,但是在逻辑空间上第一个矩形占据了第83个像素中的0.3984px的位置,所以下一个元素在绘制时应该加上这部分
  • 那么第二个矩形的宽度就变成了82.3984 + 0.3984 = 82.797 ,按四舍五入取整,实际渲染宽度为83px。但在逻辑空间上这里应该会空出83 - 82.797 = 0.203px
  • 所以第三个矩形会先填满上一个空出的0.203px,那么相当于宽度减少0.203px为82.3984 - 0.203 = 82.195px ,按四舍五入取整,实际渲染宽度为82px,但是在逻辑空间上它又会占据后一个像素的0.195px的位置,同理下一个元素在绘制时也会加上这一部分
  • 那么第四个矩形的宽度就变成了82.3984 + 0.195 = 82.593 ,按四舍五入取整,实际渲染宽度为83px,同样逻辑空间上会空出83 - 82.593 = 0.407px
  • 第五个矩形会先填满上一个空出的0.407px,相当于宽度为82.3984 - 0.407 = 81.991px,按四舍五入取整,实际渲染宽度为82px

很明显这里采用的是pixelSnappedIntRect方案来对齐渲染的。

结论

亚像素引发的典型问题

  1. 图形失真

    • 正圆变椭圆、直线边缘模糊、图标锯齿化
    • 动画旋转时抖动(如Loading图标呈现"椭圆旋转"效果)
    • 极细边框(如0.3px)因舍入归零导致消失
  2. 布局崩塌

    • 相邻元素小数像素累加导致间隙(如82.5px + 82.5px = 165px,但实际渲染可能为82px + 83px = 165px,产生1px错位)
    • 百分比布局中微小误差引发换行/溢出(常见于flex/grid布局)
  3. 跨端差异

    • 不同浏览器亚像素处理策略不同(Chrome 1/64精度 vs Firefox 1/60精度)
    • 高分屏缩放(如150%缩放时,12.5px计算值实际渲染为12px或13px)

亚像素问题本质是数学精度物理像素限制的根本性冲突,浏览器试图用逻辑亚像素(如1/64像素)模拟小数布局,但最终仍需将计算结果对齐到设备物理像素网格。目前来说这种问题好像并没有什么太好的办法去解决,我们应该主动规避亚像素产生


南玖
1.2k 声望111 粉丝