跳到主要内容
预计阅读 39 分钟

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:初始缩放比例为 1
  • maximum-scale=1.0:最大缩放比例为 1(禁止放大)
  • user-scalable=no:禁止用户手动缩放

注意:出于无障碍的考虑,现代实践中一般不建议设置 maximum-scale=1.0user-scalable=no

第3题: 不一定。1rem 等于根元素 <html>font-size 值。浏览器默认的 font-size 是 16px,所以默认情况下 1rem = 16px。但在 rem 适配方案中,会根据屏幕宽度动态设置 htmlfont-size,所以 1rem 对应的像素值是动态变化的。

三种”像素”的概念

理解移动端适配的第一步,是搞清楚三种像素。

物理像素(Physical Pixel)

也叫设备像素,是屏幕上最小的发光单元。iPhone 14 的屏幕有 1170 x 2532 个物理像素点,这是一个硬件概念,出厂就固定了,软件改变不了。

逻辑像素(Logical Pixel)

也叫设备独立像素(DIP, Device Independent Pixel)。操作系统为了屏蔽不同屏幕密度的差异,抽象出来的一个单位。开发者面对的就是这个逻辑像素。

CSS 像素(CSS Pixel)

在没有缩放的情况下,1 个 CSS 像素等于 1 个逻辑像素。当用户缩放页面时,CSS 像素和逻辑像素的比例就会变化。

设备像素比(DPR)

DPR = 物理像素 / 逻辑像素
设备物理像素宽度DPRCSS 像素宽度
iPhone SE7502375
iPhone 1411703390
iPhone 14 Pro Max12903430

在 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
">
属性作用常用值
widthviewport 宽度device-width 或具体数值
initial-scale初始缩放比例1.0
minimum-scale最小缩放比例1.0
maximum-scale最大缩放比例1.05.0
user-scalable是否允许用户缩放yesno
viewport-fitviewport 如何填充屏幕auto / contain / cover

其中 viewport-fit=cover 是为刘海屏设计的,后面会详细讲。

推荐的基础配置

<!-- 标准配置 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">

如果你的项目不需要禁止缩放(大多数情况下不应该禁止),这一行就够了。

rem 适配方案

核心思路

rem 是相对于根元素 <html>font-size 的单位。如果 htmlfont-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 方案的缺点

  1. 需要 JavaScript 参与:页面加载时需要先执行 JS 设置 font-size,可能会有短暂的闪烁
  2. 影响根元素 font-size:可能和第三方库冲突
  3. 已被官方弃用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

特性remvw
是否需要 JS需要(设置根 font-size)不需要
浏览器兼容性好(iOS 8+、Android 4.4+)
精度受 font-size 精度影响直接基于视口,精度高
第三方库冲突可能(修改了根 font-size)不会
社区趋势逐渐被替代主流方案

vw 方案的注意事项

  1. 不建议用 vw 设置 font-size:会导致文字在大屏上过大、小屏上过小,影响阅读体验
  2. 可以配合 clamp() 做限制
.title {
  /* 字体大小在 14px 到 20px 之间,中间按 vw 缩放 */
  font-size: clamp(14px, 4vw, 20px);
}
  1. 横竖屏切换时 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(默认):等同于 contain
  • contain: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-leftsafe-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 等大屏设备上字体会非常大。对于字体,更好的做法是使用 pxclamp()

/* 不推荐 */
.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 混合方案
  • 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);
  }
}

关键点:

  1. 伪元素放大对应的倍数,然后缩小回来
  2. 圆角也需要等比放大
  3. pointer-events: none 防止伪元素遮挡点击事件
  4. DPR=3 的设备需要 3 倍放大 + 1/3 缩放

挑战三:综合(⭐⭐⭐)

请实现一个底部固定操作栏,要求:

  1. 固定在屏幕底部
  2. 高度 50px
  3. 在刘海屏设备上不被底部横条遮挡
  4. 页面内容滚动时不被操作栏遮挡(底部留出足够空间)
点击查看答案
<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;
}

关键点:

  1. viewport-fit=cover 让页面延伸到安全区域外
  2. env(safe-area-inset-bottom) 获取底部安全区域间距
  3. 操作栏的总高度要包含安全区域间距
  4. 页面内容的 padding-bottom 也要包含操作栏高度 + 安全区域间距
  5. constant()env() 都写上做兼容

自我检测

读完本章后,确认你能回答以下问题:

  • 能区分物理像素、逻辑像素、CSS 像素三个概念
  • 知道 DPR 的计算方式和如何获取
  • 知道 viewport meta 标签各属性的作用
  • 能解释 rem 适配方案的核心原理
  • 能解释 vw 方案相比 rem 方案的优势
  • 知道 1px 边框问题的原因和至少两种解决方案
  • 知道 env(safe-area-inset-*) 的用法
  • 能区分什么场景用 vw,什么场景用 px 或 clamp()
  • 知道 viewport-fit: cover 的作用

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90