CSS篇 | 移动端适配方案
前言
移动端适配是前端开发绕不开的话题。同一个页面,要在 iPhone SE 的 375px 小屏上好看,也要在 iPad Pro 的 1024px 大屏上不丑,这背后涉及到像素概念、viewport 配置、弹性布局方案等一系列知识。
面试中,移动端适配也是常考内容,尤其在做过移动端项目的候选人面前,面试官会问得很细:
- “物理像素和 CSS 像素有什么区别?”
- “你们项目是用 rem 还是 vw 做适配的?”
- “1px 边框在 Retina 屏上为什么看起来很粗?怎么解决?”
- “刘海屏的安全区域怎么适配?”
这些问题如果只是背答案,很容易被追问击穿。今天我们从底层概念开始,一层一层把移动端适配讲透。
诊断自测
在开始之前,试着回答以下问题:
1. iPhone 14 的屏幕分辨率是 1170 x 2532,但在 CSS 中我们通常按 390px 宽度来写样式,这是为什么?
2. 以下 viewport meta 标签中每个属性的作用是什么?
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
3. 1rem 等于多少像素?
点击查看答案
第1题: 因为 iPhone 14 的设备像素比(DPR)是 3,即 1 个 CSS 像素对应 3x3=9 个物理像素。所以 CSS 中的布局宽度 = 物理像素宽度 / DPR = 1170 / 3 = 390px。
第2题:
width=device-width:将 viewport 宽度设置为设备的逻辑像素宽度initial-scale=1.0:初始缩放比例为 1maximum-scale=1.0:最大缩放比例为 1(禁止放大)user-scalable=no:禁止用户手动缩放
注意:出于无障碍的考虑,现代实践中一般不建议设置 maximum-scale=1.0 和 user-scalable=no。
第3题: 不一定。1rem 等于根元素 <html> 的 font-size 值。浏览器默认的 font-size 是 16px,所以默认情况下 1rem = 16px。但在 rem 适配方案中,会根据屏幕宽度动态设置 html 的 font-size,所以 1rem 对应的像素值是动态变化的。
三种”像素”的概念
理解移动端适配的第一步,是搞清楚三种像素。
物理像素(Physical Pixel)
也叫设备像素,是屏幕上最小的发光单元。iPhone 14 的屏幕有 1170 x 2532 个物理像素点,这是一个硬件概念,出厂就固定了,软件改变不了。
逻辑像素(Logical Pixel)
也叫设备独立像素(DIP, Device Independent Pixel)。操作系统为了屏蔽不同屏幕密度的差异,抽象出来的一个单位。开发者面对的就是这个逻辑像素。
CSS 像素(CSS Pixel)
在没有缩放的情况下,1 个 CSS 像素等于 1 个逻辑像素。当用户缩放页面时,CSS 像素和逻辑像素的比例就会变化。
设备像素比(DPR)
DPR = 物理像素 / 逻辑像素
| 设备 | 物理像素宽度 | DPR | CSS 像素宽度 |
|---|---|---|---|
| iPhone SE | 750 | 2 | 375 |
| iPhone 14 | 1170 | 3 | 390 |
| iPhone 14 Pro Max | 1290 | 3 | 430 |
在 JavaScript 中可以通过 window.devicePixelRatio 获取当前设备的 DPR。
console.log(window.devicePixelRatio); // iPhone 14 输出 3
为什么要区分这三种像素?
因为在高 DPR 的设备上,1 个 CSS 像素实际上对应多个物理像素。这意味着:
- 一张 200x200 的图片在 DPR=2 的设备上会被拉伸到 400x400 个物理像素,看起来会模糊
- CSS 中写
border: 1px solid在 DPR=2 的设备上实际显示 2 个物理像素宽,看起来偏粗
这两个问题在后面会分别讨论。
viewport meta 标签
在移动端开发中,必须在 HTML 的 <head> 中加入 viewport meta 标签:
<meta name="viewport" content="width=device-width, initial-scale=1.0">
为什么需要这个标签?
早期移动浏览器为了能显示桌面端网页,默认将 viewport 宽度设为 980px(各浏览器略有不同),然后把整个页面缩小到屏幕宽度。这样虽然能看到完整页面,但文字很小,用户体验很差。
viewport meta 标签的作用就是告诉浏览器:“不用缩小了,我的页面是专门为移动端设计的。“
各属性详解
<meta name="viewport" content="
width=device-width,
initial-scale=1.0,
minimum-scale=1.0,
maximum-scale=1.0,
user-scalable=no,
viewport-fit=cover
">
| 属性 | 作用 | 常用值 |
|---|---|---|
| width | viewport 宽度 | device-width 或具体数值 |
| initial-scale | 初始缩放比例 | 1.0 |
| minimum-scale | 最小缩放比例 | 1.0 |
| maximum-scale | 最大缩放比例 | 1.0 或 5.0 |
| user-scalable | 是否允许用户缩放 | yes 或 no |
| viewport-fit | viewport 如何填充屏幕 | auto / contain / cover |
其中 viewport-fit=cover 是为刘海屏设计的,后面会详细讲。
推荐的基础配置
<!-- 标准配置 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
如果你的项目不需要禁止缩放(大多数情况下不应该禁止),这一行就够了。
rem 适配方案
核心思路
rem 是相对于根元素 <html> 的 font-size 的单位。如果 html 的 font-size 是 100px,那么 1rem = 100px。
rem 适配方案的核心思路就是:根据屏幕宽度动态设置 html 的 font-size,然后所有尺寸都用 rem 表示,从而实现等比缩放。
flexible.js 方案
早期最流行的 rem 方案来自手淘团队的 lib-flexible。它的核心逻辑很简单:
// 简化版 flexible.js 核心逻辑
(function() {
const docEl = document.documentElement;
const dpr = window.devicePixelRatio || 1;
function setRemUnit() {
const width = docEl.clientWidth;
// 以设计稿 750px 为基准,将屏幕宽度分为 10 份
// 在 375px 宽的设备上:html font-size = 37.5px
docEl.style.fontSize = width / 10 + 'px';
}
setRemUnit();
window.addEventListener('resize', setRemUnit);
})();
假设设计稿宽度为 750px,设计稿上一个元素宽度为 200px:
html font-size = 750 / 10 = 75px(设计稿基准)
元素宽度 = 200 / 75 = 2.6667rem
在 375px 宽的设备上:
html font-size = 375 / 10 = 37.5px
元素实际宽度 = 2.6667 * 37.5 = 100px(刚好是设计稿的一半,等比缩放)
配合 PostCSS 插件自动转换
手动计算 rem 太痛苦了,可以用 postcss-pxtorem 插件在编译时自动把 px 转为 rem:
// postcss.config.js
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 75, // 设计稿宽度 750 / 10
propList: ['*'],
selectorBlackList: ['.no-rem'] // 不需要转换的选择器
}
}
};
这样你写 CSS 时还是用 px(按设计稿标注),编译后自动变成 rem。
rem 方案的缺点
- 需要 JavaScript 参与:页面加载时需要先执行 JS 设置 font-size,可能会有短暂的闪烁
- 影响根元素 font-size:可能和第三方库冲突
- 已被官方弃用:
lib-flexible的 README 已经明确表示,推荐使用 vw 方案代替
vw/vh 方案
核心思路
vw(viewport width)和 vh(viewport height)是相对于视口尺寸的单位:
1vw= 视口宽度的 1%1vh= 视口高度的 1%
在 375px 宽的设备上,1vw = 3.75px。
使用方式
设计稿宽度 750px,一个元素宽度 200px:
元素宽度 = 200 / 750 * 100 = 26.6667vw
同样可以用 PostCSS 插件 postcss-px-to-viewport 自动转换:
// postcss.config.js
module.exports = {
plugins: {
'postcss-px-to-viewport': {
viewportWidth: 750, // 设计稿宽度
unitPrecision: 5,
viewportUnit: 'vw',
selectorBlackList: ['.no-vw'],
minPixelValue: 1
}
}
};
vw vs rem
| 特性 | rem | vw |
|---|---|---|
| 是否需要 JS | 需要(设置根 font-size) | 不需要 |
| 浏览器兼容性 | 好 | 好(iOS 8+、Android 4.4+) |
| 精度 | 受 font-size 精度影响 | 直接基于视口,精度高 |
| 第三方库冲突 | 可能(修改了根 font-size) | 不会 |
| 社区趋势 | 逐渐被替代 | 主流方案 |
vw 方案的注意事项
- 不建议用 vw 设置 font-size:会导致文字在大屏上过大、小屏上过小,影响阅读体验
- 可以配合
clamp()做限制:
.title {
/* 字体大小在 14px 到 20px 之间,中间按 vw 缩放 */
font-size: clamp(14px, 4vw, 20px);
}
- 横竖屏切换时 vw 值会变化:如果用 vw 设置了高度,横屏时可能出现意外效果
1px 边框问题
这是移动端开发中一个经典的”像素级”问题。
问题描述
在 DPR=2 的设备上,CSS 中的 border: 1px solid 实际会显示 2 个物理像素宽的边框,在 DPR=3 的设备上则是 3 个物理像素。看起来比设计稿上的”1 像素”要粗。
设计师说的”1px”通常指的是 1 个物理像素,而我们写的 1px 是 1 个 CSS 像素。
解决方案一:transform 缩放
最常用的方案,通过伪元素 + transform 实现真正的 0.5px 边框:
.border-1px {
position: relative;
}
.border-1px::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 200%;
height: 200%;
border: 1px solid #e5e5e5;
transform: scale(0.5);
transform-origin: left top;
box-sizing: border-box;
pointer-events: none;
border-radius: inherit;
}
原理:先把伪元素放大 2 倍,画 1px 的边框,再缩小 0.5 倍,边框就变成了 0.5px(即 1 个物理像素)。
解决方案二:直接使用 0.5px
在 iOS 8+ 和较新的 Android 浏览器上,可以直接写 0.5px:
.border-half {
border: 0.5px solid #e5e5e5;
}
但这个方案兼容性不够好,在某些 Android 设备上 0.5px 会被当作 0px 处理。可以做检测后降级:
if (window.devicePixelRatio >= 2) {
const testEl = document.createElement('div');
testEl.style.border = '0.5px solid transparent';
document.body.appendChild(testEl);
if (testEl.offsetHeight === 1) {
document.documentElement.classList.add('hairlines');
}
document.body.removeChild(testEl);
}
.hairlines .border-half {
border-width: 0.5px;
}
解决方案三:使用 SVG
通过 SVG 的 background-image 来绘制 1 物理像素的边框:
.border-svg {
border: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='1'%3E%3Crect width='100%25' height='0.5' fill='%23e5e5e5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: bottom;
}
解决方案四:viewport 缩放
修改 viewport 的 initial-scale,让 1 个 CSS 像素等于 1 个物理像素:
const scale = 1 / window.devicePixelRatio;
const viewport = document.querySelector('meta[name="viewport"]');
viewport.content = `width=device-width, initial-scale=${scale}, maximum-scale=${scale}`;
这个方案很暴力但有效,不过会影响整个页面的尺寸计算,需要配合 rem 方案一起使用。
安全区域适配
随着 iPhone X 引入了刘海屏(notch)和底部的小横条(Home Indicator),安全区域适配成了移动端开发的新课题。
什么是安全区域?
安全区域(Safe Area)是指屏幕上不被刘海、圆角、底部横条遮挡的区域。内容应该放在安全区域内,否则可能被遮挡或被误触。
viewport-fit
首先需要在 viewport meta 标签中设置 viewport-fit=cover:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
viewport-fit 有三个值:
auto(默认):等同于containcontain:viewport 完全包含网页内容,安全区域外可能留白cover:网页内容完全覆盖 viewport,内容可能被遮挡
设置 cover 后,网页会延伸到安全区域之外,然后我们可以通过 CSS 环境变量来处理安全区域的内边距。
env() 和 constant()
CSS 提供了环境变量来获取安全区域的间距:
/* iOS 11.0-11.2 使用 constant() */
/* iOS 11.2+ 使用 env() */
.container {
/* 兼容写法,两个都写 */
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
padding-left: constant(safe-area-inset-left);
padding-left: env(safe-area-inset-left);
padding-right: constant(safe-area-inset-right);
padding-right: env(safe-area-inset-right);
}
四个变量分别对应上、下、左、右的安全区域间距。
实际应用:底部固定栏适配
底部固定的导航栏或操作栏,在刘海屏上最容易出问题:
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 50px;
background: #fff;
/* 在安全区域内 */
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
如果要用 calc 配合:
.bottom-bar {
height: calc(50px + constant(safe-area-inset-bottom));
height: calc(50px + env(safe-area-inset-bottom));
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
横屏适配
当手机横屏时,左右两侧都可能有安全区域间距(因为刘海在侧面),需要注意 safe-area-inset-left 和 safe-area-inset-right:
.content {
padding-left: max(16px, env(safe-area-inset-left));
padding-right: max(16px, env(safe-area-inset-right));
}
max() 函数确保即使没有安全区域间距,也至少有 16px 的内边距。
综合方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| rem + flexible.js | 动态设置根 font-size | 成熟稳定 | 需要 JS,已被弃用 |
| vw/vh | 视口相对单位 | 纯 CSS,精度高 | 文字大小需要限制 |
| rem + vw | 用 vw 设置根 font-size | 纯 CSS,兼顾 rem 和 vw | 需要计算 |
| 响应式(媒体查询) | 断点适配 | 灵活 | 工作量大 |
当前推荐的主流方案:
/* 用 vw 设置根 font-size,配合 rem 使用 */
html {
/* 设计稿 750px,1rem = 100px(在 750px 设计稿下) */
/* 100 / 750 * 100 = 13.33333vw */
font-size: 13.33333vw;
}
/* 限制最大最小值 */
@media screen and (max-width: 320px) {
html { font-size: 42.667px; } /* 320 / 750 * 100 */
}
@media screen and (min-width: 750px) {
html { font-size: 100px; }
}
常见误区
误区一:DPR 越高页面越大
DPR 只影响物理像素和逻辑像素的比例,不影响 CSS 布局。在 DPR=2 和 DPR=3 的设备上,同样的 CSS 代码渲染出来的布局尺寸是一样的(前提是正确设置了 viewport)。DPR 高只意味着屏幕更精细,不是更大。
误区二:vw 单位在所有场景都优于 rem
vw 直接和视口挂钩,如果用 vw 设置字体大小,在 iPad 等大屏设备上字体会非常大。对于字体,更好的做法是使用 px 或 clamp():
/* 不推荐 */
.text { font-size: 4.267vw; }
/* 推荐 */
.text { font-size: clamp(14px, 4.267vw, 18px); }
误区三:1px 问题只是”设计强迫症”
1px 问题在 DPR=2 的设备上差别不大,但在 DPR=3 的设备上,3 个物理像素的边框确实明显偏粗,特别是在细线条、分割线的场景下,用户是能感知到的。这不是强迫症,而是影响视觉品质的细节。
误区四:安全区域适配只需要处理底部
虽然底部横条是最常遇到的问题,但横屏模式下左右两侧也需要处理。而且刘海屏的顶部安全区域间距,在全屏页面(比如 H5 活动页)中也需要考虑。
小结
移动端适配是一个系统性的问题,从像素概念到 viewport 配置,从弹性布局到安全区域,每个环节都需要理解。
本章思维导图
- 像素概念
- 物理像素(硬件)
- 逻辑像素(操作系统抽象)
- CSS 像素
- DPR = 物理像素 / 逻辑像素
- viewport meta 标签
- width=device-width
- initial-scale
- user-scalable
- viewport-fit
- 适配方案
- rem 方案
- 动态设置根 font-size
- flexible.js(已弃用)
- postcss-pxtorem
- vw/vh 方案
- 纯 CSS,无需 JS
- postcss-px-to-viewport
- 字体用 clamp() 限制
- rem + vw 混合方案
- rem 方案
- 1px 边框问题
- 原因:CSS 1px ≠ 物理 1px
- transform 缩放(主流)
- 直接写 0.5px(兼容性一般)
- viewport 缩放(暴力但有效)
- 安全区域适配
- viewport-fit: cover
- env(safe-area-inset-*)
- 底部固定栏适配
- 横屏适配
练习挑战
挑战一:基础(⭐)
在一台 DPR=2、逻辑像素宽度 375px 的设备上,设计稿宽度 750px。设计稿上一个按钮宽 300px,高 80px。请分别用 rem 和 vw 写出这个按钮的 CSS。
假设 rem 方案中 html font-size = 屏幕宽度 / 10。
点击查看答案
rem 方案:
设计稿基准 font-size = 750 / 10 = 75px
.button {
width: 4rem; /* 300 / 75 = 4 */
height: 1.0667rem; /* 80 / 75 ≈ 1.0667 */
}
在 375px 设备上,html font-size = 375 / 10 = 37.5px 按钮实际宽度 = 4 * 37.5 = 150px 按钮实际高度 = 1.0667 * 37.5 = 40px
vw 方案:
.button {
width: 40vw; /* 300 / 750 * 100 = 40 */
height: 10.667vw; /* 80 / 750 * 100 ≈ 10.667 */
}
在 375px 设备上: 按钮实际宽度 = 40% * 375 = 150px 按钮实际高度 = 10.667% * 375 = 40px
两种方案最终效果一致,都是设计稿尺寸的一半(因为 DPR=2)。
挑战二:进阶(⭐⭐)
请用 CSS 实现一个在 DPR >= 2 设备上只有 1 物理像素的下边框。要求兼容圆角。
点击查看答案
.thin-border {
position: relative;
border-radius: 8px;
overflow: hidden;
}
.thin-border::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 200%;
height: 200%;
border-bottom: 1px solid #ddd;
border-radius: 16px; /* 圆角也要放大 2 倍 */
transform: scale(0.5);
transform-origin: left top;
box-sizing: border-box;
pointer-events: none;
}
/* 如果需要适配 DPR=3 */
@media (-webkit-min-device-pixel-ratio: 3) {
.thin-border::after {
width: 300%;
height: 300%;
border-radius: 24px;
transform: scale(0.3333);
}
}
关键点:
- 伪元素放大对应的倍数,然后缩小回来
- 圆角也需要等比放大
pointer-events: none防止伪元素遮挡点击事件- DPR=3 的设备需要 3 倍放大 + 1/3 缩放
挑战三:综合(⭐⭐⭐)
请实现一个底部固定操作栏,要求:
- 固定在屏幕底部
- 高度 50px
- 在刘海屏设备上不被底部横条遮挡
- 页面内容滚动时不被操作栏遮挡(底部留出足够空间)
点击查看答案
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<div class="page-content">
<!-- 页面内容 -->
</div>
<div class="bottom-bar">
<button>操作按钮</button>
</div>
.page-content {
/* 底部留出操作栏 + 安全区域的空间 */
padding-bottom: calc(50px + constant(safe-area-inset-bottom));
padding-bottom: calc(50px + env(safe-area-inset-bottom));
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
/* 总高度 = 操作栏高度 + 安全区域间距 */
height: calc(50px + constant(safe-area-inset-bottom));
height: calc(50px + env(safe-area-inset-bottom));
/* 内容区域 50px,安全区域用 padding 填充 */
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
background: #fff;
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.1);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
关键点:
viewport-fit=cover让页面延伸到安全区域外env(safe-area-inset-bottom)获取底部安全区域间距- 操作栏的总高度要包含安全区域间距
- 页面内容的
padding-bottom也要包含操作栏高度 + 安全区域间距 constant()和env()都写上做兼容
自我检测
读完本章后,确认你能回答以下问题:
- 能区分物理像素、逻辑像素、CSS 像素三个概念
- 知道 DPR 的计算方式和如何获取
- 知道 viewport meta 标签各属性的作用
- 能解释 rem 适配方案的核心原理
- 能解释 vw 方案相比 rem 方案的优势
- 知道 1px 边框问题的原因和至少两种解决方案
- 知道
env(safe-area-inset-*)的用法 - 能区分什么场景用 vw,什么场景用 px 或 clamp()
- 知道
viewport-fit: cover的作用
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90