移动端 H5 折叠屏适配实战:为什么 max-width 救不了你,min(vw, px) 才行
一、问题从哪来
我们有一个跑在企业微信里的 H5 应用,技术栈是 Vue 3 + Vite + Vant 4,设计稿宽度 375。适配方案很常规------postcss-px-to-viewport 把所有 px 按 375 基准转成 vw:
css
/* 源码 */
.button { width: 44px; }
/* 编译后 */
.button { width: 11.73vw; }
在 iPhone、常规安卓这些直板屏(CSS 宽度 375~430)上,表现一直很好。直到有人用折叠屏展开态打开它。
按钮变胖了,间距被撑开,整个界面像是被放大镜放大了一圈,设计稿的比例彻底失真。
二、先搞清楚折叠屏到底有多宽
很多人一上来就想<<折叠屏分辨率 2400 多,那我 max-width 设到 2000 多?>>------这是被物理像素带偏了。
以华为 Mate X5 为例:
| 指标 | 数值 |
|---|---|
| 物理分辨率(展开) | 约 2416 × 2224 |
| DPR(设备像素比) | ≈ 3 |
| CSS 逻辑像素宽 | ≈ 806 |
CSS 里的一切单位都是逻辑像素,不是物理像素。 折叠屏展开态的 CSS 宽度大多落在 768~810 这个区间,跟 iPad 竖屏(768)差不多。所以我们要对付的<<宽屏>>,是 480px 往上的逻辑宽度,而不是 2000 多。
三、为什么 max-width 单独用没救
最直觉的方案是给最外层容器加个 max-width:
css
#app { max-width: 480px; margin: 0 auto; }
跑起来你会发现:容器宽度确实被限制在 480px 了,但里面的按钮、文字还在继续放大。
原因是一个很多人忽略的细节:
vw 是相对于「视口(viewport)」的,不是相对于父元素的。
11.73vw 永远等于「视口宽度的 11.73%」。视口 806px 时它就是 94.5px,跟 #app 有没有 max-width 一点关系都没有。max-width 只能锁住容器的物理边界,锁不住内部基于 vw 计算的尺寸。
这就是死结:只要单位还是纯 vw,UI 就会跟着视口无限放大。
四、解法:让 px 在宽屏上「锁死」
思路要变一下------不是限制容器,而是让单位本身在超过某个宽度后停止放大。
CSS 的 min() 函数天然适合干这个:
css
.button { width: min(11.73vw, 56.32px); }
min() 取两个值里较小的那个,浏览器会自动在两者间切换:
- 视口 ≤ 480px 时:
11.73vw(≈56px 以下)更小 → 用 vw,跟着视口等比缩放,直板屏行为不变; - 视口 > 480px 时:
56.32px更小 → 用 px 锁死,折叠屏上 UI 不再放大。
切换临界点就是那个 px 上限对应的视口宽度。px 上限的算法是:
scss
px 上限 = 原始 px × (maxDisplayWidth / viewportWidth)
= 44 × (480 / 375)
= 56.32px
手写这些双值当然不现实。社区里有个专门做这件事的 PostCSS 插件 ------ postcss-mobile-forever,编译期自动把每个 px 转成 min(vw, px),运行时零成本。
五、核心配置
用它替换掉原来的 postcss-px-to-viewport:
bash
pnpm add -D postcss-mobile-forever
pnpm remove postcss-px-to-viewport-8-plugin
postcss.config.cjs:
js
/* eslint-disable @typescript-eslint/no-require-imports */
module.exports = {
plugins: {
'postcss-mobile-forever': {
// 移动端竖屏视口基准(与设计稿一致)
viewportWidth: 375,
// 应用最外层选择器:插件会自动给它注入 max-width + margin: auto
appSelector: '#app',
// 视口超过该宽度后,UI 锁定 px 不再放大(激活 max-vw-mode)
maxDisplayWidth: 480,
propList: ['*'],
unitPrecision: 5,
minPixelValue: 1,
},
},
}
只要配好 appSelector,插件会自动给 #app 注入居中样式,你不用手写:
css
#app {
max-width: 480px !important;
margin-left: auto !important;
margin-right: auto !important;
}
宽屏上 #app 锁定 480 居中,两侧露出 body 背景,形成「中间应用区 + 两侧留白」的分层观感。我们给 body 单独配了一个略深的背景色跟应用区做区分:
scss
:root {
--color-bg: #eff1f4; // 应用区背景
--color-bg-outer: #e2e5ea; // 宽屏两侧留白(略深,形成层次)
}
body { background: var(--color-bg-outer); }
六、三个必须知道的坑
真正上线过程中,光换插件是不够的。下面三个坑基本每个项目都会踩。
坑 1:内联 style 里的尺寸不走 PostCSS
PostCSS 只处理 .css / .scss 文件里的样式,JS 里动态计算、通过内联 style 写进去的尺寸,插件根本碰不到。
我们有个 MIcon 图标组件,size 是在 script 里算的:
ts
// 错误:只有 vw,宽屏上没有 px 上限,图标照样放大 2 倍
return `${(num / 375) * 100}vw`
内联样式绕过了插件,等于自己手写了个纯 vw。修复办法是在 JS 里手动补上 min(),逻辑和插件保持一致:
ts
const DESIGN_WIDTH = 375
const MAX_DISPLAY_WIDTH = 480 // 必须和 postcss.config.cjs 一致
const vanIconSize = computed(() => {
const s = props.size
if (s == null || s === '') return undefined
if (typeof s === 'number' || /^\d+(\.\d+)?$/.test(String(s))) {
const num = Number(s)
const vwVal = (num / DESIGN_WIDTH) * 100
const pxVal = (num * MAX_DISPLAY_WIDTH) / DESIGN_WIDTH
return `min(${vwVal}vw, ${pxVal}px)` // 手动补齐双值
}
return s // 带单位的字符串(如 '1em')原样透传
})
经验:凡是 JS 动态算出来的尺寸,都得手动 min(),且 maxDisplayWidth 要和插件配置对齐。
坑 2:Vant 弹层挂在 body 上,默认矫正失效
插件默认 appContainingBlock: 'calc' 会自动矫正 position: fixed 的元素,让它们跟随 #app 居中而不是贴着视口边缘。但它有个前提:
position: fixed和left / width必须写在同一条规则里,才会被矫正。
Vant 偏偏把它们拆开了 ------ .van-popup 上是 position: fixed,而 left: 0; width: 100% 写在 .van-popup--bottom 这类修饰类里。规则一拆,插件就不认了。结果就是弹层(Popup / ActionSheet 等注册在 body 下的组件)在宽屏上占满整个视口,被两侧留白<<推>>出去。
解法是显式告诉插件:这些选择器的包含块是根视口,拆开写的也给我矫正:
js
'postcss-mobile-forever': {
// ...其余配置
// Vant 把 fixed 与 left/width 拆到不同修饰类,默认 calc 模式矫正不到,
// 显式声明其包含块为根视口,让拆分的选择器也被矫正
rootContainingBlockSelectorList: [/van-popup/],
}
顺带提醒:JS 里写死的
:style="{ width: '100vw' }"这类弹层宽度同样绕过插件(见坑 1),要么改成100%,要么手动 min()。
坑 3:有些子树压根不想被转换
我们用了流程图组件 BpmViewer,它内部靠固定 px 精确定位节点。一旦这些 px 被转成 min(vw, px),跨机型节点就会错位。
这种子树要整棵排除。插件支持 selectorBlackList,但要注意匹配方式:
js
// 字符串是「全等于」匹配,匹配不到编译后带层级的选择器
selectorBlackList: ['.ignore-vw-'] // ✗ 匹配不到 ".ignore-vw-bpm .node-box"
// 正则是「包含」匹配,才能覆盖整棵子树
selectorBlackList: [/ignore-vw-/] // ✓
然后给流程图根节点加上约定的类名,整棵子树就被跳过了:
html
<div class="bpm-viewer ignore-vw-bpm"> ... </div>
经验:黑名单用正则做包含匹配,别用字符串全等------编译后的选择器往往带着层级前缀。
七、效果对比
| 视口宽度 | 旧方案 (px → vw) | 新方案 (px → min(vw, px)) |
|---|---|---|
| 375(iPhone SE) | 正常 | 正常 |
| 430(15 Pro Max) | 略放大 | 略放大 |
| 806(折叠屏展开) | 放大 ≈2.14 倍 | 锁定 1:1,居中留白 |
| 1920(PC 拉宽) | 放大 ≈5.12 倍 | 锁定 1:1,居中留白 |
直板屏行为完全不变,折叠屏 / PC 上应用区锁死 480 居中,UI 严格按设计稿比例。
八、怎么验证
DevTools → 设备工具栏,自定义一个 Width: 806 的设备模拟折叠屏展开态,重点看:
- 首页 + Tabbar:Tabbar 不拉宽,跟随 #app 居中;
- 详情页:NavBar、卡片间距、按钮宽度按设计稿比例,无突变;
- 弹层:Dialog / ActionSheet / Toast 跟随 #app 居中,不被留白推走;
- 图标:MIcon 尺寸锁死,不放大。
构建后可以直接在 dist 产物里搜 min( 确认插件生效:
bash
pnpm build
# 检查 CSS 中是否含 min(...vw, ...px)
# 以及 #app 是否注入了 max-width:480px!important
九、动手改造清单(照着抄就行)
前面讲了原理和坑,这一节把<<到底改哪几个文件、每个文件改成什么样>>列成分步清单。假设你的项目和我们类似:Vue + Vite + Vant,原来用的是 postcss-px-to-viewport。跟着做即可。
第 1 步:换插件
bash
pnpm add -D postcss-mobile-forever
pnpm remove postcss-px-to-viewport-8-plugin
第 2 步:改 PostCSS 配置
打开根目录的 postcss.config.cjs(没有就新建),整个替换成下面这样:
js
/* eslint-disable @typescript-eslint/no-require-imports */
module.exports = {
plugins: {
'postcss-mobile-forever': {
viewportWidth: 375, // 改成你的设计稿宽度
appSelector: '#app', // 你的根容器选择器(Vue 默认就是 #app)
maxDisplayWidth: 480, // 超过这个宽度就锁死不放大
propList: ['*'],
unitPrecision: 5,
minPixelValue: 1,
// 下面两条先不加,等第 5 步遇到问题再回来加
},
},
}
只改这一步,直板屏和折叠屏的<<放大>>问题其实就已经解决了。后面几步是处理边角情况。
第 3 步:确认根容器样式存在
插件靠 appSelector 找到 #app 并自动注入 max-width。所以你的样式文件里必须真的有一条 #app 规则 (哪怕内容很少),否则插件没地方注入。检查 reset.scss / 全局样式里有没有:
scss
#app {
width: 100%;
height: 100vh; // 若页面内用了 height:100% 撑满,父级必须是 height 而非 min-height
min-height: 100vh;
}
踩坑提示:如果你的页面组件里用了
.page { height: 100% }去撑满屏幕,那#app必须给固定height(如100vh),只给min-height会导致子元素算不出高度、页面塌掉。
第 4 步:加两侧留白背景色(可选,但推荐)
宽屏上 #app 居中后两侧会留白,露出 body 背景。给它一个比应用区略深的颜色,视觉上更有层次:
scss
// theme.scss 或全局变量文件
:root {
--color-bg: #eff1f4; // 应用区背景
--color-bg-outer: #e2e5ea; // 两侧留白色
}
// reset.scss ------ 把 body 背景指向留白色
body { background: var(--color-bg-outer); }
第 5 步:跑起来,逐个排查三类边角问题
pnpm dev 起服务,DevTools 里自定义一个 Width: 806 的设备模拟折叠屏,然后对照下面三种现象逐一处理。没遇到的就不用改。
现象 A:某些图标/元素还在放大。 说明它的尺寸是 JS 动态算的、通过内联 style 写进去的,绕过了插件。找到对应组件,把纯 vw 改成手动 min():
ts
// 改前:只有 vw,没有上限
return `${(num / 375) * 100}vw`
// 改后:手动补 px 上限,和插件逻辑一致
const vwVal = (num / 375) * 100
const pxVal = (num * 480) / 375 // 480 = maxDisplayWidth
return `min(${vwVal}vw, ${pxVal}px)`
现象 B:弹窗/ActionSheet 在宽屏上占满整个屏幕、跑到留白区外面。 这是 Vant 弹层挂在 body 下、fixed 与宽度拆开写导致的。回到 postcss.config.cjs,加一行:
js
'postcss-mobile-forever': {
// ...其余配置
rootContainingBlockSelectorList: [/van-popup/], // 加这行
}
如果你还有自己写死 :style="{ width: '100vw' }" 的弹层,把 100vw 改成 100% 或手动 min()。
现象 C:某个流程图/画布类组件节点错位。 这类组件靠固定 px 精确定位,不该被转 vw。回到 postcss.config.cjs 加黑名单(注意用正则):
js
'postcss-mobile-forever': {
// ...其余配置
selectorBlackList: [/ignore-vw-/], // 正则=包含匹配
}
然后给这个组件的根节点加类名 ignore-vw-xxx:
html
<div class="my-canvas ignore-vw-bpm"> ... </div>
第 6 步:构建验证
bash
pnpm build
在 dist 的 CSS 产物里搜 min(,能看到 min(...vw, ...px),并且 #app 被注入了 max-width:480px!important ------ 说明插件生效了。
一句话总结改动量 :核心就第 2 步(换插件配置)一个文件;第 3、4 步是确认/微调全局样式;第 5 步的三类问题遇到才改,没遇到跳过。大多数项目改完第 2 步就已经能看到明显效果。
十、小结
- CSS 里一切以逻辑像素为准,折叠屏展开态 CSS 宽度约 768~810,不是物理分辨率那个大数;
- 纯 vw 相对视口而非父元素,
max-width锁不住内部 vw 放大; - 正解是编译期把 px 转成
min(vw, px),超过maxDisplayWidth后锁死 px,postcss-mobile-forever开箱即用、运行时零成本; - 三个坑要盯紧:内联 style 手动补
min()、Vant 弹层用rootContainingBlockSelectorList矫正、不想转的子树用正则黑名单排除。
一套配下来,同一份 375 设计稿的 H5,直板屏、折叠屏、平板、PC 上都能有合理的表现。