ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

移动端适配:Viewport、REM与VW的协同关系与最佳实践

移动端适配:Viewport、REM与VW的协同关系与最佳实践 做移动端页面的兄弟应该都有体会同一套设计稿要在 iPhone SE 和折叠屏上同时端平光是字体、间距和边框就能磨掉一下午。最近在整理几个 H5 轻量游戏项目发现大家页面头部的代码长得几乎一模一样清一色都是meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno这个标签写清楚只是开始。真正决定你页面在不同屏幕下不散架、不错位的其实是 REM、VW 这两个单位以及它们和 Viewport 的配合方式。这篇文章想把这三者之间的关系彻底掰开揉碎结合我自己在项目里的实际配置和踩坑记录给同样被移动端适配折腾过的人一份能直接抄作业的思路。先交代清楚读者定位这篇适合手里已经能写出静态页面、但一听到“自适应”“等比缩放”“一像素”就觉得头疼的前端开发也适合小游戏、低代码页面、H5 活动页的后端同学快速补基础。我会把每个选择背后的为什么说透而不是只丢结论。1. 理解 Viewport移动端适配的第一块基石1.1 布局视口、视觉视口与理想视口很多同学天天写meta nameviewport但不知道这行代码到底在干什么。移动端浏览器其实存在三个“视口”概念弄混了他们适配就无从谈起。第一个叫布局视口layout viewport。在无 meta 标签约束的情况下手机浏览器默认会用一个接近 980px 宽度的“虚拟画布”去排版页面就像把一个桌面网页整个压缩塞进手机屏幕里。你看到的字是缩小的整个页面的宽度远大于屏幕宽度要想正常阅读必须手动缩放或者横竖屏来回切换。这是因为移动浏览器诞生时为了兼容老桌面网站而留下的默认行为。第二个叫视觉视口visual viewport就是用户当前屏幕上看到的那个窗口区域。当你在手机上双指放大页面时视觉视口会变小因为你看到的内容范围变少了。第三个叫理想视口ideal viewport它的宽度等于设备的屏幕物理宽度除以设备像素比devicePixelRatio后面简称 dpr对页面开发者来说就是widthdevice-width这个值。meta nameviewport标签的作用就是告诉浏览器把布局视口设置成理想视口而不是默认的 980px。这句有点绕的话翻译成人话就是让页面宽度拉满屏幕不要在手机上缩成一团。1.2 meta 参数怎么配才算稳妥viewport 标签属性里最核心的是widthdevice-width和initial-scale1.0。前者把布局视口宽度设为设备宽度后者把缩放比例卡在 100%二者同时指定时浏览器会取两者的较大视口宽度所以你现在看到的绝大多数项目都是两个一起写。加上maximum-scale1.0和user-scalableno是 H5 游戏、活动页的常见做法目的是禁止用户双指缩放和误触放大保护游戏画布和交互按钮的原始布局。我自己在实际项目里对这种写法持保留态度原因后面讲。还有一类页面会加viewport-fitcover这是配套 iPhone 刘海屏的。页面不写这个属性时Safari 默认按 safe area 布局在横屏或者全面屏下左右两侧会出现白边。加上viewport-fitcover之后页面才敢真正顶到屏幕边缘同时配合env(safe-area-inset-*)给按钮和交互元素留出安全距离。我看过不少项目的 meta 写法是meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno /如果做的是游戏、答卷、邀请函这类需要锁定视觉比例的页面这个配置可以接受但如果是内容阅读型页面我不建议把user-scalableno写死。一方面iOS 10 开始 Safari 主动忽略这个属性用户仍然可以双击和双指缩放另一方面禁掉缩放会伤害视障用户的可访问性。真要防误触可以用 CSS 的touch-action: manipulation消除双击延迟没必要牺牲阅读体验。1.3 Viewport 为什么只是地基把 Viewport 配好你能得到一个“宽度正常、不会默认缩小”的移动页面但页面里的元素尺寸依然是死的设计稿里标注 100px 的元素在 375px 宽度的手机上占100/375 ≈ 26.7%的屏宽在 768px 平板上只占到 13%。这种固定像素布局的问题在于你没法让元素随着设备一起“生长”。要让整页比例相对稳定核心思路是不再用像素绝对值描述尺寸而是寻找一个“能跟着设备宽度变化”的度量单位。REM 和 VW 就是两个候选单位它们解决的问题相似但侧重点完全不同。2. REM 方案的原理与核心要素2.1 为什么是 REM 而不是 EMCSS 里的 REM 全称是 root em它的值等于根元素html的font-size。比如你设置html { font-size: 16px }那页面上width: 2rem就强制解析为 2 × 16px 32px。万物都跟html走这就是 REM 的核心逻辑。EM 则不同它继承自父元素。父元素字体会变EM 值就跟着变很容易出现“嵌套深了数值完全失控”的局面。REM 只认唯一的根节点全局只要改html一个值整棵文档树的 REM 尺寸都会跟着变这种“一改全改”的特性正是做适配最需要的。另外还要区分 rem 和响应式里常用的%。百分比支持有限比如padding的百分比是按父元素宽度算的margin也是但height的百分比有时不生效border-radius的百分比又被规范定义为另一种算法。REM 没有这些奇怪限制凡是能用 px 描述的长度属性基本都能换成 rem。2.2 动态根字体两种主流 JS 套路REM 要真正服务于适配前提是根字体大小能跟设备宽度联动。经典方案是在页面启动时读一次document.documentElement.clientWidth然后把根字体人为设定为“屏宽的十分之一”或“设计稿 1rem 100px”等固定换算值。第一种做法以 iPhone 6 的 375px 宽为设计基准直接设置(function (doc, win) { var html doc.documentElement; function setRem() { var width html.clientWidth; html.style.fontSize width / 10 px; } setRem(); win.addEventListener(resize, setRem); })(document, window);这段代码写的1rem 375 / 10 37.5px。设计稿如果按照 750px 出图那 1rem 就对应设计稿里的 75px因为 750 / 10 75。所以一套设计稿宽度是 750 时你在 CSS 里写width: 7.5rem实际解析宽度正好是设计稿的 750px 屏宽的 1/10 × 7.5 屏宽 100%非常直观。第二种做法是通过window.matchMedia监听设备宽度变化而不是 resize 事件顺便在DOMContentLoaded之后再执行一次防止部分低端 Android 首次加载时字体还没刷上。我通常在代码里直接带上防抖function refreshRem() { var width document.documentElement.getBoundingClientRect().width; var rem Math.min(width, 768) / 10; document.documentElement.style.fontSize rem px; } var timer null; window.addEventListener(resize, function () { clearTimeout(timer); timer setTimeout(refreshRem, 200); }); refreshRem();这里Math.min(width, 768)是限制根字体在平板上不要无脑膨胀防止页面在 1024px 宽的设备上把所有元素都拉伸得太大。2.3 从设计稿到 REM 的换算公式假设你手头是一张 750px 宽的设计稿屏幕实际宽度 375pxdpr 为 2。底线思路是设计稿里的 1px 在 CSS 里对应1 / 75 rem因为 750px 设计稿对应屏宽 375px而1rem 37.5px 375 / 10。所以设计稿里的 100px 宽度CSS 写作100 ÷ 75 1.3333rem如果需要处理的元素比较多手算太容易出错。我自己的习惯是在 SCSS/Less 里写一个函数// 设计稿宽度 7501rem 75px function px2rem($px) { return $px / 75 * 1rem; } .btn { width: px2rem(200); // 宽度 2.6667rem height: px2rem(96); }不想用预处理器的可以用 PostCSS 的postcss-pxtorem插件自动换算。插件配置里有一项rootValue设成 75然后声明propList只转换需要的属性比如postcss-pxtorem: { rootValue: 75, propList: [*], selectorBlackList: [.no-rem] }用起来足够省心但要注意一个坑如果你在组件库里引入了第三方 UI 框架有些第三方样式不希望你强行改它的单位。可以让插件在遇到某些类名时跳过比如.ant-、.van-或者在第三方样式文件里写/* no-rem */注释让它跳过千万别全局一把梭。2.4 REM 方案的软肋REM 方案最大的问题在于它依赖 JavaScript。只要 JS 没执行、执行晚了、或者执行报错根字体就是默认 16px整个页面就变成“设计稿里的 100px 被解析成 100px 固定像素”在 375px 屏上看着是正常的在 414px 屏上就开始偏小偏挤。如果你的项目有大量 SSR、首屏要快、用户弱网环境多就要额外防抖和兜底 CSShtml { font-size: 37.5px; /* 兜底值JS 没跑的极端情况 */ }另外REM 的“等同缩放”是把字体、间距、图片全部等比拉大。这在游戏、活动页里很爽但在正文为主的页面里会出问题小屏手机字 12px 没法看大屏平板字 28px 又像老年机。所以现代适配策略很少让 REM 孤军奋战它通常跟 VW 或媒体查询搭配使用。3. VW 方案直接用视口宽度说话3.1 VW / VH 的基本换算逻辑VW 是 viewport width 的缩写1vw永远等于当前视口宽度的 1%。这个单位本质上比 REM 更贴近“屏幕”因为它不依赖 html 根字体也不用 JavaScript 参与浏览器自己就能解析。以 750px 设计稿为例设计稿里的 1px 对应的 VW 是1 / 750 × 100 ≈ 0.13333vw所以设计稿里 200px 的宽度CSS 写作200 × 0.13333 26.6667vw如果是 375px 的设计稿那 1px 对应1 / 375 × 100 ≈ 0.2667vw。很多同学在这两个基数之间反复横跳建议项目定稿之后把设计稿宽度固定在统一值不要今天 750 明天 375。VH 是视口高度的 1%同样很直观。不过它们还有一个兄弟单位是vmin和vmax在游戏适配里更常用vmin取vw和vh较小值vmax取较大值。对于必须保证完整可见的游戏地图、答题卡片用vmin可以让元素在横竖屏下都保持在屏幕内。3.2 纯 VW 方案的优势和坑纯 VW 方案的核心写法是html { /* 让 1rem 1vw * 100 / 7.5即适配 750 设计稿 */ font-size: 13.33333vw; }这个数字哪来的750 设计稿时100vw 750px所以1px 100/750 vw ≈ 0.13333vw。为了让1rem等于设计稿里的 75px跟之前 REM 方案对齐就要让根字体等于75 × 0.13333vw 10vw。但我们通常想让根字体在 375px 屏上是 37.5px此时10vw 37.5px对应设计稿 75px换算比 1:2。写起来html { font-size: calc(100vw / 7.5); /* 等价于 13.3333vw */ }这样整个页面继续用 rem 布局但根字体不再靠 JS 设置彻底拿掉了脚本依赖。纯 VW 方案有个非常容易踩的坑元素宽度一旦超过100vw页面就会横向滚动。桌面浏览器里滚动条会占宽度造成100vw包含滚动条宽度从而出现横向滚动条套娃现象。移动端虽然没有经典滚动条但在 WebView 里依然可能出现类似问题。解决办法是不要直接写width: 100vw改写成width: 100%height: 100vh同理在移动端遇到地址栏缩放时也不稳定我会改成100dvhdynamic viewport height兼容写法是先声明height: 100vh再覆盖height: 100dvh。另外一个坑是字体问题。如果字体也用 VW 等比缩放375px 屏 12px 的正文在 414px 屏会变成 13.2px看着没啥但在 768px 平板会变成 24px整个页面像大字报。所以正文大小应当避免纯 vw最稳的是pxclamp()body { font-size: clamp(14px, 2vw, 18px); }clamp()给出下限、理想值、上限既保证了中等屏幕的弹性又避免极端尺寸。3.3 为什么很多团队转向 VW REM 组合纯 REM 有 JS 依赖问题纯 VW 有字体和溢出问题。实际工程里两者的协同才是最实用的用 VW 解决“根字体动态化”用 REM 解决“元素尺寸弹性化”用 px 解决“字体、边框的底线”。这样既拿到了 VW 不用 JS 的优势又保住了 REM 高可读性换算的便利还能通过clamp()控制极端值。业内有一段时间特别流行 Flexible 方案手淘的lib-flexible后来官方自己也转向了 VW。原因就是 VW 方案更干净省掉了一段动态脚本同时更符合现代 CSS 规范。但也不建议你立刻把老项目全部推翻老项目跑得好好的就别折腾新项目可以优先 VWREM。4. REM / VW / Viewport 三方协同的工程实践4.1 协同方案一动态根字体 REM viewport适合页面复杂度高、设计稿严格、需要完全等比缩放的场景典型如 H5 游戏、大型活动页。整页做成一个大的“画布”里面所有元素的尺寸、间距、位置都按照设计稿等比换算成 rem。完整示例骨架如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno, viewport-fitcover title活动页 / 小游戏/title style html { /* JS 未加载时的兜底 */ font-size: 37.5px; } body { margin: 0; font-size: 16px; background: #0b0f1e; color: #fff; } /style /head body div classgame-canvas idapp.../div script (function () { var html document.documentElement; var width html.clientWidth; // 限制最大适配宽度防止在 iPad / 横屏上无脑放大 width Math.min(width, 768); var rem width / 10; html.style.fontSize rem px; })(); /script /body /html这段代码里有两个点值得展开。第一viewport-fitcover只写一次配合env(safe-area-inset-bottom)确保底部按钮不会被 iPhone 的 Home Indicator 挡住。第二JS 放 body 末尾执行避免因脚本执行太早拿不到准确clientWidth。低端 Android 在首帧渲染前执行脚本会导致白屏闪一下所以我通常会在html标签内直接内联执行或在DOMContentLoaded再刷一次。这种方案的优点是等比缩放彻底游戏界面在什么设备上长得都跟设计稿一致缺点也很明显正文阅读页这么做会牺牲可读性所以它不适合博客、新闻、内容社区。4.2 协同方案二VW 根字体 REM 元素 px 字体这种方案不需要任何 JavaScript纯 CSS 搞定是我目前新项目的默认姿势。先给 html 设置一个基于 vw 的根字体html { font-size: 2.6667vw; /* 375px 屏宽时 10px750 设计稿下 1px 0.13333vw */ }这里2.6667vw怎么算的目标让 1rem 10px 在 375px 屏宽下成立10 / 375 × 100 ≈ 2.6667vw。如果设计稿是 750一个 100px 元素对应 rem 是100 / 10 10rem非常容易口算。但上面这个写法在平板、横屏时会失控所以通常配合clamp()限制根字体的上下限html { font-size: clamp(10px, 2.6667vw, 18px); } body { font-size: 14px; /* 不做缩放 */ } media (min-width: 768px) { html { font-size: 14px; /* 在平板上放弃等比缩放 */ } }布局套路其实很简单.card { width: 8rem; padding: 1.25rem; margin-bottom: 0.75rem; border-radius: 0.5rem; } .title { font-size: 18px; /* 字体固定 px靠媒体查询调 */ }我的经验是这样所有结构性尺寸宽度、高度、间距、圆角都用 rem所有文字字号用 px需要跟屏幕宽度严格等比但又有上下限的用 clamp()。这样页面在手机之间弹性适中在平板上也不会变形到没法看。4.3 工具链PostCSS 自动转换与不转换清单纯手写 rem 对生产力打击太大建议直接上 PostCSS 插件。VW 方向推荐postcss-px-to-viewport-8-pluginREM 方向推荐postcss-pxtorem。我这里给一份 VW 方向的可参考配置module.exports { plugins: { postcss-px-to-viewport-8-plugin: { viewportWidth: 750, // 设计稿宽度 unitPrecision: 5, // 转换后精度 viewportUnit: vw, fontViewportUnit: vw, // 字体转换单位也可以设成 px 禁止转换 selectorBlackList: [.ignore-vw], // 命中这些选择器不转换 minPixelValue: 1, // 小于等于 1px 的不转换 mediaQuery: false // 媒体查询中的 px 不转换 } } };这里特别说明minPixelValue: 1的意义。边框写字1px时如果转成 vw 会得到小数在部分安卓机器上渲染出毛边在 1px 物理线需求场景下更是必须留 px 单独处理。字体我不想转 vw 的时候就配置fontViewportUnit为px或直接在selectorBlackList里排除字体选择器这样正文可读性有保障。4.4 配合媒体查询处理特殊节点适配不是一个单位打天下媒体查询依然是兜底手段。推荐按业务关键尺寸设置断点而不是写死“iPhone、iPad”。.container { width: 100%; max-width: 600px; margin: 0 auto; } media (max-width: 320px) { .card { padding: 0.5rem; } } media (min-width: 768px) and (max-width: 1024px) { .container { max-width: 768px; } }设备像素比 dpr 也值得单独区分。遇到需要适配高清屏的时候可以结合媒体查询写media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { /* 2 倍屏下的资源替换、边框优化 */ }比如你要给 2 倍屏切换2x高清图用这个 media 条件就比 JS 判断 dpr 更优雅。5. 适配中常见的坑与排查经验实录5.1 一像素痛点物理像素与逻辑像素移动端最经典的问题就是 CSS 1px 在视网膜屏上看着不止 1px而是 2px、3px。原因很简单iPhone 8 的 dpr 是 2即一个逻辑像素对应两块物理像素。设计师眼里的 1px 细线用 CSS 的 1px 去表现实际占用了 2 个物理像素看起来就是“粗线”。处理方案我按优先级排列对于需要标准 1px 细线的边框优先用transform: scale(0.5)配合伪元素把高度压成一半.hairline::after { content: ; position: absolute; left: 0; top: 0; width: 200%; height: 200%; border: 1px solid #ddd; transform-origin: 0 0; transform: scale(0.5); box-sizing: border-box; pointer-events: none; }对于背景分割线可以直接用 CSS 渐变模拟.divider { height: 1px; background: linear-gradient(180deg, transparent 0%, #eee 50%, transparent 100%); }某些现代浏览器可以识别border: 0.5px solid #ddd但兼容性还不够统一不建议作为唯一方案。全局缩放 viewport 的思路不推荐代价太大会影响布局视口宽度和手势缩放。5.2 字体大小该用 px 还是 rem我上面提过字体不要用 rem/vw 全局等比缩放。这里给一个更具体的例子某个 H5 项目把正文font-size设成0.4rem在以 375px 屏宽为基准、根字体 37.5px 时算出来是 15px看着正常但换到 414px 的 iPhone X根字体变成 41.4px正文就变成了 16.56px不明显等用户拿到一台 768px 的 iPad 横屏根字体瞬间变成 51.2px正文直接 20px这是灾难。所以我一直用这个组合策略正文用px固定再配合媒体查询调整常用字号档位body { font-size: 14px; } h1 { font-size: 22px; } media (min-width: 375px) { body { font-size: 15px; } h1 { font-size: 24px; } } media (min-width: 414px) { body { font-size: 16px; } h1 { font-size: 26px; } }如果想在字体上获得视觉节奏clamp(16px, 1vw 8px, 20px)这类公式也能用但注意别直接上100vw / 25否则在横屏大屏上会失控。5.3 横屏、刘海屏和安全区的坑横屏是另外一个经常被忽略的场景。游戏类 H5 一旦方向锁死横屏适配就成了硬需求。锁方向可以靠media (orientation: landscape)配合屏幕宽度判断但不要依赖window.orientation这种老 API移动端 Safari 已经逐步丢弃。对于必须横屏的游戏最好在进入游戏前给一个“请横屏”提示层竖排时显示提示横屏时自动隐藏.rotate-tip { display: none; } media (orientation: portrait) { .rotate-tip { display: flex; } }刘海屏安全区是另一个高优先级的坑。主页之前提到viewport-fitcover之后底部会被 Home Indicator 遮住。处理办法是加env()间距.footer { padding-bottom: 12px; padding-bottom: calc(12px env(safe-area-inset-bottom, 0px)); }env()的第二个参数是兜底值不支持的浏览器会用 0。constant()是旧版 iOS Safari 的写法前面几个月内还能见到建议两个都写把constant()放前面padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);5.4 动态视口高度与移动端键盘移动浏览器地址栏的显示/隐藏会导致100vh不可靠地址栏收起时 100vh 略高地址栏展开时 100vh 略低底部内容经常被地址栏遮挡或空白。新单位dvhdynamic viewport height就是为解决这个问题的它实时反映动态视口高度建议用.full-screen { height: 100vh; height: 100dvh; }在安卓 WebView 中还有软键盘弹起导致的窗口 resize 问题。通常的处理是监听window.visualViewport的resize事件或者用interactive-widgetresizes-content这个新 meta 属性控制 WebView 行为。这个属性是较新的 H5 适配方式要真在项目里落地先确认你的 Android WebView 版本和 iOS 版本的支持情况别直接全量上线。5.5 单位换算/工具链出错导致的整体崩盘PostCSS 转换出现问题的场景也很典型。我遇到过三次项目里所有尺寸都带着 rem但根字体没设置过结果浏览器全部按默认 16px 解析整个页面跟设计稿差了近 2 倍然后开发组在那边排查了半天“为什么我的 rem 无效”。排查顺序建议是这样先查html是否有font-size是不是被其他样式覆盖了再查PostCSS配置rootValue或viewportWidth是否跟设计稿一致看meta viewport里的宽度是否是device-width不是那布局视口宽度就不是屏宽vw 计算会偏最后看浏览器控制台有没有 JavaScript 报错导致动态 rem 脚本没跑起来。6. 从方案到落地一套我自用的适配检查清单文章最后我不打算来一段虚的总结直接把我现在做移动端适配时会逐条过的 check list 放出来你照着检查就能少踩很多坑。viewport meta标准写法widthdevice-width, initial-scale1.0活动页再加maximum-scale1.0, user-scalableno需要刘海屏适配就加viewport-fitcover根字体方案新项目优先 VW 根字体 REM 布局老项目如果已用 Flexible JS 方案确认脚本有兜底且防抖字体大小用 px 媒体查询或 clamp()避免全局等比缩放字体1px 边框统一用伪元素 transform: scale(0.5) 的封装类安全区所有底部按钮补env(safe-area-inset-bottom)间距动态高度100vh的地方评估是否换100dvh横竖屏需要锁横屏的游戏页面必须有“请横屏/已检测到横屏”的提示层逻辑调试真机优先Chrome DevTools 的设备模式仅做辅助因为 WebView 和浏览器的行为并不完全一致。我自己在实际项目里的体会是适配方案没有银弹但思路可以收敛。REM、VW、Viewport 三者从来都不是互相替代的关系而是一条链路Viewport 管浏览器的“视口协议”VW 提供纯粹的“屏宽比例尺”REM 则在 VW 之上做了一层方便换算和统一调配的“根字体抽象”。把这条逻辑理顺了不管设计稿出 375 还是 750不管 UI 框架怎么换你都能在十分钟内配出一套不散架的移动端页面。最后分享一个小经验别迷信任何“一行代码解决移动端适配”的方案。所有适配问题最后都会在你没想到的机型上露出马脚。项目里永远留一个“真机回归测试”环节至少覆盖iPhone SE 系列、iPhone Pro Max 系列、一台中低端安卓分辨率别太高、一台大屏折叠屏或平板。在这些设备上把主要页面都过一遍你才能安心提交代码。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进