移动端适配是前端工程里最容易被低估的一块工作。它看起来只是“写个媒体查询”,实际上牵扯到物理像素与 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 个物理像素
你当前设备的读数:
(试着把浏览器窗口拖到不同显示器上,或者用系统缩放把比例改成 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 个,于是边缘就糊了。
解决方案有两类:一是准备多倍图(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 三种视口
document.documentElement.clientWidth
window.visualViewport.width
2.2 不加 meta 会发生什么
移动端浏览器为了让桌面网页能完整显示,会默认给页面一个大约 980px 的布局视口,然后把它整体缩小塞进屏幕里。结果就是你精心设计的页面在手机上看起来像一张缩小的海报,字小得看不清,点击目标小得点不中。
// 布局视口 ≈ 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-*) 来避让。
/* 底部固定操作栏 */
.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() 的声明在前面:
padding-bottom: 12px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}
2.6 interactive-widget:软键盘与视口
这是一个较新的属性,用来控制虚拟键盘弹出时视口的行为:
100vh 类的高度会跟着变,适合聊天输入框
三、长度单位全景:九个单位,各有各的脾气
CSS 里的长度单位远比想象中多。它们大致可以分成三类:绝对单位、相对单位、视口单位。选错单位是移动端布局“看起来差一点”的最常见原因。
3.1 绝对单位
实践建议:cm、mm、in 在屏幕上没有任何实用价值,因为“1 英寸等于多少物理像素”完全取决于设备,浏览器只能按固定比例换算。pt 只应该在 @media print 里出现。
3.2 相对单位:em 与 rem
em 相对于当前元素的字号,rem 相对于根元素(html)的字号。这个区别看起来微不足道,实际影响却很大。
拖动滑块改变父容器字号:2em 的宽度会跟着变,而 2rem 和 32px 岿然不动。这就是 em 的“级联放大”特性——也是它最容易出事的地方。
3.3 em 的嵌套放大陷阱
下面这段代码是 em 最经典的翻车现场:
.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
100vw 等于整个视口宽度
100vh 等于整个视口高度
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 { min-height: 100svh; }
/* 固定底部容器:用 dvh 让它随地址栏动态伸缩 */
.app-shell { height: 100dvh; }
/* 兼容写法:老浏览器回落 */
.hero {
min-height: 100vh;
min-height: 100svh;
}
需要注意的是,dvh 会在滚动过程中持续变化,如果它触发的是布局变化(比如改变元素高度),会引起频繁的重排。所以 dvh 适合用在不影响文档流的位置,或者用于 position: fixed 的容器。
3.8 其他值得知道的单位
height 的百分比需要父元素有确定高度才生效
3.9 clamp():让尺寸自动在区间内浮动
clamp(min, preferred, max) 是移动端适配的利器,它能用一行代码实现“随屏幕缩放但有上下限”:
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()
这两个函数也很有用,它们分别取参数中的最小值和最大值:
.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 年至今,主流方案经历了四次迭代,每一代都在解决上一代的痛点,同时也带来新的问题。点击下面的卡片了解每一代方案:
4.1 第一代:固定宽度
.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。
/* 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:
.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 + 容器查询
现代方案不再追求“整体等比缩放”,而是让不同的东西用不同的方式响应:
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 断点尺:看看你的窗口落在哪个区间
下面这条尺子会实时反映当前浏览器窗口宽度,改变窗口大小看看:
手机
大屏手机
平板
笔记本
桌面
5.6 一套可用的断点参考
再次强调:这只是起点,不是标准答案。真实的断点应该从你的内容里“长出来”,而不是从这张表里“抄下来”。
六、响应式图片与高清屏细节
移动端流量的大头是图片。做好图片的响应式,对加载速度和流量消耗的影响,往往比优化 JS 更明显。
6.1 最容易被忽略的两行属性
loading="lazy"
decoding="async"
width="800"
height="600"
alt="示例图片">
loading="lazy":图片进入视口附近才开始加载,首屏之外的图片完全不占用带宽。这一行代码的收益可能超过所有 JS 优化。decoding="async":允许浏览器在解码图片时不阻塞渲染,避免大图导致的卡顿。width/height:必须写。它们让浏览器能提前算出图片的宽高比,从而预留空间。否则图片加载完成时会把下方内容“顶”下去,这就是讨厌的 CLS(累积布局偏移)。
配合 CSS 让图片自适应容器:
max-width: 100%;
height: auto; /* 保持宽高比 */
display: block; /* 消除 inline 元素底部的间隙 */
}
6.2 srcset:为不同 DPR 提供不同文件
<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。
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> 元素解决的是“不同场景需要不同的图”这个问题:
<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 背景图片的响应式
.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 上粗两倍。三种常见的解决方案:
.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() 的四个变量
注意:只有在写了 viewport-fit=cover 时,这些变量才可能有非 0 的值。如果没写,浏览器会自动把页面限制在安全区内,这些变量都是 0。
7.2 安全区域实验台
拖动下面的滑块模拟不同的刘海 / 横条尺寸,观察页面顶部栏与底部栏如何自动避让:
7.3 固定底栏的完整写法
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 横屏时,刘海会跑到左侧或右侧。如果你的页面有固定左侧栏或右侧操作按钮,需要同时处理左右安全区:
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 全屏视频与图片
全屏媒体元素最容易被刘海挡住关键内容。解决思路是:让背景铺满,让内容避让。
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。低于这个尺寸,误触率会急剧上升。
如果视觉上必须做小图标,可以用伪元素扩大可点击区域:
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 禁止缩放,牺牲无障碍
.search-input {
font-size: 16px;
transform: scale(0.875);
transform-origin: left center;
width: 114%; /* 补偿缩放导致的宽度损失 */
}
8.5 滚动穿透
当弹窗打开时,用户滚动弹窗内容,背后的页面也跟着滚——这就是滚动穿透。经典解法是给 body 加 overflow: hidden,但这在 iOS 上会造成页面跳回顶部。
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 阻止滚动链传到父级:
overflow-y: auto;
overscroll-behavior: contain; /* 滚动到底后不再传递 */
}
8.6 移动端没有 hover,但有 sticky hover
前面第五节已经提到,桌面端的 :hover 效果在触摸屏上会“粘住”。除了用 @media (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 明确声明意图:
overflow-x: auto;
touch-action: pan-x; /* 只响应水平滑动 */
scroll-snap-type: x mandatory;
}
.carousel .slide {
scroll-snap-align: center;
flex: 0 0 100%;
}
8.9 惯性滚动与滚动吸附
.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 的变化:
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 图片懒加载与占位
.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; }
下面是一个骨架屏的示例,它在图片和文字加载完成前提供视觉占位,能显著降低用户感知的等待时间:
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 可以让浏览器跳过屏幕外元素的渲染工作:
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() |
合成(但开销大) | 慎用 |
.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之类的组合)。
el.addEventListener('mouseenter', () => {
el.style.willChange = 'transform';
});
el.addEventListener('transitionend', () => {
el.style.willChange = 'auto';
});
9.5 字体加载策略
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 减少移动端的主线程负担
requestIdleCallback 或分片处理
offsetHeight 又写样式
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)
但要注意,DevTools 的模拟永远无法完全替代真机。以下几个问题只有在真机上才会暴露:
- 真实的
env(safe-area-inset-*)数值(DevTools 里通常都是 0); - iOS 的橡皮筋回弹、滚动惯性、输入框自动放大;
- 软键盘弹出时的视口变化;
- 真实的触摸事件序列与多点触控;
- 低端安卓机的实际渲染性能。
10.2 真机远程调试
// 1. 手机开启「开发者选项 → USB 调试」
// 2. USB 连接电脑
// 3. 电脑 Chrome 打开
chrome://inspect
// 4. 手机上打开目标页面
// 5. 点击 inspect 即可远程调试
// 1. 手机:设置 → Safari → 高级
// → 打开「网页检查器」
// 2. USB 连接电脑
// 3. 电脑 Safari:开发 → 你的设备
// → 选择目标页面
// 前提:电脑 Safari 的「开发」菜单需先在
// 偏好设置 → 高级 里勾选显示
10.3 移动端调试面板
当无法连接电脑时(比如测试同事的手机、客户现场演示),可以临时注入一个页面内的调试面板:
<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 动态根字号),今天可能已经变成了反模式。
但底层的判断标准其实一直没变:这个页面在各种屏幕、各种输入方式、各种用户设置下,是否依然能让人顺畅地读完、点中、用起来?抓住这个标准,剩下的都是工具选择的问题。
最后提醒一句:永远不要只在你的开发机上判断“适配做完了”。把页面发给同事,借一台安卓千元机,把系统字号调到最大,把屏幕转到横屏——你会发现自己漏掉的东西,往往比你想象的多。