移动端适配:视口、像素比与 rem/vw 两条链路
移动端适配只解决一件事:把设计稿上的 px 映射成随屏幕变的单位。文章把三层视口、 设备像素比、rem 与 vw 两条换算链路的公式、以及两个 PostCSS 插件的真实默认值 摆在一起(含实测产物),最后给一套按屏幕形态选方案的判断顺序。
一句话概括
移动端适配就一句话:别用 px 把尺寸写死,改用”相对屏幕”的单位。 剩下的全是细节——相对谁、谁来换算、换算发生在什么时候。
面试为什么爱问它?因为它一次考四样东西:视口(viewport)概念、设备像素比(DPR)、构建期插件链路、方案取舍。而且紧接着一定会追问”那 1px 边框怎么办”“100vh 为什么超出一屏”——这些追问的答案都在这篇里。
先给结论,再展开:
- 换算单位只有两个候选:
vw(视口宽度的 1%)和rem(根元素字号)。 - 换算这件事不该手写,交给 PostCSS 插件在构建期做。
- 两条链路的分歧只在一处:谁负责缩放。
vw由浏览器原生缩放,rem靠 JS 运行时改html字号。
核心知识点
1. 先把「视口」这三层分清楚
这是最容易被含糊过去的地方,但它决定了后面所有公式怎么推。
| 名字 | 是什么 | 怎么读 |
|---|---|---|
| 布局视口(layout viewport) | 页面布局真正用来计算的宽度 | document.documentElement.clientWidth |
| 视觉视口(visual viewport) | 用户当前看得见的区域,捏合缩放会变 | window.visualViewport.width / .scale |
| 理想视口(ideal viewport) | 一个概念而不是 API:内容刚好铺满、不用缩放的那个宽度 | 移动端大致 320~430 CSS px |
关键点:<meta name="viewport" content="width=device-width, initial-scale=1"> 干的事就是把布局视口设成设备宽度。不写这一行,移动浏览器会给你一个约 980px 的虚拟布局视口然后整体缩小显示——媒体查询全部失效。MDN 对这个行为的评语很直白:这种虚拟视口机制会削弱响应式设计的效果。
几条来自 MDN 的硬事实,面试可以直接引用:
width除了写数值还能写device-width,而且这个值直接决定vw的单位大小;height同理决定vh。user-scalable默认是yes。设成no会挡住低视力用户阅读内容,WCAG 要求至少支持 2× 缩放(最佳实践是 5×);而且 iOS 10+ 默认就忽略这条规则——所以”禁掉缩放”既不合规也不保险。viewport-fit有auto/contain/cover三个值,只有cover会让页面铺到刘海、圆角屏幕的边缘,MDN 明确提醒要配合安全区变量使用。interactive-widget控制虚拟键盘怎么影响视口:resizes-visual(默认,只改视觉视口,页面布局不动)、resizes-content(连布局视口一起改)、overlays-content(都不改)。注意 MDN 的一句话:布局视口一旦被改变,初始包含块跟着变,vw这类视口单位算出来的值也会变。
2. 设备像素比:750 的设计稿为什么会落到 375 的屏幕上
iPhone 6/7/8 的物理像素是 750×1334,但浏览器里的 CSS 像素是 375×667,这两个数的比值就是 DPR。
1
2
3
window.devicePixelRatio // 2(iPhone 6/7/8)、3(多数全面屏 iPhone)、2~3(多数安卓机)
window.innerWidth // 375 —— 这是 CSS 像素
screen.width // iOS 上通常也是 375(CSS 像素),不是 750
所以设计稿写”宽 750”是在物理像素的语境下画的,而页面里能用的宽度是 375 个 CSS 像素。适配要做的就是:把 750 语境下的数值,映射到当前设备能用的 CSS 像素宽度上。
MDN 还给过一条”默认像素比”规则:屏幕密度低于 200dpi 时 ratio 是 1.0,200~300dpi 之间是 1.5,超过 300dpi 取 floor(密度 / 150)。但这条只在视口缩放等于 1 时成立——用户一捏合放大,这个关系就跟着当前缩放走了。所以业务代码里别自作聪明去算,直接读 devicePixelRatio。
DPR 还会连带出三件事:@2x/@3x 位图素材、canvas 要按 DPR 放大绘制缓冲区(否则高分屏发虚)、以及 1px 细线(CSS 的 1px 在 DPR=2 上占 2 个物理像素,看起来很粗)。这三件都是同一件事的后遗症。
3. 两条换算链路的公式
假设设计稿宽 750,稿上有个元素宽 100px。
vw 链路
1
2
3
4
100px 占设计稿的比例 = 100 / 750 = 13.3333%
而 1vw 就是"视口宽度的 1%",所以 13.3333vw 等价于"占当前屏幕的 13.3333%"
公式:vw = px / 设计稿宽度 × 100
rem 链路
1
2
3
4
约定:把屏幕宽度切成 10 份,每份就是 1rem
于是换算基准 rootValue = 设计稿宽度 / 10 = 75
100px 换算 = 100 / 75 = 1.3333rem
运行时动态设置:html 的 font-size = 当前屏幕宽度 / 10
对照表一看就明白:
| vw 方案 | rem 方案 | |
|---|---|---|
| 写法 | 13.3333vw | 1.3333rem |
| 换算系数 | 设计稿宽 / 100 | 设计稿宽 / 10 |
| 谁负责缩放 | 浏览器原生,自动随视口变 | JS,运行时改 html 的 font-size |
| 运行时开销 | 0 | 一次 resize 监听 + 一次样式写入 |
| 上限控制 | 只能靠 max-width / clamp() 逐项挡 | 根字号是个变量,可以统一夹住 |
一句话记忆:两条链路算出来的百分比是同一个数,区别只在”谁在缩放”。
阿里那套 amfe-flexible 的核心就十几行,值得记一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var docEl = document.documentElement
var dpr = window.devicePixelRatio || 1
// 1rem = 视口宽度 / 10
function setRemUnit() {
docEl.style.fontSize = docEl.clientWidth / 10 + 'px'
}
setRemUnit()
window.addEventListener('resize', setRemUnit) // 尺寸变化就重算
window.addEventListener('pageshow', function (e) {
if (e.persisted) setRemUnit() // 从 bfcache 恢复也要重算
})
// 正文字号不跟着 rem 等比缩,直接按 DPR 钉死
document.body.style.fontSize = 12 * dpr + 'px'
最后一行是这套方案里最容易被忽略的设计:不是所有东西都该等比缩放。标题、间距跟着缩没问题,但正文如果也跟着缩,小屏手机上字就小到看不清了。所以它把 body 字号按 DPR 钉死,只让布局尺寸随 rem 变。
4. 两个插件的真实默认值(本地实测)
换算不该手写,交给 PostCSS。但这两个主流插件的默认行为不一样,而且和很多博客里写的不一样。下面是我在同一段输入 CSS 上实跑出来的产物:
1
.a { width: 100px; font-size: 16px; padding: 0 30px; border: 1px solid #ccc; }
postcss-pxtorem(只配 rootValue: 16,其余用默认值)
1
.a { width: 100px; font-size: 1rem; padding: 0 30px; border: 1px solid #ccc; }
只有 font-size 被转了。原因是它的默认 propList 是 ["font", "font-size", "line-height", "letter-spacing"]——默认只处理字体相关属性。想让宽高也转换,必须显式写 propList: ['*'],这时 1px 也一起被转了:
1
2
/* propList: ['*'] 之后 */
.a { width: 6.25rem; font-size: 1rem; padding: 0 1.875rem; border: 0.0625rem solid #ccc; }
于是第一个坑出现:它的 minPixelValue 默认是 0,1px 边框会被转成 0.0625rem。正确配置要两个都管住:
1
2
3
4
5
6
7
8
9
10
// postcss.config.js
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 75, // = 设计稿宽度 / 10(不是插件的默认值 16)
propList: ['*'], // 默认只转字体属性,要全转必须显式写
minPixelValue: 2, // ✅ 小于 2px 的不转,1px 边框保住了
},
},
}
postcss-px-to-viewport(配 viewportWidth: 750)
1
.a { width: 13.33333vw; font-size: 2.13333vw; padding: 0 4vw; border: 1px solid #ccc; }
1px 保住了,因为它默认 minPixelValue: 1。但它的出厂默认 viewportWidth 是 320——忘了配设计稿宽度,全站尺寸会直接错一倍多。
| postcss-pxtorem | postcss-px-to-viewport | |
|---|---|---|
| 换算目标 | rem | vw |
| 必须配的项 | rootValue = 设计稿宽 / 10 | viewportWidth = 设计稿宽 |
| 出厂默认值 | rootValue: 16(不是 75) | viewportWidth: 320(不是 750) |
propList 默认 | 只含字体相关属性 | 无此限制,全部转 |
minPixelValue 默认 | 0(1px 会被转) | 1(1px 保留) |
mediaQuery 默认 | false(媒体查询里的 px 不转) | false |
所以面试被问”px 怎么自动转成 vw”,完整答案是:PostCSS 插件在构建期转换,系数按 设计稿宽 / 100 算;同时靠 minPixelValue 和 selectorBlackList 把不该转的值放过。 能说到这一步,说明你真配过而不是背过。
5. vw 方案的三个真实坑
坑一:vh 是”大视口”,不是”小视口”。 MDN 现在的定义很明确:vh 等价于 lvh、vw 等价于 lvw,也就是基于”浏览器界面收起时”的大视口。这就是”iOS 上 height: 100vh 会超出一屏、底部按钮被顶出屏幕”的真正原因——地址栏收起时视口变高,100vh 跟着变大。
解法是改用 dvh(动态视口),但要注意 MDN 的提醒:dvh 会随用户滚动持续变化,用它做大范围布局会导致内容不断重排,有性能代价。更稳的做法是用 100svh(小视口,永远不超出可见区),或者干脆 height: 100% 配好祖先链。
坑二:小数舍入会凑出横向滚动条。 375 / 750 × 100 = 50vw,两栏各写 50vw 看似正好,但叠上边框和亚像素舍入,可能凑出 100.01vw,页面就能左右拖一两个像素。兜底办法是给根容器加 overflow-x: hidden——但要慎用,它会连带裁掉吸附定位和阴影。
坑三:大屏上元素被等比放大到失控。 vw 是纯百分比,没有上限。平板、折叠屏展开、甚至桌面浏览器拉宽,元素都会一直变大。常规处理是容器 max-width,或者给字号上 clamp():
1
2
3
4
/* ✅ 字号跟着视口缩,但永远落在 [14px, 20px] 之间 */
.title {
font-size: clamp(14px, 4vw, 20px);
}
6. 怎么选:按屏幕形态判断,别按流行程度
判断顺序是这样的:
- 手机 H5 / WebView 里的页面? → 默认上
vw(postcss-px-to-viewport)。没有 JS 开销,也不会因为脚本执行时机导致首屏抖动。 - 需要给字号设上下限,或者想统一改整体缩放? → 上
rem(postcss-pxtorem+ 一行设置根字号的脚本)。rem唯一不可替代的优势就是”根字号是一个你能随时改的变量”。 - 两者都要? → 混着用:布局尺寸走
vw,字号走clamp()或rem。这是目前最实用的组合。 - 大屏 / 数据看板? → 两套都不用,直接整体
transform: scale()更省事。 - 以桌面为主的响应式站点? → 用媒体查询 +
clamp()/ 容器查询就够了,不需要 rem/vw 转换链路。
还有一个容易踩的工程细节:rem 方案在 SSR / 首屏会闪一下。根字号是 JS 在页面加载后才设置的,HTML/CSS 先到、脚本后跑,中间那一瞬间是按浏览器默认字号渲染的。修法有两个——把设置根字号的那几行内联进 <head>(不打包、不异步),或者在服务端渲染时把 font-size 直接写进 html 的内联 style 里。
其实你每天都在用
- 手机上打开一个没写 viewport meta 的老网站,字小得看不清、得双击放大——那是 980px 的虚拟布局视口在起作用。
- 微信里打开 H5,点输入框时页面被顶上去一截,就是
interactive-widget: resizes-content的效果(默认的resizes-visual只改视觉视口,页面布局不动)。 - DevTools 设备模式里那个
375 × 667旁边标着dpr: 2,那个 2 就是 CSS 像素到物理像素的倍数。 - 设计稿角落写着”750×1334”,那是在描述 iPhone 6 的物理像素,换成 CSS 像素只有 375×667。
- 320 宽的小屏手机上页面溢出、375 上正好——像素级还原的必然代价,得靠弹性布局兜底。
- 用户在系统设置里把字体调大,页面文字跟着变大撑破布局,是没处理系统字号与
text-size-adjust。 - 折叠屏展开后元素被放大得离谱,那是
vw没有上限、只能靠max-width和clamp()逐个挡。 - 一个 H5 首屏进来先”跳一下”才正常,十有八九是 rem 方案在等 JS 执行完设根字号。
常见误解(FAQ)
❌ 误区1:”加上 width=device-width 就适配好了。” 那一行只把布局视口设成设备宽度,让媒体查询按真实宽度生效。尺寸单位仍然是写死的 px,页面在 320 和 430 上还是一样宽——适配才刚开始。
❌ 误区2:”设计稿 750,说明 iPhone 6 的屏幕宽度是 750。” 750 是物理像素,CSS 像素是 375,DPR = 2。这两个概念一混,后面所有公式都会推错。
❌ 误区3:”配好 PostCSS 插件,px 就会自动全转成 rem/vw。” 实测不成立。postcss-pxtorem 的默认 propList 只含字体相关属性,width、padding 默认原样保留,必须显式写 propList: ['*'];而且它的 rootValue 出厂默认是 16,忘了配就全站错。
❌ 误区4:”vw 不需要 JS,所以一定比 rem 好。” vw 少一次 JS 执行是真优势,但它没有”统一调节点”。rem 的根字号是个变量,你可以一把夹住整体缩放、可以让正文字号不跟着缩;纯 vw 想控制这些,只能靠 max-width 和逐属性的 clamp()。这是取舍,不是优劣。
❌ 误区5:”user-scalable=no 是移动端标配。” 无障碍红线。MDN 明确警告禁用缩放会让低视力用户无法阅读,WCAG 要求至少支持 2× 缩放;而且 iOS 10+ 默认忽略这条规则,设了也不一定生效。真不想让 iOS 在输入框聚焦时自动放大,正确姿势是把输入框字号设到 16px 以上,而不是禁缩放。
❌ 误区6:”适配和 DPR 没关系,那是图片的事。” 至少三处直接相关:100vh 超出一屏(因为 vh ≡ lvh)、canvas 不按 DPR 放大缓冲区会发虚、CSS 的 1px 在 DPR=2/3 上占 2~3 个物理像素。它们的根都在同一处:CSS 像素 ≠ 物理像素。
❌ 误区7:”vw 方案算出来的是精确值。” 它要经过”取百分比 → 舍入到 N 位小数 → 浏览器再乘回像素”三步,每一步都有误差。所以移动端不存在真正的”像素级还原”,只能做到肉眼一致;边框、分隔线这类 1px 级别的东西要额外处理。
一句话总结
移动端适配就是把设计稿的 px 换算成随屏幕变的单位:vw 交给浏览器原生缩放、rem 交给 JS 改根字号,两条链路公式不同但百分比相同;真正的坑都在换算之外——vh 其实是大视口、1px 会被插件顺手转掉、以及别用 user-scalable=no 堵住无障碍。