移动端适配实践
移动端适配是前端开发中避不开的核心课题。从最早的 rem 方案到如今的容器查询,从刘海屏安全区域到 PWA 离线缓存,本文系统梳理移动端适配的全链路实践。
一、移动端适配方案对比
1.1 核心方案概述
移动端适配的本质是解决 不同屏幕尺寸、不同像素密度 下页面的一致性展示问题。主流方案经历了四代演进:
| 方案 | 核心原理 | 代表实现 | 兼容性 | 维护状态 |
|---|---|---|---|---|
| viewport + vw/vh | 基于视口宽度的相对单位,配合 CSS 函数 calc() | 纯 CSS | iOS 9+ / Android 4.4+ | 活跃(推荐) |
| viewport + rem | 通过 JS 动态设置根字号,rem 基于根字号计算 | flexible.js (已废弃) | iOS 7+ / Android 4.0+ | 不建议新项目使用 |
| 媒体查询 @media | 在不同断点下写多套 CSS 规则 | Bootstrap / 手写 | 全平台 | 基础手段,配合使用 |
| 容器查询 @container | 基于父容器尺寸而非视口进行响应 | 原生 CSS Containment | Chrome 105+ / Safari 16+ | 新一代(需关注兼容性) |
1.2 vw/vh 方案(推荐)
原理:vw 和 vh 是相对于视口宽度和高度的单位。100vw = 视口宽度的 100%,1vw = 视口宽度的 1%。
这是目前 最推荐 的方案,无需任何 JS 依赖,纯 CSS 即可完成适配。
/* 基础用法:设计稿 375px,将 px 转换为 vw */
/* px -> vw 公式:100 * px / 设计稿宽度 */
/* ✅ 推荐做法:配合 postcss-px-to-viewport 自动转换 */
/* 设计稿宽度 375px */
.box {
width: 375px; /* 转换后: 100vw */
font-size: 14px; /* 转换后: 3.733vw (100 * 14 / 375) */
padding: 0 16px; /* 转换后: 0 4.267vw */
}
/* 手动写法(不推荐,可读性差) */
.box-manual {
width: 100vw;
font-size: 3.733vw;
}
配置 postcss-px-to-viewport:
// postcss.config.js
module.exports = {
plugins: {
'postcss-px-to-viewport': {
viewportWidth: 375, // 设计稿宽度(iPhone 6/7/8)
unitPrecision: 5, // 精度
viewportUnit: 'vw', // 转换单位
selectorBlackList: ['.ignore', '.hairlines'], // 忽略类
minPixelValue: 1, // 小于等于 1px 不转换
mediaQuery: false, // 媒体查询内不转换
exclude: [/node_modules/],
},
},
};
限制场景处理:
| 场景 | 问题 | 解决方案 |
|---|---|---|
| 1px 边框 | vw 转换后小于 1px 被忽略 | minPixelValue: 1 跳过,单独处理 |
| 大屏字体过大 | 100vw 宽度下字号无上限 | clamp() 限制范围 |
| iframe 内嵌 | 视口不可控 | 使用固定宽度或百分比 |
/* 使用 clamp() 限制字号最大最小值 */
.title {
font-size: clamp(14px, 3.733vw, 24px);
}
1.3 rem 方案(flexible.js,已不推荐)
原理:通过 JS 动态设置 <html> 的 font-size,页面所有尺寸用 rem 单位。淘宝 flexible.js 是最著名的实现。
// flexible.js 的核心逻辑(已废弃,仅作理解参考)
(function flexible() {
// 根据设备宽度动态设置根字号
const setRem = () => {
const docEl = document.documentElement;
const width = docEl.clientWidth;
// 设计稿 750px -> 1rem = 75px(除以 10)
const rem = width / 10;
docEl.style.fontSize = rem + 'px';
};
setRem();
window.addEventListener('resize', setRem);
})();
为什么不推荐了:
| 问题 | 说明 |
|---|---|
| JS 依赖 | 页面加载后有 FOUT(Flash of Unstyled Text) |
| 浮点精度 | 不同设备 rem 值产生小数,存在渲染误差 |
| 动态计算性能 | 频繁触发 resize 和 reflow |
| vw 已全面支持 | 现代浏览器 vw/vh 兼容性已足够,无需 rem 兜底 |
| 维护停滞 | flexible.js 已多年不更新,GitHub 仓库标明废弃 |
1.4 媒体查询方案
原理:通过 @media 在不同视口宽度下写多套样式规则,适用于 断点明确 的场景。
/* Mobile First 写法 */
.card {
display: flex;
flex-direction: column;
width: 100%;
padding: 12px;
}
/* >= 768px(平板) */
@media (min-width: 768px) {
.card {
flex-direction: row;
padding: 20px;
max-width: 720px;
margin: 0 auto;
}
}
/* >= 1024px(桌面) */
@media (min-width: 1024px) {
.card {
padding: 32px;
max-width: 980px;
}
}
| 场景 | 适合 @media | 不适合 @media |
|---|---|---|
| 整体布局切换 | ✅ 导航栏从底部 Tab 切换到侧边栏 | ❌ 单个元素大小微调 |
| 显隐控制 | ✅ 移动端隐藏侧边栏 | ❌ 字体大小微调(vw 更方便) |
| 跨断点重构 | ✅ 列表模式切换为网格模式 | ❌ 动画参数调整 |
1.5 容器查询方案(新一代)
原理:@container 让元素响应 父容器尺寸 而非视口,真正实现组件级响应式。
/* 定义容器上下文 */
.card-container {
container-type: inline-size;
container-name: card;
}
/* 容器查询 */
@container card (min-width: 300px) {
.card {
display: flex;
flex-direction: row;
}
.card-avatar {
width: 80px;
height: 80px;
}
}
@container card (max-width: 299px) {
.card {
flex-direction: column;
}
.card-avatar {
width: 40px;
height: 40px;
}
}
| 维度 | 媒体查询 @media | 容器查询 @container |
|---|---|---|
| 参考基准 | 视口/屏幕宽度 | 父容器可用宽度 |
| 复用性 | 差,依赖页面布局 | 强,组件独立响应 |
| 适用场景 | 页面级布局 | 组件级响应式 |
| 兼容性 | 全平台 | Chrome 105+, Safari 16+ |
| 适合业务 | 整体页面适配 | 通用组件库、卡片组件 |
1.6 方案选型对比
| 评估维度 | vw/vh(推荐) | rem | @media | @container |
|---|---|---|---|---|
| 实现复杂度 | 低(配合 postcss 自动化) | 中(需 JS 辅助) | 中(手动写多套) | 低(纯 CSS) |
| 灵活度 | 高 | 中 | 中 | 高(组件级) |
| 性能 | 优(无 JS 开销) | 中(reflow 开销) | 优 | 优 |
| 维护成本 | 低 | 中 | 高(断点多时) | 低 |
| iOS 兼容 | 9+ ✅ | 7+ ✅ | 全兼容 ✅ | 16+ ⚠️ |
| Android 兼容 | 4.4+ ✅ | 4.0+ ✅ | 全兼容 ✅ | 未普及 ⚠️ |
| 推荐度 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐(面向未来) |
建议:新项目优先选择 vw/vh + postcss-px-to-viewport,组件库开发可结合 @container 做组件级响应式,媒体查询用于整体布局的断点切换。
二、响应式布局实践
2.1 CSS Grid + Flexbox 弹性布局
移动端布局的核心思路:Flexbox 用于一维布局,Grid 用于二维布局。
Flexbox 适用场景:
| 场景 | 说明 |
|---|---|
| 导航栏 | 水平分布、两端对齐 |
| 列表项 | 图标+文字垂直居中 |
| 底部 Tab | 均分空间 |
| 表单 | 标签+输入框对齐 |
/* 移动端常见弹性布局模式 */
/* 1. 底部 Tab 栏:均分空间 */
.tab-bar {
display: flex;
justify-content: space-around;
align-items: center;
height: 50px;
/* 适配安全区域 */
padding-bottom: env(safe-area-inset-bottom);
}
.tab-item {
flex: 1;
text-align: center;
}
/* 2. 列表项:图标左、内容中、操作右 */
.list-item {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 12px;
}
.list-item__content {
flex: 1;
min-width: 0; /* 防止溢出 */
}
.list-item__action {
flex-shrink: 0;
}
/* 3. 流式布局:自动换行 */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
CSS Grid 适用场景:
| 场景 | 说明 |
|---|---|
| 商品网格 | 2列/3列自动切换 |
| 看板/仪表盘 | 区域划分 |
| 表单布局 | 标签-输入框对齐 |
/* 1. 自动适应列数的网格 */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
padding: 12px;
}
/* 2. 固定列数的网格(移动端 2 列) */
.product-grid-fixed {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
/* 3. Grid + 安全区域 */
.page-layout {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
min-height: 100dvh; /* dynamic viewport height */
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
2.2 断点设计
Mobile First (推荐)
核心思想:先写移动端样式,再通过 min-width 逐步增强。
/* ====== 基准:移动端(0 - 767px) ====== */
/* 默认样式就是移动端样式,无需媒体查询 */
.sidebar { display: none; } /* 移动端隐藏侧边栏 */
.header { height: 44px; } /* 移动端紧凑导航 */
.layout { flex-direction: column; } /* 移动端纵向排列 */
/* ====== 平板 (768px+) ====== */
@media (min-width: 768px) {
.sidebar { display: block; width: 240px; }
.header { height: 56px; }
.layout { flex-direction: row; }
}
/* ====== 桌面 (1024px+) ====== */
@media (min-width: 1024px) {
.sidebar { width: 280px; }
.content { max-width: 960px; margin: 0 auto; }
}
/* ====== 大屏 (1440px+) ====== */
@media (min-width: 1440px) {
.container { max-width: 1200px; margin: 0 auto; }
}
Desktop First
/* ====== 桌面端(基准) ====== */
.layout { display: flex; gap: 24px; }
.sidebar { width: 280px; }
/* ====== 平板 (max-width: 1023px) ====== */
@media (max-width: 1023px) {
.sidebar { width: 200px; }
}
/* ====== 移动端 (max-width: 767px) ====== */
@media (max-width: 767px) {
.layout { flex-direction: column; }
.sidebar { width: 100%; }
.sidebar.collapsed { display: none; }
}
| 维度 | Mobile First | Desktop First |
|---|---|---|
| CSS 量 | 少(基准已是最小屏) | 多(需覆盖多处覆写) |
| 移动端性能 | 优(无冗余样式覆盖) | 中(有样式覆盖开销) |
| 理解难度 | 易(渐进增强直觉) | 中(需记多处覆写) |
| 适用场景 | 移动端为主/H5 | 管理后台/PC 为主 |
2.3 栅格系统
移动端栅格建议使用 4 列或 6 列栅格,相比 12 列栅格更简洁。
/* 简易 4 列栅格 */
.grid-row {
display: flex;
flex-wrap: wrap;
margin: 0 -6px;
}
.grid-col {
padding: 0 6px;
box-sizing: border-box;
}
/* 4 列栅格类生成 */
.grid-col-1 { width: 25%; }
.grid-col-2 { width: 50%; }
.grid-col-3 { width: 75%; }
.grid-col-4 { width: 100%; }
/* ✅ 配合响应式断点 */
/* 移动端: 100% */ .col { width: 100%; }
/* 平板: 50% */ @media (min-width: 768px) { .col-md-2 { width: 50%; } }
/* 桌面: 25% */ @media (min-width: 1024px) { .col-lg-1 { width: 25%; } }
Vue / React 中的栅格组件实现:
<p data-version="vue">
<!-- Vue 3 + Element Plus 栅格 -->
<script setup lang="ts">
defineProps<{
span: number // 栅格占位 1-24(Element Plus 用 24 列栅格)
offset?: number
xs?: number // <768px
sm?: number // >=768px
md?: number // >=992px
lg?: number // >=1200px
xl?: number // >=1920px
}>()
</script>
<template>
<el-row :gutter="16">
<!-- 移动端 24 占满,平板 12 占一半,桌面 8 占三分之一 -->
<el-col :xs="24" :sm="12" :md="8" v-for="item in items" :key="item.id">
<div class="card">{{ item.name }}</div>
</el-col>
</el-row>
</template>
</p>
<p data-version="react">
// React + Ant Design 栅格 + 自定义 Hooks
import { Row, Col } from 'antd';
const ResponsiveGrid: React.FC<{ items: Item[] }> = ({ items }) => {
return (
<Row gutter={[16, 16]}>
{items.map((item) => (
<Col xs={24} sm={12} md={8} lg={6} key={item.id}>
<div className="card">{item.name}</div>
</Col>
))}
</Row>
);
};
</p>
2.4 CSS 容器查询(新一代响应式方案)
与媒体查询的根本区别:容器查询感知的是 父容器的宽度,而非视口宽度。这使得一个组件在不同的容器中能自动适配。
/* 定义容器 */
.widget-container {
container-type: inline-size;
}
/* 窄容器布局 */
@container (max-width: 300px) {
.widget {
flex-direction: column;
}
.widget__chart {
height: 120px;
}
.widget__legend {
font-size: 12px;
}
}
/* 宽容器布局 */
@container (min-width: 600px) {
.widget {
flex-direction: row;
align-items: center;
}
.widget__chart {
height: 200px;
flex: 1;
}
.widget__info {
width: 200px;
padding-left: 24px;
}
}
容器查询单位:
| 单位 | 相对基准 | 说明 |
|---|---|---|
cqw |
容器宽度 | 1cqw = 容器宽度的 1% |
cqh |
容器高度 | 1cqh = 容器高度的 1% |
cqi |
容器内联尺寸 | 水平书写模式下 = cqw |
cqb |
容器块向尺寸 | 水平书写模式下 = cqh |
cqmin |
容器较小值 | min(cqi, cqb) |
cqmax |
容器较大值 | max(cqi, cqb) |
/* 使用容器查询单位自动缩放 */
.card {
font-size: clamp(12px, 4cqi, 20px);
padding: 2cqi;
border-radius: 1cqi;
}
三、移动端适配细节
3.1 Viewport Meta 标签配置
viewport meta 是移动端适配的基石,配置得当可以避免大量渲染问题。
<!-- ✅ 标准配置(推荐) -->
<meta name="viewport"
content="width=device-width,
initial-scale=1.0,
maximum-scale=1.0,
minimum-scale=1.0,
user-scalable=no" />
<!-- ✅ 简化配置(兼容性更好) -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
参数说明:
| 参数 | 推荐值 | 说明 |
|---|---|---|
width |
device-width |
页面宽度等于设备宽度 |
initial-scale |
1.0 |
初始缩放比例 |
maximum-scale |
1.0 |
最大缩放(控制用户放大) |
minimum-scale |
1.0 |
最小缩放 |
user-scalable |
no |
是否允许用户缩放 |
viewport-fit |
cover |
适配刘海屏(配合 env() 使用) |
注意:
user-scalable=no和maximum-scale=1.0会影响无障碍访问(视障用户可能需要缩放)。如果业务允许,可考虑保留缩放能力。WCAG 2.2 建议不要禁用缩放。
3.2 1px 边框问题
问题:在高 DPR(Device Pixel Ratio)屏幕(如 iPhone Retina)上,CSS 的 1px 实际渲染为物理像素的 2px 或 3px,导致边框过粗。
解决方案对比:
| 方案 | 原理 | 兼容性 | 复杂度 |
|---|---|---|---|
| 伪元素 + transform: scale | 伪元素渲染为 2x 大小,scale(0.5) 缩小 | 全平台 | 低 |
| 直接写 0.5px | 部分 iOS 支持 0.5px | iOS 8+ / Android 部分版本 | 极低(兼容性有限) |
| SVG border-image | 1px 宽的 SVG 做边框图片 | 全平台 | 高 |
| box-shadow 模拟 | box-shadow: 0 0 0 0.5px #ddd |
全平台 | 中 |
推荐方案:伪元素 + transform: scale
// ✅ 推荐方案:伪元素 + transfrom scale
// 优点:兼容性好,不影响 DOM 结构
// 缺点:需要伪元素,不支持 border-radius 组合(需额外处理)
@mixin hairline($direction: bottom, $color: #eee) {
position: relative;
&::after {
content: '';
position: absolute;
#{$direction}: 0;
left: 0;
right: 0;
height: 1px;
background: $color;
transform: scaleY(0.5);
transform-origin: 0 0;
}
// 根据 DPR 进一步压缩
@media (-webkit-min-device-pixel-ratio: 3) {
&::after {
transform: scaleY(0.333);
}
}
}
// 使用
.cell {
@include hairline(bottom, #e5e5e5);
padding: 14px 16px;
}
// ✅ 全边框方案
@mixin hairline-all($color: #eee) {
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 200%;
height: 200%;
border: 1px solid $color;
border-radius: inherit;
transform-origin: 0 0;
transform: scale(0.5);
box-sizing: border-box;
pointer-events: none;
}
}
.card {
@include hairline-all(#e5e5e5);
border-radius: 8px;
}
直接使用 0.5px(备选,适用于 iOS):
// ✅ 利用设备对 0.5px 的支持(仅部分设备)
.hairline {
border-bottom: 0.5px solid #e5e5e5;
}
// ❌ 问题:部分 Android 设备会将 0.5px 解析为 0px,导致边框消失
3.3 300ms 点击延迟
问题历史:早期 iOS 为了区分"单击"和"双击缩放",在首次点击后等待 300ms 确认。现代浏览器已解决此问题。
解决方案:
| 方案 | 说明 | 推荐度 |
|---|---|---|
touch-action: manipulation |
CSS 属性,告诉浏览器只处理触控操作,无需等待双击检测 | ⭐⭐⭐⭐⭐ |
| FastClick 库 | 通过 touch 事件模拟 click 消除延迟 | ⭐(已废弃) |
<meta> 禁止缩放 |
user-scalable=no 间接消除延迟 |
⭐⭐⭐(仅部分场景) |
推荐:touch-action: manipulation
/* ✅ 推荐方案:全局设置 */
html {
touch-action: manipulation;
}
/* 在特定交互元素上设置 */
button, a, .clickable {
touch-action: manipulation;
}
为什么 FastClick 已不推荐:
- 兼容性已足够:iOS 9.3+ / Android 4.4+ 均不再有 300ms 延迟
- Bug 较多:FastClick 在 iOS 11+ 上会导致点击穿透、键盘弹出失效等问题
- 额外 JS 负担:页面加载和触摸事件监听的开销
3.4 安全区域适配(iPhone 刘海屏/灵动岛)
问题:iPhone X 及之后的机型有刘海屏、圆角和底部 Home Indicator,页面内容可能被遮挡。
核心 API:env(safe-area-inset-*) 和 constant(safe-area-inset-*)(iOS 11.0 用 constant,iOS 11.2+ 用 env)。
/* ✅ 标准安全区域适配 */
/* 1. viewport-fit=cover 是前提 */
/* <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> */
/* 2. CSS 安全区域变量 */
.safe-area-page {
padding-top: env(safe-area-inset-top, 0px); /* 状态栏高度(刘海区域) */
padding-bottom: env(safe-area-inset-bottom, 0px); /* 底部 Home Indicator */
padding-left: env(safe-area-inset-left, 0px); /* 横屏左侧 */
padding-right: env(safe-area-inset-right, 0px); /* 横屏右侧 */
}
/* 3. 底部 Tab 栏适配 */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(50px + env(safe-area-inset-bottom, 0px));
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* 4. 顶部导航栏适配 */
.nav-bar {
padding-top: env(safe-area-inset-top, 20px);
height: calc(44px + env(safe-area-inset-top, 20px));
}
/* 5. 兼容 iOS 11.0(constant 已废弃但仍需保留) */
@supports (padding-top: constant(safe-area-inset-top)) {
.safe-area-fallback {
padding-top: constant(safe-area-inset-top);
padding-bottom: constant(safe-area-inset-bottom);
}
}
安全区域适配工具函数:
// ✅ 防御式检测环境
export function getSafeAreaInsets(): {
top: number
bottom: number
left: number
right: number
} {
const defaultInsets = { top: 0, bottom: 0, left: 0, right: 0 };
// 不是浏览器环境
if (typeof window === 'undefined' || !document.documentElement?.style) {
return defaultInsets;
}
const style = getComputedStyle(document.documentElement);
const parseInset = (key: string): number => {
// 先尝试 env,再尝试 constant
const value =
style.getPropertyValue(key) ||
style.getPropertyValue(key.replace('env', 'constant'));
return value ? parseInt(value, 10) : 0;
};
return {
top: parseInset('--safe-area-inset-top') || defaultInsets.top,
bottom: parseInset('--safe-area-inset-bottom') || defaultInsets.bottom,
left: parseInset('--safe-area-inset-left') || defaultInsets.left,
right: parseInset('--safe-area-inset-right') || defaultInsets.right,
};
}
常见机型安全区域参考:
| 机型 | safe-area-inset-top | safe-area-inset-bottom |
|---|---|---|
| iPhone X / XS / 11 Pro | 44px | 34px |
| iPhone XR / 11 | 44px | 34px |
| iPhone 12 / 13 / 14 | 47px | 34px |
| iPhone 14 Pro / 15 Pro | 59px(灵动岛) | 34px |
| iPhone SE (2nd/3rd) | 20px(无刘海) | 0px |
| 普通 Android | 24px(状态栏) | 0px |
3.5 软键盘弹出问题
问题:移动端输入框 focus 时,软键盘弹出可能遮挡输入框,尤其是在 fixed 定位的底部输入栏场景。
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const inputRef = ref<HTMLElement>()
const isKeyboardVisible = ref(false)
const keyboardHeight = ref(0)
// ✅ 方案一:监听 resize(大部分 Android 适用)
const handleResize = () => {
const visualHeight = window.visualViewport?.height ?? window.innerHeight
const screenHeight = window.screen.height
keyboardHeight.value = Math.max(0, screenHeight - visualHeight)
isKeyboardVisible.value = keyboardHeight.value > 100
}
// ✅ 方案二:使用 VisualViewport API(推荐,iOS + Android)
const handleVisualViewport = () => {
if (!window.visualViewport) return
const { height, offsetTop } = window.visualViewport
document.documentElement.style.setProperty('--vh', `${height * 0.01}px`)
// 输入框滚动到可视区域
if (inputRef.value && offsetTop > 0) {
setTimeout(() => {
inputRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 300)
}
}
onMounted(() => {
window.visualViewport?.addEventListener('resize', handleVisualViewport)
// Android 备选
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.visualViewport?.removeEventListener('resize', handleVisualViewport)
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<!-- ✅ 用 --vh 替代 100vh,避免键盘弹出时布局错乱 -->
<div class="page" :style="{ height: 'calc(var(--vh, 1vh) * 100)' }">
<!-- 内容区域 -->
<div class="content">
<!-- ... -->
</div>
<!-- 底部输入栏:键盘弹出时跟随 -->
<div
class="bottom-input"
:class="{ 'is-keyboard': isKeyboardVisible }"
ref="inputRef"
>
<input type="text" placeholder="输入消息..." />
<button>发送</button>
</div>
</div>
</template>
<style scoped>
.bottom-input {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
padding: 8px 12px;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
background: #fff;
transition: transform 0.3s;
}
/* ✅ 键盘弹出时的正确行为 */
.is-keyboard {
/* 不需要额外处理,VisualViewport 已经缩小了视口 */
/* 但要注意 fixed 定位在键盘弹出时可能失效 */
}
</style>
// React Hooks 版本
import { useEffect, useRef, useState, useCallback } from 'react';
interface KeyboardState {
visible: boolean;
height: number;
}
export function useKeyboard(): KeyboardState & { inputRef: React.RefObject<HTMLDivElement> } {
const inputRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<KeyboardState>({ visible: false, height: 0 });
const handleViewportChange = useCallback(() => {
const vv = window.visualViewport;
if (!vv) return;
const screenHeight = window.screen.height;
const kbHeight = Math.max(0, screenHeight - vv.height);
const isVisible = kbHeight > 100;
setState({ visible: isVisible, height: kbHeight });
// 设置 CSS 变量
document.documentElement.style.setProperty('--vh', `${vv.height * 0.01}px`);
// 滚动到输入框
if (isVisible && inputRef.current) {
setTimeout(() => {
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 300);
}
}, []);
useEffect(() => {
window.visualViewport?.addEventListener('resize', handleViewportChange);
return () => window.visualViewport?.removeEventListener('resize', handleViewportChange);
}, [handleViewportChange]);
return { ...state, inputRef };
}
// 使用
const ChatInput: React.FC = () => {
const { visible, height, inputRef } = useKeyboard();
return (
<div
ref={inputRef}
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
paddingBottom: `calc(8px + env(safe-area-inset-bottom))`,
background: '#fff',
transform: visible ? `translateY(-${height}px)` : 'none',
transition: 'transform 0.3s',
}}
>
<input placeholder="输入消息..." />
<button>发送</button>
</div>
);
};
软键盘问题踩坑汇总:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 底部固定栏被键盘顶起 | fixed 元素在键盘弹出时定位失效 | 改用 position: sticky 或监听 VisualViewport 动态调整 |
| 100vh 布局错乱 | 键盘弹出后视口高度减小,100vh 超出 | 用 100dvh(dynamic viewport height)或 JS 设置 --vh |
| 输入框被键盘遮挡 | 键盘弹出后页面未自动滚动 | element.scrollIntoView() + 300ms 延时 |
| Android 上滚动回弹 | 键盘弹出导致的滚动事件冲突 | overscroll-behavior: contain 限制滚动链 |
| iOS Safari 键盘收起页面不回弹 | Safari 固有行为 | window.scrollTo(0, 0) 手动恢复 |
四、H5 与小程序适配
4.1 H5 与原生交互(JSBridge)
JSBridge 是 H5 与原生 App(iOS/Android)之间通信的桥梁,核心是两端约定好的一套协议。
通信链路:
H5 (JS) ──postMessage──> 原生 (Native)
原生 (Native) ──evaluateJavascript──> H5 (JS)
// ✅ 封装统一的 JSBridge(防御式设计)
interface BridgePayload {
action: string
params?: Record<string, unknown>
callback?: string // 回调函数名
}
interface BridgeResponse {
code: number
data?: unknown
message?: string
}
class JSBridge {
private callbackMap = new Map<string, (res: BridgeResponse) => void>()
private callbackId = 0
// 调用原生能力
invoke(action: string, params?: Record<string, unknown>): Promise<BridgeResponse> {
return new Promise((resolve) => {
const id = `cb_${++this.callbackId}_${Date.now()}`
this.callbackMap.set(id, (res: BridgeResponse) => {
this.callbackMap.delete(id)
resolve(res)
})
// 根据运行环境选择通信方式
if (this.isNativeApp()) {
// 方式一:通过注入的 bridge 对象调用
if ((window as any).NativeBridge) {
;(window as any).NativeBridge.postMessage(JSON.stringify({
action,
params,
callback: id,
}))
return
}
// 方式二:通过 URL Scheme(webview 拦截)
const scheme = `jsbridge://${action}?params=${encodeURIComponent(
JSON.stringify(params ?? {})
)}&callback=${id}`
// 创建 iframe 触发(避免 location.href 导致页面跳转)
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = scheme
document.body.appendChild(iframe)
setTimeout(() => document.body.removeChild(iframe), 100)
} else {
// 非 App 环境:降级处理
console.warn('[JSBridge] 不在 App 环境中,无法调用:', action)
resolve({ code: -1, message: 'not in app environment' })
}
})
}
// 接收原生回调
onCallback(callbackId: string, data: BridgeResponse): void {
const handler = this.callbackMap.get(callbackId)
if (handler) {
handler(data)
}
}
// 判断是否在原生 App 中
private isNativeApp(): boolean {
return (
typeof (window as any).NativeBridge !== 'undefined' ||
/myapp/i.test(navigator.userAgent) ||
/myapp/i.test(window.location.search)
)
}
}
export const bridge = new JSBridge()
// 业务使用
async function getDeviceInfo() {
// ✅ 防御处理:永远假设原生能力可能不存在
try {
const res = await bridge.invoke('getDeviceInfo', {})
if (res.code === 0) {
return res.data as { platform: string; version: string }
}
// 默认降级值
return { platform: 'web', version: 'unknown' }
} catch {
return { platform: 'web', version: 'unknown' }
}
}
原生注册回调(iOS WKWebView 示例):
// iOS 端注入 JS 回调
webView.evaluateJavaScript("window.bridge.onCallback('\(callbackId)', { code: 0, data: { battery: 80 } })")
JSBridge 方案对比:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 注入对象 | window.NativeBridge.postMessage(JSON) |
可靠、类型化 | 需原生配合注入 |
| URL Scheme | jsbridge://action?params=... |
简单、无注入 | URL 长度限制,性能差 |
| WebView Message Handler | addJavascriptInterface (Android) |
高效双向 | 平台差异大 |
| 拦截 console.log | 原生拦截 console API | 纯 Hack | 不推荐,影响调试 |
4.2 微信 JS-SDK 接入
微信 JS-SDK 是 H5 在微信浏览器中调用微信原生能力的桥梁(分享、支付、拍照等)。
// ✅ 封装微信 JS-SDK(防御式)
interface WechatConfig {
debug?: boolean
appId: string
timestamp: string
nonceStr: string
signature: string
jsApiList: string[]
}
class WechatSDK {
private ready = false
private readyCallbacks: Array<() => void> = []
private errorCallbacks: Array<(err: unknown) => void> = []
// 初始化(需先加载 JS-SDK 脚本)
async init(config: WechatConfig): Promise<void> {
// 1. 确保 wx 对象存在
if (typeof wx === 'undefined') {
// 动态加载 JS-SDK
await this.loadScript('https://res.wx.qq.com/open/js/jweixin-1.6.0.js')
}
return new Promise((resolve, reject) => {
wx.config({
debug: config.debug ?? false,
appId: config.appId,
timestamp: config.timestamp,
nonceStr: config.nonceStr,
signature: config.signature,
jsApiList: config.jsApiList,
})
wx.ready(() => {
this.ready = true
this.readyCallbacks.forEach(cb => cb())
resolve()
})
wx.error((err: unknown) => {
this.errorCallbacks.forEach(cb => cb(err))
reject(err)
})
})
}
// 配置微信分享
setupShare(options: {
title: string
desc: string
link: string
imgUrl: string
}): void {
// ❌ 错误:直接调用 wx.onMenuShareTimeline(已废弃)
// wx.onMenuShareTimeline({ ... });
// ✅ 正确:使用新版 wx.updateAppMessageShareData(自 JS-SDK 1.4.0)
this.readyCallbacks.push(() => {
wx.updateAppMessageShareData({
title: options.title,
desc: options.desc,
link: options.link,
imgUrl: options.imgUrl,
success: () => console.log('[WechatSDK] 分享配置成功'),
})
wx.updateTimelineShareData({
title: options.title,
link: options.link,
imgUrl: options.imgUrl,
success: () => console.log('[WechatSDK] 朋友圈分享配置成功'),
})
})
}
private loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = src
script.onload = () => resolve()
script.onerror = () => reject(new Error(`加载脚本失败: ${src}`))
document.head.appendChild(script)
})
}
}
export const wechat = new WechatSDK()
微信 JS-SDK 踩坑汇总:
| 问题 | 原因 | 解决方案 |
|---|---|---|
invalid signature |
signature 计算错误或 URL 不一致 | 确保签名 URL 与当前页面 URL 完全一致(去 hash) |
permission denied |
未在 jsApiList 中申明 API | 后台配置 + 前端 jsApiList 均需申明 |
| 分享配置不生效 | 使用了已废弃的 API | 使用 updateAppMessageShareData(v1.4.0+) |
| 仅在 iOS 上不工作 | 微信安全域名校验问题 | 检查是否在微信 JS 安全域名下 |
| 本地开发无法调试 | 微信要求绑定域名 | 使用 ngrok 或将本地 IP 加入白名单 |
4.3 uni-app / Taro 跨端方案
方案定位:
| 维度 | uni-app(Vue 生态) | Taro(React 生态) |
|---|---|---|
| 框架基础 | Vue 3 / 2 | React |
| 支持平台 | 微信/支付宝/百度/头条/QQ 小程序 + H5 + App | 微信/支付宝/百度/头条/QQ/京东小程序 + H5 + RN |
| 编译原理 | 模板编译 + 运行时适配 | AST 编译转换 + 运行时适配 |
| 开发体验 | 类似 Vue SFC | 类似 React JSX |
| 社区活跃度 | 高(国内) | 高(京东开源) |
| 推荐场景 | Vue 技术栈项目 | React 技术栈项目 |
Vue:uni-app 实践要点
<!-- uni-app 跨端页面示例 -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
interface Product {
id: number
name: string
price: number
image: string
}
const products = ref<Product[]>([])
const loading = ref(false)
const page = ref(1)
// 获取商品列表
async function fetchProducts() {
loading.value = true
try {
const res = await uni.request({
url: '/api/products',
data: { page: page.value, pageSize: 10 },
})
// ✅ 防御式处理:永远不信任后端数据
const data = (res.data as any)?.data ?? []
products.value = [...products.value, ...data.map(normalizeProduct)]
} catch (err) {
uni.showToast({ title: '加载失败', icon: 'error' })
} finally {
loading.value = false
}
}
// 防御:数据规范化
function normalizeProduct(raw: any): Product {
return {
id: raw?.id ?? 0,
name: raw?.name ?? '未知商品',
price: raw?.price ?? 0,
image: raw?.image ?? '/default.png',
}
}
// 上拉加载更多
onReachBottom(() => {
page.value++
fetchProducts()
})
// 下拉刷新
onPullDownRefresh(() => {
page.value = 1
products.value = []
fetchProducts().finally(() => uni.stopPullDownRefresh())
})
onMounted(() => fetchProducts())
</script>
<template>
<view class="product-list">
<view
class="product-item"
v-for="item in products"
:key="item.id"
@click="navigateTo(`/pages/detail?id=${item.id}`)"
>
<image :src="item.image" mode="aspectFill" class="product-image" />
<view class="product-info">
<text class="product-name">{{ item.name }}</text>
<text class="product-price">¥{{ item.price.toFixed(2) }}</text>
</view>
</view>
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
</view>
</template>
<style scoped>
.product-item {
display: flex;
padding: 12px;
border-bottom: 1rpx solid #eee;
/* uni-app 用 rpx 单位,设计稿 750px 时 1rpx = 1px */
}
</style>
uni-app 踩坑汇总:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 组件条件编译不生效 | 语法写错 | <!-- #ifdef MP-WEIXIN --> ... <!-- #endif --> |
| 部分 CSS 属性不兼容 | 小程序限制 | 查阅各平台兼容性文档;使用 display: flex 等通用属性 |
| HTTPS 要求 | 各平台强制要求 | 所有请求必须使用 HTTPS |
| 分包限制 | 微信小程序主包 2MB | 做好分包规划,静态资源放 CDN |
| 全局变量共享 | 不同平台实现不同 | 使用 Vuex/Pinia 或 uni.$emit/uni.$on |
React:Taro 实践要点
// Taro 跨端页面示例
import { useState, useEffect, useCallback } from 'react'
import { View, Text, Image } from '@tarojs/components'
import Taro, { useReachBottom, usePullDownRefresh } from '@tarojs/taro'
interface Product {
id: number
name: string
price: number
image: string
}
const ProductList: React.FC = () => {
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
// ✅ 请求封装,防御处理
const fetchProducts = useCallback(async (pageNum: number) => {
setLoading(true)
try {
const res = await Taro.request({
url: '/api/products',
data: { page: pageNum, pageSize: 10 },
})
// 防御式:永远不信任后端返回的数据结构
const list = (res.data as any)?.data ?? []
const normalized = list.map((item: any): Product => ({
id: item?.id ?? 0,
name: item?.name ?? '',
price: parseFloat(item?.price) || 0,
image: item?.image ?? '',
}))
setProducts(prev => (pageNum === 1 ? normalized : [...prev, ...normalized]))
} catch {
Taro.showToast({ title: '加载失败', icon: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchProducts(1)
}, [fetchProducts])
// 上拉加载更多
useReachBottom(() => {
const nextPage = page + 1
setPage(nextPage)
fetchProducts(nextPage)
})
// 下拉刷新
usePullDownRefresh(() => {
setPage(1)
fetchProducts(1).finally(() => Taro.stopPullDownRefresh())
})
return (
<View className="product-list">
{products.map((item) => (
<View
key={item.id}
className="product-item"
onClick={() => Taro.navigateTo({ url: `/pages/detail?id=${item.id}` })}
>
<Image src={item.image} mode="aspectFill" className="product-image" />
<View className="product-info">
<Text className="product-name">{item.name}</Text>
<Text className="product-price">¥{item.price.toFixed(2)}</Text>
</View>
</View>
))}
{loading && <View className="loading">加载中...</View>}
</View>
)
}
export default ProductList
Taro 与 uni-app 相比的独特点:
| 特性 | Taro | uni-app |
|---|---|---|
| JSX 转换 | 编译时将 JSX 转换为小程序模板 | 使用 Vue 模板语法 |
| Hooks 支持 | 完整支持 React Hooks | Vue Composition API |
| 第三方组件 | 部分 React 组件可直接使用 | 部分 Vue 组件可直接使用 |
| CSS Modules | 支持 | 需额外配置 |
| TypeScript | 一等公民 | 支持 |
| 自定义 tabBar | 需各平台单独处理 | 提供 uni.setTabBarItem |
五、移动端调试
5.1 vConsole / Eruda 移动端调试工具
在手机浏览器/H5 页面中直接查看 console 日志、网络请求、性能信息。
| 工具 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| vConsole | 轻量 (60KB)、功能全面、UI 直观 | 第三方平台上样式偶有冲突 | 日常开发调试首选 |
| Eruda | 类似 Chrome DevTools、功能更强 | 体积较大 (150KB)、资源消耗高 | 需要详细性能分析时 |
vConsole 集成:
// ✅ 条件加载(仅开发/测试环境)
// 方式一:通过 npm 包管理
import VConsole from 'vconsole'
if (import.meta.env.DEV || window.location.search.includes('debug=1')) {
const vConsole = new VConsole()
// vConsole 实例可以全局访问,用于动态开关
;(window as any).__vconsole = vConsole
}
// 方式二:通过动态 script 注入
export function initVConsole() {
// ❌ 不要在生产环境默认加载
if (window.location.hostname === 'production.com') return
const script = document.createElement('script')
script.src = 'https://unpkg.com/vconsole@3/dist/vconsole.min.js'
script.onload = () => {
new (window as any).VConsole()
}
document.head.appendChild(script)
}
Eruda 集成:
// Eruda 功能更丰富,适合性能调试
export async function initEruda() {
if (window.location.hostname === 'production.com') return
const eruda = await import('eruda')
eruda.default.init()
// 加载额外插件
await import('eruda-dom') // DOM 查看
await import('eruda-timing') // 性能时间线
}
vConsole 功能速查:
| Tab | 功能 | 说明 |
|---|---|---|
| Log | console.log/warn/error | 日常日志输出 |
| Network | XHR/Fetch 请求详情 | 查看请求头、响应体、耗时 |
| Element | HTML 元素查看 | 查看但不可编辑 DOM |
| Storage | localStorage/cookie/Session | 查看和清除存储数据 |
| System | 设备信息、UserAgent | 查看屏幕尺寸、像素比 |
5.2 Chrome Remote Debugging
真机调试方案对比:
| 方案 | 方式 | 功能完整度 | 上手难度 |
|---|---|---|---|
| Chrome DevTools + USB | Android 真机 + USB 连接 | 完整 DevTools | 低 |
| Safari Web Inspector | iOS 真机 + USB + Safari | 完整 Web Inspector | 中 |
| Whistle/Charles 代理 | 代理抓包 + 远程映射 | 网络层面 | 中 |
| Chrome DevTools + 端口转发 | Android 无线调试 | 完整 DevTools | 中 |
| localtunnel/ngrok | 将本地服务暴露到公网 | 快速验证 | 低 |
Chrome 远程调试步骤(Android):
# 1. 连接设备
adb devices
# 2. 端口转发(如果 adb 已识别设备,Chrome 自动发现)
adb forward tcp:9222 localabstract:chrome_devtools_remote
# 3. 在 Chrome 地址栏访问
chrome://inspect
# 4. 勾选 "Discover USB devices" 后即可调试
# 5. 无线调试(无需 USB)
adb tcpip 5555
adb connect <device_ip>:5555
Safari Web Inspector 步骤(iOS):
# 1. 开启 iOS Safari 的 Web Inspector
# 设置 > Safari > 高级 > Web Inspector (开启)
# 2. macOS 上开启 Safari 开发者菜单
# Safari > 设置 > 高级 > 显示开发者菜单
# 3. 连接设备后,Safari > 开发 > 选择设备 > 选择页面
5.3 真机调试技巧
// ✅ 实用调试技巧集合
// 1. 轻量级远程日志(用于无法使用 DevTools 的场景)
class RemoteLogger {
private logs: string[] = []
private readonly endpoint: string
constructor(endpoint: string) {
this.endpoint = endpoint
}
log(...args: unknown[]): void {
const msg = `[${new Date().toISOString()}] ${args.map(String).join(' ')}`
this.logs.push(msg)
// 如果日志超过 100 条,自动发送
if (this.logs.length >= 100) {
this.flush()
}
}
flush(): void {
if (this.logs.length === 0 || !this.endpoint) return
// 使用 sendBeacon 确保页面关闭时也能发送
navigator.sendBeacon(this.endpoint, JSON.stringify({ logs: this.logs }))
this.logs = []
}
}
// 2. 调试定时清理
export class DebugOverlay {
private container?: HTMLDivElement
show(info: Record<string, unknown>): void {
if (window.location.hostname === 'production.com') return
if (!this.container) {
this.container = document.createElement('div')
Object.assign(this.container.style, {
position: 'fixed',
top: '0',
left: '0',
zIndex: '99999',
background: 'rgba(0,0,0,0.8)',
color: '#0f0',
fontSize: '12px',
padding: '8px',
maxHeight: '200px',
overflow: 'auto',
width: '100%',
pointerEvents: 'none',
})
document.body.appendChild(this.container)
}
this.container.textContent = Object.entries(info)
.map(([k, v]) => `${k}: ${String(v)}`)
.join(' | ')
// 5 秒后自动清除
setTimeout(() => this.container!.textContent = '', 5000)
}
destroy(): void {
this.container?.remove()
}
}
真机调试常见问题:
| 问题 | 原因 | 解决 |
|---|---|---|
| USB 连接后 Chrome 不识别 | 未开启 USB 调试模式 | Android:开发者选项 > USB 调试 |
| iOS 无法连接 Web Inspector | 未信任电脑 | 解锁设备后点击"信任此电脑" |
| HTTPS 混合内容警告 | 页面 HTTPS 但资源 HTTP | 统一使用 HTTPS 或 ngrok HTTPS 隧道 |
| vConsole/Eruda 按钮被遮挡 | 页面元素层级问题 | 调整 z-index 或使用手势打开 |
| 代理后页面加载异常 | HTTPS 证书问题 | 安装 Whistle/Charles 的 CA 证书 |
六、移动端性能
6.1 首屏速度优化
移动端网络环境(4G/5G 甚至弱网)远差于桌面端,首屏优化是移动端性能的重中之重。
核心优化策略:
| 策略 | 预期收益 | 实现方式 |
|---|---|---|
| 静态资源预加载 | 减少 DNS/连接时间 | <link rel="dns-prefetch"> + <link rel="preconnect"> |
| 关键 CSS 内联 | 消除 CSS 阻塞渲染 | 首屏 CSS 直接写 HTML <style> 中 |
| 图片懒加载 | 减少首屏传输量 | loading="lazy" + IntersectionObserver |
| 骨架屏 | 减少白屏感 | 组件级骨架屏(Skeleton) |
| 组件异步加载 | 拆分 JS Bundle | defineAsyncComponent / React.lazy |
| Service Worker 预缓存 | 秒开体验 | 预缓存首屏资源 |
<!-- ✅ 关键资源预连接 -->
<head>
<!-- DNS 预解析 -->
<link rel="dns-prefetch" href="//api.example.com" />
<link rel="dns-prefetch" href="//static.example.com" />
<!-- 预连接(包含 TLS 握手) -->
<link rel="preconnect" href="https://api.example.com" crossorigin />
<link rel="preconnect" href="https://static.example.com" />
<!-- 预加载关键资源 -->
<link rel="preload" href="/fonts/iconfont.woff2" as="font" crossorigin />
<link rel="preload" href="/css/critical.css" as="style" />
</head>
<!-- Vue 3 异步加载 + 骨架屏 -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
const HeavyComponent = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: () => <div class="skeleton" />,
delay: 200,
timeout: 3000,
})
</script>
<template>
<HeavyComponent />
</template>
<style scoped>
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
height: 200px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
// React 组件懒加载 + 骨架屏
import React, { Suspense, lazy } from 'react'
import { Skeleton } from 'antd-mobile'
const HeavyComponent = lazy(() => import('./HeavyComponent'))
const LoadingFallback: React.FC = () => (
<div className="skeleton-container">
<Skeleton.Title animated />
<Skeleton.Paragraph lineCount={3} animated />
</div>
)
const Page: React.FC = () => (
<Suspense fallback={<LoadingFallback />}>
<HeavyComponent />
</Suspense>
)
6.2 图片适配
移动端图片是最大的带宽占用者,需要结合 CDN 进行不同尺寸的输出。
CDN 图片处理参数:
// ✅ 统一的图片 URL 处理(以七牛/阿里云 OSS 为例)
interface ImageOptions {
width?: number
height?: number
quality?: number // 1-100
format?: 'webp' | 'jpeg' | 'png'
blur?: number // 高斯模糊
}
function buildImageUrl(baseUrl: string, options: ImageOptions): string {
// ❌ 安全校验:防止空 URL
if (!baseUrl) return '/default.png'
const params: string[] = []
// 宽/高缩放
if (options.width) params.push(`w_${options.width}`)
if (options.height) params.push(`h_${options.height}`)
// 质量压缩
if (options.quality) {
// 移动端通常 70-80 即可,肉眼几乎无法区分
params.push(`q_${Math.max(1, Math.min(100, options.quality))}`)
}
// WebP 格式(体积比 JPEG 小 25-35%)
if (options.format === 'webp') {
params.push('format_webp')
}
// 渐进式加载(模糊占位)
if (options.blur) {
params.push(`blur_${options.blur}`)
}
// 裁剪模式:fill 等比填充
params.push('m_fill')
return params.length > 0
? `${baseUrl}?x-oss-process=image/resize,${params.join(',')}`
: baseUrl
}
// 生成缩略图
function getThumbnail(url: string): string {
// 防止 CDN 处理失败的兜底
try {
return buildImageUrl(url, {
width: 200,
quality: 75,
format: 'webp',
})
} catch {
return url
}
}
// 生成详情大图
function getDetailImage(url: string, deviceWidth: number): string {
const width = Math.min(deviceWidth * 2, 1200) // 2x 分辨率
return buildImageUrl(url, { width, quality: 80 })
}
<!-- Vue 3 图片组件 -->
<script setup lang="ts">
import { ref, computed, onErrorCaptured } from 'vue'
const props = withDefaults(defineProps<{
src: string
alt?: string
lazy?: boolean
}>(), {
alt: '',
lazy: true,
})
const loaded = ref(false)
const error = ref(false)
// ✅ 边加载边模糊(渐进式图片)
const thumbnailSrc = computed(() =>
buildImageUrl(props.src, { width: 50, blur: 20 })
)
const fullSrc = computed(() =>
buildImageUrl(props.src, { width: 750, quality: 80, format: 'webp' })
)
const imgRef = ref<HTMLImageElement>()
// 使用 IntersectionObserver 实现懒加载
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && imgRef.value) {
imgRef.value.src = fullSrc.value
observer.unobserve(imgRef.value)
}
})
})
function handleImageLoad() {
loaded.value = true
}
function handleImageError() {
// ✅ 防御式:图片加载失败使用兜底图
error.value = true
console.warn('[Image] 图片加载失败:', props.src)
}
onErrorCaptured(() => {
error.value = true
return false
})
</script>
<template>
<div class="image-container" :class="{ loaded }">
<!-- 模糊占位 -->
<img
v-if="!error"
:src="thumbnailSrc"
:alt="alt"
class="image-thumb"
:class="{ loaded }"
/>
<!-- 实际图片(懒加载) -->
<img
v-show="!error"
ref="imgRef"
v-lazy="fullSrc"
:alt="alt"
class="image-full"
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- 加载失败兜底 -->
<div v-if="error" class="image-fallback">
<span>加载失败</span>
</div>
</div>
</template>
// React 图片组件
import { useState, useRef, useEffect } from 'react';
interface OptimizedImageProps {
src: string;
alt?: string;
width?: number;
}
const OptimizedImage: React.FC<OptimizedImageProps> = ({ src, alt = '', width = 750 }) => {
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
// 构建 CDN URL(防御式)
const buildUrl = (opts: { w?: number; blur?: number }) => {
if (!src) return '/default.png';
try {
const params = [];
if (opts.w) params.push(`w_${opts.w}`);
if (opts.blur) params.push(`blur_${opts.blur}`);
params.push('format_webp', 'q_75', 'm_fill');
return `${src}?x-oss-process=image/resize,${params.join(',')}`;
} catch {
return src;
}
};
// 懒加载 IntersectionObserver
useEffect(() => {
if (!imgRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && imgRef.current) {
imgRef.current.src = buildUrl({ w: width, blur: 0 });
observer.unobserve(imgRef.current);
}
},
{ rootMargin: '200px' }
);
observer.observe(imgRef.current);
return () => observer.disconnect();
}, [width]);
return (
<div className="image-container" style={{ position: 'relative', overflow: 'hidden' }}>
{/* 模糊占位 */}
{!error && (
<img
src={buildUrl({ w: 50, blur: 20 })}
alt={alt}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
filter: loaded ? 'none' : 'blur(20px)',
transition: 'filter 0.3s',
}}
/>
)}
{/* 实际图片 */}
{!error && (
<img
ref={imgRef}
alt={alt}
style={{ width: '100%', display: 'block' }}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
/>
)}
{/* 兜底 */}
{error && (
<div className="image-fallback">
<span>图片加载失败</span>
</div>
)}
</div>
);
};
图片优化清单:
| 优化项 | 收益 | 实现 |
|---|---|---|
| WebP 格式 | 减少 25-35% 体积 | CDN 参数 / <picture> 标签 |
| 质量压缩 75% | 减少 30-50% 体积 | 肉眼几乎无差异 |
| 响应式图片 | 减少不必要传输 | srcset + sizes 属性 |
| 懒加载 | 减少首屏传输量 | loading="lazy" / IntersectionObserver |
| 渐进式 JPEG | 改善加载体验 | 图片压缩时启用 progressive |
| 模糊占位 | 避免白屏 | CDN 参数 + CSS blur |
| AVIF(未来) | 再减少 20% 体积 | 注意兼容性 |
6.3 滚动性能优化
已废弃的 -webkit-overflow-scrolling: touch:
这个属性在 iOS 13+ 中已废弃,使用后反而可能引发滚动卡顿:
/* ❌ 已废弃,切勿使用 */
.container {
-webkit-overflow-scrolling: touch;
}
/* ✅ 现代替代方案 */
.container {
overflow-y: auto;
overscroll-behavior: contain; /* 阻止滚动链 */
-webkit-overflow-scrolling: auto; /* iOS 默认行为更好 */
}
滚动性能核心实践:
/* ✅ 滚动容器优化 */
.scroll-container {
/* 启用硬件加速 */
will-change: scroll-position;
/* 阻止滚动链(嵌套滚动时的回弹问题) */
overscroll-behavior: contain;
/* iOS 回弹效果(默认行为) */
-webkit-overflow-scrolling: auto;
/* 限制滚动范围 */
overflow-y: auto;
-webkit-overflow-scrolling: auto;
}
/* ✅ 列表项优化(避免大量重排) */
.list-item {
/* 开启硬件加速(仅对有动画效果的元素) */
transform: translateZ(0);
/* 性能关键路径 */
content-visibility: auto;
}
长列表虚拟滚动:
<!-- Vue 3 虚拟滚动(简化版) -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
items: unknown[]
itemHeight: number
containerHeight: number
}>()
const scrollTop = ref(0)
const containerRef = ref<HTMLElement>()
// 可视范围计算
const visibleRange = computed(() => {
const start = Math.floor(scrollTop.value / props.itemHeight)
// 上下一屏的 buffer,防止快速滚动白屏
const buffer = Math.ceil(props.containerHeight / props.itemHeight)
return {
start: Math.max(0, start - buffer),
end: Math.min(props.items.length, start + buffer * 2),
}
})
// 可视区域偏移
const offsetY = computed(() => visibleRange.value.start * props.itemHeight)
// 总高度占位
const totalHeight = computed(() => props.items.length * props.itemHeight)
// 可视列表
const visibleItems = computed(() =>
props.items.slice(visibleRange.value.start, visibleRange.value.end)
)
function handleScroll() {
scrollTop.value = containerRef.value?.scrollTop ?? 0
}
onMounted(() => {
containerRef.value?.addEventListener('scroll', handleScroll, { passive: true })
})
onUnmounted(() => {
containerRef.value?.removeEventListener('scroll', handleScroll)
})
</script>
<template>
<div
ref="containerRef"
class="virtual-scroll"
:style="{ height: `${containerHeight}px`, overflow: 'auto' }"
>
<div class="virtual-scroll__spacer" :style="{ height: `${totalHeight}px` }">
<div
class="virtual-scroll__visible"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="(item, index) in visibleItems"
:key="visibleRange.start + index"
class="virtual-scroll__item"
:style="{ height: `${itemHeight}px` }"
>
<slot :item="item" :index="visibleRange.start + index" />
</div>
</div>
</div>
</div>
</template>
滚动性能常见问题:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 滚动卡顿 | 大量 DOM 重排/重绘 | 使用 transform 代替 top/left 动画 |
| iOS 回弹失焦 | -webkit-overflow-scrolling: touch |
移除该属性,使用 overscroll-behavior |
| 快速滚动白屏 | 虚拟滚动 buffer 不够 | 加大 buffer 值,或使用 position: sticky |
| 滚动事件频繁 | 高频触发 | 添加 passive: true + requestAnimationFrame |
| 滚动穿透 | 弹窗蒙层下页面滚动 | overflow: hidden 锁定 body,组件封装 |
七、PWA 移动端实践
PWA(Progressive Web App)让 Web 应用接近原生 App 体验:可安装到主屏幕、离线可用、接收推送通知。
7.1 Web App Manifest
manifest.json 控制 PWA 添加到主屏幕时的表现(图标、名称、主题色、启动方式)。
{
"name": "移动端应用名称",
"short_name": "应用",
"description": "移动端 H5 应用描述",
"start_url": "/?utm_source=pwa_homescreen",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#1677ff",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"splash_pages": null
}
manifest 字段说明:
| 字段 | 作用 | 推荐值 |
|---|---|---|
display |
启动显示模式 | standalone(无浏览器 UI,类似原生) |
orientation |
锁定屏幕方向 | portrait-primary / landscape |
theme_color |
工具栏颜色 | 品牌色 |
background_color |
启动时白屏颜色 | 品牌色/白色 |
scope |
可访问路径范围 | / 或子路径 |
start_url |
启动时打开的 URL | 首页路径 |
categories |
应用分类 | ["utilities", "lifestyle"] |
display 模式对比:
| 模式 | 浏览器 UI | 平台差异 |
|---|---|---|
fullscreen |
无,全屏 | iOS 不支持 |
standalone |
无地址栏,有状态栏 | 各平台一致 |
minimal-ui |
有最小化 UI 控件 | iOS Safari 不支持 |
browser |
正常浏览器 | 默认值 |
<!-- 在 HTML 中引用 manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- iOS 特有 meta(fallback) -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/icons/icon-152x152.png" />
7.2 Service Worker 离线缓存
Service Worker 是 PWA 的核心,可以拦截网络请求、缓存资源、实现离线访问。
// sw.ts / sw.js - Service Worker 注册与缓存策略
const CACHE_NAME = 'app-cache-v1'
const STATIC_ASSETS = [
'/',
'/index.html',
'/css/app.css',
'/js/app.js',
'/icons/icon-192x192.png',
]
// 安装:预缓存静态资源
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[SW] 预缓存静态资源')
return cache.addAll(STATIC_ASSETS)
})
)
// 立即激活,不等待旧 SW 关闭
self.skipWaiting()
})
// 激活:清理旧缓存
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('[SW] 删除旧缓存:', name)
return caches.delete(name)
})
)
})
)
// 控制所有客户端,包括未受控的
self.clients.claim()
})
// 请求拦截:不同策略
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url)
// API 请求:网络优先(Network First)
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request))
return
}
// 静态资源:缓存优先(Cache First)
if (
url.pathname.match(/\.(css|js|png|jpg|webp|woff2?)$/)
) {
event.respondWith(cacheFirst(event.request))
return
}
// HTML 页面:网络优先,回退到缓存
event.respondWith(networkFirst(event.request))
})
// 策略实现
async function cacheFirst(request: Request): Promise<Response> {
const cached = await caches.match(request)
if (cached) return cached
try {
const response = await fetch(request)
// 仅缓存成功的响应
if (response.ok) {
const cache = await caches.open(CACHE_NAME)
cache.put(request, response.clone())
}
return response
} catch {
// 离线时返回一个占位响应(非关键资源)
return new Response('离线', { status: 503 })
}
}
async function networkFirst(request: Request): Promise<Response> {
try {
const response = await fetch(request)
if (response.ok) {
const cache = await caches.open(CACHE_NAME)
cache.put(request, response.clone())
}
return response
} catch {
const cached = await caches.match(request)
if (cached) return cached
// API 请求离线时返回缓存数据或默认值
return new Response(
JSON.stringify({ code: -1, message: '当前离线' }),
{ headers: { 'Content-Type': 'application/json' } }
)
}
}
// ✅ 主线程注册 Service Worker(防御式)
export function registerServiceWorker(swPath = '/sw.js'): void {
// 1. 检查浏览器是否支持
if (!('serviceWorker' in navigator)) {
console.warn('[PWA] 当前浏览器不支持 Service Worker')
return
}
// 2. 只在生产环境或非 localhost 注册(避免开发时干扰)
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
console.log('[PWA] 开发环境跳过 SW 注册')
return
}
// 3. 注册 Service Worker
window.addEventListener('load', () => {
navigator.serviceWorker
.register(swPath)
.then((registration) => {
console.log('[PWA] Service Worker 注册成功:', registration.scope)
// 检查更新
registration.addEventListener('updatefound', () => {
const installingWorker = registration.installing
if (installingWorker) {
installingWorker.addEventListener('statechange', () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// 新版本可用,提示用户刷新
showUpdatePrompt()
}
}
})
}
})
})
.catch((error) => {
// 注册失败不抛出异常
console.warn('[PWA] Service Worker 注册失败:', error.message)
})
})
}
// 3. 通知用户更新
function showUpdatePrompt(): void {
// 触发自定义事件,由 UI 层处理
window.dispatchEvent(new CustomEvent('sw-update-available'))
}
// 4. 注册更新监听
export function onServiceWorkerUpdate(callback: () => void): void {
window.addEventListener('sw-update-available', callback)
}
// 5. 跳过等待并刷新
export function skipWaitingAndReload(): void {
navigator.serviceWorker.ready.then((registration) => {
registration.waiting?.postMessage({ type: 'SKIP_WAITING' })
window.location.reload()
})
}
缓存策略对比:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| Cache First | 先读缓存,无缓存再请求网络 | CSS/JS/字体/图标等静态资源 |
| Network First | 先请求网络,失败回退缓存 | HTML 页面、API 数据 |
| Stale While Revalidate | 返回缓存同时异步更新 | 非关键数据(如配置) |
| Network Only | 仅网络,不缓存 | 支付、登录等敏感操作 |
| Cache Only | 仅缓存 | 离线时的降级页面 |
7.3 Push Notification
Push Notification 由两部分组成:服务端推送(通过 Web Push Protocol)和 客户端通知展示。
// 客户端订阅推送
export async function subscribePushNotifications(): Promise<void> {
// 1. 检查浏览器支持
if (!('Notification' in window)) {
console.warn('[Push] 当前浏览器不支持通知')
return
}
// 2. 检查权限状态
if (Notification.permission === 'denied') {
console.warn('[Push] 通知权限已被用户拒绝')
return
}
// 3. 请求权限(如果尚未请求)
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
console.warn('[Push] 用户未授予通知权限')
return
}
}
// 4. 等待 Service Worker 就绪
const registration = await navigator.serviceWorker.ready
// 5. 如果有现成的 VAPID 公钥,直接订阅
// 注意:永远不要将私钥放在前端代码中
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // 必须为 true
applicationServerKey: urlBase64ToUint8Array(
'BJf...VAPID_PUBLIC_KEY...' // 从服务器获取,非硬编码
),
})
// 6. 将订阅信息发送到服务器
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
})
console.log('[Push] 推送订阅成功')
} catch (error) {
console.warn('[Push] 订阅失败:', error)
}
}
// VAPID key 转换
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
return new Uint8Array([...rawData].map(char => char.charCodeAt(0)))
}
// Service Worker 中处理推送事件
// 在 sw.ts/sw.js 中添加:
self.addEventListener('push', (event: PushEvent) => {
if (!event.data) return
try {
const payload = event.data.json()
const options: NotificationOptions = {
body: payload.body || '',
icon: '/icons/icon-192x192.png',
badge: '/icons/icon-72x72.png',
vibrate: [200, 100, 200],
data: {
url: payload.url || '/',
dateOfArrival: Date.now(),
},
actions: [
{ action: 'open', title: '查看详情' },
{ action: 'close', title: '关闭' },
],
}
event.waitUntil(
self.registration.showNotification(payload.title || '新消息', options)
)
} catch {
console.warn('[SW] 推送消息解析失败')
}
})
// 通知点击事件处理
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close()
const urlToOpen = event.notification.data?.url || '/'
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clients) => {
// 如果有已打开的页面,聚焦到该页面并导航
for (const client of clients) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.navigate(urlToOpen)
return client.focus()
}
}
// 否则打开新窗口
return self.clients.openWindow(urlToOpen)
})
)
})
PWA 各平台支持情况:
| 功能 | Android Chrome | iOS Safari | 微信/内置浏览器 |
|---|---|---|---|
| Manifest 添加到主屏幕 | ✅ 支持 | ✅ 支持(Safari 12.2+) | ❌ 不支持 |
| Service Worker 离线 | ✅ 支持 | ✅ 支持(iOS 12.2+) | ❌ 不支持 |
| Push Notification | ✅ 支持 | ❌ 不支持 | ❌ 不支持 |
| 后台同步 | ✅ 支持 | ❌ 不支持 | ❌ 不支持 |
| Badge(角标) | ✅ 支持 | ✅ 支持(iOS 16.4+) | ❌ 不支持 |
PWA 踩坑注意事项:
| 问题 | 说明 | 解决方案 |
|---|---|---|
| iOS 不支持 Push | Safari 未实现 Web Push API | 使用 APNs 替代(通过本地通知降级) |
| 微信内置浏览器不支持 PWA | 微信屏蔽了 SW | 降级为普通 H5,不做 PWA 依赖 |
| SW 注册需要 HTTPS | 安全策略要求 | 开发用 localhost,部署必须 HTTPS |
| 更新后用户看不到新版本 | SW 默认不更新 | 使用 skipWaiting() + 提示用户刷新 |
| 缓存过大 | 旧缓存未清理 | activate 事件中清理,限制缓存大小 |
总结
移动端适配是一个 从页面展示到交互体验再到性能 的全链路工程,没有银弹方案。最佳实践组合如下:
| 维度 | 推荐方案 | 理由 |
|---|---|---|
| 适配方案 | vw/vh + postcss-px-to-viewport | 纯 CSS,无需 JS 依赖 |
| 响应式布局 | Mobile First + Flexbox/Grid | 渐进增强,兼容性好 |
| 布局断点 | min-width + 3~4 个断点 | 维护成本低,覆盖主流设备 |
| 1px 边框 | 伪元素 + transform: scale | 兼容性好,方案成熟 |
| 安全区域 | env(safe-area-inset-*) | 原生支持,无需 JS |
| 跨端开发 | uni-app(Vue)/ Taro(React) | 一套代码跑多端 |
| 移动端调试 | vConsole(日常)+ Chrome DevTools(深度) | 兼顾轻量与完整 |
| 图片优化 | CDN 压缩 + WebP + 懒加载 | 带宽节省 50%+ |
| 长列表 | 虚拟滚动 | CPU 内存双优 |
| PWA | Manifest + SW 网络优先策略 | 增强体验,非必需 |
核心理念:永远不要假设用户的设备和网络环境。防御式编程、渐进增强、合理降级,才是移动端适配的底层逻辑。