文章

移动端 1px 边框、安全区与交互兼容性

1px 线为什么在 Retina 上变粗、安全区变量为什么取出来全是 0、点击为什么有延迟和穿透、 弹窗后面的页面为什么会跟着滚——四类移动端细节题的成因、可落地写法和两个插件实测陷阱。

移动端 1px 边框、安全区与交互兼容性

一句话概括

移动端真正难的从来不是”页面能不能跑”,而是这四类细节:

  1. 设计稿上的 1px 线,在 DPR=2/3 的屏上变成 2~3 个物理像素,看起来比设计稿粗。
  2. 刘海屏的安全区,env(safe-area-inset-*) 取出来全是 0,写了等于没写。
  3. 点一下要等 300ms 才响应,以及蒙层关掉后”点透”了下层按钮。
  4. 弹窗打开后手指一滑,背后页面跟着一起滚。

这四个看着不挨着,本质是同一件事:移动端的”像素”和”交互”都不止一层,浏览器在中间替你做了大量默认处理。 你不认识这些默认值,就会踩坑。

面试为什么爱问?因为它筛的是”你是把移动端当缩小版 PC 网页写,还是真的在真机上修过 bug”。答这种题最忌讳只说结论,一定要把”默认行为是什么、为什么这么设计、失效条件是什么”讲出来。

核心知识点

1. 1px 边框:变粗的根因,和五种写法的取舍

先说根因,一句话:CSS 的 1px 是 1 个 CSS 像素,不是 1 个物理像素。 DPR=2 时 1 CSS 像素 = 2×2 个物理像素,所以 border: 1px 在屏幕上实际是一条 2 物理像素宽的线。DPR=3 就是 3 个。

那”我直接写 0.5px 不就行了?”——这是最直觉也最不可靠的做法:

1
2
3
4
/* ❌ 看起来最短,实际最不稳 */
.box {
  border: 0.5px solid #ddd;
}

iOS 8 之后基本能画出来,但安卓端历史上大量内核会把 0.5px 四舍五入成 0,线直接消失。同一份代码在一半机器上有线、另一半没线,这就是灾难。

下面五种写法,按”能不能上生产”排序:

写法原理致命问题
① border: 0.5px直接交给内核画半像素安卓部分内核舍入成 0,线消失
② 伪元素 + transform: scale(0.5)用 1px 画出线,再整体缩小一半需要配 transform-origin,漏了会偏移
③ box-shadow: 0 0.5px 0 0 #ccc用阴影当线半像素阴影会被抗锯齿摊开,颜色比设定值淡
④ background-image: linear-gradient渐变做细线改色要改代码,多色/多边很啰嗦
⑤ border-image + SVG矢量描边圆角、border-radius 支持差,要额外资源

结论:日常用 ②,单边分隔线可以用 ④(或 ③ 但接受偏淡),只在需要矢量细节时上 ⑤。① 只能当渐进增强,不能当主方案。

② 的标准写法(四边 + 圆角):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.hairline {
  position: relative;
}

.hairline::after {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 200%;               /* ✅ 先把"画布"放大一倍 */
  height: 200%;
  box-sizing: border-box;    /* ✅ 让 border 算进 200% 里面 */
  border: 1px solid #ddd;
  border-radius: 16px;       /* ✅ 设计值 8px 的 2 倍,缩放后才是 8px */
  transform: scale(0.5);     /* ✅ 整体缩一半 → 1px 线视觉上变 0.5 CSS px */
  transform-origin: 0 0;     /* ✅ 必须配左上角,否则缩小后位置会飘 */
  pointer-events: none;      /* ✅ 伪元素别挡住点击 */
}

四个细节全踩过才算真会:

  • transform-origin 必须和缩放原点配对。 只做下边框时写 transform: scaleY(0.5); transform-origin: left bottom;——origin 是”缩放时哪个点不动”,不写默认 50% 50%(居中),线会往中间收。
  • border-radius 要写设计值的 2 倍,因为你是在 200% 的画布上画,缩完才还原。
  • 圆角边框不要用 box-shadow 或 linear-gradient,它们做不出圆角跟随的 1px 线。
  • 别用子元素 div 画线。伪元素不占布局、不进 DOM 查询;插一个 <div class="line"> 既污染结构,又会被 flex/grid 当成一个真实子项参与布局。

还有一个不显眼但要命的坑:transform 不是 none 的元素,会成为后代 position: fixed 的包含块。 也就是说这个伪元素的父元素如果挂了 transform,里面所有 position: fixed 的下拉、弹层会突然变成”相对这个元素定位”而不是相对视口。1px 方案本身给伪元素加 transform 没问题(伪元素通常没子元素),但如果你用同一个类给容器加了 transform,就会连带炸掉整个页面里所有 fixed 定位。

配 PostCSS 时的实测陷阱(这条最容易漏)

上面方案里的 width: 200% 没事,但 border: 1px 和 height: 1px 会被 PostCSS 插件顺手转掉。本地实测(postcss-pxtorem,rootValue: 75):

1
2
3
4
5
/* 输入 */
.b::after { height: 1px; transform: scaleY(0.5); }

/* ❌ propList:['*'] 但没设 minPixelValue(默认 0)→ 产物 */
.b::after { height: 0.01333rem; transform: scaleY(0.5); }

1px 变成了 0.01333rem,而 rem 会被运行时不断改的根字号影响——你的”1 物理像素”线会随屏幕宽度变粗变细,彻底失去意义。修法是 minPixelValue: 2(小于 2px 的值不转)。

顺带一个确定性结论:transform: scaleY(0.5) 里的 0.5 是无单位数字,插件不碰它。所以 1px 方案里唯一需要防的是带 px 的那几个值。

顺带一个几乎没人注意的 at-rule 陷阱

transform: scale() 只在 DPR ≥ 2 时有意义,所以大家会包一层媒体查询。但两个主流插件的处理方式不一样,本地实测(附源码依据):

1
2
@media (min-width: 375px) { .m { width: 100px; } }
@supports (padding: env(safe-area-inset-bottom)) { .s { width: 100px; } }
插件配置@media 体的声明@media 的条件@supports 体的声明
postcss-pxtorem(默认)转 → 1.33333rem不转 → 375px转
postcss-pxtorem + mediaQuery:true转转 → 5rem转
postcss-px-to-viewport(默认)整块跳过,一律不转不转整块跳过,一律不转
postcss-px-to-viewport + mediaQuery:true转 → 13.33333vw不转转

px-to-viewport 的这个行为,源码里就一行:它判断的是 rule.parent.params 存不存在,!params || (params && mediaQuery)——选项名叫 mediaQuery,实际门禁的是”所有带参数的 at-rule”,@supports 和 @layer 一起被挡。

后果很具体:如果你打算用 @supports (padding: env(safe-area-inset-bottom)) 给安全区写降级样式,那么在 px-to-viewport 项目里,这一整块 CSS 的 px 都不会被转成 vw,于是这个区块会和其他区域尺寸体系不一致。

2. 安全区:viewport-fit=cover 是前提,fallback 是保险

env() 读的是”用户代理定义的环境变量”,安全区只是它最常见的用途。四个变量:safe-area-inset-top / -right / -bottom / -left。

它取不到值的原因有且只有两个,记住这两条就够了:

原因一:没写 viewport-fit=cover。 这是前提条件,不是可选项。不写的话 iOS 会默认把页面限制在安全区域内,四个值全部返回 0,你写的 padding 一点效果都没有。MDN 对 viewport-fit 的说明也很明确:只有 cover 会让页面铺到刘海、圆角屏幕的边缘,并配合安全区变量使用。

1
2
<!-- ✅ 前提:必须写 cover -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

原因二:没给 fallback,整条声明在”计算值阶段”失效。 MDN 原文的意思是:含 env() 的声明在解析阶段一律视为合法,真正校验延迟到计算值阶段——变量替换完(或没找到变量就用 fallback)才检查。如果值非法且没有 fallback,这条声明不会被忽略掉,而是回落到属性的初始值或继承值。这句话翻译成人话:

1
2
3
4
5
/* ❌ 没有 fallback:在不支持该变量的浏览器上,padding-bottom 会变成 0,你的基础间距也没了 */
.footer { padding-bottom: env(safe-area-inset-bottom); }

/* ✅ 有 fallback:变量不存在时用 0px,基础间距照旧 */
.footer { padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }

注意 calc() 的用法:不要把 env() 当成全部间距,要当成”在基础间距上再加一点”。因为安全区在各种设备上真的可能是 0——没有刘海的手机是 0、iPad 是 0、桌面浏览器是 0。只写 env() 的下场是:有刘海的机器正好,没刘海的机器贴边。

三个配套要点:

  • constant() 是 iOS 11.0~11.2 的旧写法,写在 env() 前面,靠后者的层叠覆盖前者。项目里如果还留着 constant(),顺序写反就白写了。
  • 横屏时刘海跑到侧边,safe-area-inset-left/right 变成非 0(大约 44~48px)、top 变小。只调 padding-top/bottom 的布局,手机一转屏内容就钻到刘海底下了。四个方向都要管。
  • 绝不要硬编码 34px / 44px。那些数字只对某一类机型成立:有 Home 键的 iPhone 底部是 0、iPad 是 20px、桌面是 0。你硬编码的数字迟早会在某台机器上变成一块莫名的空白。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* ✅ 完整姿势:四边都管,且都能退化成 0 */
.safe-pad {
  padding-top: calc(12px + env(safe-area-inset-top, 0px));
  padding-right: calc(12px + env(safe-area-inset-right, 0px));
  padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
  padding-left: calc(12px + env(safe-area-inset-left, 0px));
}

/* ✅ 吸底 bar:高度撑开,内容自己也往上抬,否则会被 Home 指示条压住 */
.tabbar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  padding-bottom: env(safe-area-inset-bottom, 0px);
  background: #fff;
}

3. 300ms 延迟与点击穿透:一个已消失的问题,一个没消失的问题

300ms 延迟的成因:早期移动浏览器给 touchend 到 click 之间加了 300~350ms 的等待,用来判断用户是不是要双击缩放。

它的现状是”官方已经修掉了”,这点最容易被背错。 Chrome 官方博客写得很清楚:从 Chrome 32(2014 年) 起,只要页面带 width=device-width 的 viewport meta,这个延迟就被移除,而且双指捏合缩放仍然保留(不用牺牲无障碍)。Firefox / IE/Edge 随后跟进,iOS 在 9.3(2016 年 3 月)也上了同样的修复。

所以面试正确答法是:

300ms 延迟是双击缩放的历史包袱,现在基本已经不存在了——只要写了 width=device-width,Chrome 32、iOS 9.3 之后都移除了这个延迟。所以现在不需要引入 FastClick 这类库;真要针对老设备,可以用 touch-action: manipulation,但 Safari 不支持,所以 viewport meta 才是首选方案。

点击穿透的成因(这个是真实存在的,跟延迟是两件事):

移动端事件顺序是 touchstart → touchmove → touchend → click,click 排在最后。假设 A 在 B 上面,A 在 touchstart(或 touchend)的回调里把自己隐藏了——但浏览器随后还会照常派发一次 click,此时 A 已经不在文档里,这个 click 就落到了底下的 B 上。如果 B 是个链接或绑了 click,就会”莫名其妙”地跳转/触发。

注意这里的要害不是”300ms”,而是“隐藏上层”和”派发 click”分属两个不同的事件阶段。老设备上这两步之间还隔着 300ms(看起来特别像”延迟导致”),现在延迟没了,穿透照样发生——只是时序更紧凑,更难排查。

复现的三个条件缺一不可:上层用 touch 系事件关闭自己 + 下层绑 click + 两层是重叠的。

解法:

1
2
3
4
5
6
7
8
9
10
11
// ❌ 用 touchstart 代替 click:滑动也会触发,且必然引发穿透
overlay.addEventListener('touchstart', () => { overlay.hidden = true })

// ✅ 方案一(首选):一律用 click,别用 touch 替代它,穿透就不成立
overlay.addEventListener('click', () => { overlay.hidden = true })

// ✅ 方案二:确实要在 touch 里处理时,阻止该事件后续的 click 派发
overlay.addEventListener('touchend', (e) => {
  e.preventDefault()          // 阻止浏览器补发 click
  overlay.hidden = true
})

方案二有个必须知道的坑:touchstart 和 touchmove 在 window、document、body 这三个”根目标”上默认是 passive 的(Chrome 56 起为了不阻塞滚动)。passive 监听器里调 preventDefault() 不但无效,控制台还会警告。所以:

  • 上面例子里用的 touchend 不在默认 passive 之列,preventDefault() 可以直接生效。
  • 但如果你的方案是拦 touchstart / touchmove(滚动穿透常用),就必须显式声明 { passive: false },否则代码看着对、实际一点作用没有:
1
2
// ✅ passive: false 才会让 preventDefault() 生效
el.addEventListener('touchmove', onMove, { passive: false })

“为什么不用 touchstart 全面替代 click”这个追问要能答上:touchstart 是手指碰到屏幕就触发,用户想滑动时也会先触发它——把点击逻辑挂在 touchstart 上,滑动会误触发。这也是为什么组件库(Vant 等)始终以 click 为主。

4. 滚动穿透:overflow: hidden 在 iOS 上为什么不管用

现象:弹窗打开后手指在弹层(或遮罩)上滑,背后的页面跟着滚;关掉弹窗发现页面位置变了。

根因分两层:

第一层,iOS Safari 上给 body 设 overflow: hidden 拦不住触摸滚动。 这不是 bug 而是移动端滚动模型的产物——触摸滚动不完全走”滚动容器能不能滚”这套判断,还有橡皮筋回弹(rubber-band)在推着页面动。

第二层,滚动链(scroll chaining)。 弹层自己滚到底之后,滚动继续传给祖先滚动容器,也就是文档本身。MDN 对 overscroll-behavior 的定义就是为这件事准备的:

  • auto(默认):正常行为,含滚动链。
  • contain:本元素的回弹效果保留,但不会传给相邻滚动区;同时会禁用下拉刷新和左右滑返回这类浏览器原生手势。
  • none:既不传滚动链,也去掉本元素的回弹效果。

MDN 里有一句关键说明,是”用 CSS 锁背景”的源码级依据:一个没有可滚动溢出的滚动容器(比如 overflow: hidden 的元素)永远被认为处于滚动边界,所以给它设 contain / none 就能阻止滚动链传给它上面的祖先。这正是”弹窗打开时给背景层设 overscroll-behavior: contain“能生效的原因。

工程上可靠的组合是三件事一起做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1)锁位置:记下当前 scrollY,用 fixed 把 body 钉住
let savedY = 0

function lockScroll() {
  savedY = window.scrollY
  document.body.style.position = 'fixed'
  document.body.style.top = `-${savedY}px`      // ✅ 负 top 补回滚走的那段
  document.body.style.width = '100%'
}

function unlockScroll() {
  document.body.style.position = ''
  document.body.style.top = ''
  document.body.style.width = ''
  window.scrollTo(0, savedY)                    // ✅ 必须还原,否则页面跳到顶部
}
1
2
3
4
5
6
7
8
9
10
11
12
/* 2)断滚动链:弹层自己吃掉溢出,不往上传 */
.modal {
  overscroll-behavior: contain;
  overflow-y: auto;                 /* ✅ 弹层内容超长时自己能滚 */
  max-height: 90vh;
  -webkit-overflow-scrolling: touch;
}

/* 3)兜底:遮罩层不参与任何手势 */
.backdrop {
  touch-action: none;               /* ⚠️ 只加在遮罩,加到弹层上会让弹层自己滚不动 */
}

三个必须说出口的细节:

  • position: fixed 会把页面顶到 scrollY = 0,所以一定要存 savedY 并在解锁时 scrollTo 回去,否则关弹窗页面会跳走——这是”滚动穿透”修完最常见的第二个 bug。
  • overscroll-behavior 只解决”滚动链”,不解决”元素自己能不能滚”。 用户直接滚背景(不是从弹层滚到底传过去),它一点忙都帮不上。所以它是配合项,不是唯一解。
  • 桌面端还会遇到滚动条消失导致的布局抖动(padding-right 补偿即可),移动端看不见滚动条,不用管。

5. 顺手要记住的几个移动端默认值

这几个不算”问题”,但面试里经常被当成追问,答不上来很掉分:

默认行为说明
user-scalable 默认 yes设 no 会挡住低视力用户,WCAG 要求至少支持 2× 缩放;且 iOS 10+ 默认忽略这条规则
点击高亮安卓/iOS 默认给可点元素加一层半透明高亮,去掉用 -webkit-tap-highlight-color: transparent
长按选中不该被选中的按钮上加 user-select: none,但正文别加(用户要复制)
-webkit-overflow-scrolling: touchiOS 弹性滚动的老开关,iOS 13+ 已默认,基本可以不再写
输入框聚焦缩放iOS 上输入框字号 < 16px 会自动放大页面。正解是把字号提到 16px 以上,不是禁缩放

其实你每天都在用

  • 列表每行下面那条灰线,在 iPhone 上比安卓上”实”,就是两边内核把 1px 画成不同物理像素数的结果。
  • 微信里打开的 H5,顶部标题栏和底部安全区常常让你觉得”这块空白是浪费”——那是 viewport-fit=cover 加 env() 的正常表现,去掉就会顶到刘海。
  • 拼多多、淘宝那种”全屏铺满 + 内容避开刘海”的页面,就是 cover + 四个方向的 env()。
  • 抖音/微信的底部 Tab 栏比设计稿高一点点,那多出来的部分就是 env(safe-area-inset-bottom)。
  • 早期项目里那句 FastClick.attach(document.body),现在基本可以从代码里删掉了——Chrome 32 / iOS 9.3 之后已经不需要。
  • 手机上点一个按钮”要按下去一会儿才变色”,如果是老项目,多半是没用 viewport meta 而是靠 FastClick 硬撑。
  • 打开一个弹窗,手指一滑背后页面动了、关掉后位置还变了,就是滚动穿透 + 没还原 scrollY 两件事叠在一起。
  • 在手机上想复制一段文字,结果选中了一整块卡片,是容器上 user-select: none 加宽了。
  • 点输入框时整个页面被放大了一格,退出又不缩回去——输入框字号小于 16px 的经典后果。

常见误解(FAQ)

❌ 误区1:”border: 0.5px 就是 1px 问题的标准答案。” 它是”最直觉”的答案,不是标准答案。安卓端历史上大量内核会把 0.5px 舍入成 0,线直接消失;同一份代码在不同机器上表现不一致。生产环境主方案应该是伪元素 + transform: scale()。

❌ 误区2:”安全区变量取出来是 0,是浏览器不支持。” 绝大多数情况是你没写 viewport-fit=cover。不写这个值,iOS 就默认把页面限制在安全区内,四个变量自然全是 0——变量支持得好好的,只是没有非零值可报。

❌ 误区3:”写了 env() 就一定有间距。” env() 在桌面、无刘海手机上就是 0。所以正确写法是 calc(基础间距 + env(..., 0px)),而不是只用 env();同时别忘了 fallback——MDN 明确说没有 fallback 且值非法时,声明会回落到初始值/继承值,你的基础间距也会一起丢。

❌ 误区4:”现在还有 300ms 延迟,所以要上 FastClick。” 已经不需要了。Chrome 32(2014)起对带 width=device-width 的站点移除了延迟,iOS 9.3(2016-03)也修了,而且没有牺牲捏合缩放。今天还引 FastClick 属于典型的”跟着老博客写代码”。

❌ 误区5:”点击穿透是因为有 300ms 延迟,延迟没了穿透也没了。” 两件事。穿透是 touch 事件和 click 事件混用的产物:touchstart 里把自己隐藏,之后派发的 click 落到了下层元素上。就算延迟是 0,只要上层用 touch、下层用 click,穿透照样发生。

❌ 误区6:”给 body 加 overflow: hidden 就能锁住背景滚动。” 桌面端基本够用,iOS Safari 上不可靠,触摸滚动照样能带滚页面。可靠做法是 position: fixed + 记录/还原 scrollY,再配 overscroll-behavior: contain 断掉滚动链。

❌ 误区7:”overscroll-behavior: contain 能防止背景滚动。” 它只防滚动链(弹层滚到底后继续传上去),防不住用户直接在背景层上滑动。而且要记住 MDN 那条前提:它只对滚动容器生效,<iframe> 不是滚动容器,设在 iframe 上无效——要控 iframe 的滚动链,得设在 iframe 文档的 html 和 body 上。

❌ 误区8:”1px 方案的 CSS 里那个 1px 不用管,插件不会转。” 会转。本地实测 postcss-pxtorem 在 propList: ['*'] 且 minPixelValue 用默认值 0 时,height: 1px 会被转成 0.01333rem,而根字号是运行时变的——你的”1 物理像素线”会随屏幕宽度变粗细。必须配 minPixelValue: 2。

❌ 误区9:”给元素加 transform 只影响它自己。” transform 不是 none 的元素会成为后代 position: fixed 的包含块。给一个容器加 transform(哪怕只是为了做 1px 线),页面里所有 fixed 定位的下拉、弹层都会改成相对它定位。

一句话总结

移动端细节题的底层是同一件事——浏览器做了很多默认处理:1px 是”CSS 像素”而非物理像素,所以要用伪元素 + transform: scale(0.5) 并且别让 PostCSS 把那个 1px 转走;安全区变量只在 viewport-fit=cover 下才有非零值,且必须写 fallback 并四边都管;300ms 延迟已经被 width=device-width 修掉了,真正要修的是 touch/click 混用导致的点击穿透和 iOS 上靠 overflow: hidden 锁不住的滚动穿透。

本文由作者按照 CC BY 4.0 进行授权