1. 为什么移动端的 1px 会“胖”成 2px 甚至 3px
1.1 从 DPR 说起:CSS 像素和物理像素的换算关系
做移动端 H5 的老前端,应该都遇到过这个现象:设计稿上用放大镜看,明明是根头发丝一样的分隔线,切到手机上却变成了一道肉眼可见的“粗杠”。第一次遇到,我以为是设计师标错了,后来排查一圈才发现,问题不在设计稿,也不在切图,而在 CSS 里那个看起来人畜无害的1px。
要搞清楚这个问题,先得把“物理像素”和“CSS 像素”分开。屏幕上的每一个发光点,叫物理像素,这是硬件层面确定的;浏览器布局用的单位,叫 CSS 像素,这是逻辑层面抽象出来的。两者之间的换算比例,就是devicePixelRatio,也就是常说的 DPR。主流手机的 DPR 通常是 2 或 3,换句话说,CSS 里写width: 1px,实际渲染时会被映射到 2 个或者 3 个物理像素。
举个例子,某台 DPR=2 的手机屏幕宽度是 750 物理像素,但浏览器视口逻辑宽度只有 375 CSS 像素。设计师给的设计稿通常按 750 宽度出图,设计稿里一条 1px 的线,真实目标是“屏幕上的 1 个物理像素”,换算成 CSS 应该是 0.5px。结果开发时直接写了border: 1px,就变成了 2 个物理像素的宽度,视觉上当然粗了一倍。DPR=3 的设备上更夸张,直接粗两倍。同一个页面在不同手机上粗细不一样,原因就在这。
1.2 为什么直接写 0.5px 不行
既然目标是 1 个物理像素,那 CSS 里写0.5px不就行了?听起来很通顺,但现实是移动端 WebView 对小数长度的支持非常敷衍。CSS 规范确实允许非整数长度,规范是规范,浏览器不落实也是白搭。
iOS 的 Safari 从某一代开始支持0.5px,部分安卓 Chrome 也能识别,但大量安卓 WebView,尤其是国内厂商定制内核,遇到0.5px直接四舍五入取整。取整的结果有两种:一种把它当成 1px 渲染,那等于没解决问题;另一种更坑,直接忽略,边框没了。我在某台低端安卓机上就见过“下边框消失”的现象,排查了很久才发现是0.5px被 WebView 吞掉了。
那有人说,我不写小数,写width: 1px; height: 1px去做一条分隔线行不行?也不行。这个1px依旧是 CSS 像素,在 DPR=2 或 3 的屏幕上照样会被映射成 2 个或 3 个物理像素。说到底,问题的瓶颈在于:我们想让浏览器在“一个 CSS 像素”里只画“一分之一格”或“三分之一格”,但常规的 border 和宽高都做不到这种亚像素级控制。
2. 方案选型:主流的几类解决办法
2.1 一张表格看清主流方案的原理与优缺点
我在项目里前前后后试过好几种方案,各有各的适用场景。先放一张对比表,把主流的做法、原理和坑一次性说清楚。
| 方案 | 核心原理 | 优点 | 坑点 | 适用场景 |
|---|---|---|---|---|
| 直接写 0.5px | 利用 CSS 小数长度 | 实现最简单,代码量最小 | 安卓 WebView 兼容性差,可能取整或丢失 | 仅 iOS 内部页面,不推荐通用 |
| 伪元素 + transform: scale | 生成 1px 伪元素后按 DPR 缩放 | 兼容性好,视觉精度高,不影响布局 | 需注意伪元素定位、overflow 裁剪、圆角适配 | 通用首选,适合列表分隔线 |
| border-image | 用图片做边框 | 实现简单,也能做出渐变边框 | 颜色改起来麻烦,不支持圆角,不能动态响应主题 | 固定颜色、固定样式的边框 |
| linear-gradient | 背景渐变填充一半像素 | 无需伪元素,不占用标签 | 只能画一条固定边,透明度控制差 | 列表底部横线、背景装饰线 |
| SVG hairline | 内联 SVG 画 0.5 单位线条 | 颜色可控,兼容较好 | 数据 URI 编码繁琐,圆角处理难 | 作为伪元素方案的替代 |
| viewport 缩放 | 把整个视口缩到物理像素粒度 | 全局所有 1px 都自动变细 | 页面整体缩放,字体和图片会受影响 | 老项目迁移,新项目不建议 |
这张表是我这几年实践下来的精简总结。核心思路就一条:想办法让边框在渲染时只占用 1 个物理像素,而不是 1 个 CSS 像素。
2.2 我推荐的首选方案:伪元素 + transform: scale
如果非要说一个“最稳的通用方案”,我的首选是伪元素 +transform: scale。原理不复杂:先用伪元素画一条和普通1px一样宽的边框,然后把整个伪元素放大到 200%,再通过transform: scale(0.5)缩回一半。由于transform是在合成层做缩放,不参与文档流布局,也不会引发重排,最终渲染出来的线就是物理像素级别的细线。
直接看代码,最简单的下边框:
.cell { position: relative; } .cell::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: #e5e5e5; transform: scaleY(0.5); transform-origin: 0 0; }这里的transform: scaleY(0.5)会把这条高度为 1px 的横线在 Y 轴方向上压缩一半,在 DPR=2 的设备上正好变成 1 个物理像素。transform-origin: 0 0必须写,否则默认按元素中心缩放,位置会整体偏移。
如果要画四周边框,可以用另一个写法,把整个伪元素放大两倍再缩回来:
.box { position: relative; } .box::after { content: ""; position: absolute; left: 0; top: 0; width: 200%; height: 200%; border: 1px solid #e5e5e5; transform: scale(0.5); transform-origin: 0 0; box-sizing: border-box; pointer-events: none; }这个写法的好处是一次性把四边框全部处理完,适合卡片、面板这类需要完整描边的元素。缺点也和transform缩放有关:父元素如果设置了overflow: hidden,伪元素超出部分会被直接裁掉,边框肉眼可见地“缺了一块”。这个问题我后面单独讲。
2.3 特殊场景备选:SVG 和 linear-gradient
伪元素方案虽好,但有些场景它并不合适。比如边框颜色要支持运行时动态切换且不能引入额外标签,又比如伪元素被::before和::after都占用的时候,这时候可以考虑 SVG hairline。
SVG 思路是用内联 SVG 作为background-image,SVG 内部画一条stroke-width="0.5"的线,再配合background-size: 100% 1px控制位置。因为 SVG 渲染时会按物理像素做抗锯齿处理,所以在 Retina 屏幕上也能得到清晰的细线。但实际用起来比较麻烦:颜色要写死在 data URI 里,想换颜色得重新编码;SVG 作为背景也只能画固定的边,圆角接缝处基本没法处理。所以我一般只把它当备选。
linear-gradient的思路更讨巧。它不画真正的 border,而是利用背景渐变在 1px 高度的区域内只填充一半:
.separator { background-image: linear-gradient(to bottom, transparent 0%, transparent 50%, #e5e5e5 50%, #e5e5e5 100%); background-size: 100% 1px; background-repeat: no-repeat; background-position: bottom; }这段样式的效果是:背景区域高度只有 1px,但下半部分是有颜色的。在 DPR=2 的屏幕上,这个 1px 的高度对应 2 个物理像素,颜色只覆盖了下面一半,也就是 1 个物理像素。视觉上就是一条细线。这个方案胜在简单,不产生额外元素,适合在长列表里批量画分隔线;短板是颜色和透明度调整起来别扭,也不能像伪元素那样随意控制四条边。
2.4 旧项目常见的 viewport 缩放方案,为什么新项目不推荐
还有一类方案不是针对某一条边框,而是“全局治理”。思路是把整个 viewport 的宽度设置成物理像素宽度,再让页面等比缩放。因为页面整体被缩小了,原本 1px 的 CSS 像素在物理屏幕上就只占 1 个物理像素。这个方案前几年很流行,配套的还有 px 自动转 rem 的工具链。
我当时接手的一个老项目,就用了这套思路,页面里所有 1px 边框确实都自动变细了,省了很多改样式的功夫。但代价也很明显:页面缩放会连带影响字号、图片尺寸,而且viewport动态修改之后,横竖屏切换、页面回退时的缩放状态经常丢,还容易出兼容性 bug。新项目我基本不碰这套方案,老老实实用伪元素方案,虽然每个边框都要处理一下,但可控性高得多。
3. 实战封装:一套可复用的 hairline 工具类
3.1 核心 mixin 与全局类
方案选好了,剩下的问题就是怎么避免每个地方都复制粘贴一遍伪元素样式。我在公司团队里做过一套hairline工具类,后来几个项目直接复用,效果不错。核心是一个 SCSS mixin,支持画上、下、左、右、四边五种方向。
@mixin hairline($direction: bottom, $color: #e5e5e5) { position: relative; &::after { content: ""; position: absolute; left: 0; top: 0; width: 200%; height: 200%; border: 0; box-sizing: border-box; pointer-events: none; transform: scale(0.5); transform-origin: 0 0; @if $direction == top { border-top: 1px solid $color; } @else if $direction == right { border-right: 1px solid $color; } @else if $direction == bottom { border-bottom: 1px solid $color; } @else if $direction == left { border-left: 1px solid $color; } @else { border: 1px solid $color; } } }用的时候,直接给目标元素加个类就行:
.order-card { @include hairline(bottom, #f0f0f0); }这套 mixin 的好处是,业务代码里看不到一堆::after和transform,视觉效果和设计稿的还原度都是统一的。产品后来要求改边框颜色,我只需要把全局变量改一遍,不用逐条去翻页面。
3.2 细节:定位、缩放原点、content、pointer-events
样式写出来容易,真正决定成败的是细节。第一个容易被忽略的是父元素定位。伪元素是绝对定位,它的包含块是最近的position非 static 的祖先元素。所以给目标元素加上position: relative必须养成习惯,少写了这一行,伪元素就会飘到祖先容器角落去,定位完全错乱。
第二个是transform-origin。默认情况下,transform: scale(0.5)是以元素中心为原点的,伪元素宽度是父元素的 200%,整体缩放后位置会偏移到右下角。只有把原点设为0 0,也就是左上角,缩放才能贴着父元素左上角进行。
第三个是content: ""。伪元素没有这个空字符串属性,根本不会渲染。这不是浏览器 bug,是 CSS 伪元素的基本规则,但踩坑的人真的不少。还有pointer-events: none,如果不加,伪元素会拦截用户的点击事件,列表项点击失效、按钮 click 区域异常,大概率就是它导致的。
第四个是box-sizing: border-box。伪元素宽高是 200%,如果不设 border-box,画出来的边框会额外增加伪元素尺寸,四边位置会不齐。这类问题用肉眼看不出来,一放到真机上,边框对不齐的毛病就现形了。
3.3 圆角 + 细线怎么处理
上面的 mixin 有个明显短板:不支持圆角。原因是伪元素是矩形,scale(0.5)之后四个角还是直角,而原元素是圆角的话,伪元素的角会凸出来,视觉上非常明显。处理圆角时,必须把圆角半径也按比例放大,等于原圆角的两倍。
@mixin hairline-rounded($radius: 8px, $color: #e5e5e5) { position: relative; &::after { content: ""; position: absolute; left: 0; top: 0; width: 200%; height: 200%; border: 1px solid $color; border-radius: $radius * 2; transform: scale(0.5); transform-origin: 0 0; box-sizing: border-box; pointer-events: none; } }为什么圆角半径要乘 2,不用死记。因为整个伪元素被scale(0.5)缩了一半,伪元素原本16px的圆角,缩放后视觉上正好是8px,和原元素一致。这个方案我在订单卡片上用过,效果比直接写border: 1px精细得多。不过需要提醒一点:圆角元素如果同时结构复杂,内部还有背景图之类,最好先在真机上验证缩放后的接缝,偶尔会有像素级误差。
3.4 在组件库中落地时的注意事项
如果是团队里有统一组件库,我更建议把hairline做成原子类,而不是让每个开发自己写 mixin。类名的设计可以是.hairline、.hairline--t、.hairline--r、.hairline--b、.hairline--l,再用颜色变量做修饰类。业务开发时只需要在标签上追加类名,视觉一致性会好很多,产品改起样式来也快。
但组件库落地时有一个隐藏问题:一个标签同时只能有一个::before和一个::after。如果某个组件已经在用伪元素画角标、箭头之类的装饰,再叠一个hairline类,两个伪元素直接冲突,后定义的样式会把先定义的覆盖掉。我在某个跨端项目里就遇到过这种问题,按钮的角标突然消失,排查半天才发现是伪元素被边框方案占了。后来遇到这种情况,要么把装饰元素改成真实 DOM,要么用 SVG 背景代替。
另一个性能方面的建议是,长列表里的细线不要对每个 cell 都用伪元素方案。每个伪元素都会产生额外绘制,列表滚动时容易掉帧。更划算的做法是在列表容器上用linear-gradient画分隔线,一行背景搞定几百行的分割需求。
4. 实操过程:接入业务页面的完整示例
4.1 场景:列表卡片化改造
拿一个真实做过的页面来说。某个移动端订单列表,设计师要求卡片与卡片之间的分隔线必须是“头发丝级”的细线,不能是平时那种粗边框。当时页面结构很简单,每个订单是一张卡片,卡片底部一条线。一开始我图省事直接写了border-bottom: 1px solid #eee,放到 DPR=2 的测试机上,线明显比设计稿粗。
后来改用hairlinemixin,给卡片类加上@include hairline(bottom, #ececec),真机上一对比,线条终于和设计稿对上了。当时我还特意在模拟器和真机之间来回切,确认不是心理作用。模拟器因为渲染方式和真机不同,有时候看不出差别,但真机上一放大截图,两条线的物理像素宽度差异一目了然。
4.2 代码实现与视觉效果核对
完整实操代码大概是这样的:
<div class="order-list"> <div class="order-card"> <div class="order-title">订单内容</div> </div> </div>.order-card { position: relative; padding: 16px; background: #fff; &::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: #ececec; transform: scaleY(0.5); transform-origin: 0 0; } }这里我特意用了单一边的写法,而不是四边200%缩放,原因很简单:订单卡片顶部还有上一张卡片的底边,两条线如果都用四边缩放,相邻处容易叠出更粗的线。用单独一条伪元素画底部线,视觉上最干净。
写完样式后,我在 Chromium 内核的调试工具里把 DPR 模拟成 3,看这条线的渲染宽度。原则上,DPR=3 时,height: 1px加上scaleY(0.5)后,最终高度相当于 1.5 物理像素,理论上会有一点点偏粗。但实测大多数浏览器会对scaleY做边界抗锯齿处理,取整到最近的物理像素边界,视觉上依然是一条清晰细线。因此如果遇到 DPR=3 的设备上线条似乎不够细,可以调成scaleY(0.3333)精确匹配,不过正常情况下scaleY(0.5)已经够用了。
4.3 关键验证点:真机调试与截图对比
样式写完不等于任务结束,真机验证这步不能省。我一般会抽查三个环境:iOS 内置浏览器、安卓 Chrome、安卓微信内置 WebView。三条线在这三个环境里偶尔会有差异,尤其是早期安卓 WebView 对transform的合成层处理不够精细,线条会出现虚边。
验证技巧是:在真机上打开页面,截图后用图片工具放大到 200%,数一下“分隔线区域”有几个物理像素的着色行。如果是 1 个物理像素,那么截图放大后只看到一行的边界清晰颜色;如果是 2 个,说明1px直接按 CSS 像素渲染了,方案没生效。
这个方法听起来原始,但比任何调试工具的标注都可靠。我当时靠这个办法排查出某个旧版安卓 WebView 里transform-origin: 0 0失效的问题,后来在媒体查询里给该版本做了特殊处理,才最终稳定。
5. 常见问题与排查技巧实录
5.1 我踩过的一些典型坑
先说一个最常见的:父元素overflow: hidden把伪元素裁掉了。伪元素缩放方案里,伪元素的宽高是父元素的 200%,父级一旦overflow: hidden,超出的部分会直接消失。表现就是边框只显示一半,另一半像被切断一样。这个坑在带圆角并且内部可能要裁剪内容的卡片上尤其常见。解决方法是把overflow: hidden放在更外层,或者改用单边伪元素方案。
第二个坑是相邻边框合并后变粗。页面有多个连续卡片,每个卡片底部都有hairline,两个卡片的底边相邻时,上一张的底边和下一张的底边叠在一起,视觉上又变成了一条粗线。我实际处理时,要么给第一个卡片取消底边,要么用列表容器统一画分隔线,要么在第二个卡片上加一个负的margin-top: -1px抵消重叠。
第三个坑是圆角 + 四边伪元素方案导致的四角“猫耳朵”。这个前面提过,不设置圆角半径乘以 2 的话,伪元素直角会从原元素圆角边缘戳出来,非常难看。而且这个现象在浅色背景下不明显,一旦边框颜色深,比如黑色、深蓝色,问题就特别醒目。
第四个坑是安卓低版本 WebView 上transform: scale(0.5)之后的线会发虚。原因是合成层绘制时没有正确处理亚像素抗锯齿。我当时的解决方式是把单边线的background从#e5e5e5改成略微加深的颜色,视觉上“找补”回来一点,因为虚边会让颜色看起来偏浅,深一档颜色在视觉上更接近原设计。
第五个坑是 rem 方案和 transform 混用时的精度问题。如果项目里用 rem 做适配,1px可能被自动转成0.01rem之类的值,转回来时出现小数位,再叠加scaleY,线就软了。解决思路是把这个 mixin 里的长度单位强制用 px,不让后处理工具做转换。
5.2 问题速查表
把上面这些经验整理成一个速查表,方便复查。
| 现象 | 排查方向 | 解决方案 |
|---|---|---|
| 边框只有一半,像被切断 | 父级是否 overflow: hidden | 把 overflow 放到外层,或换单边伪元素 |
| 连续卡片之间的线变粗 | 相邻伪元素重叠 | 去掉其中一条,或加负 margin 抵消 |
| 圆角元素四角有直角露出来 | 伪元素没有设置对应圆角 | 圆角半径乘以 2,再配合 scale(0.5) |
| 线条发虚、颜色偏浅 | WebView 合成层抗锯齿问题 | 加深颜色,或微调缩放比例 |
| 边框消失 | 安卓 WebView 吃掉 0.5px | 不要写 0.5px,统一用伪元素方案 |
| 列表点击失效 | 伪元素挡住 pointer | 加 pointer-events: none |
| 线条位置偏了 | transform-origin 没设 | 设置 transform-origin: 0 0 |
5.3 关于“要不要用 JS 判断 DPR”的思考
很多老教程会教你用window.devicePixelRatio判断是否是 Retina 屏,然后动态加类:
if (window.devicePixelRatio >= 2) { document.documentElement.classList.add('hairlines'); }我在早期项目里也这么干过,后来发现其实没有必要。原因是伪元素 +transform方案在 DPR=1 的普通屏幕上会显得过细,但当前开发环境基本遇不到这种设备了,模拟器例外。与其用 JS 判断设备类型,不如把它做成媒体查询,让旧环境自动回退到普通边框,新环境启用细线:
.border-line { border-bottom: 1px solid #e5e5e5; } @media (min-resolution: 2dppx) { .border-line { border-bottom: none; } .border-line::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: #e5e5e5; transform: scaleY(0.5); transform-origin: 0 0; } }这样的好处是干净、无 JS、无兼容性分支。我在后续项目里都改成这种写法,少维护一段脚本,也少了很多“为什么真机上没生效”的疑问。
1px 边框做了这么多年,本质上就是一个“CSS 像素和物理像素打架”的问题。我在实际项目里越来越深的体会是,技术方案不是越炫越好,稳定、可复用、团队里每个人都能理解,才是关键。如果你现在正被“线粗一格”折磨,不妨直接抄本文的 mixin 和媒体查询写法,先跑通再说。后续如果设计有了新要求,比如渐变边框、双色边框,再在这个基础上迭代也来得及。