移动端适配最佳实践:从像素到响应式单位

张玥 2026年9月21日 阅读时间 50分钟
移动端适配 响应式 viewport CSS单位 高清屏
前端开发技巧之移动端适配最佳实践:从像素到响应式单位

移动端适配是前端工程里最容易被低估的一块工作。它看起来只是“写个媒体查询”,实际上牵扯到物理像素与 CSS 像素的换算、视口的三重身份、九种长度单位的取舍、图片在 2x / 3x 屏上的清晰度、刘海屏的安全区域、300 毫秒点击延迟的历史包袱、以及输入框自动放大的诡异行为。更麻烦的是,这些知识点分散在规范文档、浏览器实现细节和无数篇过时的博客里,彼此还经常互相矛盾。本篇 50 分钟长文按“像素 → 视口 → 单位 → 方案 → 断点 → 图片 → 安全区 → 交互 → 性能 → 调试”的顺序,把这套体系从头到尾串一遍。每一节都配有可直接复制运行的代码、可以在页面上直接拖动体验的交互演示,以及来自真实项目的踩坑记录。读完它,你未必能记住所有 API,但你一定能在下次遇到“这个页面在 iPhone 上怎么歪了”的时候,知道该往哪个方向排查。

一、像素的三重身份:一切混乱的源头

“这个按钮宽 100 像素”——这句话在移动端几乎是没有意义的,因为“像素”至少有三个不同的含义,而它们之间的换算关系取决于设备。不理解这一点,后面所有的适配讨论都会变成玄学。

1.1 物理像素(Device Pixel)

物理像素是屏幕硬件上真实存在的发光单元。一块 1080×2340 的手机屏幕,就是横向 1080 个、纵向 2340 个物理发光点。这个数字在设备出厂时就固定了,前端代码无法直接控制它。

1.2 CSS 像素(CSS Pixel)

CSS 像素是我们在样式表里写的 px,是一个抽象的逻辑单位。规范对它的定义是“在距离观察者一臂之遥时,约等于一个视觉角度单位”。换句话说,CSS 像素的设计目标是在不同设备上看起来大小差不多,而不是和物理像素一一对应。

这就解释了一个经典疑问:为什么同样是 width: 100px 的按钮,在 4.7 英寸手机和 6.7 英寸手机上看起来差不多大,但占用的物理像素数量却不同?答案就在下面这个比值里。

1.3 设备像素比(DPR)

// 设备像素比的定义
DPR = 物理像素 / CSS 像素

// 浏览器里读取
window.devicePixelRatio; // 1 / 1.5 / 2 / 3 ...

// 举例:一台 iPhone 15 Pro
// 物理分辨率:1179 × 2556
// CSS 视口宽:393 px
// 1179 / 393 = 3 → DPR = 3

// 一个 width:100px 的按钮,在这台设备上
// 实际占用 300 个物理像素

你当前设备的读数:

devicePixelRatio = –  |  物理分辨率约 –  |  CSS 视口 –

(试着把浏览器窗口拖到不同显示器上,或者用系统缩放把比例改成 125%,这个数字会变化。)

1.4 三种像素的对照

名称 别名 由谁决定 前端能否控制
物理像素 设备像素、硬件像素 屏幕硬件 不能
CSS 像素 逻辑像素、独立像素 浏览器 + DPR 完全可控
设备独立像素 DIP / dp 操作系统 间接影响

1.5 为什么位图在高清屏上会糊

一张 100×100 的 PNG,在 DPR=1 的屏幕上正好铺满 100×100 个 CSS 像素,也就是 100×100 个物理像素,一个像素对一块。但在 DPR=2 的屏幕上,同一个 100×100 的 CSS 区域对应着 200×200 个物理像素,而图里只有 100×100 份数据——浏览器只能把每个像素点“拉大”成 4 个,于是边缘就糊了。

DPR = 1 100×100 的图 → 100×100 物理像素,1:1 清晰
DPR = 2 100×100 的图 → 200×200 物理像素,每个像素被放大 4 倍
DPR = 3 100×100 的图 → 300×300 物理像素,每个像素被放大 9 倍

解决方案有两类:一是准备多倍图(srcset,见第六节),二是尽量使用 SVG 矢量图。图标、logo、简单插画都应该优先考虑 SVG,它在任何 DPR 下都是锐利的,而且体积通常更小。

1.6 浏览器缩放与页面缩放

还有两个容易混淆的“缩放”:

  • 浏览器缩放(Ctrl + 加号):改变的是 CSS 像素与物理像素的映射关系,devicePixelRatio 会跟着变化,但布局宽度(window.innerWidth)也会变化。
  • 页面缩放(zoom 属性 / transform: scale()):zoom 会真正改变元素的布局尺寸,而 transform: scale() 只影响渲染,不影响布局占位。
推荐做法
  • 布局尺寸用 CSS 像素思考,不要去想物理像素
  • 图标优先用 SVG,位图用 srcset 提供 2x 版本
  • 1 像素细线用 transform: scaleY(0.5) 处理
常见误区
  • 用 screen.width 判断设备宽度(它返回的是 CSS 像素,不是物理像素)
  • 认为 DPR 一定是整数(安卓上常见 1.5、2.75)
  • 把所有图都导出 3 倍图,白白浪费带宽

二、viewport 元标签:移动端适配的第一道门

如果把移动端适配比作一栋建筑,那么 <meta name="viewport"> 就是地基。这一行写错,后面所有努力都会打折扣。

2.1 三种视口

布局视口 Layout Viewport —— CSS 布局所依据的宽度,document.documentElement.clientWidth
视觉视口 Visual Viewport —— 用户当前实际看到的区域,会随缩放变化,window.visualViewport.width
理想视口 Ideal Viewport —— 布局视口等于设备宽度时的状态,正是我们要追求的目标

2.2 不加 meta 会发生什么

移动端浏览器为了让桌面网页能完整显示,会默认给页面一个大约 980px 的布局视口,然后把它整体缩小塞进屏幕里。结果就是你精心设计的页面在手机上看起来像一张缩小的海报,字小得看不清,点击目标小得点不中。

<!-- 没有 meta viewport -->
// 布局视口 ≈ 980px
// 页面被整体缩小到屏幕宽度
// 16px 的字渲染出来只有约 6px 高
// 用户需要双指放大才能阅读

document.documentElement.clientWidth;
// → 980
<!-- 正确配置 -->
<meta name="viewport"
      content="width=device-width,
               initial-scale=1">

document.documentElement.clientWidth;
// → 393(iPhone 15 Pro)

2.3 content 里可以写什么

属性 含义 推荐值
width 布局视口的宽度 device-width
height 布局视口的高度 一般不写
initial-scale 初始缩放比例 1
minimum-scale 允许的最小缩放 不建议限制
maximum-scale 允许的最大缩放 不要写 1
user-scalable 是否允许用户缩放 不要写 no
viewport-fit 内容如何填充屏幕 cover(配合安全区)
interactive-widget 软键盘弹出时视口如何变化 resizes-content

2.4 关于禁止缩放:一个必须澄清的误区

很多老教程会建议写 user-scalable=no, maximum-scale=1,理由是这样能避免用户误触缩放、也能“修复” iOS 输入框自动放大的问题。但这个做法存在两个严重问题:

  • 无障碍问题:低视力用户依赖双指放大来阅读内容。禁止缩放等于剥夺了他们的基本使用能力。WCAG 2.1 明确要求页面必须允许缩放到 200%。
  • 不一定生效:iOS 10 之后,Safari 出于无障碍考虑会忽略 user-scalable=no(在部分场景下);而 Android 上的 Chrome 也逐步跟进。

正确做法是:不要禁止缩放,而是通过合理的字号、间距、以及 font-size: 16px 的输入框来避免用户“不得不”去缩放。

2.5 viewport-fit 与安全区域

iPhone X 之后,屏幕上出现了刘海和底部横条。默认情况下,浏览器会把页面限制在“安全区域”内,两侧留出空白。viewport-fit=cover 则是让页面铺满整个屏幕,然后由开发者自己用 env(safe-area-inset-*) 来避让。

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

/* 底部固定操作栏 */
.bottom-bar {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  padding: 12px 16px;
  /* 在有横条的设备上自动增加内边距 */
  padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
  /* 没有横条的设备回落为 0 */
}

env() 的第二个参数是兜底值。在不支持 env() 的老浏览器上,整个声明会失效,所以最好再写一条不带 env() 的声明在前面:

.bottom-bar {
  padding-bottom: 12px;
  padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}

2.6 interactive-widget:软键盘与视口

这是一个较新的属性,用来控制虚拟键盘弹出时视口的行为:

resizes-visual 默认值。只缩小视觉视口,布局视口不变,键盘会盖住内容
resizes-content 同时缩小布局视口,100vh 类的高度会跟着变,适合聊天输入框
overlays-content 视口完全不变,键盘浮在内容之上

三、长度单位全景:九个单位,各有各的脾气

CSS 里的长度单位远比想象中多。它们大致可以分成三类:绝对单位、相对单位、视口单位。选错单位是移动端布局“看起来差一点”的最常见原因。

3.1 绝对单位

px CSS 像素,最常用的绝对单位,不随任何东西缩放
cm / mm / in 物理长度单位。1in = 2.54cm = 96px,但在屏幕上并不精确
pt / pc 印刷单位。1pt = 1/72in ≈ 1.333px,主要用于打印样式表

实践建议:cm、mm、in 在屏幕上没有任何实用价值,因为“1 英寸等于多少物理像素”完全取决于设备,浏览器只能按固定比例换算。pt 只应该在 @media print 里出现。

3.2 相对单位:em 与 rem

em 相对于当前元素的字号,rem 相对于根元素(html)的字号。这个区别看起来微不足道,实际影响却很大。

父容器 font-size 16px
2em
2rem
32px

拖动滑块改变父容器字号:2em 的宽度会跟着变,而 2rem 和 32px 岿然不动。这就是 em 的“级联放大”特性——也是它最容易出事的地方。

3.3 em 的嵌套放大陷阱

下面这段代码是 em 最经典的翻车现场:

/* 每一层都写 font-size: 1.2em */
.level1 { font-size: 1.2em; } /* 16 × 1.2 = 19.2px */
.level2 { font-size: 1.2em; } /* 19.2 × 1.2 = 23.04px */
.level3 { font-size: 1.2em; } /* 23.04 × 1.2 = 27.6px */
.level4 { font-size: 1.2em; } /* 27.6 × 1.2 = 33.2px */

/* 四层嵌套后,字号几乎是初始值的两倍 */
/* 而设计师的本意可能只是"每一层都稍微大一点" */

所以规则很明确:做字号时用 rem,做组件内部的间距时用 em。em 在“随组件字号等比缩放”这个场景下非常好用,比如按钮的内边距——当按钮字号变大时,内边距自动跟着变大,视觉比例保持一致。

/* 好用法:间距随按钮字号等比缩放 */
.btn {
  font-size: 1rem;
  padding: 0.75em 1.5em; /* 相对自身字号 */
  border-radius: 0.375em;
}

.btn.large { font-size: 1.25rem; }
/* 字号变大,内边距自动按比例变大,无需重写 */

3.4 视口单位:vw / vh / vmin / vmax

vw 视口宽度的 1%,100vw 等于整个视口宽度
vh 视口高度的 1%,100vh 等于整个视口高度
vmin vw 和 vh 中较小的那个,适合做等比缩放的方形元素
vmax vw 和 vh 中较大的那个

vmin 有一个非常实用的场景:让一个元素始终保持正方形,且不超出屏幕。

/* 无论屏幕是横是竖,都是正方形且不溢出 */
.square {
  width: 80vmin;
  height: 80vmin;
  margin: auto;
}

3.5 100vw 的经典坑

100vw 看起来等价于“屏幕宽度”,但它有一个致命问题:它包含了垂直滚动条的宽度。在一个有滚动条的桌面浏览器里,width: 100vw 的元素会比 width: 100% 宽出大约 15px,从而出现横向滚动条。

会溢出
width: 100vw;
在有滚动条的环境下比可视区域宽,触发横向滚动
安全
width: 100%;
相对父元素,永远不会超出

如果确实需要全宽且要避免溢出,可以用 width: 100vw 配合 overflow-x: hidden,但更好的做法是检查一下为什么非要 100vw——大多数时候 100% 就够了。

3.6 100vh 在移动端的灾难

这是移动端最著名的坑之一。在手机浏览器里,地址栏会随着滚动收起和展开,而 100vh 在大多数实现里始终等于“地址栏收起时”的高度。结果就是:

  • 页面一打开,底部内容被地址栏挡住一截;
  • 用户往下滚,地址栏收起,页面底部突然多出一块空白;
  • 如果用 100vh 做全屏容器,滚动条会永远存在。

3.7 新单位:dvh / svh / lvh

为了解决上面的问题,CSS 引入了三个新的视口高度单位:

单位 全称 含义
svh Small Viewport Height 地址栏展开时的高度(最小值)
lvh Large Viewport Height 地址栏收起时的高度(最大值)
dvh Dynamic Viewport Height 实时跟随当前视口变化

对应的横向单位 svw / lvw / dvw 也存在。选哪个取决于你的诉求:

/* 全屏 hero 区域:用 svh 保证首屏内容永远不被遮挡 */
.hero { min-height: 100svh; }

/* 固定底部容器:用 dvh 让它随地址栏动态伸缩 */
.app-shell { height: 100dvh; }

/* 兼容写法:老浏览器回落 */
.hero {
  min-height: 100vh;
  min-height: 100svh;
}

需要注意的是,dvh 会在滚动过程中持续变化,如果它触发的是布局变化(比如改变元素高度),会引起频繁的重排。所以 dvh 适合用在不影响文档流的位置,或者用于 position: fixed 的容器。

3.8 其他值得知道的单位

% 相对父元素。height 的百分比需要父元素有确定高度才生效
ch 当前字体下字符 “0” 的宽度,适合控制文本行长(45–75ch 最易读)
ex 小写字母 x 的高度,实际很少使用
fr Grid 专用,表示剩余空间的分配比例
cqw / cqh 容器查询单位,相对最近的容器,是容器查询的好搭档
lh 等于当前元素的 line-height,用于垂直居中或对齐文本块

3.9 clamp():让尺寸自动在区间内浮动

clamp(min, preferred, max) 是移动端适配的利器,它能用一行代码实现“随屏幕缩放但有上下限”:

/* 字号在 16px 到 24px 之间随视口变化 */
h1 {
  font-size: clamp(1rem, 0.5rem + 2vw, 1.5rem);
}

/* 容器宽度:最小 320px,理想是 90vw,最大 1200px */
.wrap {
  width: clamp(320px, 90vw, 1200px);
  margin-inline: auto;
}

/* 内边距:小屏 16px,大屏 48px,中间平滑过渡 */
.section {
  padding-inline: clamp(16px, 4vw, 48px);
}

clamp() 里的 preferred 通常写成 基础值 + 视口单位 的形式。这样设计的目的是让缩放曲线更平缓——纯 vw 在超大屏上会失控,纯 px 又不会响应,两者相加刚刚好。

3.10 min() 与 max()

这两个函数也很有用,它们分别取参数中的最小值和最大值:

/* 宽度不超过 600px,但至少占满容器 */
.card { width: min(600px, 100%); }

/* 字号不小于 14px */
.caption { font-size: max(14px, 0.85rem); }

/* 等价于 clamp 的写法 */
.title { font-size: max(1rem, min(2vw + 0.5rem, 1.5rem)); }

四、适配方案的演进:四代方案的取舍

移动端适配不是一成不变的。从 2010 年至今,主流方案经历了四次迭代,每一代都在解决上一代的痛点,同时也带来新的问题。点击下面的卡片了解每一代方案:

1
固定宽度
2010 前后
页面固定 320px 或 640px 宽,超出部分横向滚动。优点:实现最简单,与设计稿一比一。缺点:大屏浪费空间,小屏出现横向滚动,体验差。
2
流式布局
2012 前后
用百分比、flex、max-width 让容器自适应。优点:不挑屏幕,弹性好。缺点:字号无法跟随缩放,大屏上元素被拉得很宽,比例失调。
3
rem 适配
2015 前后
按设计稿比例动态设置根字号,所有尺寸用 rem。优点:整体等比缩放,还原度高。缺点:字号可能过大或过小,与用户系统字号设置冲突,调试不直观。
4
现代混合
2020 至今
媒体查询定断点 + clamp() 做弹性 + Grid/Flex 做布局 + 容器查询做组件级响应。优点:各司其职,可维护性最好。缺点:需要设计系统配合,前期投入较大。

4.1 第一代:固定宽度

/* 页面固定 640px 宽,居中显示 */
.page {
  width: 640px;
  margin: 0 auto;
}

/* 小屏上出现横向滚动条 */
/* 大屏上两侧留大片空白 */

这个方案今天基本已经淘汰,但在某些特殊场景下仍然有它的位置:需要在 iPad 上展示的固定尺寸演示页、面向特定机型的 kiosk 应用、以及给老年用户使用的超大字号简易页面。

4.2 第二代:流式布局

/* 容器最大宽度限制 + 百分比布局 */
.container {
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 16px;
}

/* 三列布局在小屏变一列 */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 20px;
}

auto-fit + minmax() 是流式布局的终极形态:不需要写任何媒体查询,卡片会自动根据可用宽度决定每行放几个。这是目前做卡片列表最推荐的写法。

4.3 第三代:rem 动态根字号

这套方案在 2015—2019 年间统治了移动端开发,由淘宝的 flexible.js 推广开来。核心思想是:以设计稿宽度(通常是 750px)为基准,动态计算根字号,让所有 rem 尺寸等比缩放。

// 手淘方案的简化版
const DESIGN_WIDTH = 750; // 设计稿宽度
const BASE_FONT = 100; // 设计稿 750 下 1rem = 100px

function setRootFontSize() {
  const clientWidth = document.documentElement.clientWidth;
  // 限制最大宽度,避免 iPad 上字号过大
  const width = Math.min(clientWidth, 750);
  const fontSize = (width / DESIGN_WIDTH) * BASE_FONT;
  document.documentElement.style.fontSize = fontSize + 'px';
}

setRootFontSize();
window.addEventListener('resize', setRootFontSize);
window.addEventListener('orientationchange', setRootFontSize);

// 设计稿上一个 200px 宽的按钮 → 写 2rem
// 在 375px 屏幕上:根字号 50px → 按钮实际 100px
// 在 414px 屏幕上:根字号 55.2px → 按钮实际 110.4px

这套方案的优点是还原度高——设计稿上量多少,除以 100 就是 rem 值,视觉比例在任意屏幕上都能保持一致。但它有三个无法回避的问题:

  • 字号不尊重用户设置:整个页面的字号都由 JS 计算得出,用户在系统里调大字号没有任何效果。
  • 大字屏上内容被撑大:iPad 或折叠屏展开后,一个字可能显示成 40px,看起来非常奇怪(所以必须加 max-width 限制)。
  • 调试不直观:在 DevTools 里看到一个元素宽 3.75rem,你得心算才知道它是 140px。

4.4 第四代:vw 适配

vw 方案的思路和 rem 一样是等比缩放,但它把“动态计算根字号”这一步交给了 CSS,不再需要 JS。

/* 以 750px 设计稿为基准 */
/* 1vw = 屏幕宽度的 1% */
/* 750px 设计稿上 1px = 100 / 750 vw ≈ 0.1333vw */

html {
  font-size: 13.3333vw; /* 1rem = 100px 设计稿 */
}

/* 限制超大屏上的字号 */
@media (min-width: 750px) {
  html { font-size: 100px; }
}

.btn {
  width: 2rem; /* 设计稿 200px */
  height: 0.8rem; /* 设计稿 80px */
}

如果不想用 rem 做中转,也可以直接用 vw 写尺寸,但这样写出来的数字很难看(width: 26.6667vw)。更现代的方式是配合 PostCSS 插件,在源码里写 px、构建时自动转成 vw:

/* 源码:直接按设计稿写 px */
.card {
  width: 690px;
  padding: 24px;
  border-radius: 16px;
}

/* 构建后自动转换(postcss-px-to-viewport) */
.card {
  width: 92vw;
  padding: 3.2vw;
  border-radius: 2.1333vw;
}

4.5 第五代:媒体查询 + clamp + 容器查询

现代方案不再追求“整体等比缩放”,而是让不同的东西用不同的方式响应:

布局 用 Grid / Flex 的自动换行能力,减少断点数量
字号 用 clamp() 做平滑缩放,保留用户字号设置的影响
间距 用 clamp() 或 CSS 变量配合媒体查询
组件 用容器查询(@container),让组件根据自身可用宽度响应

4.6 容器查询:组件级响应式

媒体查询只能感知视口大小,但一个卡片组件可能出现在侧边栏(宽 280px),也可能出现在主内容区(宽 900px)。容器查询让组件能感知自己容器的宽度:

/* 声明一个容器 */
.card-slot {
  container-type: inline-size;
  container-name: card;
}

/* 卡片默认竖向布局 */
.card { display: flex; flex-direction: column; }

/* 当容器宽度 ≥ 420px 时改为横向 */
@container card (min-width: 420px) {
  .card { flex-direction: row; align-items: center; }
  .card .thumb { width: 140px; height: 140px; }
}

/* 容器查询也能用单位 cqw / cqh */
@container card (min-width: 420px) {
  .card .title { font-size: clamp(1rem, 4cqw, 1.5rem); }
}

容器查询是近几年 CSS 领域最重要的进展之一。它让“一次编写,到处适配”的组件设计真正成为可能——组件不再需要知道自己在页面的哪个位置,只需要关心自己被给了多少空间。

4.7 四代方案横向对比

方案 核心手段 还原度 可维护性 推荐度
固定宽度 width: 640px 高 低 已淘汰
流式布局 百分比 + flex 中 高 部分场景
rem 动态 JS 设置根字号 很高 中 遗留项目
vw 适配 构建时 px → vw 很高 中 营销页
现代混合 媒体查询 + clamp + 容器查询 高 很高 推荐

需要强调的是:这不是一个“新方案取代旧方案”的线性过程。很多团队至今仍在用 rem 方案维护着大量页面,而一些活动页、H5 营销页用 vw 方案反而更合适——因为它们的生命周期短、设计要求高、不需要考虑无障碍细节。选方案时要看具体场景,而不是盲目追求“最新”。

五、媒体查询与断点设计

媒体查询是响应式设计的基石。但“在哪些宽度加断点”这个问题,比语法本身要难得多。

5.1 断点应该由内容决定,而不是由设备决定

十年前的做法是“给 iPhone 一套、给 iPad 一套、给桌面一套”,断点值直接取自当时的设备宽度(320、768、1024)。但设备尺寸每年都在变,折叠屏、超宽屏、各种奇形怪状的 Android 机型让这种思路彻底失效。

更稳健的做法是:慢慢缩小浏览器窗口,当布局开始“看起来不对劲”的那一刻,就是断点所在。比如三列卡片在 900px 时每张只有 260px,标题开始换行、按钮文字被挤压——那断点就应该设在 900px,而不是“iPad 的 768px”。

5.2 移动优先 vs 桌面优先

/* 移动优先:从最小屏幕开始写 */
.card {
  width: 100%;
  padding: 16px;
}

@media (min-width: 768px) {
  .card { width: 50%; padding: 24px; }
}

@media (min-width: 1200px) {
  .card { width: 33.33%; }
}
/* 桌面优先:从最大屏幕开始写 */
.card {
  width: 33.33%;
  padding: 24px;
}

@media (max-width: 1199px) {
  .card { width: 50%; }
}

@media (max-width: 767px) {
  .card { width: 100%; padding: 16px; }
}

推荐移动优先,理由有三:

  • 移动端流量占比更高,且移动端设备的性能通常更弱,优先保证基础体验更合理;
  • 移动优先的 CSS 是“不断添加能力”,桌面优先是“不断覆盖能力”,前者更容易维护;
  • 移动优先天然避免了 max-width 断点在窄屏上的“重叠”问题。

5.3 区间语法:更直观的写法

传统的 min-width / max-width 组合容易写错边界(比如 768 和 769 之间漏掉 1px)。媒体查询 Level 4 引入了区间语法:

/* 传统写法 */
@media (min-width: 768px) and (max-width: 1023px) { ... }

/* 区间语法(更直观,且不会有 1px 缝隙) */
@media (768px <= width < 1024px) { ... }

/* 也支持单向 */
@media (width < 768px) { ... }
@media (width >= 1024px) { ... }

5.4 常用的其他媒体特性

特性 用途 示例
orientation 横竖屏 @media (orientation: landscape)
hover 是否有悬停能力 @media (hover: hover)
pointer 指针精度 @media (pointer: coarse)
prefers-color-scheme 深色模式 @media (prefers-color-scheme: dark)
prefers-reduced-motion 减少动画 @media (prefers-reduced-motion: reduce)
resolution 像素密度 @media (min-resolution: 2dppx)
display-mode PWA 独立窗口 @media (display-mode: standalone)

其中 hover 和 pointer 特别值得关注。移动端没有真正的“悬停”状态,所以桌面端那种 .card:hover { transform: translateY(-4px) } 的写法在手机上会导致:用户点击后元素保持“悬停”状态,直到点别处才恢复。正确做法是把它包在 @media (hover: hover) 里:

/* 只在真正支持悬停的设备上应用悬停效果 */
@media (hover: hover) and (pointer: fine) {
  .card:hover {
    transform: translateY(-4px);
    box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
  }
}

/* 触摸设备上用 :active 给出反馈 */
@media (hover: none) {
  .card:active { transform: scale(0.98); }
}

5.5 断点尺:看看你的窗口落在哪个区间

下面这条尺子会实时反映当前浏览器窗口宽度,改变窗口大小看看:

≤ 480
手机
481–768
大屏手机
769–1024
平板
1025–1440
笔记本
≥ 1441
桌面
当前视口宽度 – px,命中区间:–

5.6 一套可用的断点参考

360–430px 主流手机竖屏,单列布局,字号 14–16px
600px 大屏手机横屏 / 小平板,可以考虑两列
768px 平板竖屏,两列或三列
1024px 平板横屏 / 小笔记本,可以启用侧边栏
1280px+ 桌面,内容区限制最大宽度避免行宽过长

再次强调:这只是起点,不是标准答案。真实的断点应该从你的内容里“长出来”,而不是从这张表里“抄下来”。

六、响应式图片与高清屏细节

移动端流量的大头是图片。做好图片的响应式,对加载速度和流量消耗的影响,往往比优化 JS 更明显。

6.1 最容易被忽略的两行属性

<img src="photo.jpg"
     loading="lazy"
     decoding="async"
     width="800"
     height="600"
     alt="示例图片">
  • loading="lazy":图片进入视口附近才开始加载,首屏之外的图片完全不占用带宽。这一行代码的收益可能超过所有 JS 优化。
  • decoding="async":允许浏览器在解码图片时不阻塞渲染,避免大图导致的卡顿。
  • width / height:必须写。它们让浏览器能提前算出图片的宽高比,从而预留空间。否则图片加载完成时会把下方内容“顶”下去,这就是讨厌的 CLS(累积布局偏移)。

配合 CSS 让图片自适应容器:

img {
  max-width: 100%;
  height: auto; /* 保持宽高比 */
  display: block; /* 消除 inline 元素底部的间隙 */
}

6.2 srcset:为不同 DPR 提供不同文件

<!-- 用 x 描述符指定像素密度 -->
<img
  src="logo.png"
  srcset="logo.png 1x, logo@2x.png 2x, logo@3x.png 3x"
  alt="Logo">

// 浏览器会自动根据 devicePixelRatio 选择
// DPR=1 → logo.png
// DPR=2 → logo@2x.png
// DPR=3 → logo@3x.png

6.3 sizes + w 描述符:更精细的控制

x 描述符只考虑了 DPR,没有考虑图片实际会被渲染成多大。如果一个 800px 宽的图片在手机上只显示成 300px 宽,那么在 DPR=2 时其实只需要 600px 的源文件,而不是 1600px。

<img
  src="photo-800.jpg"
  srcset="photo-400.jpg 400w,
          photo-800.jpg 800w,
          photo-1200.jpg 1200w,
          photo-1600.jpg 1600w"
  sizes="(max-width: 600px) 100vw,
         (max-width: 1200px) 50vw,
         600px"
  alt="示例">

sizes 告诉浏览器“这张图在不同媒体条件下会占多宽”,浏览器结合当前 DPR 就能算出需要多少物理像素,然后从 srcset 里挑最接近的一档。必须同时写 sizes 和 w 描述符,只写其中一个的话,浏览器只能按默认规则猜(通常假设图片占满整个视口)。

6.4 picture:艺术方向与格式回退

<picture> 元素解决的是“不同场景需要不同的图”这个问题:

<!-- 1. 艺术方向:手机用竖图,桌面用横图 -->
<picture>
  <source media="(max-width: 600px)" srcset="hero-portrait.jpg">
  <img src="hero-landscape.jpg" alt="首图">
</picture>

<!-- 2. 格式回退:优先 AVIF,其次 WebP,最后 JPEG -->
<picture>
  <source type="image/avif" srcset="photo.avif">
  <source type="image/webp" srcset="photo.webp">
  <img src="photo.jpg" alt="照片">
</picture>

浏览器会从上到下检查 <source>,选择第一个条件满足的。<img> 必须放在最后作为兜底。

6.5 背景图片的响应式

/* image-set 让背景图也能根据 DPR 选择 */
.banner {
  background-image: image-set(
    url("banner.png") 1x,
    url("banner@2x.png") 2x,
    url("banner@3x.png") 3x
  );
  background-size: cover;
  background-position: center;
}

/* 现代写法:直接用 type() 指定格式 */
.banner {
  background-image: image-set(
    url("banner.avif") type("image/avif"),
    url("banner.jpg") type("image/jpeg")
  );
}

6.6 1 像素边框问题

设计师在 750px 设计稿上画了一条 1px 的线。如果你直接写 border-bottom: 1px solid,在 DPR=2 的设备上它会渲染成 2 个物理像素,看起来比设计稿粗一倍;在 DPR=3 上粗两倍。三种常见的解决方案:

直接写 1px
DPR=2 时实际 2 物理像素,偏粗
transform: scaleY(0.5)
伪元素缩放,视觉上更接近 1 物理像素
渐变背景模拟
用背景渐变画线,无额外元素
/* 方案 A:伪元素 + transform 缩放(兼容性最好) */
.hairline-bottom {
  position: relative;
}
.hairline-bottom::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background: #e2e8f0;
  transform: scaleY(0.5);
  transform-origin: bottom;
}

/* 方案 B:直接用 0.5px(iOS 8+ 支持,安卓部分机型会取整为 1px) */
.hairline-bottom { border-bottom: 0.5px solid #e2e8f0; }

/* 方案 C:渐变背景画线 */
.hairline-bottom {
  background-image: linear-gradient(to bottom, #e2e8f0 0, #e2e8f0 50%, transparent 50%);
  background-size: 100% 2px;
  background-repeat: repeat-y;
  background-position: bottom;
}

实践中最推荐方案 A:它对所有 DPR 都生效,不依赖浏览器对小数像素的处理策略,也不会有方案 B 在部分安卓机上的取整问题。缺点是需要一个伪元素,写起来稍繁琐——所以通常会封装成一个 Sass mixin 或工具类。

七、安全区域与刘海屏

从 iPhone X 开始,“屏幕不是完整的矩形”成了常态。刘海、挖孔、曲面边缘、底部横条,都会侵占原本属于内容的空间。

7.1 env() 的四个变量

safe-area-inset-top 顶部安全距离,通常是状态栏或刘海高度
safe-area-inset-right 右侧安全距离,横屏时可能不为 0
safe-area-inset-bottom 底部安全距离,Home Indicator 所在区域
safe-area-inset-left 左侧安全距离,横屏时可能不为 0

注意:只有在写了 viewport-fit=cover 时,这些变量才可能有非 0 的值。如果没写,浏览器会自动把页面限制在安全区内,这些变量都是 0。

7.2 安全区域实验台

拖动下面的滑块模拟不同的刘海 / 横条尺寸,观察页面顶部栏与底部栏如何自动避让:

顶部栏 padding-top: calc(8px + env(safe-area-inset-top))
底部操作栏 padding-bottom: calc(10px + env(safe-area-inset-bottom))

7.3 固定底栏的完整写法

.bottom-nav {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 50;

  /* 基础内边距 */
  padding: 10px 16px;
  /* 老浏览器回落 */
  padding-bottom: 10px;
  /* 支持 env 的浏览器叠加安全区 */
  padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));

  background: white;
  box-shadow: 0 -2px 12px rgba(15, 23, 42, 0.08);
}

/* 页面主体需要留出底栏高度,否则最后一条内容会被挡住 */
.page-main {
  padding-bottom: 72px;
  padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
}

7.4 横屏下的左右安全区

在 iPhone 横屏时,刘海会跑到左侧或右侧。如果你的页面有固定左侧栏或右侧操作按钮,需要同时处理左右安全区:

.side-panel {
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  width: 260px;

  padding-left: env(safe-area-inset-left, 0px);
  padding-right: env(safe-area-inset-right, 0px);
}

7.5 全屏视频与图片

全屏媒体元素最容易被刘海挡住关键内容。解决思路是:让背景铺满,让内容避让。

.video-hero {
  position: relative;
  height: 100svh;
  background: #000;
}

/* 视频铺满整个容器,不做避让 */
.video-hero video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* 文字内容避让安全区 */
.video-hero .caption {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: 24px 20px;
  padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
  color: white;
  background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
}

7.6 调试安全区的技巧

  • Chrome DevTools 的设备模式里有 iPhone 12 Pro、iPhone 14 Pro 等带刘海的机型,可以直接看到安全区效果。
  • 在 Console 里执行 getComputedStyle(document.documentElement).getPropertyValue('--sat') 之类是没用的,env() 不是 CSS 变量,只能通过实际渲染观察。
  • 一个实用技巧:临时给 body 加上 outline: 2px solid red; outline-offset: -2px; 来观察安全区边界。
  • 模拟器 / 真机测试是必须的环节,桌面浏览器永远模拟不出真实的安全区数值。

八、触摸交互的十个细节

移动端的手指不是鼠标。这个差异带来了大量在桌面上不存在的交互问题。

8.1 300 毫秒点击延迟的历史与现状

早期移动浏览器为了判断用户是“单击”还是“双击缩放”,会在 click 事件上延迟约 300ms。这个延迟让页面感觉“黏糊糊”的。

现在的情况已经好转很多:

  • 如果 viewport 里写了 width=device-width,现代浏览器会取消这个延迟;
  • Chrome 32+ 在页面不可缩放时也会取消延迟;
  • iOS 9.3+ 对 width=device-width 的页面同样取消了延迟。

如果你仍然遇到延迟,可以用 touch-action 明确告诉浏览器这个元素不需要处理双击缩放:

/* 只允许垂直滚动,去掉双击缩放判定 */
.clickable {
  touch-action: manipulation;
}

/* 其他可选值 */
/* touch-action: none; 禁用所有触摸手势 */
/* touch-action: pan-y; 只允许垂直滚动 */
/* touch-action: pan-x pan-y; 允许双向滚动 */
/* touch-action: pinch-zoom; 允许缩放 */

8.2 点击高亮与点击态

/* 去掉移动端点击时的灰色/蓝色高亮块 */
* {
  -webkit-tap-highlight-color: transparent;
}

/* 但一定要给回点击反馈,否则用户不知道点没点中 */
.btn {
  transition: transform 0.1s ease, background-color 0.1s ease;
}
.btn:active {
  transform: scale(0.96);
  background-color: #1d4ed8;
}

注意::active 在移动端需要元素可点击才生效。如果元素是 div,需要加上 cursor: pointer 或者绑定 touchstart 事件。

8.3 触摸目标的最小尺寸

Apple 的 Human Interface Guidelines 建议触摸目标至少 44×44 pt,Google 的 Material Design 建议至少 48×48 dp。低于这个尺寸,误触率会急剧上升。

24 × 24 低于推荐值,虚线圈表示理想的 44px 触摸区
44 × 44 达到 iOS HIG 推荐的最小尺寸

如果视觉上必须做小图标,可以用伪元素扩大可点击区域:

.icon-btn {
  position: relative;
  width: 24px;
  height: 24px;
}

/* 视觉上 24px,实际可点区域 48px */
.icon-btn::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 48px;
  height: 48px;
}

8.4 输入框自动放大问题

在 iOS Safari 里,如果输入框的 font-size 小于 16px,聚焦时页面会自动放大。这个问题有几种解法,按推荐程度排序:

推荐 把输入框字号设为 font-size: 16px,从根本上避免
次选 用 transform: scale() 视觉缩小,但布局尺寸仍为 16px
不推荐 写 maximum-scale=1 禁止缩放,牺牲无障碍
/* 方案:视觉上小,实际字号仍是 16px */
.search-input {
  font-size: 16px;
  transform: scale(0.875);
  transform-origin: left center;
  width: 114%; /* 补偿缩放导致的宽度损失 */
}

8.5 滚动穿透

当弹窗打开时,用户滚动弹窗内容,背后的页面也跟着滚——这就是滚动穿透。经典解法是给 body 加 overflow: hidden,但这在 iOS 上会造成页面跳回顶部。

// 更稳妥的方案:固定 body 并记录滚动位置
let scrollY = 0;

function lockScroll() {
  scrollY = window.scrollY;
  document.body.style.position = 'fixed';
  document.body.style.top = `-${scrollY}px`;
  document.body.style.width = '100%';
}

function unlockScroll() {
  document.body.style.position = '';
  document.body.style.top = '';
  document.body.style.width = '';
  window.scrollTo(0, scrollY);
}

如果弹窗本身需要滚动,还要用 overscroll-behavior: contain 阻止滚动链传到父级:

.modal-body {
  overflow-y: auto;
  overscroll-behavior: contain; /* 滚动到底后不再传递 */
}

8.6 移动端没有 hover,但有 sticky hover

前面第五节已经提到,桌面端的 :hover 效果在触摸屏上会“粘住”。除了用 @media (hover: hover) 包裹,还可以用 :hover: hover 伪类(注意语法重复,这是故意的):

/* 只在支持 hover 的设备上应用 */
.btn:hover:hover {
  background: #eff6ff;
}

8.7 长按与上下文菜单

/* 禁止长按弹出系统菜单(谨慎使用) */
.no-callout {
  -webkit-touch-callout: none;
  user-select: none;
}

/* 允许文本选择(阅读类页面必须开启) */
.article-body {
  user-select: text;
  -webkit-user-select: text;
}

不要滥用 user-select: none。很多页面为了“防止用户复制”,把整站都设成了不可选中,结果连正常的文本复制、地址栏粘贴都受到影响。只应该对按钮、图标这类纯交互元素禁用选择。

8.8 手势冲突与 touch-action

当你在一个可横向滑动的轮播图里放了可垂直滚动的列表时,手势会打架。用 touch-action 明确声明意图:

.carousel {
  overflow-x: auto;
  touch-action: pan-x; /* 只响应水平滑动 */
  scroll-snap-type: x mandatory;
}

.carousel .slide {
  scroll-snap-align: center;
  flex: 0 0 100%;
}

8.9 惯性滚动与滚动吸附

/* iOS 上让内部滚动容器有惯性 */
.scroll-box {
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

/* 滚动吸附:轮播、横向标签栏的利器 */
.tab-scroller {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x proximity;
  scroll-padding-inline: 16px;
  /* 隐藏滚动条 */
  scrollbar-width: none;
}
.tab-scroller::-webkit-scrollbar { display: none; }
.tab-scroller .tab { scroll-snap-align: start; }

8.10 输入法与视口

软键盘弹出时,页面高度会变化。如果你用 position: fixed 做了底部输入框,可能会被键盘顶起或遮挡。配合第二节提到的 interactive-widget=resizes-content,并监听 visualViewport 的变化:

if (window.visualViewport) {
  window.visualViewport.addEventListener('resize', () => {
    const vv = window.visualViewport;
    // 键盘高度 ≈ 窗口高度 - 可视高度 - 顶部偏移
    const keyboardHeight = window.innerHeight - vv.height - vv.offsetTop;
    document.documentElement.style.setProperty(
      '--keyboard-height',
      Math.max(0, keyboardHeight) + 'px'
    );
  });
}

/* 输入框容器根据键盘高度上移 */
.chat-input {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  transform: translateY(calc(-1 * var(--keyboard-height, 0px)));
  transition: transform 0.2s ease;
}

九、移动端性能:适配不只是布局

移动端设备的 CPU 和 GPU 性能差异巨大。同一段代码在旗舰机上丝滑,在千元机上可能卡成幻灯片。适配工作中,性能是不可分割的一部分。

9.1 图片懒加载与占位

/* 用 aspect-ratio 预留空间,彻底消除布局偏移 */
.img-wrap {
  aspect-ratio: 16 / 9;
  background: #f1f5f9;
  border-radius: 8px;
  overflow: hidden;
}

.img-wrap img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* 加载完成后淡入,避免生硬切换 */
  opacity: 0;
  transition: opacity 0.3s ease;
}

.img-wrap img.loaded { opacity: 1; }

下面是一个骨架屏的示例,它在图片和文字加载完成前提供视觉占位,能显著降低用户感知的等待时间:

.skeleton-line {
  height: 10px;
  border-radius: 5px;
  background: linear-gradient(
    90deg,
    #e2e8f0 25%,
    #f1f5f9 37%,
    #e2e8f0 63%
  );
  background-size: 400% 100%;
  animation: shimmer 1.4s ease infinite;
}

@keyframes shimmer {
  0% { background-position: 100% 50%; }
  100% { background-position: 0 50%; }
}

/* 尊重用户的"减少动画"偏好 */
@media (prefers-reduced-motion: reduce) {
  .skeleton-line { animation: none; }
}

9.2 长列表:content-visibility

对于内容很长的页面,content-visibility: auto 可以让浏览器跳过屏幕外元素的渲染工作:

.article-block {
  content-visibility: auto;
  /* 声明预估高度,避免滚动条跳动 */
  contain-intrinsic-size: auto 500px;
}

这一行代码在长文档页面上的性能提升可能达到 30% 以上,而且完全不影响可访问性(搜索引擎和屏幕阅读器依然能看到内容)。

9.3 动画只动 transform 和 opacity

改变 width、height、top、left、margin 会触发布局重排,代价最高;改变 color、box-shadow 会触发重绘,代价中等;只有 transform 和 opacity 可以完全在合成层完成,代价最低。

属性 触发阶段 移动端表现
transform 仅合成 流畅
opacity 仅合成 流畅
color / background-color 重绘 一般
box-shadow 重绘 一般
width / height 重排 易卡顿
top / left 重排 易卡顿
filter: blur() 合成(但开销大) 慎用
/* 反例:动画改 left,每帧都重排 */
.dot {
  position: absolute;
  left: 0;
  animation: move-bad 1s infinite alternate;
}
@keyframes move-bad {
  to { left: 200px; }
}

/* 正解:动画改 transform,只在合成层处理 */
.dot {
  position: absolute;
  left: 0;
  animation: move-good 1s infinite alternate;
}
@keyframes move-good {
  to { transform: translateX(200px); }
}

9.4 慎用 will-change

will-change 可以提前把元素提升为合成层,但滥用会导致内存暴涨。每个合成层都会占用一份 GPU 显存,移动端显存本来就紧张。原则是:

  • 只在动画即将开始时添加 will-change,动画结束后移除;
  • 不要给大量元素同时加 will-change: transform;
  • 不要写 will-change: all(这不是合法值,但有人会写 will-change: transform, opacity, left, top 之类的组合)。
const el = document.querySelector('.panel');

el.addEventListener('mouseenter', () => {
  el.style.willChange = 'transform';
});

el.addEventListener('transitionend', () => {
  el.style.willChange = 'auto';
});

9.5 字体加载策略

@font-face {
  font-family: 'CustomFont';
  src: url('font.woff2') format('woff2');
  /* swap:先用系统字体渲染,字体加载完再替换 */
  font-display: swap;
  /* 只加载需要的字符集 */
  unicode-range: U+4E00-9FFF;
}

/* 用系统字体栈兜底,保证首屏可读 */
body {
  font-family:
    'CustomFont',
    -apple-system,
    BlinkMacSystemFont,
    'PingFang SC',
    'Hiragino Sans GB',
    'Microsoft YaHei',
    sans-serif;
}

font-display: swap 与 font-display: optional 的取舍:前者保证最终一定用上自定义字体,但会有一次闪烁;后者在字体加载慢时直接使用系统字体,页面最稳定但可能永远用不上自定义字体。品牌要求高选前者,性能要求高选后者。

9.6 减少移动端的主线程负担

拆分长任务 超过 50ms 的同步任务会阻塞交互,用 requestIdleCallback 或分片处理
防抖节流 滚动、resize、输入框输入都需要节流,移动端尤其明显
事件委托 减少监听器数量,动态内容自动生效
避免强制同步布局 不要在循环里读 offsetHeight 又写样式
慎用大型库 一个 200KB 的日期库可能比整个页面其他代码都大
/* 反例:读写交替,每次循环都强制重排 */
items.forEach((item) => {
  // 读
  const h = item.offsetHeight;
  // 写(触发重排,下一次读又得重新计算)
  item.style.height = h + 10 + 'px';
});

/* 正解:先全部读,再全部写 */
const heights = items.map((item) => item.offsetHeight);
items.forEach((item, i) => {
  item.style.height = heights[i] + 10 + 'px';
});

十、调试与真机测试

移动端适配最大的痛点是“在我电脑上好好的”。这一节整理一套可靠的调试流程。

10.1 Chrome DevTools 设备模式

设备工具栏 Ctrl + Shift + M(Mac 上是 Cmd + Shift + M)
DPR 模拟 在工具栏顶部可以切换 DPR,验证 1px 边框和多倍图
网络限速 Network 面板可切到 Slow 4G,模拟真实移动网络
CPU 限速 Performance 面板可降速 4x/6x,模拟低端机
传感器 More tools → Sensors 可模拟地理位置、方向、触摸

但要注意,DevTools 的模拟永远无法完全替代真机。以下几个问题只有在真机上才会暴露:

  • 真实的 env(safe-area-inset-*) 数值(DevTools 里通常都是 0);
  • iOS 的橡皮筋回弹、滚动惯性、输入框自动放大;
  • 软键盘弹出时的视口变化;
  • 真实的触摸事件序列与多点触控;
  • 低端安卓机的实际渲染性能。

10.2 真机远程调试

// Android + Chrome
// 1. 手机开启「开发者选项 → USB 调试」
// 2. USB 连接电脑
// 3. 电脑 Chrome 打开
chrome://inspect

// 4. 手机上打开目标页面
// 5. 点击 inspect 即可远程调试
// iOS + Safari
// 1. 手机:设置 → Safari → 高级
// → 打开「网页检查器」
// 2. USB 连接电脑
// 3. 电脑 Safari:开发 → 你的设备
// → 选择目标页面

// 前提:电脑 Safari 的「开发」菜单需先在
// 偏好设置 → 高级 里勾选显示

10.3 移动端调试面板

当无法连接电脑时(比如测试同事的手机、客户现场演示),可以临时注入一个页面内的调试面板:

<!-- vConsole:轻量,适合快速排查 -->
<script src="https://unpkg.com/vconsole/dist/vconsole.min.js"></script>
<script>
  new VConsole();
</script>

<!-- eruda:功能更全,有 Elements 面板 -->
<script src="https://unpkg.com/eruda/eruda.js"></script>
<script>eruda.init();</script>

// 只在特定条件下开启,避免影响线上用户
if (location.search.includes('debug=1')) {
  const s = document.createElement('script');
  s.src = 'https://unpkg.com/eruda/eruda.js';
  s.onload = () => eruda.init();
  document.head.appendChild(s);
}

10.4 移动端常见 Bug 速查

现象 可能原因 解决方向
页面整体比屏幕宽,出现横向滚动 某个元素用了 100vw 或固定大宽度 改用 100%,或给容器加 overflow-x: hidden
底部内容被地址栏挡住 用了 100vh 改用 100svh 或 100dvh
点击按钮没反应 元素被其他元素覆盖,或 pointer-events: none 用 DevTools 检查层级
输入框聚焦时页面放大 输入框字号小于 16px 设置 font-size: 16px
固定底栏被 Home 横条挡住 没有处理安全区 加 env(safe-area-inset-bottom)
弹窗打开后背景还能滚 滚动穿透 锁定 body 或加 overscroll-behavior
1px 边框看起来太粗 DPR > 1 用 transform: scaleY(0.5)
图片在高清屏上模糊 只提供了 1x 图 用 srcset 或多倍图
切到后台再回来布局错乱 用 vh 计算了尺寸,但没监听 resize 监听 visualViewport.resize
横屏时内容被刘海挡住 没处理左右安全区 加 env(safe-area-inset-left/right)

10.5 一份真机测试清单

必测项
  • iOS Safari 与 Android Chrome 各测一台
  • 竖屏 / 横屏切换后布局是否正常
  • 软键盘弹出后输入框是否可见
  • 带刘海设备的安全区表现
  • 低速网络下的首屏体验
容易漏测
  • 系统字号调大后的表现
  • 深色模式下的配色对比度
  • 折叠屏展开 / 折叠的过渡
  • 浏览器返回时的滚动位置恢复
  • 页面进入后台再切回的状态

十一、速查表与落地清单

最后把全文浓缩成一份可以随时查阅的速查表。左列是“你遇到什么问题”,右列是“应该往哪个方向走”。

遇到的问题 首选方案 关键代码
移动端页面被缩小显示 viewport meta width=device-width, initial-scale=1
字号需要随屏幕平滑变化 clamp() clamp(1rem, 0.5rem + 2vw, 1.5rem)
100vh 在手机上被地址栏遮挡 svh / dvh min-height: 100svh
卡片列表要自动换列 Grid auto-fit repeat(auto-fit, minmax(260px, 1fr))
组件在不同容器里要不同布局 容器查询 @container card (min-width: 420px)
桌面 hover 效果在手机上粘住 hover 媒体查询 @media (hover: hover)
图片在高清屏上模糊 srcset / picture srcset="a.png 1x, a@2x.png 2x"
1px 边框太粗 transform 缩放 transform: scaleY(0.5)
底部栏被 Home 横条挡住 安全区变量 env(safe-area-inset-bottom)
输入框聚焦时页面放大 字号 16px font-size: 16px
点击有 300ms 延迟 touch-action touch-action: manipulation
弹窗滚动穿透到背景 锁定滚动 overscroll-behavior: contain
长页面滚动卡顿 content-visibility content-visibility: auto
动画掉帧 只动 transform/opacity @keyframes { to { transform: ... } }
图片加载导致布局跳动 aspect-ratio + 宽高 aspect-ratio: 16 / 9

11.1 落地清单

  • viewport 只写必要的。width=device-width, initial-scale=1 加上 viewport-fit=cover 就够了,不要禁止缩放。
  • 字号用 rem,间距用 em 或 rem,尺寸用 clamp。不要用 vw 写字号,除非你能接受它在超宽屏上失控。
  • 移动优先写 CSS。默认样式给小屏,用 min-width 逐级增强。
  • 断点由内容决定。慢慢缩窗口,哪里开始难看就在哪里加断点。
  • 图片必须有 width/height。或者用 aspect-ratio 预留空间,杜绝 CLS。
  • 首屏之外的图片加 loading="lazy"。一行代码,收益巨大。
  • 固定定位的元素考虑安全区。底部栏、侧边栏、全屏容器都要检查。
  • 触摸目标不小于 44×44。视觉可以小,可点击区域不能小。
  • 输入框字号设为 16px。这是避免 iOS 自动放大最省事的办法。
  • 动画只动 transform 和 opacity。需要改尺寸时,考虑用 scale 代替 width。
  • hover 效果包在 @media (hover: hover) 里。触摸设备上改用 :active。
  • 真机测试不可省略。DevTools 能覆盖 80% 的问题,剩下 20% 只有真机能发现。
  • 尊重用户的系统设置。prefers-color-scheme、prefers-reduced-motion、系统字号,都要考虑。
  • 衡量标准:把手机横过来再竖回去,把系统字号调大两档,把地址栏滚出来再滚回去——页面是否依然正常?
/* 一份可以直接抄走的移动端基础样式 */

/* 1. 视口与盒模型 */
*, *::before, *::after { box-sizing: border-box; }

/* 2. 根字号与基础字体 */
html {
  font-size: 16px;
  -webkit-text-size-adjust: 100%; /* 防止横屏时字体突变 */
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC',
               'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

/* 3. 触摸细节 */
* { -webkit-tap-highlight-color: transparent; }
button, a { touch-action: manipulation; }

/* 4. 图片默认自适应 */
img, video {
  max-width: 100%;
  height: auto;
  display: block;
}

/* 5. 输入框不小于 16px */
input, textarea, select {
  font-size: 16px;
  font-family: inherit;
}

/* 6. 安全区工具变量 */
:root {
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  --safe-left: env(safe-area-inset-left, 0px);
  --safe-right: env(safe-area-inset-right, 0px);
}

/* 7. 弹性间距 */
.container {
  width: min(1200px, 100%);
  margin-inline: auto;
  padding-inline: clamp(16px, 4vw, 48px);
}

/* 8. 响应式栅格 */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
  gap: clamp(12px, 2vw, 24px);
}

/* 9. 减少动画偏好 */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

移动端适配最反直觉的一点是:它不是一个可以“一次做完”的任务,而是一种需要持续保持的敏感度。新的设备形态、新的 CSS 特性、新的浏览器行为,每隔一两年就会刷新一遍最佳实践。五年前正确的事情(比如禁止缩放、用 rem 动态根字号),今天可能已经变成了反模式。

但底层的判断标准其实一直没变:这个页面在各种屏幕、各种输入方式、各种用户设置下,是否依然能让人顺畅地读完、点中、用起来?抓住这个标准,剩下的都是工具选择的问题。

最后提醒一句:永远不要只在你的开发机上判断“适配做完了”。把页面发给同事,借一台安卓千元机,把系统字号调到最大,把屏幕转到横屏——你会发现自己漏掉的东西,往往比你想象的多。