Skip to content

移动端适配与踩坑 ​

看在前面 ​

移动端适配的痛,根源是一场三方谈判:CSS 像素、物理像素、视口各说各话。PC 上浏览器窗口多宽页面就多宽,天塌不下来。手机上屏幕只有 375 个 CSS 像素宽,而网站是按 980 宽设计的。于是浏览器开始"缩放假装",所有适配方案都是在收拾这场假装的残局。

我的对照锚点:

  • viewport 三层 ≈ JVM 虚拟内存与物理内存的映射(逻辑视图 vs 物理资源,中间靠换算层)。
  • DPR ≈ 缩放系数。
  • rpx ≈ 一套等比缩放的"货币汇率"。

素材来自 star-site-app-demo2(uni-app,微信小程序 + H5 双端生产代码)。风格对齐 typescript/从Java视角看TypeScript-开篇,PC 端布局基础见 CSS布局体系与BFC。

viewport:三个"窗口"的谈判 ​

面试官:移动端 meta viewport 那行到底干了什么?

html
<meta name="viewport" content="width=device-width, initial-scale=1.0">

不写这行,手机浏览器默认把页面放进 980px 宽的**布局视口(layout viewport)**再整体缩小到屏幕大小。用户看到的是"缩小版 PC 页",字小到捏着放大。三个视口各司其职:

布局视口 layout viewport   ── CSS 布局所在的"画布",默认 980px
视觉视口 visual viewport   ── 用户真正看到的那扇"窗",可缩放可平移
理想视口 ideal viewport    ── 画布缩到与屏宽 1:1 的理想状态(如 375px)

width=device-width:布局视口 = 理想视口(画布不再是 980 的假宽度)
initial-scale=1     :视觉视口与布局视口 1:1(不自动缩小)
user-scalable=no    :禁手动缩放(现代浏览器为无障碍已允许忽略)

适配的本质就是让布局视口等于理想视口,然后让布局代码适应任意屏宽。默认 980 是历史兼容的决定,width=device-width 是对它的修正——讲清"为什么默认是 980",比背 meta 标签高一个段位。

1px 问题:CSS 像素与物理像素的汇率差 ​

面试官:设计稿上的 1px 细线,手机上为什么显示得比设计稿粗?

屏幕的物理像素密度高于 CSS 像素:DPR(devicePixelRatio)= 物理像素 / CSS 像素。iPhone 的 DPR=3 意味着 1 CSS px 用 3×3 物理像素渲染。这本来是让内容更精细的好事。但设计稿说" hairline 1px"时,指的是物理 1px。写 border: 1px 实际占 3 物理像素,视觉上就"粗了"。解法梯队:

css
/* 方案一:伪元素 + transform 缩放(最通用,无兼容问题) */
.hairline::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; background: #eee;
  transform: scaleY(0.33);            /* DPR=3 时缩成物理 1px */
  transform-origin: 0 0;
}
/* 方案二:媒体查询按 DPR 分档给 scaleY(0.5 / 0.33) */
/* 方案三:直接用 0.5px(iOS 8+ / 新安卓支持,渐进增强) */

追问链的下一环:为什么不用 border-width: 0.5px 一把梭——老安卓不认小数会渲染成 0。为什么 transform 缩放优先——它走合成层(见 浏览器渲染与事件循环),不触发重排。

rem 与 vw:两套等比缩放的货币体系 ​

面试官:rem 方案和 vw 方案各是什么?怎么选?

共同前提:设计稿 750px 宽,目标"任意屏宽下页面等比缩放"。rem 方案靠根字号当汇率:JS 或媒体查询把 html{font-size} 设成 屏宽/7.5,全部尺寸写 rem,改汇率即全站缩放。vw 方案把汇率写进单位本身:100vw = 屏宽,1px 设计稿 = 100/750 vw,配合 PostCSS 插件自动换算,零 JS:

维度rem 方案vw 方案
汇率载体根字号(JS 动态设或媒体查询)单位本身(100vw=屏宽)
依赖首屏一段 JS(抖一下)纯 CSS,构建期换算
超宽屏可设最大根字号兜底需 clamp() 限最大值
大屏体验字被无限放大(iPad 上惨案)同左,需 clamp

uni-app 的 rpx 是官方托管的一套等比货币:750rpx = 屏宽,编译期换算成 rem/百分比(H5)或小程序原生 rpx。demo2 的页面样式直接写 rpx,一次编写双端生效。代价是"等比缩放"默认没有上限,超宽屏(折叠屏/平板)要主动配最大宽度兜底。趋势共识:整页等比(大屏小屏一个模子)正在让位于"流式 + 弹性 + 断点"。文字信息流用弹性宽度,交互组件用等比缩放,别全站一刀切。

安全区:全面屏的物理税 ​

刘海屏、灵动岛、手势条侵占屏幕边缘,内容贴边会被遮。CSS 环境变量暴露安全区 insets,配合 viewport-fit=cover 生效:

html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
css
/* 吸底按钮避开 iPhone 底部手势条 */
.fixed-btn {
  bottom: calc(16px + env(safe-area-inset-bottom));   /* constant() 是 iOS11.0 老语法兜底 */
  padding-bottom: env(safe-area-inset-bottom);
}

追问链:为什么 H5 常看到"网页未适配安全区"的白边——没写 viewport-fit=cover 时浏览器自动留白。uni-app 里 safeArea 在 manifest/页面 style 里声明即可托管。微信小程序的胶囊按钮位置还要用 wx.getMenuButtonBoundingClientRect() 算——导航栏自定义时的高频坑。

触摸与滚动:300 ms、passive 与惯性 ​

面试官:移动端点击 300ms 延迟是怎么回事?现在还有吗?

历史问题:浏览器等 300 ms 判断"是否双击缩放"。现代浏览器在 width=device-width 的页面上已取消等待——这就是"写 viewport meta 顺便修了点击延迟"的原理。但触控本身还有三笔账要算:

touchstart → touchmove → touchend → click(合成事件,仍要等判断)
  • 其一,click 合成事件在 touchend 后才派发,快速响应场景用 touch 或 pointer 事件。
  • 其二,addEventListener('touchmove', fn, { passive: true }) 声明"回调绝不 preventDefault",浏览器就能不等 JS 执行先滚动——滚动性能的关键开关。移动端监听 touch/wheel 默认 passive,想 preventDefault 必须 passive: false 显式声明。
  • 其三,touch-action: none/pan-y 用 CSS 声明允许的手势,从源头省掉 JS 判断。

demo2 里 pages.json 的 "enablePullDownRefresh": true(如 index/reports 页)是 uni-app 托管的下拉刷新。原生组件接管手势,不走 JS 层,这本身就是"手势放原生层"的落地。

touch 与 click 的经典衍生坑:穿透 ​

移动端点"弹层关闭按钮",touchend 关闭弹层后,约 300 ms 后同一位置派发 click。点到了弹层底下刚露出来的元素,这就是"点击穿透"。机理就一句:touchend 先于 click,且两个事件命中同一坐标。解法梯队:弹层用 click 而非 touchend 关闭(节奏对齐);或 touchend 里 e.preventDefault() 阻止合成 click;或弹层消失加 300 ms 延迟(丑但兜底)。统一用 pointer 事件或 tap 封装(fastclick 思路)的团队基本免疫——这也是"为什么老项目里有个 fastclick 依赖"的答案。

软键盘顶起布局:Web 与小程序两套行为 ​

面试官:输入框弹键盘,页面被顶上去收不回来怎么办?

Android 常见"整页被顶起"(resize 模式),iOS 是"视口滚动"(fixed 元素漂移)。Web 侧纪律:吸底输入条别用 position: fixed; bottom: 0,用 visualViewport API 监听键盘高度贴底;表单页关闭滚动穿透。

小程序侧更稳:input 的 adjust-position 让页面自动上推。cursor-spacing 控制输入框与键盘的距离。hold-keyboard 防止点击按钮时键盘收起抖动。demo2 的 report-form(拍报告录入页)就靠这组配置兜住多字段连续录入。通用兜底:键盘弹起后重算布局的一律放 visualViewport/键盘事件回调里,别用定时器猜。

一码双端:uni-app 的条件编译清单 ​

demo2(uni-app)一套代码编译到 H5 与微信小程序,适配差异靠构建期条件编译分支。这是纯 Web 项目没有的机制:

ts
// star-site-app-demo2/src/utils/request.ts 的 BASE_URL(思路示意)
// #ifndef H5
const BASE_URL = import.meta.env.VITE_API_BASE   // 小程序端:完整域名(小程序不允许跨域代理)
// #endif
// #ifdef H5
const BASE_URL = '/api'                          // H5 端:走 vite dev 代理,同源免 CORS
// #endif
维度H5小程序
网络请求axios/fetch + CORSwx.request/uni.request,无 CORS 概念,域名要后台配置白名单
存储localStorage同步的 storage API,10 MB 上限
锚点/滚动window.scrollTopageScrollTo,页面级滚动
单位rpx 编译为 rem/百分比原生 rpx

条件编译的哲学:多端差异在构建期消解而不是运行期 if——产物里根本没有另一端的代码。对照 Java:这不是 if (env == "prod"),而是 Maven profile 按环境裁剪依赖。适配的另一半是小程序侧的滚动/导航栏差异(onReachBottom 替代 scroll 监听、自定义导航栏要自己算胶囊位置)。双线程架构对这套机制的约束见下一篇 小程序双线程模型。

面试官视角小结:30 秒版本 ​

  1. viewport:布局/视觉/理想三视口。meta 那行 = 布局视口对齐理想视口 + 取消自动缩放。默认 980 是历史兼容。
  2. 1px 问题:DPR 是物理/CSS 的汇率,hairline 用伪元素 scaleY 或 0.5px 渐进增强。
  3. rem vs vw:都是"屏宽当基准的等比货币",vw 免 JS 但要 clamp 限宽。rpx 是 uni-app 托管版。趋势是弹性+断点替代全站等比。
  4. 触摸:300 ms 已随 meta 消亡;passive 让滚动不等 JS;手势能下沉原生层就下沉。
  5. 键盘与安全区:viewport-fit=cover + env(safe-area-inset-*);键盘用 visualViewport/小程序 adjust-position,别用定时器猜。
  6. 双端差异:uni-app 条件编译在构建期裁剪(≈ Maven profile)。条件差异表里最常被追问的是"小程序无 CORS、要域名白名单"。

开场模板:

"移动端适配我当汇率问题治理:viewport meta 是逻辑视图对齐物理屏幕。rem/vw/rpx 是等比缩放的货币体系。1px 与安全区是物理像素和全面屏收的税。所以每个方案我都能讲清它修的是哪一层的换算。"

2026-10 新增(F 轨前端 · 首批)。