移动端适配方案
What — 是什么
移动端适配是指通过一系列技术手段,让同一套代码在不同尺寸、不同分辨率、不同像素密度的移动设备上呈现出一致且合理的UI效果。
核心概念:
- CSS像素(CSS Pixel):浏览器使用的逻辑像素单位,CSS代码中写的
px就是CSS像素,它是相对单位 - 物理像素(Physical Pixel):屏幕实际拥有的发光点数,设备出厂就决定了,是绝对单位
- 设备像素比(DPR):
DPR = 物理像素数 / CSS像素数,iPhone6的DPR为2,即1个CSS像素对应2×2=4个物理像素 - viewport(视口):浏览器用来展示网页的区域,分为布局视口、视觉视口和理想视口三种
- 理想视口(Ideal Viewport):与设备屏幕等宽的视口,是移动端适配的基准,通常通过meta标签设置
关键特性:
- 移动端屏幕尺寸碎片化严重,从320px到428px+宽度不等,需要一套代码自适应
- 高清屏(Retina)下DPR≥2,1个CSS像素被多个物理像素渲染,导致图片模糊、边框变粗
- 移动浏览器默认视口(布局视口)通常为980px,会导致页面缩小显示,必须设置理想视口
- iOS安全区域(刘海屏、底部横条)需要额外适配,否则内容被遮挡
- 触摸交互有别于鼠标,存在300ms点击延迟等特有问题
viewport三种视口详解:
| 视口类型 | 说明 | 获取方式 |
|---|---|---|
| 布局视口(Layout Viewport) | 浏览器默认的页面渲染区域,移动端通常980px | document.documentElement.clientWidth |
| 视觉视口(Visual Viewport) | 用户当前可见的页面区域,会随缩放变化 | window.innerWidth |
| 理想视口(Ideal Viewport) | 与设备屏幕CSS宽度一致的视口 | 设置meta后与布局视口一致 |
Why — 为什么
适用场景:
- H5营销活动页,需要在各种手机上展示一致的视觉效果
- 移动端电商页面,商品布局和字体大小需要合理适配
- Web App / PWA应用,需接近原生App的视觉体验
- 微信公众号内嵌H5,用户设备不可控
- 跨平台混合开发(WebView容器),一套代码多端运行
- 响应式网站在移动端的显示优化
对比替代方案:
| 维度 | rem适配 | vw适配 | vw+rem组合 | 媒体查询 |
|---|---|---|---|---|
| 原理 | 根据屏幕宽度动态设置html的font-size | 利用vw单位直接相对于视口宽度 | vw设置根字号,rem继承根字号 | 针对断点写不同样式 |
| 兼容性 | 极佳(Android 2.1+) | 好(Android 4.4+) | 好(Android 4.4+) | 极佳 |
| 精确度 | 高(JS动态计算) | 高(浏览器原生) | 高 | 低(区间跳跃) |
| 依赖 | 需要lib-flexible等JS库 | 无依赖 | 无依赖 | 无依赖 |
| 维护性 | 需维护JS+PostCSS | 只需PostCSS | 只需PostCSS | 纯CSS手动维护 |
| 极端屏幕 | 可设最大最小值 | 需额外限制 | 可通过rem限制 | 天然断点 |
| SSR支持 | 不友好(依赖JS) | 友好(纯CSS) | 友好(纯CSS) | 友好 |
| 缩放适配 | 需监听resize | 自动响应 | 自动响应 | 自动响应 |
优缺点:
- ✅ 优点:
- 一套代码适配多端,开发效率高
- vw方案无JS依赖,性能好、支持SSR
- rem方案兼容性好,适合需要兼容老旧设备的场景
- vw+rem组合方案兼顾两者优势,可限制极端屏幕下的缩放
- PostCSS插件自动转换,开发时仍用px写代码,心智负担低
- ❌ 缺点:
- 无法完全还原设计稿的像素级精确(字体、间距的等比缩放可能过大或过小)
- 大屏设备上等比放大可能不美观(如平板),需额外限制最大宽度
- rem方案依赖JS,首屏渲染可能闪动
- 横竖屏切换时需要额外处理
- 部分第三方组件库可能不支持rem/vw,需额外配置
How — 怎么用
快速上手
第一步:设置viewport meta标签(所有方案通用)
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
第二步:选择适配方案(以vw方案为例)
npm install postcss-px-to-viewport-8-plugin -D
// postcss.config.js
module.exports = {
plugins: {
'postcss-px-to-viewport-8-plugin': {
viewportWidth: 375, // 设计稿宽度
unitPrecision: 5, // 小数位数
viewportUnit: 'vw', // 转换单位
selectorBlackList: ['.ignore'], // 忽略的类名
minPixelValue: 1, // 小于1px不转换
mediaQuery: false, // 不转换媒体查询中的px
}
}
}
第三步:按设计稿用px写代码,构建时自动转换
.container {
width: 375px; /* → 100vw */
padding: 16px; /* → 4.26667vw */
font-size: 14px; /* → 3.73333vw */
}
viewport meta标签详解
<!-- 完整viewport meta标签 -->
<meta name="viewport"
content="
width=device-width, /* 视口宽度=设备宽度,即理想视口 */
initial-scale=1.0, /* 初始缩放比例1:1 */
maximum-scale=1.0, /* 最大缩放比例(限制用户缩放) */
minimum-scale=1.0, /* 最小缩放比例 */
user-scalable=no, /* 禁止用户手动缩放 */
viewport-fit=cover /* 适配刘海屏,内容覆盖安全区域 */
"
>
各属性详解:
| 属性 | 作用 | 推荐值 | 说明 |
|---|---|---|---|
width | 设置布局视口宽度 | device-width | 关键属性,让视口=屏幕宽度 |
initial-scale | 初始缩放比例 | 1.0 | 设为1.0才能得到理想视口 |
maximum-scale | 最大缩放比例 | 1.0 或 5.0 | 无障碍要求允许缩放到5倍 |
minimum-scale | 最小缩放比例 | 1.0 | 防止页面缩小 |
user-scalable | 是否允许用户缩放 | no 或不设 | iOS10+已忽略此属性 |
viewport-fit | 视口覆盖模式 | cover | 自动/auto/cover/contain |
viewport-fit模式:
/* cover: 页面内容覆盖整个屏幕(含安全区外) */
<meta name="viewport" content="width=device-width, viewport-fit=cover">
/* contain: 页面内容仅在安全区内显示(默认值auto等同contain) */
<meta name="viewport" content="width=device-width, viewport-fit=contain">
rem适配方案
方案原理: 根据屏幕宽度动态设置html元素的font-size,所有使用rem单位的元素随之等比缩放。
1. lib-flexible方案(经典)
npm install lib-flexible -S
npm install postcss-pxtorem -D
// main.js 入口引入
import 'lib-flexible/flexible'
// postcss.config.js
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 37.5, // 设计稿宽度375 / 10 = 37.5
propList: ['*', '!border*'], // 需要转换的属性,border不转换避免1px问题
selectorBlackList: ['.van-'], // 忽略vant组件
minPixelValue: 2, // 小于2px不转换
}
}
}
2. 手写flexible核心逻辑
// flexible.js — 核心只有十几行
(function flexible(window, document) {
var docEl = document.documentElement;
var dpr = window.devicePixelRatio || 1;
// 设置data-dpr属性,CSS中可根据dpr做差异化处理
docEl.setAttribute('data-dpr', Math.floor(dpr));
function setRemUnit() {
var rem = docEl.clientWidth / 10; // 将屏幕宽度10等分
docEl.style.fontSize = rem + 'px';
}
setRemUnit();
// 页面resize时重新计算
window.addEventListener('resize', function () {
setRemUnit();
});
// pageshow处理从缓存加载的页面(iOS后退不触发resize)
window.addEventListener('pageshow', function (e) {
if (e.persisted) {
setRemUnit();
}
});
})(window, document);
3. rem方案的CSS写法(根据dpr差异化字体)
/* 根据data-dpr属性设置不同dpr下的字体大小,保证文字清晰 */
[data-dpr="1"] .text { font-size: 12px; }
[data-dpr="2"] .text { font-size: 24px; } /* DPR=2时2倍 */
[data-dpr="3"] .text { font-size: 36px; } /* DPR=3时3倍 */
/* 常规rem布局 */
.container {
width: 10rem; /* 满屏宽度 */
padding: 0.4rem; /* 375/10=37.5 → 15px */
font-size: 0.37333rem; /* 14px */
}
4. rem方案限制极端屏幕
// 限制最大最小rem,防止在平板/电视上过大
function setRemUnit() {
var clientWidth = docEl.clientWidth;
// 限制在320-540之间
clientWidth = Math.max(320, Math.min(clientWidth, 540));
var rem = clientWidth / 10;
docEl.style.fontSize = rem + 'px';
}
vw/vh适配方案
方案原理: vw是视口宽度的1/100,1vw = 视口宽度的1%。无需JS计算,纯CSS方案。
1. postcss-px-to-viewport配置
npm install postcss-px-to-viewport-8-plugin -D
// postcss.config.js
module.exports = {
plugins: {
'postcss-px-to-viewport-8-plugin': {
viewportWidth: 375, // 设计稿宽度(UI稿标准)
unitPrecision: 5, // vw小数精度
viewportUnit: 'vw', // 转换单位
selectorBlackList: ['.ignore', '.van-'], // 不转换的选择器
minPixelValue: 1, // 小于1px不转换
mediaQuery: false, // 媒体查询中px不转换
exclude: [/node_modules/], // 排除第三方库
include: [/src/], // 只处理src目录
}
}
}
2. vw适配的CSS写法
/* 开发时用px,构建后自动转vw */
.header {
height: 44px; /* → 11.73333vw */
padding: 0 16px; /* → 0 4.26667vw */
font-size: 16px; /* → 4.26667vw */
}
.card {
width: 343px; /* → 91.46667vw */
margin: 0 auto 12px;/* → 0 auto 3.2vw */
border-radius: 8px; /* → 2.13333vw */
}
/* 不希望被转换的样式,加上.ignore类名 */
.ignore-fixed {
position: fixed;
bottom: 0;
height: 50px; /* 保持50px,不转换 */
}
3. vw限制极端屏幕
/* 利用clamp()限制根字号,间接限制vw缩放 */
html {
/* 设计稿375:根字号100px → 100/375*100 = 26.6667vw */
/* 限制在 320px(85.3333vw对应320) 到 540px(144vw对应540) */
font-size: clamp(85.333px, 26.6667vw, 144px);
}
/* 或使用min+max */
html {
font-size: max(85.333px, min(26.6667vw, 144px));
}
vw+rem组合方案
方案原理: 用vw设置html根字号,其他元素用rem。兼顾vw的无JS优势和rem的字号继承特性,同时方便限制极端屏幕。
/* 核心设置:vw设置根字号 */
html {
/* 设计稿375宽度,1rem = 100px → 100/375*100 ≈ 26.6667vw */
font-size: 26.6667vw;
}
/* 限制极端屏幕 */
@media screen and (min-width: 540px) {
html { font-size: 144px; } /* 540 * 0.266667 = 144 */
}
@media screen and (max-width: 320px) {
html { font-size: 85.333px; } /* 320 * 0.266667 ≈ 85.33 */
}
/* 使用rem写样式,1rem = 100px(设计稿中) */
.container {
width: 3.75rem; /* 375/100 = 3.75rem → 满屏 */
padding: 0.16rem; /* 16/100 = 0.16rem */
font-size: 0.14rem; /* 14/100 = 0.14rem */
}
.box {
width: 1.6rem; /* 160px */
height: 1.2rem; /* 120px */
margin: 0.12rem auto; /* 12px auto */
}
PostCSS自动转换配置:
// postcss.config.js — vw+rem组合
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 100, // 1rem = 100px
propList: ['*'],
selectorBlackList: ['.van-'],
minPixelValue: 2,
}
}
}
// html的font-size手动用vw设置,不经过PostCSS转换
1px边框问题的5种解决方案
问题原因: DPR≥2的设备上,CSS写的1px边框实际由2×2=4个物理像素渲染,视觉上看起来比设计稿的1物理像素粗。
方案一:viewport缩放(最优雅)
<!-- 根据DPR动态设置viewport的scale -->
<script>
var dpr = window.devicePixelRatio || 1;
var scale = 1 / dpr;
var viewport = document.querySelector('meta[name=viewport]');
viewport.setAttribute('content',
'width=device-width, initial-scale=' + scale +
', maximum-scale=' + scale + ', minimum-scale=' + scale +
', user-scalable=no, viewport-fit=cover'
);
// 同时需要将html的font-size放大dpr倍来抵消缩放
document.documentElement.style.fontSize = document.documentElement.clientWidth * dpr / 10 + 'px';
</script>
/* 缩放后1px边框就真正是1物理像素 */
.border-1px {
border-bottom: 1px solid #eee;
}
/* 但注意所有px尺寸都要用rem,因为页面被缩小了 */
.container {
width: 10rem;
font-size: 0.28rem; /* 实际显示14px */
}
方案二:border-image
/* 用2倍图做1px边框 */
.border-image-1px {
border-width: 0 0 1px 0;
border-style: solid;
border-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAKCAYAAABqS2EJAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAABlJREFUeNpi+P///wMIMIiC9YTBIM0AAbYDCgMCAAQBADaeOQbGCn8AAAAAElFTkSuQmCC") 2 stretch;
}
/* 缺点:颜色不灵活,圆角不支持 */
方案三:transform scale(最常用)
/* 通用1px边框mixin */
.border-1px {
position: relative;
}
.border-1px::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 1px;
background-color: #eee;
transform: scaleY(0.5);
transform-origin: 0 0;
}
/* 4边1px边框 */
.border-1px-all {
position: relative;
}
.border-1px-all::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 200%;
height: 200%;
border: 1px solid #eee;
transform: scale(0.5);
transform-origin: 0 0;
pointer-events: none; /* 不影响点击事件 */
}
/* 根据DPR选择缩放比 */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
.border-1px::after { transform: scaleY(0.5); }
.border-1px-all::after { transform: scale(0.5); }
}
@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) {
.border-1px::after { transform: scaleY(0.333); }
.border-1px-all::after { transform: scale(0.333); }
}
Sass/SCSS封装:
// 1px边框mixin
@mixin border-1px($color: #eee, $direction: bottom, $radius: 0) {
position: relative;
&::after {
content: '';
position: absolute;
background-color: $color;
@if $direction == bottom {
left: 0;
bottom: 0;
width: 100%;
height: 1px;
transform: scaleY(0.5);
transform-origin: 0 0;
} @else if $direction == top {
left: 0;
top: 0;
width: 100%;
height: 1px;
transform: scaleY(0.5);
transform-origin: 0 0;
} @else if $direction == left {
left: 0;
top: 0;
width: 1px;
height: 100%;
transform: scaleX(0.5);
transform-origin: 0 0;
} @else if $direction == right {
right: 0;
top: 0;
width: 1px;
height: 100%;
transform: scaleX(0.5);
transform-origin: 0 0;
} @else if $direction == all {
left: 0;
top: 0;
width: 200%;
height: 200%;
border: 1px solid $color;
border-radius: $radius * 2;
transform: scale(0.5);
transform-origin: 0 0;
}
@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) {
@if $direction == bottom or $direction == top {
transform: scaleY(0.333);
} @else if $direction == left or $direction == right {
transform: scaleX(0.333);
} @else if $direction == all {
transform: scale(0.333);
}
}
pointer-events: none;
}
}
// 使用
.card {
@include border-1px(#ddd, bottom);
}
.box {
@include border-1px(#ccc, all, 8px);
}
方案四:SVG
/* 利用SVG画1px线,矢量不失真 */
.border-svg-1px {
border-bottom: 1px solid transparent;
border-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3Crect width='1' height='0.5' fill='%23eee'/%3E%3C/svg%3E") 1 stretch;
}
/* 支持圆角的SVG方案 */
.border-svg-radius {
position: relative;
border-radius: 8px;
}
.border-svg-radius::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 8px;
border: 1px solid transparent;
border-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3Crect width='1' height='0.5' fill='%23eee'/%3E%3C/svg%3E") 1 stretch;
pointer-events: none;
}
方案五:meta缩放(全局方案)
<!-- 与方案一类似,但更简洁的写法 -->
<script>
!function () {
var dpr = window.devicePixelRatio || 1;
document.write(
'<meta name="viewport" content="width=' +
screen.width * dpr +
', initial-scale=' + (1 / dpr) +
', maximum-scale=' + (1 / dpr) +
', minimum-scale=' + (1 / dpr) +
', user-scalable=no">'
);
}();
</script>
/* 此方案下1px直接就是1物理像素,无需额外处理 */
.thin-border {
border: 1px solid #eee; /* 在DPR=2设备上显示为真正的1物理像素 */
}
5种方案对比:
| 方案 | 原理 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|---|
| viewport缩放 | 整体缩放页面 | 一劳永逸,所有1px自动变细 | 影响全部尺寸,需配合rem | ★★★★★ |
| border-image | 图片做边框 | 简单 | 颜色不灵活,不支持圆角 | ★★ |
| transform scale | 伪元素缩放 | 兼容性好,灵活 | 伪元素占位,代码多 | ★★★★ |
| SVG | 矢量线做边框 | 颜色可控 | 圆角实现复杂 | ★★★ |
| meta缩放 | 全局viewport缩放 | 最简洁 | SSR不友好,document.write | ★★★ |
安全区域适配
1. 基本概念与meta设置
<!-- 设置viewport-fit=cover让内容覆盖安全区域 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
2. env()函数适配安全区域
/* env()安全区域变量 */
:root {
/* 四个方向的安全区域内边距 */
--safe-area-top: env(safe-area-inset-top); /* 顶部安全区(状态栏/刘海) */
--safe-area-right: env(safe-area-inset-right); /* 右侧安全区(横屏刘海) */
--safe-area-bottom: env(safe-area-inset-bottom); /* 底部安全区(Home Indicator横条) */
--safe-area-left: env(safe-area-inset-left); /* 左侧安全区(横屏刘海) */
}
/* 顶部导航栏适配刘海 */
.header {
padding-top: env(safe-area-inset-top); /* 非刘海屏为0,刘海屏为44px */
height: calc(44px + env(safe-area-inset-top));
}
/* 底部固定栏适配Home Indicator */
.footer-fixed {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
padding-bottom: env(safe-area-inset-bottom); /* 非刘海屏为0,刘海屏为34px */
}
/* 完整的安全区域适配布局 */
.page {
padding-top: constant(safe-area-inset-top); /* iOS 11.0 */
padding-top: env(safe-area-inset-top); /* iOS 11.2+ */
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
/* 两侧适配(横屏刘海) */
.content {
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);
}
3. 全面屏底部安全区最佳实践
/* 方案一:直接用padding-bottom */
.bottom-bar {
position: fixed;
bottom: 0;
width: 100%;
height: 50px;
padding-bottom: env(safe-area-inset-bottom);
background: #fff;
}
/* 方案二:用额外div做安全区填充 */
.safe-area-bottom {
position: fixed;
bottom: 0;
width: 100%;
height: env(safe-area-inset-bottom);
background: #fff; /* 与底部栏同色 */
}
/* 方案三:calc计算完整高度 */
.bottom-bar-full {
position: fixed;
bottom: 0;
width: 100%;
height: calc(50px + env(safe-area-inset-bottom));
box-sizing: border-box;
padding-bottom: env(safe-area-inset-bottom);
}
横屏适配处理
1. CSS媒体查询检测横竖屏
/* orientation媒体查询 */
@media screen and (orientation: portrait) {
/* 竖屏样式 */
.layout {
flex-direction: column;
}
.sidebar {
width: 100%;
height: 200px;
}
}
@media screen and (orientation: landscape) {
/* 横屏样式 */
.layout {
flex-direction: row;
}
.sidebar {
width: 200px;
height: 100vh;
}
}
2. 强制竖屏(锁定屏幕方向)
// 使用Screen Orientation API锁定竖屏
function lockPortrait() {
if (screen.orientation && screen.orientation.lock) {
screen.orientation.lock('portrait').catch(function () {
// 部分浏览器不支持锁定
});
}
}
// 监听方向变化
window.addEventListener('orientationchange', function () {
if (Math.abs(window.orientation) === 90) {
// 横屏状态,提示用户旋转设备
showRotateTip();
} else {
// 竖屏状态,隐藏提示
hideRotateTip();
}
});
3. 横屏提示遮罩(最常用方案)
<!-- 横屏时显示旋转提示 -->
<div class="rotate-tip" id="rotateTip">
<div class="rotate-icon">📱</div>
<p>请旋转手机至竖屏查看</p>
</div>
.rotate-tip {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 9999;
justify-content: center;
align-items: center;
flex-direction: column;
color: #fff;
}
.rotate-icon {
animation: rotate-anim 1.5s ease-in-out infinite;
}
@keyframes rotate-anim {
0% { transform: rotate(0deg); }
50% { transform: rotate(-90deg); }
100% { transform: rotate(0deg); }
}
/* 横屏时显示提示 */
@media screen and (orientation: landscape) {
.rotate-tip {
display: flex;
}
}
图片适配
1. srcset属性 — 根据DPR加载不同图片
<!-- 根据设备像素比选择图片 -->
<img
src="image-1x.jpg"
srcset="image-1x.jpg 1x, image-2x.jpg 2x, image-3x.jpg 3x"
alt="适配不同DPR的图片"
>
<!-- 根据视口宽度选择图片(w描述符) -->
<img
src="image-400.jpg"
srcset="image-200.jpg 200w, image-400.jpg 400w, image-800.jpg 800w, image-1600.jpg 1600w"
sizes="(max-width: 375px) 200px, (max-width: 768px) 400px, 800px"
alt="响应式图片"
>
2. picture元素 — 根据条件加载不同图片
<picture>
<!-- 横屏加载宽图 -->
<source media="(orientation: landscape)" srcset="wide-image.jpg">
<!-- 竖屏加载窄图 -->
<source media="(orientation: portrait)" srcset="narrow-image.jpg">
<!-- 默认fallback -->
<img src="narrow-image.jpg" alt="方向适配图片">
</picture>
<picture>
<!-- WebP格式优先 -->
<source type="image/webp" srcset="photo.webp">
<!-- AVIF更优 -->
<source type="image/avif" srcset="photo.avif">
<!-- 兜底JPEG -->
<img src="photo.jpg" alt="格式适配图片">
</picture>
3. CSS背景图适配
/* 使用image-set根据DPR选择背景图 */
.hero {
background-image: image-set(
"hero-1x.jpg" 1x,
"hero-2x.jpg" 2x,
"hero-3x.jpg" 3x
);
background-size: cover;
}
/* 使用媒体查询 */
.hero {
background-image: url("hero-1x.jpg");
}
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
.hero {
background-image: url("hero-2x.jpg");
}
}
@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) {
.hero {
background-image: url("hero-3x.jpg");
}
}
触摸事件处理
1. 300ms点击延迟问题
<!-- 方案一:设置viewport的user-scalable=no(最简单) -->
<meta name="viewport" content="width=device-width, user-scalable=no">
<!-- 方案二:设置touch-action(推荐) -->
/* 禁用双击缩放,消除300ms延迟 */
* {
touch-action: manipulation; /* 只允许平移和缩放手势,禁用双击缩放 */
}
/* touch-action值说明 */
.no-scroll {
touch-action: none; /* 禁止所有触摸手势 */
}
.horizontal-scroll {
touch-action: pan-y; /* 只允许纵向平移 */
}
.pinch-zoom {
touch-action: pan-x pan-y pinch-zoom; /* 允许平移和缩放 */
}
2. FastClick库(兼容旧浏览器)
npm install fastclick -S
// main.js
import FastClick from 'fastclick';
// 消除300ms延迟
if ('addEventListener' in document) {
document.addEventListener('DOMContentLoaded', function () {
FastClick.attach(document.body);
}, false);
}
// 现代浏览器不需要FastClick,可以按需引入
if ('ontouchstart' in window && !('touchAction' in document.documentElement.style)) {
// 仅在需要时加载
import('fastclick').then(module => {
module.attach(document.body);
});
}
3. passive事件监听
// passive: true 告诉浏览器不会调用preventDefault
// 浏览器可以不等JS执行完就开始滚动,提升滚动性能
// ❌ 错误:默认passive为false,会阻塞滚动
document.addEventListener('touchmove', function (e) {
// 这里即使不调用preventDefault,也会阻塞浏览器滚动
}, false);
// ✅ 正确:声明passive: true
document.addEventListener('touchmove', function (e) {
// 纯监听,不阻止默认行为
console.log('touching', e.touches[0].clientX);
}, { passive: true });
// ✅ 需要阻止默认行为时不设passive
document.addEventListener('touchmove', function (e) {
e.preventDefault(); // 阻止滚动(如自定义下拉刷新)
}, { passive: false });
// Chrome默认对touchstart/touchmove设置了passive:true
// 如果需要preventDefault,必须显式设passive:false
4. 触摸事件基础用法
// 触摸事件类型:touchstart / touchmove / touchend / touchcancel
const element = document.querySelector('.swipe-area');
let startX = 0, startY = 0;
element.addEventListener('touchstart', function (e) {
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
}, { passive: true });
element.addEventListener('touchmove', function (e) {
const touch = e.touches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
// 判断滑动方向
if (Math.abs(deltaX) > Math.abs(deltaY)) {
console.log(deltaX > 0 ? '向右滑' : '向左滑');
} else {
console.log(deltaY > 0 ? '向下滑' : '向上滑');
}
}, { passive: true });
// 点击(tap)判定:移动距离小于10px且时间小于300ms
element.addEventListener('touchend', function (e) {
const touch = e.changedTouches[0];
const deltaX = Math.abs(touch.clientX - startX);
const deltaY = Math.abs(touch.clientY - startY);
if (deltaX < 10 && deltaY < 10) {
console.log('tap事件');
}
}, { passive: true });
常见问题与踩坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 1px边框在高清屏变粗 | DPR≥2时1个CSS像素由2×2物理像素渲染 | 使用transform:scaleY(0.5)伪元素方案或viewport缩放方案 |
| iOS底部内容被Home Indicator遮挡 | iPhone X+底部有34px安全区 | padding-bottom: env(safe-area-inset-bottom),配合viewport-fit=cover |
| 安卓键盘弹起遮挡输入框 | 软键盘弹出改变visual viewport | 监听resize或使用visualViewportAPI,滚动输入框到可见区域 |
| 软键盘收起页面不回弹 | iOS WebView键盘收起后页面偏移 | 键盘收起时执行window.scrollTo(0, 0)或window.scrollTo(0, scrollY) |
| iOS橡皮筋效果导致页面溢出 | Safari默认overscroll行为 | overscroll-behavior: none或监听touchmove阻止默认行为 |
| 长按选中文字/弹出菜单 | 浏览器默认长按行为 | -webkit-touch-callout: none; -webkit-user-select: none; |
| 固定定位在键盘弹起时错位 | iOS Safari中fixed元素随键盘移动 | 将fixed改为absolute,或使用visualViewportAPI动态调整 |
| 点击元素闪灰 | iOS点击高亮 | -webkit-tap-highlight-color: transparent; |
| 图片模糊 | 1x图在2x/3x屏幕上被拉伸 | 使用srcset或image-set提供2x/3x图 |
| rem方案首屏闪烁 | JS加载前font-size为默认16px | 内联flexible脚本到<head>中,或使用vw方案替代 |
| 横屏后布局错乱 | 未处理orientation变化 | 使用CSS媒体查询orientation或JS监听orientationchange |
| 安卓低版本不支持vw | Android 4.3-不支持vw单位 | 降级使用rem方案或postcss-px-to-viewport的viewportUnit回退 |
安卓键盘弹起遮挡输入框的详细解决方案:
// 方案一:使用visualViewport API(推荐)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', function () {
var activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) {
// 将活动输入框滚动到可视区域
activeEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
}
// 方案二:监听focus事件
document.addEventListener('DOMContentLoaded', function () {
var inputs = document.querySelectorAll('input, textarea');
inputs.forEach(function (input) {
input.addEventListener('focus', function () {
// 延迟执行,等键盘弹起
setTimeout(function () {
input.scrollIntoView({ block: 'center' });
}, 300);
});
});
});
iOS键盘收起页面不回弹的详细解决方案:
// 监听输入框blur事件,强制回弹
document.addEventListener('DOMContentLoaded', function () {
var inputs = document.querySelectorAll('input, textarea');
inputs.forEach(function (input) {
input.addEventListener('blur', function () {
setTimeout(function () {
var scrollHeight = document.documentElement.scrollHeight;
var scrollTop = document.documentElement.scrollTop;
if (scrollTop > 0) {
window.scrollTo(0, scrollTop - 1);
window.scrollTo(0, scrollTop);
}
}, 100);
});
});
});
最佳实践
- viewport meta标签必须写,所有移动端页面第一步就是设置理想视口
- 优先选择vw方案:纯CSS方案无JS依赖,SSR友好,性能最优
- 大屏限制:使用
clamp()或媒体查询限制最大宽度/字号,防止在平板上等比放大过度 - 1px边框统一用transform方案:封装成mixin/function全局复用,按DPR选缩放比
- 安全区域用env()适配:配合
viewport-fit=cover,constant()做iOS 11.0兼容 - 图片至少提供2x资源:使用srcset或image-set,优先WebP/AVIF格式
- touch-action: manipulation消除300ms延迟:比引入FastClick库更轻量
- 滚动监听设passive: true:提升滚动性能,Chrome对touchmove默认passive
- 避免在移动端使用fixed定位:iOS Safari键盘弹起时fixed行为异常,改用absolute或方案规避
- 字号用px而不用rem/vw:正文字号14px-16px在所有设备上应保持可读,不应等比缩放到过大或过小
- 使用
overscroll-behavior: none:防止页面过度滚动和橡皮筋效果 - 内联关键CSS:适配相关样式(如html的font-size)应内联到
<head>中避免闪烁
面试题
Q1: 移动端适配方案有哪些?各自的原理是什么?
主要有四种方案:
- rem方案:通过JS动态设置
html的font-size为屏幕宽度的1/10,所有元素用rem单位(相对于html字号),从而实现等比缩放。配合PostCSS插件开发时用px编写;- vw方案:利用CSS的
vw单位(1vw = 视口宽度的1%),通过PostCSS插件将px自动转vw,无需JS,纯CSS实现;- vw+rem组合:用vw设置html的font-size,其他元素用rem。兼具两者优点,方便限制极端屏幕;
- 媒体查询:针对不同屏幕宽度写不同的CSS规则,实现断点适配,精确但不连续。
Q2: rem和vw适配方案的区别是什么?怎么选择?
核心区别:
- rem需要JS动态计算根字号,vw是CSS原生单位无需JS;
- rem兼容性更好(Android 2.1+),vw需要Android 4.4+;
- rem方案首屏可能闪烁(JS未加载时字号为默认16px),vw无此问题;
- vw支持SSR(服务端渲染),rem依赖客户端JS不适合SSR;
- rem可以通过JS设置最大最小字号限制极端屏幕,vw需要配合clamp()或媒体查询实现。
选择建议:新项目优先选vw方案(无JS依赖、性能好、SSR友好);需要兼容Android 4.3以下选rem方案;需要精确控制缩放范围选vw+rem组合。
Q3: 什么是1px边框问题?有哪些解决方案?
问题:在DPR≥2的高清屏上,CSS写的
1px边框实际由2×2(DPR=2)或3×3(DPR=3)个物理像素渲染,视觉上比设计稿的1物理像素粗。解决方案:
- viewport缩放:将页面按
1/DPR缩放,再通过rem将内容放大DPR倍还原,这样1px边框就对应1物理像素;- transform:scaleY(0.5):用伪元素画1px线,再沿Y轴缩放0.5(DPR=3时0.333),最常用;
- border-image:用1px高的图片(base64)做边框,简单但颜色不灵活、不支持圆角;
- SVG:用SVG画0.5高度的线做border-image,矢量不失真;
- meta全局缩放:动态设置viewport的scale为1/DPR,一劳永逸但需要全部用rem写尺寸。
Q4: DPR是什么?它和CSS像素、物理像素的关系?
**DPR(Device Pixel Ratio)**是设备像素比,等于物理像素数除以CSS像素数:
DPR = 物理像素 / CSS像素
- iPhone 6/7/8:物理分辨率750×1334,CSS分辨率375×667,DPR=2
- iPhone X/11/12:物理分辨率1125×2436,CSS分辨率375×812,DPR=3
- 普通显示器:DPR=1
DPR决定了1个CSS像素由多少物理像素渲染:DPR=2时1个CSS像素=2×2=4个物理像素。这导致:1px CSS边框在DPR=2设备上实际占2个物理像素宽度(视觉上变粗),1x图片在DPR=2设备上1个CSS像素需要4个物理像素来渲染(拉伸模糊,需要2x图)。
Q5: viewport有哪几种?分别解释?
三种viewport:
- 布局视口(Layout Viewport):浏览器用于计算CSS布局的区域。移动浏览器默认宽度通常为980px(各家不同),导致桌面网页在手机上缩小显示。通过
document.documentElement.clientWidth获取;- 视觉视口(Visual Viewport):用户当前在屏幕上看到的页面区域,会随用户缩放而变化。通过
window.innerWidth获取。缩放时视觉视口大小改变,但布局视口不变;- 理想视口(Ideal Viewport):最理想的布局视口尺寸,即与设备屏幕CSS宽度一致。通过设置
<meta name="viewport" content="width=device-width">实现,此时布局视口=理想视口。这是移动端适配的前提。
Q6: iOS安全区域怎么处理?env()和constant()有什么区别?
处理步骤:
- 设置
viewport-fit=cover让页面内容延伸到安全区域外;- 用
env(safe-area-inset-*)获取四个方向的安全区内边距,设置给需要避让的元素;- 常见场景:顶部导航栏
padding-top: env(safe-area-inset-top)避让刘海,底部固定栏padding-bottom: env(safe-area-inset-bottom)避让Home Indicator。env()和constant()区别:
constant()是iOS 11.0(Safari 11.0)引入的初始语法;env()是iOS 11.2(Safari 11.2)及之后的标准语法,取代了constant();- 为了兼容两个版本,需要同时写两行,
constant()在前、env()在后,浏览器会忽略不支持的;- 代码:
padding-top: constant(safe-area-inset-top); padding-top: env(safe-area-inset-top);
Q7: 移动端300ms点击延迟的原因和解决方案?
原因:早期移动浏览器为了支持双击缩放(double tap to zoom),在检测到第一次tap后需要等待约300ms判断用户是否会再次tap(双击)。如果300ms内没有第二次tap,才触发click事件。
解决方案:
- 设置viewport的user-scalable=no:禁用缩放后浏览器无需等待双击判断,但影响无障碍(iOS10+忽略此属性);
- 设置touch-action: manipulation(推荐):只允许平移和缩放手势,禁用双击缩放,轻量无副作用;
- FastClick库:在touchend时主动触发click事件,跳过300ms等待。但现代浏览器已不再需要;
- 使用pointer事件:
pointerdown/pointerup替代click,无延迟;- 现代浏览器已自动优化:Chrome 32+在设置了
width=device-width的页面上自动取消300ms延迟;Safari在iOS 9.3+也对不可缩放页面取消延迟。
Q8: 如何做横竖屏适配?有哪些方案?
方案:
- CSS媒体查询
orientation:用@media (orientation: portrait/landscape)为横竖屏写不同样式,最常用。适合布局需要重排的场景;- Screen Orientation API锁定方向:
screen.orientation.lock('portrait')强制竖屏,部分浏览器不支持(需要全屏模式);- 横屏提示遮罩:横屏时显示全屏遮罩提示用户旋转设备,最简单有效的方案,几乎所有H5活动页都使用;
- 监听
orientationchange事件:JS检测方向变化,动态调整布局或显示提示。注意用screen.orientation.angle代替已废弃的window.orientation;- 响应式布局:使用Flex/Grid布局让页面自动适应不同宽高比,无需特别区分横竖屏;
- viewport单位
vh/vw:利用视口单位让尺寸自动适应,但注意横屏时vw和vh含义互换可能需要额外处理。
相关链接: