移动端适配
视口与像素
像素体系
| 概念 |
含义 |
示例 |
| 物理像素 |
屏幕真实的发光点数量 |
iPhone 15: 1179 x 2556 |
| 逻辑像素 |
开发者使用的 CSS 尺寸单位(px) |
375 x 812(iPhone 逻辑像素) |
| CSS 像素 |
布局时的抽象单位,与逻辑像素等价 |
width: 375px |
| 设备像素比(DPR) |
物理像素 / 逻辑像素 |
iPhone 15 DPR = 3(1179/393) |
物理像素: 1179px (实际发光点)
逻辑像素: 393px (CSS 尺寸)
DPR = 1179 / 393 = 3
所以 1pt (iOS) = 3x3 物理像素
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
| 参数 |
含义 |
推荐值 |
width=device-width |
视口宽度 = 设备逻辑宽度 |
必设,适配所有设备 |
initial-scale=1.0 |
初始缩放比例 |
1.0 |
maximum-scale=1.0 |
最大缩放比例 |
1.0(可省略,保留用户缩放能力更友好) |
user-scalable=no |
禁止用户缩放 |
谨慎使用(影响无障碍) |
不设置 viewport 时,移动端浏览器默认使用 980px 宽度渲染页面,然后缩小适配屏幕,导致文字太小。
Retina 屏适配
| 技术 |
原理 |
适用场景 |
| @2x @3x 图片 |
准备多套倍图 + 服务端/JS 判断 DPR |
背景图片 |
srcset |
浏览器根据 DPR 和视口选择资源 |
<img> 标签 |
| icon font → SVG |
矢量图标,任意缩放不模糊 |
图标、Logo |
image-set() |
CSS 中根据 DPR 选择图片 |
CSS 背景图 |
<!-- srcset 响应式按 DPR 选择 -->
<img src="logo.png"
srcset="logo@2x.png 2x, logo@3x.png 3x"
alt="logo">
<!-- CSS image-set -->
.background {
background-image: image-set(
url(bg.png) 1x,
url(bg@2x.png) 2x,
url(bg@3x.png) 3x
);
}
rem 方案
原理
rem = root em,相对于 <html> 元素的 font-size
- 动态设置根元素的
font-size,使 1rem 对应设计稿的 px 值
// flexible.js 核心逻辑(简化版)
(function() {
const docEl = document.documentElement;
const setRem = () => {
const width = docEl.clientWidth;
// 设计稿宽度 750px(@2x 尺寸),设置 1rem = 100px(÷7.5)
const rem = Math.min(width, 750) / 7.5;
docEl.style.fontSize = `${rem}px`;
};
setRem();
window.addEventListener('resize', setRem);
})();
// iPhone 6/7/8 宽度 375px → 1rem = 375/7.5 = 50px
// 设计稿 750px 上 200px → 200/100 = 2rem → 实际 2*50 = 100px(比例正确)
postcss-pxtorem
// postcss.config.js
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 100, // 对应 1rem = 100px(设计稿 750)
propList: ['*'], // 转换所有属性
exclude: /node_modules/i, // 排除 node_modules
minPixelValue: 2, // < 2px 不转换
},
},
};
/* 输入 */
.box {
width: 200px; /* → 2rem */
height: 100px; /* → 1rem */
border: 1px; /* 保留(< minPixelValue) */
}
/* 输出 */
.box {
width: 2rem;
height: 1rem;
border: 1px;
}
rem 换算公式
1rem = html font-size(动态值)
设计稿尺寸(px) / rootValue = rem 值
显示尺寸(px) = rem * 当前根 font-size
| 设计稿宽度 |
rootValue |
逻辑屏 375px 时 1rem |
| 750(@2x) |
100 |
50px |
| 375(@1x) |
37.5 |
37.5px |
rem 的缺点: 切换设备时所有尺寸都重新计算,1rem 在不同屏幕物理尺寸不同。vw/vh 方案逐渐取代 rem。
vw/vh 方案
原理
1vw = 视口宽度的 1%
1vh = 视口高度的 1%
100vw = 视口完整宽度
100vh = 视口完整高度
// postcss-px-to-viewport 配置
module.exports = {
plugins: {
'postcss-px-to-viewport': {
viewportWidth: 375, // 设计稿宽度(逻辑像素)
unitPrecision: 5, // 精度
viewportUnit: 'vw',
minPixelValue: 1,
exclude: [/node_modules/],
},
},
};
/* 输入: 设计稿 375px 上 100px 宽 */
.box { width: 100px; }
/* 输出 */
.box { width: 26.66667vw; } /* 100/375*100 ≈ 26.667 */
vw 的兼容性问题
| 问题 |
原因 |
解决 |
| 100vw 溢出 |
100vw 包含滚动条宽度,window.innerWidth < 100vw |
使用 width: 100% 替代 |
| 100vh 在移动端溢出 |
移动端浏览器地址栏收起时视口高度变化 |
使用 dvh / svh / lvh |
安全区适配(iPhone 刘海/底部指示条)
/* safe-area-inset-* 只在 iPhone X+ 生效 */
.container {
padding-top: env(safe-area-inset-top); /* 刘海 */
padding-bottom: env(safe-area-inset-bottom); /* 底部指示条 */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/* 固定底部安全适配 */
.footer {
padding-bottom: constant(safe-area-inset-bottom); /* 旧语法 iOS 11.0 */
padding-bottom: env(safe-area-inset-bottom); /* 新语法 iOS 11.2+ */
}
动态视口单位(dvh / svh / lvh)
| 单位 |
含义 |
说明 |
100vh |
最大视口高度(地址栏收起时) |
地址栏展开时内容被遮挡 |
100dvh |
动态视口高度(随地址栏变化) |
地址栏展开/收起时实时变化 |
100svh |
最小视口高度(地址栏展开时) |
始终可见区域最小高度 |
100lvh |
最大视口高度,同 vh |
与 vh 等价 |
/* 推荐用 dvh 或 svh 替代 vh */
.fullscreen {
height: 100dvh; /* 跟随地址栏动态变化 */
/* 或 */
min-height: 100svh; /* 确保始终覆盖可见区域 */
}
1px 边框
方案对比
| 方案 |
原理 |
兼容性 |
说明 |
| 伪元素 + transform scale(0.5) |
通过 ::after + 缩放 |
全兼容 |
最推荐 |
| viewport 方案 |
设置 initial-scale=0.5 |
响应式需额外处理 |
全局影响太大 |
border: 0.5px |
直接设置 0.5px |
iOS 8+ / 部分 Android |
兼容性有限 |
伪元素 + scale 方案
/* 1px 下边框 */
.one-px-border {
position: relative;
}
.one-px-border::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 1px;
background: #ddd;
transform: scaleY(0.5); /* DPR=2 时缩为一半 */
transform-origin: 0 0;
}
/* DPR 3 时 */
@media (-webkit-min-device-pixel-ratio: 3) {
.one-px-border::after {
transform: scaleY(0.333); /* DPR=3 缩 1/3 */
}
}
响应式
媒体查询(移动优先)
/* 移动优先——基础样式默认就是移动端 */
.container {
display: flex;
flex-direction: column;
padding: 16px;
}
/* 平板 */
@media (min-width: 768px) {
.container {
flex-direction: row;
padding: 24px;
}
}
/* 桌面 */
@media (min-width: 1024px) {
.container {
max-width: 1200px;
margin: 0 auto;
}
}
弹性布局技术
| 技术 |
适用场景 |
说明 |
| flex |
一维布局(行/列内分布) |
最常用 |
| grid |
二维布局(行列网格) |
复杂页面骨架 |
clamp() |
流式排版 |
字体/间距在一定区间自适应 |
/* clamp(MIN, PREFERRED, MAX) */
h1 {
font-size: clamp(1.5rem, 2.5vw + 1rem, 3rem);
/* 最小 1.5rem, 随视口增长, 最大 3rem */
}
.container {
padding: clamp(16px, 4vw, 48px);
}
Touch 事件
300ms 延迟
| 原因 |
解决方案 |
| 浏览器等待 300ms 判断是单次点击还是双击缩放 |
1. <meta viewport width=device-width> — iOS 9+ 自动消除 |
|
2. touch-action: manipulation — 禁止双击缩放 |
|
3. FastClick 库(已不推荐,现代浏览器不再需要) |
/* 推荐 — 声明所有触摸操作为立即操作 */
html {
touch-action: manipulation;
}
手势库原理
| 库 |
原理 |
说明 |
| Hammer.js |
基于 Touch Events(touchstart/touchmove/touchend) |
经典手势库,支持 tap/swipe/pan/pinch/rotate |
| Pointer Events |
W3C 标准,统一鼠标/触控/笔事件 |
pointerdown/pointermove/pointerup,兼容性 >95% |
// Hammer.js
const el = document.getElementById('canvas');
const hammertime = new Hammer(el);
hammertime.on('swipe', (e) => {
console.log('swipe', e.direction, e.distance);
});
// Pointer Events(原生)
element.addEventListener('pointerdown', (e) => {
// e.pointerType: 'mouse' | 'touch' | 'pen'
});