Appearance
移动端适配方案 - rem与vw完整指南
1. 移动端适配概述
1.1 为什么需要适配
移动端设备屏幕尺寸和分辨率差异巨大,需要统一的适配方案确保在不同设备上的一致体验。
typescript
const adaptationChallenges = {
screenSizes: {
mobile: '320px - 428px',
tablet: '768px - 1024px',
desktop: '1280px+'
},
resolutions: {
normal: '1x',
retina: '2x',
superRetina: '3x'
},
issues: [
'1px边框问题',
'图片模糊',
'布局错乱',
'字体大小不一致',
'触摸目标过小'
],
solutions: [
'rem适配',
'vw/vh适配',
'百分比布局',
'Flexbox/Grid',
'媒体查询'
]
};1.2 像素概念
typescript
const pixelConcepts = {
physicalPixel: {
name: '物理像素',
description: '设备屏幕实际的像素点',
example: 'iPhone 14 Pro: 1179 x 2556'
},
logicalPixel: {
name: '逻辑像素(CSS像素)',
description: 'CSS中使用的抽象像素单位',
example: 'iPhone 14 Pro: 393 x 852'
},
dpr: {
name: '设备像素比',
formula: 'DPR = 物理像素 / 逻辑像素',
example: 'iPhone 14 Pro: 1179/393 = 3',
values: ['1 (普通屏)', '2 (Retina)', '3 (Super Retina)']
},
ppi: {
name: '像素密度',
unit: 'pixels per inch',
retina: '>= 300 PPI'
}
};2. rem适配方案
2.1 rem基础
css
/* rem = root em,相对于根元素(html)的font-size */
html {
font-size: 16px; /* 1rem = 16px */
}
.box {
width: 10rem; /* 160px */
height: 5rem; /* 80px */
font-size: 1rem; /* 16px */
padding: 0.5rem; /* 8px */
}
/* 子元素的rem始终相对于根元素 */
.parent {
font-size: 20px;
}
.child {
font-size: 1rem; /* 仍然是16px,不是20px */
}2.2 动态rem适配
javascript
// 方案1: 基于设计稿宽度
(function() {
const designWidth = 750; // 设计稿宽度
const rem = designWidth / 10; // 1rem = 75px
function setRemUnit() {
const clientWidth = document.documentElement.clientWidth;
const ratio = clientWidth / designWidth;
const fontSize = ratio * rem;
document.documentElement.style.fontSize = fontSize + 'px';
}
setRemUnit();
window.addEventListener('resize', setRemUnit);
window.addEventListener('pageshow', (e) => {
if (e.persisted) setRemUnit();
});
})();
// 设计稿750px,元素宽度200px
// 200px / 75 = 2.67rem
// 在375px设备上: 2.67rem * (375/750 * 75) = 100px
// 在750px设备上: 2.67rem * (750/750 * 75) = 200px2.3 flexible方案(淘宝)
javascript
// lib-flexible.js
(function flexible(window, document) {
const docEl = document.documentElement;
const dpr = window.devicePixelRatio || 1;
// 设置body字体大小
function setBodyFontSize() {
if (document.body) {
document.body.style.fontSize = 12 * dpr + 'px';
} else {
document.addEventListener('DOMContentLoaded', setBodyFontSize);
}
}
setBodyFontSize();
// 设置rem基准值
function setRemUnit() {
const rem = docEl.clientWidth / 10;
docEl.style.fontSize = rem + 'px';
}
setRemUnit();
// 监听窗口变化
window.addEventListener('resize', setRemUnit);
window.addEventListener('pageshow', (e) => {
if (e.persisted) {
setRemUnit();
}
});
// 检测0.5px支持
if (dpr >= 2) {
const fakeBody = document.createElement('body');
const testElement = document.createElement('div');
testElement.style.border = '.5px solid transparent';
fakeBody.appendChild(testElement);
docEl.appendChild(fakeBody);
if (testElement.offsetHeight === 1) {
docEl.classList.add('hairlines');
}
docEl.removeChild(fakeBody);
}
}(window, document));2.4 rem转换工具
javascript
// px转rem函数
function px2rem(px, baseSize = 75) {
return (px / baseSize) + 'rem';
}
// 使用
const width = px2rem(200); // '2.67rem'
const height = px2rem(100); // '1.33rem'
// PostCSS插件配置
// postcss.config.js
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 75, // 根元素字体大小
unitPrecision: 5, // 保留小数位数
propList: ['*'], // 转换的属性
selectorBlackList: [], // 忽略的选择器
replace: true,
mediaQuery: false, // 媒体查询中的px
minPixelValue: 0 // 最小转换值
}
}
};
// 在Vite中配置
import postcsspxtorem from 'postcss-pxtorem';
export default {
css: {
postcss: {
plugins: [
postcsspxtorem({
rootValue: 75,
propList: ['*']
})
]
}
}
};3. vw/vh适配方案
3.1 vw/vh基础
css
/* 视口单位 */
.box {
width: 50vw; /* 视口宽度的50% */
height: 50vh; /* 视口高度的50% */
font-size: 5vw; /* 视口宽度的5% */
}
/* vmin和vmax */
.square {
width: 50vmin; /* 视口宽高中较小值的50% */
height: 50vmin;
}
.cover {
width: 100vmax; /* 视口宽高中较大值的100% */
height: 100vmax;
}
/* 计算 */
/* 设计稿750px,元素200px
200 / 750 * 100 = 26.67vw */
.element {
width: 26.67vw; /* 在任何宽度下都是26.67% */
}3.2 vw方案优势
typescript
const vwAdvantages = {
pros: [
'纯CSS解决方案,无需JavaScript',
'真正的响应式',
'计算简单',
'兼容性好(现代浏览器)',
'不受字体大小影响'
],
cons: [
'无法限制最大最小值(需配合calc)',
'1px问题仍存在',
'横竖屏切换需处理',
'老旧浏览器兼容性'
]
};3.3 vw限制方案
css
/* 限制最大最小值 */
.container {
/* 基于750px设计稿 */
width: 100vw;
max-width: 750px;
min-width: 320px;
margin: 0 auto;
}
/* 使用calc限制 */
.box {
width: min(26.67vw, 200px); /* 不超过200px */
width: max(26.67vw, 100px); /* 不小于100px */
width: clamp(100px, 26.67vw, 200px); /* 100-200px之间 */
}
/* 响应式字体 */
.text {
font-size: clamp(14px, 4vw, 20px);
/* 最小14px,理想4vw,最大20px */
}3.4 PostCSS px转vw
javascript
// postcss.config.js
module.exports = {
plugins: {
'postcss-px-to-viewport': {
viewportWidth: 750, // 设计稿宽度
viewportHeight: 1334, // 设计稿高度
unitPrecision: 5, // 转换精度
viewportUnit: 'vw', // 单位
selectorBlackList: [], // 不转换的类名
minPixelValue: 1, // 最小转换值
mediaQuery: false, // 媒体查询中的px
exclude: [/node_modules/] // 排除文件
}
}
};
// CSS写法
.box {
width: 200px; /* 自动转换为 26.67vw */
height: 100px; /* 自动转换为 13.33vw */
}4. React移动端适配
4.1 动态rem Hook
tsx
// useRem.ts
import { useEffect } from 'react';
export function useRem(designWidth = 750, remBase = 75) {
useEffect(() => {
function setRemUnit() {
const clientWidth = document.documentElement.clientWidth;
const ratio = clientWidth / designWidth;
const fontSize = ratio * remBase;
document.documentElement.style.fontSize = fontSize + 'px';
}
setRemUnit();
window.addEventListener('resize', setRemUnit);
window.addEventListener('pageshow', (e) => {
if (e.persisted) setRemUnit();
});
return () => {
window.removeEventListener('resize', setRemUnit);
};
}, [designWidth, remBase]);
}
// App.tsx
function App() {
useRem(750, 75);
return <div className="app">...</div>;
}4.2 px转换工具
tsx
// utils/px2rem.ts
const designWidth = 750;
const remBase = 75;
export function px2rem(px: number): string {
return `${px / remBase}rem`;
}
export function px2vw(px: number): string {
return `${(px / designWidth) * 100}vw`;
}
// 使用
import { px2rem, px2vw } from '@/utils/px2rem';
export function Component() {
return (
<div style={{
width: px2rem(200), // '2.67rem'
height: px2vw(100), // '13.33vw'
padding: px2rem(20)
}}>
Content
</div>
);
}
// styled-components版本
import styled from 'styled-components';
const Container = styled.div`
width: ${px2rem(750)};
max-width: 750px;
margin: 0 auto;
`;
const Box = styled.div`
width: ${px2vw(200)};
height: ${px2rem(100)};
`;4.3 Tailwind CSS配置
javascript
// tailwind.config.js
module.exports = {
theme: {
extend: {
spacing: {
// rem单位
'1r': '0.13rem', // 10px @ 75rem
'2r': '0.27rem', // 20px
'4r': '0.53rem', // 40px
'8r': '1.07rem', // 80px
},
fontSize: {
// rem字体
'xs-r': '0.16rem', // 12px
'sm-r': '0.19rem', // 14px
'base-r': '0.21rem', // 16px
'lg-r': '0.24rem', // 18px
'xl-r': '0.27rem', // 20px
}
}
},
plugins: [
// 自定义rem插件
function({ addUtilities }) {
const remUtilities = {};
for (let i = 1; i <= 100; i++) {
remUtilities[`.w-${i}r`] = {
width: `${i / 75}rem`
};
remUtilities[`.h-${i}r`] = {
height: `${i / 75}rem`
};
}
addUtilities(remUtilities);
}
]
};
// 使用
<div className="w-200r h-100r p-4r">
Content
</div>5. 1px边框问题
5.1 问题分析
typescript
const onePixelProblem = {
issue: '在DPR为2或3的设备上,1px边框实际显示为2px或3px',
causes: [
'CSS的1px是逻辑像素',
'物理像素 = 逻辑像素 * DPR',
'设计稿要求真正的1物理像素'
],
solutions: [
'transform: scale()',
'border-image',
'box-shadow',
'viewport + rem',
'伪元素 + transform'
]
};5.2 解决方案
css
/* 方案1: transform scale */
.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;
pointer-events: none;
}
/* DPR为3的设备 */
@media (-webkit-min-device-pixel-ratio: 3),
(min-device-pixel-ratio: 3) {
.border-1px::after {
width: 300%;
height: 300%;
transform: scale(0.333);
}
}
/* 方案2: border-image */
.border-1px-image {
border: 1px solid transparent;
border-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><line x1="0" y1="0" x2="0" y2="100%" stroke="%23e5e5e5" stroke-width="1"/></svg>') 1;
}
/* 方案3: box-shadow */
.border-1px-shadow {
box-shadow: inset 0 -1px 0 0 #e5e5e5;
}
/* 方案4: viewport缩放 */
const scale = 1 / window.devicePixelRatio;
document.querySelector('meta[name="viewport"]')
.setAttribute('content',
`width=device-width,initial-scale=${scale},maximum-scale=${scale},minimum-scale=${scale}`
);5.3 通用1px方案
tsx
// Border1px.tsx
export function Border1px({
color = '#e5e5e5',
position = 'bottom'
}: {
color?: string;
position?: 'top' | 'right' | 'bottom' | 'left' | 'all';
}) {
const [dpr, setDpr] = useState(1);
useEffect(() => {
setDpr(window.devicePixelRatio || 1);
}, []);
const borderStyle: React.CSSProperties = {
position: 'absolute',
content: '""',
pointerEvents: 'none',
width: position === 'all' ? `${100 * dpr}%` : '100%',
height: position === 'all' ? `${100 * dpr}%` : '100%',
transform: `scale(${1 / dpr})`,
transformOrigin: position === 'all' ? 'left top' : 'center',
border: position === 'all' ? `1px solid ${color}` : 'none',
...(position === 'top' && { top: 0, left: 0, borderTop: `1px solid ${color}` }),
...(position === 'bottom' && { bottom: 0, left: 0, borderBottom: `1px solid ${color}` }),
...(position === 'left' && { left: 0, top: 0, borderLeft: `1px solid ${color}` }),
...(position === 'right' && { right: 0, top: 0, borderRight: `1px solid ${color}` })
};
return <div style={borderStyle} />;
}
// 使用
<div style={{ position: 'relative' }}>
Content
<Border1px position="bottom" color="#ddd" />
</div>6. 图片适配
6.1 响应式图片
html
<!-- 不同DPR的图片 -->
<img
src="image.png"
srcset="
image.png 1x,
image@2x.png 2x,
image@3x.png 3x
"
alt="Responsive Image"
>
<!-- 不同尺寸的图片 -->
<img
src="small.jpg"
srcset="
small.jpg 320w,
medium.jpg 640w,
large.jpg 1024w
"
sizes="(max-width: 640px) 100vw, 640px"
alt="Different Sizes"
>6.2 背景图片适配
css
/* 普通屏幕 */
.bg {
background-image: url('bg.png');
background-size: cover;
}
/* Retina屏幕 */
@media (-webkit-min-device-pixel-ratio: 2),
(min-resolution: 2dppx) {
.bg {
background-image: url('bg@2x.png');
}
}
/* 3x屏幕 */
@media (-webkit-min-device-pixel-ratio: 3),
(min-resolution: 3dppx) {
.bg {
background-image: url('bg@3x.png');
}
}
/* image-set */
.bg-modern {
background-image: image-set(
url('bg.png') 1x,
url('bg@2x.png') 2x,
url('bg@3x.png') 3x
);
}6.3 React图片组件
tsx
// ResponsiveImage.tsx
export function ResponsiveImage({
src,
alt,
className
}: {
src: string;
alt: string;
className?: string;
}) {
const dpr = window.devicePixelRatio || 1;
const getSrc = (base: string, dpr: number) => {
const ext = base.split('.').pop();
const name = base.replace(`.${ext}`, '');
if (dpr >= 3) return `${name}@3x.${ext}`;
if (dpr >= 2) return `${name}@2x.${ext}`;
return base;
};
return (
<img
src={getSrc(src, dpr)}
alt={alt}
className={className}
loading="lazy"
/>
);
}7. 完整适配方案
7.1 综合方案
typescript
// adapt.ts
class MobileAdapter {
private designWidth: number;
private remBase: number;
private dpr: number;
constructor(designWidth = 750, remBase = 75) {
this.designWidth = designWidth;
this.remBase = remBase;
this.dpr = window.devicePixelRatio || 1;
this.init();
}
init() {
this.setViewport();
this.setRem();
this.handleResize();
this.handleOrientation();
}
setViewport() {
let viewport = document.querySelector('meta[name="viewport"]');
if (!viewport) {
viewport = document.createElement('meta');
viewport.setAttribute('name', 'viewport');
document.head.appendChild(viewport);
}
// 不缩放方案
viewport.setAttribute('content',
'width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no'
);
// 或缩放方案
// const scale = 1 / this.dpr;
// viewport.setAttribute('content',
// `width=device-width,initial-scale=${scale},maximum-scale=${scale},minimum-scale=${scale}`
// );
}
setRem() {
const clientWidth = document.documentElement.clientWidth;
const ratio = clientWidth / this.designWidth;
const fontSize = ratio * this.remBase;
document.documentElement.style.fontSize = fontSize + 'px';
document.body.style.fontSize = 12 * this.dpr + 'px';
}
handleResize() {
window.addEventListener('resize', () => {
this.setRem();
});
window.addEventListener('pageshow', (e) => {
if (e.persisted) {
this.setRem();
}
});
}
handleOrientation() {
window.addEventListener('orientationchange', () => {
setTimeout(() => {
this.setRem();
}, 300);
});
}
px2rem(px: number): string {
return `${px / this.remBase}rem`;
}
px2vw(px: number): string {
return `${(px / this.designWidth) * 100}vw`;
}
}
// 使用
const adapter = new MobileAdapter(750, 75);
export default adapter;7.2 React集成
tsx
// App.tsx
import { useEffect } from 'react';
import adapter from './utils/adapter';
function App() {
useEffect(() => {
// 已在adapter.ts中自动初始化
}, []);
return (
<div className="app">
<MobileLayout />
</div>
);
}
// index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no">
<title>移动端应用</title>
<script>
// 防止页面闪烁
(function() {
const width = document.documentElement.clientWidth;
const fontSize = (width / 750) * 75;
document.documentElement.style.fontSize = fontSize + 'px';
})();
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>8. 最佳实践
typescript
const mobileBestPractices = {
general: [
'选择合适的适配方案',
'设置viewport',
'使用相对单位',
'处理1px问题',
'图片DPR适配'
],
rem: [
'设置合理的remBase',
'限制最大最小值',
'使用PostCSS自动转换',
'处理字体大小',
'考虑横竖屏切换'
],
vw: [
'使用clamp限制',
'配合max-width',
'响应式字体',
'PostCSS自动转换',
'兼容性检测'
],
performance: [
'减少计算开销',
'防抖resize事件',
'缓存计算结果',
'使用CSS变量',
'优化图片加载'
]
};9. 总结
移动端适配方案的核心要点:
- rem方案: JavaScript动态设置,灵活但依赖JS
- vw方案: 纯CSS,真正响应式
- 1px问题: transform scale或viewport缩放
- 图片适配: srcset或DPR判断
- PostCSS: 自动px转换
- viewport: 正确设置meta标签
- 限制范围: max-width/clamp
选择合适的方案并正确实施,可以实现完美的移动端适配。