📱 移动端 Web 开发概述
移动端 Web 开发是指在手机、平板等移动设备浏览器上运行的网页开发。与 PC 端相比,移动端有屏幕小、交互方式不同(触摸)、网络环境复杂、性能受限等特点。
移动端 vs PC 端核心差异
| 维度 | PC 端 | 移动端 |
|---|---|---|
| 屏幕尺寸 | 1024px ~ 1920px+ | 320px ~ 428px(手机) |
| 交互方式 | 鼠标(click、hover、右键) | 触摸(tap、swipe、pinch、长按) |
| 像素密度 | 1x(96 DPI) | 2x / 3x(Retina 高清屏) |
| 网络环境 | 宽带 / WiFi 为主 | 4G / 5G / WiFi,可能不稳定 |
| 性能 | CPU / 内存充裕 | 受限于设备,需优化 |
| 浏览器 | Chrome / Edge / Firefox | Safari(iOS)/ Chrome / 微信内置 / 各厂商 WebView |
| 输入方式 | 键盘 + 鼠标 | 虚拟键盘 + 触摸 |
移动端开发的三种形态
| 形态 | 技术 | 适用场景 |
|---|---|---|
| 移动 Web(H5) | HTML + CSS + JS | 营销页、活动页、内嵌 H5 |
| 混合 App(Hybrid) | H5 + 原生壳(WebView) | 功能不复杂的 App |
| 小程序 | 各平台私有 DSL | 微信 / 支付宝 / 抖音等生态 |
本章聚焦于移动 Web(H5)的开发最佳实践,大部分经验同样适用于 Hybrid 和小程序场景。
🔍 Viewport 视口与适配基础
Viewport 是移动端开发最基础也最重要的概念。没有正确配置 viewport,页面在手机上会以缩放形式显示,用户体验极差。
三大视口概念
| 视口类型 | 含义 | 获取方式 |
|---|---|---|
| 布局视口 | CSS 布局的基准区域,默认约 980px | document.documentElement.clientWidth |
| 视觉视口 | 用户当前看到的区域,可缩放 | window.visualViewport.width |
| 理想视口 | 设备屏幕的 CSS 像素宽度 | screen.width |
标准 Viewport Meta 标签
<!-- ⭐ 移动端必须添加,放在 <head> 最前面 -->
<meta name="viewport"
content="width=device-width, initial-scale=1.0,
maximum-scale=1.0, minimum-scale=1.0,
user-scalable=no, viewport-fit=cover">
<!-- 参数详解 -->
<!-- width=device-width → 布局视口 = 设备宽度(理想视口)-->
<!-- initial-scale=1.0 → 初始缩放比例 1:1 -->
<!-- maximum-scale=1.0 → 最大缩放比例(禁止放大)-->
<!-- minimum-scale=1.0 → 最小缩放比例 -->
<!-- user-scalable=no → 禁止用户手动缩放 -->
<!-- viewport-fit=cover → 适配 iPhone X+ 刘海屏安全区域 -->
- 应用型页面(SPA)可以设置禁止缩放,防止双击误触缩放
- 内容型页面(文章、文档)建议保留缩放能力,保障无障碍访问
- iOS 10+ 中该属性可能被 Safari 忽略
通过 JS 获取设备与屏幕信息
// ===== 获取设备与屏幕信息 =====
const deviceInfo = {
// 设备像素比(DPR):物理像素 / CSS 像素
dpr: window.devicePixelRatio || 1,
// 屏幕尺寸(CSS 像素)
screenWidth: screen.width,
screenHeight: screen.height,
// 可视区域尺寸(布局视口)
viewportWidth: document.documentElement.clientWidth,
viewportHeight: document.documentElement.clientHeight,
// 物理分辨率
physicalWidth: screen.width * (window.devicePixelRatio || 1),
physicalHeight: screen.height * (window.devicePixelRatio || 1),
// 判断设备类型
isMobile: /Mobi|Android|iPhone/i.test(navigator.userAgent),
isIOS: /iPhone|iPad|iPod/i.test(navigator.userAgent),
isAndroid: /Android/i.test(navigator.userAgent),
isWeChat: /MicroMessenger/i.test(navigator.userAgent),
};
console.table(deviceInfo);
👆 触摸事件处理
移动端最核心的交互是触摸。JavaScript 提供 Touch Events(多点触摸)和 Pointer Events(统一输入)两套体系。
Touch 事件体系
| 事件 | 触发时机 | 核心属性 |
|---|---|---|
touchstart | 手指触摸屏幕时 | touches(所有触点列表) |
touchmove | 手指在屏幕上滑动时 | touches(所有触点列表) |
touchend | 手指离开屏幕时 | changedTouches(变化的触点) |
touchcancel | 触摸被中断时(如来电、通知) | changedTouches |
触摸事件实战:滑动方向判断
// ===== 判断滑动方向 =====
let startX = 0, startY = 0;
document.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
}, { passive: true });
document.addEventListener('touchend', (e) => {
const endX = e.changedTouches[0].clientX;
const endY = e.changedTouches[0].clientY;
const diffX = endX - startX;
const diffY = endY - startY;
const threshold = 50; // 最小滑动距离(px)
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > threshold) {
console.log(diffX > 0 ? '👉 右滑' : '👈 左滑');
} else if (Math.abs(diffY) > threshold) {
console.log(diffY > 0 ? '👇 下滑' : '👆 上滑');
}
});
Pointer Events(推荐:统一鼠标 + 触摸)
Pointer Events 是 W3C 标准,统一了鼠标、触摸、触控笔,推荐在不需多指操作的场景使用。
// ===== Pointer Events(统一鼠标和触摸)=====
const box = document.getElementById('box');
box.addEventListener('pointerdown', (e) => {
console.log('按下:', e.pointerType); // 'mouse' | 'touch' | 'pen'
box.setPointerCapture(e.pointerId); // 捕获指针,防止丢失
});
box.addEventListener('pointermove', (e) => {
console.log(`移动: (${e.clientX}, ${e.clientY})`);
});
box.addEventListener('pointerup', (e) => {
console.log('抬起:', e.pointerType);
box.releasePointerCapture(e.pointerId);
});
box.addEventListener('pointercancel', (e) => {
console.log('指针取消(如来电中断)');
});
移动端事件触发顺序
touchstart → touchmove → touchend → (延迟 ~300ms) → mousedown → mouseup → click
理解这个顺序对于解决点击穿透等问题至关重要。
⏱️ 300ms 点击延迟与解决方案
移动端浏览器为了区分「单击」和「双击缩放」,会在 touchend 后等待约 300ms 才触发 click 事件。这导致交互有卡顿感。
解决方案对比
| 方案 | 原理 | 推荐度 |
|---|---|---|
| ① Viewport 禁止缩放 | user-scalable=no 告诉浏览器无需等待双击 |
⭐⭐⭐⭐ |
| ② FastClick 库 | 监听 touchend,立即触发 click 事件 | ⭐⭐⭐(老旧方案) |
| ③ touch-action CSS | touch-action: manipulation 禁用双击缩放 |
⭐⭐⭐⭐⭐ |
| ④ 直接用 touch 事件 | 用 touchend 替代 click 处理交互 | ⭐⭐⭐ |
推荐做法:touch-action + Viewport
/* ===== CSS 方式:消除 300ms 延迟 ===== */
/* 全局设置,适合应用型页面 */
html {
touch-action: manipulation;
}
/* 针对特定交互元素 */
button, a, .clickable {
touch-action: manipulation;
}
点击穿透问题
当上层元素在 touchend 后消失(如遮罩层关闭),300ms 后触发的 click 会落在下层元素上,造成"穿透点击"。
// ===== 解决点击穿透的三种方式 =====
// 方式一:阻止默认行为 + 阻止冒泡
mask.addEventListener('touchend', (e) => {
e.preventDefault(); // 阻止后续 click
mask.style.display = 'none';
});
// 方式二:延迟隐藏(等待 click 完成)
mask.addEventListener('touchend', () => {
setTimeout(() => { mask.style.display = 'none'; }, 350);
});
// 方式三:使用 touch-action + pointer-events
mask.addEventListener('touchend', () => {
mask.style.pointerEvents = 'none'; // 下层收不到事件
// 需要时再恢复: mask.style.pointerEvents = 'auto';
});
📐 移动端布局方案
移动端布局经历了多个阶段的演进,目前主流方案是 Flexbox + rem/vw + 媒体查询 的组合。
四大布局方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 百分比 + 媒体查询 | % 宽度 + @media 断点适配 | 简单直观,兼容性好 | 断点有限,不够精细 |
| rem + 动态根字体 | 1rem = 根元素 fontSize,JS 动态设置 | 等比缩放,设计稿直接换算 | 需要 JS 配合,大屏可能过大 |
| vw/vh 视口单位 | 1vw = 视口宽度的 1% | 纯 CSS,真正响应式 | 部分场景需配合 calc() |
| Flexbox / Grid | 弹性布局,自动分配空间 | 灵活强大,现代标准 | 学习曲线,IE 不支持 |
Flexbox 移动端常用模式
/* ===== 移动端 Flex 布局常用模式 ===== */
/* 顶部导航栏:左右分布 */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 底部 Tab 栏:均分 */
.tab-bar {
display: flex;
}
.tab-bar .tab-item {
flex: 1;
text-align: center;
}
/* 列表项:左侧图标 + 右侧内容 */
.list-item {
display: flex;
align-items: center;
gap: 12px;
}
/* 圣杯布局:头 + 内容 + 底(满屏) */
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.page .content {
flex: 1; /* 撑满剩余空间 */
overflow-y: auto; /* 内容区独立滚动 */
}
CSS Grid 移动端实战
/* ===== 移动端 Grid 布局 ===== */
/* 商品卡片网格(2 列自适应) */
.product-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
/* 图文混排(3:2 比例) */
.card {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 16px;
}
/* 自适应列数 */
.auto-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
📏 rem / vw 响应式适配方案
移动端适配的核心目标是让设计稿在不同屏幕宽度的手机上等比显示。
方案一:rem + 动态根字体(经典方案)
原理:以设计稿宽度(通常 375px)为基准,动态设置 html { font-size },页面元素用 rem 单位。
// ===== rem 适配方案(设计稿 375px 基准)=====
(function () {
const BASE_WIDTH = 375; // 设计稿宽度
const BASE_FONT = 100; // 基准 font-size(1rem = 100px)
function setRem() {
const scale = document.documentElement.clientWidth / BASE_WIDTH;
document.documentElement.style.fontSize =
Math.min(scale * BASE_FONT, BASE_FONT * 1.5) + 'px';
// Math.min 限制最大字体,防止平板端过大
}
setRem();
window.addEventListener('resize', setRem);
// 处理屏幕旋转
window.addEventListener('orientationchange', () => {
setTimeout(setRem, 100);
});
})();
// 使用:设计稿 20px → 0.2rem(20 / 100)
方案二:vw 视口单位(现代方案,推荐)
原理:1vw = 屏幕宽度的 1%,直接用 vw 单位,无需 JS。
/* ===== vw 适配方案(设计稿 375px 基准)===== */
/* 换算公式:设计稿 px / 375 * 100 = vw */
/* 例:设计稿 20px → 20 / 375 * 100 ≈ 5.333vw */
/* 使用 CSS 自定义属性简化 */
:root {
/* 以 375px 设计稿为基准 */
--vw: calc(100vw / 375);
}
.element {
width: calc(20 * var(--vw)); /* 设计稿 20px */
height: calc(44 * var(--vw)); /* 设计稿 44px */
font-size: calc(14 * var(--vw)); /* 设计稿 14px */
}
/* 限制最大宽度(平板场景) */
@media (min-width: 768px) {
:root {
--vw: calc(768px / 375); /* 768px 以上固定比例 */
}
}
方案三:postcss-px-to-viewport(工程化方案)
在 Webpack/Vite 项目中使用 PostCSS 插件自动转换:
// postcss.config.js
module.exports = {
plugins: {
'postcss-px-to-viewport-8-plugin': {
viewportWidth: 375, // 设计稿宽度
unitPrecision: 5, // vw 精度
viewportUnit: 'vw',
selectorBlackList: ['.ignore', '.hairlines'],
minPixelValue: 1,
mediaQuery: false,
// 直接写 px,自动转 vw,无需手动计算
}
}
};
- 小型项目:直接使用 vw + CSS 变量
- 工程化项目:使用 postcss-px-to-viewport 插件
- 需要兼容老旧设备:rem + JS 动态设置
📏 1px 边框问题
在 Retina(2x/3x)屏幕上,CSS 的 1px 实际渲染为 2~3 个物理像素,导致边框看起来比设计稿粗。
解决方案汇总
| 方案 | 原理 | 适用场景 |
|---|---|---|
| transform: scale() | 用伪元素画边框,transform 缩放 0.5 | 最通用,推荐 |
| viewport + rem | 根据 DPR 动态设置 viewport 缩放 | 全页面适配 |
| box-shadow | 用阴影模拟细边框 | 简单场景 |
| border-image / svg | 使用图片边框 | 复杂边框 |
推荐方案:伪元素 + transform
/* ===== 1px 细边框(最通用方案)===== */
/* 底部 1px 边框 */
.hairline-bottom {
position: relative;
}
.hairline-bottom::after {
content: '';
position: absolute;
left: 0; bottom: 0;
width: 100%; height: 1px;
background: #e0e0e0;
transform: scaleY(0.5);
transform-origin: 0 0;
}
/* 四边 1px 边框 */
.hairline-border {
position: relative;
}
.hairline-border::after {
content: '';
position: absolute;
top: 0; left: 0;
width: 200%; height: 200%;
border: 1px solid #e0e0e0;
border-radius: inherit;
transform: scale(0.5);
transform-origin: 0 0;
pointer-events: none; /* 不阻挡点击 */
}
/* 使用 CSS 变量适配不同 DPR */
@media (-webkit-min-device-pixel-ratio: 3) {
.hairline-bottom::after {
transform: scaleY(0.333); /* 3x 屏 */
}
}
使用 transform: scale() 后,伪元素的 border-radius 也会被缩放。如果需要圆角边框,确保父元素设置 border-radius 且伪元素继承。
📱 安全区域适配(刘海屏 / 底部指示条)
iPhone X 及之后的全面屏机型有刘海、底部 Home 指示条等,需要使用 safe-area-inset 环境变量进行适配。
关键 CSS 环境变量
| 变量 | 含义 | 典型值(iPhone X) |
|---|---|---|
safe-area-inset-top | 顶部安全距离 | 44px |
safe-area-inset-bottom | 底部安全距离 | 34px |
safe-area-inset-left | 左侧安全距离 | 0px |
safe-area-inset-right | 右侧安全距离 | 0px |
完整适配方案
/* ===== 安全区域全局适配 ===== */
/* ① Viewport 必须添加 viewport-fit=cover */
/* <meta name="viewport" content="..., viewport-fit=cover"> */
/* ② 使用 constant() 和 env() 双写兼容 */
:root {
--safe-top: constant(safe-area-inset-top);
--safe-top: env(safe-area-inset-top);
--safe-bottom: constant(safe-area-inset-bottom);
--safe-bottom: env(safe-area-inset-bottom);
}
/* ③ 顶部导航栏留出刘海空间 */
.header {
padding-top: var(--safe-top);
/* 背景色延伸到安全区域 */
background: #fff;
}
/* ④ 底部固定栏留出 Home 指示条空间 */
.tab-bar {
padding-bottom: var(--safe-bottom);
}
/* ⑤ 全屏背景延伸 */
body {
padding-top: var(--safe-top);
padding-bottom: var(--safe-bottom);
}
JS 获取安全区域值
// ===== JS 读取安全区域值 =====
function getSafeAreaInsets() {
const style = getComputedStyle(document.documentElement);
return {
top: parseInt(style.getPropertyValue('env(safe-area-inset-top)')) || 0,
bottom: parseInt(style.getPropertyValue('env(safe-area-inset-bottom)')) || 0,
left: parseInt(style.getPropertyValue('env(safe-area-inset-left)')) || 0,
right: parseInt(style.getPropertyValue('env(safe-area-inset-right)')) || 0,
};
}
// 判断是否是全面屏(有刘海)
const insets = getSafeAreaInsets();
const isNotchScreen = insets.top > 20 || insets.bottom > 20;
console.log('安全区域:', insets, '全面屏:', isNotchScreen);
⌨️ 软键盘处理
软键盘弹出是移动端 Web 开发中最头疼的问题之一,主要涉及页面顶起、布局错乱、滚动异常等问题。
iOS 与 Android 的差异
| 行为 | iOS Safari | Android Chrome |
|---|---|---|
| 键盘弹出 | 页面不压缩,键盘覆盖在页面上方 | 可视区域缩小,页面可能被压缩 |
| 页面高度 | 不变,window.innerHeight 不变 | 变小,window.innerHeight 变化 |
| 输入框聚焦 | 不会自动滚入视野 | 自动 scrollIntoView |
| 键盘收起 | 页面可能有空白残留 | 恢复正常 |
监听键盘弹出与收起
// ===== 监听软键盘状态 =====
let lastHeight = window.innerHeight;
window.addEventListener('resize', () => {
const currentHeight = window.innerHeight;
const diff = lastHeight - currentHeight;
if (diff > 100) {
console.log('⌨️ 键盘弹出', diff + 'px');
// 处理键盘弹出:如固定底部按钮上移
document.querySelector('.fixed-bottom')
.style.bottom = diff + 'px';
} else if (diff < -50) {
console.log('⬆️ 键盘收起');
document.querySelector('.fixed-bottom')
.style.bottom = '0';
}
lastHeight = currentHeight;
});
常见问题与解决方案
/* ===== 软键盘相关 CSS 处理 ===== */
/* 问题 1:键盘弹出后页面滚动到顶部 */
/* 解决:输入框聚焦时手动滚动 */
input.addEventListener('focus', (e) => {
setTimeout(() => {
e.target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 300); // iOS 需要延迟
});
/* 问题 2:iOS 键盘收起后页面留白 */
/* 解决:失焦时滚动回顶部 */
input.addEventListener('blur', () => {
setTimeout(() => {
window.scrollTo(0, 0);
// 或触发一下 body 重绘
document.body.scrollTop = 0;
}, 100);
});
/* 问题 3:固定定位元素被键盘顶起 */
/* 解决:键盘弹出时改为 absolute 或隐藏 */
@media (max-height: 500px) {
.fixed-bottom {
position: absolute;
}
}
- 表单尽量放在页面中部以上,避免键盘遮挡
- 底部固定按钮在键盘弹出时应隐藏或上移
- iOS 上
<input>的font-size至少 16px,否则聚焦时会缩放 - 避免在
input聚焦时执行耗时操作
📜 滚动优化
移动端滚动体验直接影响用户感知,主要涉及平滑滚动、惯性滚动、下拉刷新、滚动穿透等问题。
iOS 惯性滚动(橡皮筋效果)
/* ===== 开启 iOS 平滑惯性滚动 ===== */
.scroll-container {
overflow-y: auto;
-webkit-overflow-scrolling: touch; /* iOS 惯性滚动 */
/* 注意:该属性在 iOS 13+ 已自动支持,旧版需手动添加 */
}
/* 隐藏滚动条但保留滚动功能 */
.scroll-hidden {
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE */
}
.scroll-hidden::-webkit-scrollbar {
display: none; /* Chrome / Safari */
}
滚动穿透问题
当弹窗/遮罩层打开时,背景页面仍可滚动。
// ===== 解决滚动穿透 =====
// 方式一:body 固定(最常用)
function preventScrollThrough(isOpen) {
if (isOpen) {
const scrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';
} else {
const scrollY = document.body.style.top;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, parseInt(scrollY || '0') * -1);
}
}
// 方式二:弹窗内阻止 touchmove 冒泡
modal.addEventListener('touchmove', (e) => {
e.stopPropagation(); // 阻止事件冒泡到背景
}, { passive: false });
// 方式三:CSS overscroll-behavior
.modal-scroll {
overscroll-behavior: contain; /* 滚动边界不传递到父元素 */
}
下拉刷新与上拉加载
// ===== 下拉刷新 & 上拉加载更多 =====
let startY = 0;
let isLoading = false;
// 下拉刷新检测
document.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (window.scrollY === 0) {
const deltaY = e.touches[0].clientY - startY;
if (deltaY > 60 && !isLoading) {
console.log('🔄 触发下拉刷新');
// refreshData();
}
}
}, { passive: true });
// 上拉加载更多(滚动到底部检测)
window.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
if (scrollTop + clientHeight >= scrollHeight - 100) {
if (!isLoading) {
console.log('📄 触发加载更多');
// loadMore();
}
}
}, { passive: true });
⚡ 移动端性能优化
移动端设备性能有限,网络不稳定,性能优化直接影响用户体验和转化率。
渲染性能优化
| 优化方向 | 具体措施 | 效果 |
|---|---|---|
| 减少重排重绘 | 用 transform/opacity 做动画,避免修改 width/height/left/top | ⭐⭐⭐⭐⭐ |
| 使用 will-change | 提前告知浏览器哪些属性会变化 | ⭐⭐⭐⭐ |
| 避免 layout thrashing | 批量读取和写入 DOM,不要交叉 | ⭐⭐⭐⭐ |
| 使用 requestAnimationFrame | 动画统一用 rAF,避免 setInterval | ⭐⭐⭐⭐⭐ |
| GPU 加速 | transform: translateZ(0) 创建独立图层 | ⭐⭐⭐ |
动画性能最佳实践
/* ===== 高性能动画 CSS ===== */
/* ✅ 推荐:只触发 Composite 的动画属性 */
.good-animation {
transform: translateX(100px); /* GPU 加速,不触发重排 */
opacity: 0.5; /* 不触发重排 */
}
/* ❌ 避免:触发 Layout 的属性 */
.bad-animation {
width: 200px; /* 触发重排 */
left: 100px; /* 触发重排 */
margin: 20px; /* 触发重排 */
}
/* ===== 使用 will-change 优化 ===== */
.smooth-scroll {
will-change: transform; /* 提前创建独立图层 */
}
/* ⚠️ 不要滥用,用完记得移除 */
.smooth-scroll.animation-done {
will-change: auto;
}
资源加载优化
/* ===== 移动端资源加载策略 ===== */
/* ① 图片懒加载 */
<img src="placeholder.jpg" data-src="real.jpg" class="lazy">
// IntersectionObserver 实现
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
}
});
});
document.querySelectorAll('.lazy').forEach(img => observer.observe(img));
/* ② 响应式图片 */
<img srcset="small.jpg 375w, medium.jpg 768w, large.jpg 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
src="fallback.jpg" alt="">
/* ③ 字体优化 */
@font-face {
font-family: 'Custom';
src: url('font.woff2') format('woff2'); /* 优先 woff2 */
font-display: swap; /* 先显示系统字体,加载后替换 */
}
网络请求优化
- 减少请求数量:合并 CSS/JS,使用雪碧图或 SVG Sprite
- 减小资源体积:Gzip/Brotli 压缩,代码 minify,图片 WebP
- 利用缓存:合理设置 Cache-Control,使用 Service Worker
- 预加载关键资源:
<link rel="preload">关键 CSS/字体 - 延迟加载非关键资源:JS 加
defer/async,非首屏图片懒加载 - CDN 加速:静态资源上 CDN,减少延迟
🤏 手势操作
移动端常见手势包括单击、双击、长按、滑动、缩放(pinch)、旋转等。
封装常用手势
// ===== 移动端手势工具函数 =====
// 长按事件
function onLongPress(el, callback, duration = 600) {
let timer;
el.addEventListener('touchstart', (e) => {
timer = setTimeout(() => {
callback(e);
}, duration);
});
el.addEventListener('touchend', () => clearTimeout(timer));
el.addEventListener('touchmove', () => clearTimeout(timer));
el.addEventListener('touchcancel', () => clearTimeout(timer));
}
// 双击事件
function onDoubleTap(el, callback, interval = 300) {
let lastTap = 0;
el.addEventListener('touchend', (e) => {
const now = Date.now();
if (now - lastTap < interval) {
callback(e);
}
lastTap = now;
});
}
// 双指缩放(Pinch)
function onPinch(el, callback) {
let lastDist = 0;
el.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
lastDist = getDistance(e.touches[0], e.touches[1]);
}
});
el.addEventListener('touchmove', (e) => {
if (e.touches.length === 2) {
const dist = getDistance(e.touches[0], e.touches[1]);
const scale = dist / lastDist;
callback(scale); // scale > 1 放大,< 1 缩小
lastDist = dist;
}
});
}
function getDistance(t1, t2) {
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
🐛 移动端调试技巧
移动端调试比 PC 端复杂,以下是常用调试方案。
调试方案对比
| 方案 | 适用场景 | 操作方式 |
|---|---|---|
| Chrome DevTools 模拟 | 开发阶段快速调试 | F12 → 切换设备模式(Toggle Device Toolbar) |
| USB 真机调试 | Android Chrome 调试 | USB 连接 → chrome://inspect |
| Safari 远程调试 | iOS Safari 调试 | Mac Safari → 开发 → iPhone |
| vConsole | 内嵌调试面板(推荐) | 引入 vConsole JS,页面内显示控制台 |
| Charles / Fiddler | 抓包 / HTTPS 代理 | 设置代理,查看请求和响应 |
| eruda | 移动端调试面板 | 类似 vConsole,轻量级 |
vConsole 快速集成
<!-- ===== 引入 vConsole 移动端调试 ===== -->
<script src="https://cdn.jsdelivr.net/npm/vconsole@latest/dist/vconsole.min.js"></script>
<script>
// 仅在开发/测试环境启用
if (location.hostname === 'localhost' || location.search.includes('debug=1')) {
new VConsole();
}
</script>
真机调试技巧
- 局域网访问:
npx serve -l 3000后手机访问http://你的IP:3000 - ngrok 内网穿透:
ngrok http 3000获取公网 URL - 数据线 + Chrome:Android 手机 USB 连接,chrome://inspect 调试
- alert 快速定位:最简单粗暴的调试方式
alert(JSON.stringify(data)) - 注入调试信息:在页面底部固定显示关键状态变量
🔄 兼容性处理
移动端浏览器的兼容性问题主要集中在iOS Safari、Android WebView、微信内置浏览器。
各浏览器常见坑点
| 浏览器 | 常见问题 | 解决方案 |
|---|---|---|
| iOS Safari | 不支持 WebP(iOS 14-)、Date 解析差异、position:fixed 在键盘弹出时异常 | 图片兜底 JPG/PNG、使用 dayjs 处理日期、避免 fixed 在输入场景 |
| Android 微信 | 不支持 ES6 Module、缓存策略激进、长按图片会弹出菜单 | 打包为 ES5、加版本号/v参数控制缓存、用 CSS 禁止长按 |
| iOS 微信 | 音频视频自动播放限制、scroll 事件不连续触发 | 需要用户手势触发播放、使用 IntersectionObserver 替代 |
| 各厂商 WebView | 内核版本低、不支持新 CSS/JS 特性 | 使用 Babel + Autoprefixer、特性检测 + Polyfill |
常用兼容性写法
/* ===== 移动端兼容性常用代码 ===== */
/* ① 禁止 iOS 长按弹出菜单 */
* {
-webkit-touch-callout: none; /* iOS 长按链接菜单 */
-webkit-user-select: none; /* 禁止选中文字 */
user-select: none;
}
input, textarea {
-webkit-user-select: auto; /* 输入框允许选中 */
user-select: auto;
}
/* ② 禁止图片长按保存(微信) */
img {
-webkit-touch-callout: none;
pointer-events: none; /* 简单粗暴 */
}
/* ③ iOS 输入框圆角重置 */
input, textarea {
-webkit-appearance: none;
border-radius: 0;
}
/* ④ 去除点击高亮背景 */
* {
-webkit-tap-highlight-color: transparent;
}
/* ⑤ 安全区域兼容写法 */
.fixed-bottom {
padding-bottom: constant(safe-area-inset-bottom); /* iOS 11.0-11.2 */
padding-bottom: env(safe-area-inset-bottom); /* iOS 11.2+ */
}
JS 兼容处理
// ===== 移动端 JS 兼容处理 =====
// ① 特性检测(优于 UA 判断)
if ('geolocation' in navigator) {
// 支持定位
}
if (window.IntersectionObserver) {
// 支持懒加载
}
// ② 微信环境判断
const isWeChat = /MicroMessenger/i.test(navigator.userAgent);
// ③ iOS 版本判断
function getIOSVersion() {
const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/);
if (match) {
return parseInt(match[1], 10);
}
return 0;
}
// ④ 低版本浏览器 Promise polyfill
if (typeof Promise === 'undefined') {
// 动态加载 polyfill
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js';
document.head.appendChild(script);
}
🔗 Hybrid 开发与 JSBridge 通信
Hybrid App 是 H5 页面嵌入原生 App WebView 的开发模式。核心在于 JSBridge——JS 与原生代码互相调用的桥梁。
Hybrid 架构示意
┌──────────────────────────────────────────┐
│ 原生 App 容器 │
│ ┌────────────────────────────────────┐ │
│ │ WebView (H5 页面) │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ HTML + CSS + JS 业务逻辑 │ │ │
│ │ └──────────┬───────────────────┘ │ │
│ │ │ JSBridge │ │
│ └─────────────┼──────────────────────┘ │
│ │ │
│ ┌─────────────▼──────────────────────┐ │
│ │ 原生能力 (相机/定位/支付/推送) │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
JSBridge 通信原理
| 方案 | 原理 | 适用平台 |
|---|---|---|
| URL Scheme 拦截 | JS 发起自定义协议请求(如 myapp://),原生拦截解析 |
Android / iOS 通用 |
| 注入 JS 对象 | Android 通过 addJavascriptInterface,iOS 通过 WKScriptMessageHandler 注入原生对象到 JS 上下文 |
Android / iOS 各不同 |
| evaluateJavaScript | 原生直接执行 JS 代码(常用于原生调用 JS) | Android / iOS 通用 |
| Prompt 拦截 | WebView 拦截 window.prompt() 传递消息 |
Android(早期方案) |
JSBridge 调用封装
// ===== 通用 JSBridge 调用封装 =====
const JSBridge = {
// 判断是否在 App 环境中
isApp() {
return /MyApp/i.test(navigator.userAgent);
},
// 调用原生方法
call(method, params = {}) {
const data = JSON.stringify({ method, params });
// Android: 注入的对象
if (window.NativeBridge) {
window.NativeBridge.invoke(method, JSON.stringify(params));
return;
}
// iOS: WKWebView messageHandlers
if (window.webkit?.messageHandlers?.NativeBridge) {
window.webkit.messageHandlers.NativeBridge.postMessage({ method, params });
return;
}
// 降级方案: URL Scheme
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = `myapp://bridge?data=${encodeURIComponent(data)}`;
document.body.appendChild(iframe);
setTimeout(() => iframe.remove(), 100);
},
// 注册回调(原生调用 JS)
on(event, callback) {
window[`__bridge_callback_${event}`] = callback;
}
};
// 使用示例
JSBridge.call('getLocation');
JSBridge.call('share', { title: '分享标题', url: location.href });
JSBridge.on('locationResult', (data) => {
console.log('定位结果:', data.latitude, data.longitude);
});
- 回调丢失:页面跳转或 WebView 销毁时回调可能丢失,需要超时兜底机制
- 注入时机:JSBridge 对象可能在
DOMContentLoaded之后才注入,需要轮询等待 - 参数序列化:Android 部分版本不支持 JSON 对象传递,需要
JSON.stringify - 线程问题:iOS WKWebView 回调在非主线程,更新 UI 需 dispatch 到主线程
- URL Scheme 长度限制:部分设备限制 URL 约 2KB,大数据用注入方式
微信 JSSDK 使用
微信内置浏览器提供了 JSSDK,用于调用微信原生能力(分享、支付、图像、音频等)。
<!-- ===== 微信 JSSDK 集成 ===== -->
<!-- 1. 引入 JS 文件 -->
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
// 2. 通过后端接口获取签名配置
async function initWxConfig() {
const resp = await fetch('/api/wx-signature?url=' + encodeURIComponent(location.href));
const config = await resp.json();
wx.config({
debug: false, // 开发时可开启 debug 模式
appId: config.appId,
timestamp: config.timestamp,
nonceStr: config.nonceStr,
signature: config.signature,
jsApiList: [ // ⭐ 需要使用的 JS 接口列表
'updateAppMessageShareData', // 自定义分享给朋友
'updateTimelineShareData', // 自定义分享到朋友圈
'chooseImage', // 选择图片
'previewImage', // 预览图片
'getLocation', // 获取地理位置
'scanQRCode', // 扫码
]
});
wx.ready(() => {
// 配置成功后,调用分享接口
wx.updateAppMessageShareData({
title: '分享标题',
desc: '分享描述',
link: location.href,
imgUrl: 'https://example.com/share-icon.jpg',
});
});
wx.error((res) => {
console.error('微信 JSSDK 配置失败:', res);
});
}
initWxConfig();
// 3. 常用微信 JSSDK API 调用示例
// 选择图片
wx.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success(res) {
const localIds = res.localIds;
console.log('选中图片:', localIds);
}
});
// 获取地理位置
wx.getLocation({
type: 'wgs84',
success(res) {
console.log(`纬度:${res.latitude}, 经度:${res.longitude}`);
}
});
</script>
- 签名 URL:签名用的 URL 必须是当前页面完整 URL(不含 hash),iOS 用第一次进入的 URL
- 域名绑定:JS 接口安全域名需在公众号后台配置,不支持 IP 和端口
- SPA 路由:使用 vue-router 等时,每次路由切换需重新
wx.config() - 上传图片:
chooseImage获取的是 localId,需用uploadImage上传到微信服务器再下载
Hybrid 开发最佳实践
- 版本控制:H5 资源加版本号,利用 WebView 缓存,避免每次加载
- 降级策略:原生能力不可用时,优雅降级到 H5 实现(如 WebRTC、Geolocation API)
- 离线包:将 H5 资源打包到 App 本地,通过离线包 + 增量更新提升加载速度
- 安全校验:敏感操作(支付、授权)必须由原生完成,不可纯 H5 实现
- Cookie 同步:Android WebView 默认不共享 Cookie,需要原生层做同步
Hybrid / JSBridge 避坑指南
| 坑点 | 问题描述 | 解决方案 |
|---|---|---|
| JSBridge 注入时机不确定 | 原生注入的 Bridge 对象可能在页面 JS 执行之后才就绪,window.NativeBridge 为 undefined |
H5 端实现轮询等待机制(最大超时 5s),或原生在页面加载前注入(onPageStarted) |
| 回调 ID 冲突 | 并发调用多个原生方法时,回调函数可能互相覆盖,导致结果混乱 | 使用唯一 callbackId(时间戳 + 随机数),维护回调映射表而非全局变量 |
| JSON 序列化深度嵌套 | 复杂嵌套对象通过 URL Scheme 传递时可能丢失数据或超出长度限制 | 优先使用注入方式传参;URL Scheme 方案限制参数层级 ≤ 2 层,单次数据 < 2KB |
| 页面跳转导致回调丢失 | 调用原生方法后 H5 立即跳转页面,原生回调时 JS 上下文已销毁 | 跳转前等待回调完成;或原生将结果缓存到 localStorage,新页面读取 |
| iOS WKWebView 跨域限制 | WKWebView 中 AJAX 请求跨域被拦截(file:// 协议尤其严重) |
原生端配置允许任意跨域;使用原生 HTTP 代理转发请求;H5 资源用本地服务器加载 |
| Android WebView 内存泄漏 | WebView 持有 Activity 引用,未正确销毁导致 Activity 无法回收,内存持续增长 | 单独进程运行 WebView;onDestroy 中先 removeAllViews() 再 destroy();使用 ApplicationContext 创建 WebView |
| 软键盘遮挡输入框 | 键盘弹出时 WebView 不会自动调整布局,H5 输入框被软键盘遮挡 | 原生端设置 android:windowSoftInputMode="adjustResize";H5 端监听 visualViewport 手动滚动到焦点元素 |
| UserAgent 伪造风险 | 仅靠 UserAgent 判断 App 环境不可靠,攻击者可伪造 UA 绕过安全校验 | 结合 JSBridge 探活双重验证:UA 检测 + typeof window.NativeBridge !== 'undefined' |
| 微信 JSSDK 签名失败 | SPA 应用中路由切换后签名失效,或 iOS 和 Android 签名 URL 不一致 | SPA 每次路由切换重新 wx.config();iOS 使用首次进入页面的 URL 签名,Android 使用当前 URL |
| Android 4.4 以下兼容性 | Android 4.4 以下使用 WebKit 内核,不支持 ES6、Flexbox、CSS 变量等 | 确认最低支持版本,如需兼容则使用 Polyfill + Autoprefixer + ES5 转译;否则引导升级 |
| WebView 白屏时间过长 | H5 资源加载慢或网络差时,WebView 长时间白屏,用户体验差 | 原生先展示骨架屏/Loading;使用离线包优先加载;开启 WebView 缓存和预加载池 |
| 调试效率低 | Hybrid 开发涉及两端,排查 JSBridge 通信问题需要同时调试 H5 和原生代码 | Android: chrome://inspect 远程调试;iOS: Safari → 开发 → 选择设备;封装统一的 Bridge 日志开关,两端可打印通信日志 |
🎬 移动端音视频处理
移动端的音视频播放受平台策略限制较大,尤其是自动播放和全屏控制,需要特殊处理。
自动播放策略
| 平台 | 策略 | 限制条件 |
|---|---|---|
| iOS Safari | 严格禁止自动播放(含声音) | 必须由用户手势触发(click/touchend),且在同一事件循环内 |
| Android Chrome | 部分允许 | 静音视频可自动播放;有声需用户有过交互(MEI 指数) |
| 微信内置浏览器 | 严格禁止 | 需通过 WeixinJSBridge 的 WeixinJSBridgeReady 事件触发,或用户手势 |
| WebView(App 内) | 由原生配置决定 | iOS: mediaPlaybackRequiresUserAction=NO;Android: setMediaPlaybackRequiresUserGesture(false) |
自动播放兼容方案
// ===== 移动端视频自动播放兼容方案 =====
async function autoPlayVideo(videoEl) {
// ① 先尝试静音自动播放
videoEl.muted = true;
videoEl.playsInline = true; // iOS 内联播放(不全屏)
try {
await videoEl.play();
console.log('静音自动播放成功');
// 用户交互后取消静音
document.addEventListener('touchend', () => {
videoEl.muted = false;
}, { once: true });
} catch (err) {
console.log('自动播放被阻止:', err.message);
// ② 降级:等待用户首次交互
document.addEventListener('touchend', () => {
videoEl.play();
}, { once: true });
}
}
// ③ 微信环境特殊处理
function autoPlayInWeChat(videoEl) {
if (typeof WeixinJSBridge !== 'undefined') {
WeixinJSBridge.invoke('getNetworkType', {}, () => {
videoEl.play();
});
} else {
document.addEventListener('WeixinJSBridgeReady', () => {
videoEl.play();
}, false);
}
}
Video 标签关键属性
| 属性 | 作用 | 移动端注意事项 |
|---|---|---|
playsinline |
内联播放,不自动全屏 | iOS 必须加,否则强制全屏;Android 默认内联 |
webkit-playsinline |
iOS 8-9 兼容写法 | 与 playsinline 同时使用 |
x5-video-player-type="h5" |
腾讯 X5 内核同层播放 | Android 微信/QQ 浏览器需要,否则弹出独立播放器 |
x5-video-player-fullscreen="true" |
X5 内核全屏模式 | 横屏视频需要设置为 true |
x5-video-orientation |
X5 播放器方向 | landscape 横屏 / portrait 竖屏 |
preload="metadata" |
预加载策略 | 移动端建议 metadata,auto 浪费流量 |
poster |
封面图 | 必须设置,部分 Android 不设置会显示黑屏 |
controls |
显示原生控件 | 各平台样式不统一,建议自定义控件 |
完整 Video 标签模板
<!-- ===== 移动端 Video 完整写法 ===== -->
<video
src="video.mp4"
poster="cover.jpg"
controls
playsinline
webkit-playsinline
x5-video-player-type="h5"
x5-video-player-fullscreen="true"
x5-video-orientation="landscape"
preload="metadata"
loop
style="width:100%; object-fit:contain;"
>
您的浏览器不支持 video 标签
</video>
移动端视频常见坑与解决
| 问题 | 原因 | 解决方案 |
|---|---|---|
| iOS 点击播放自动全屏 | 未设置 playsinline |
添加 playsinline 属性 |
| 微信/QQ 弹出独立播放器 | 未设置 X5 同层播放 | 添加 x5-video-player-type="h5" |
| Android 视频层级覆盖其他元素 | X5 视频默认在最顶层 | 设置 x5-video-player-type="h5-page" |
| 视频无法自动播放 | 浏览器策略限制 | 先静音播放,用户交互后取消静音 |
| 封面图不显示 | 部分 Android 的 bug | 在 video 上层叠加 cover div,播放后隐藏 |
| iOS Safari 视频内联但占满屏幕 | CSS 宽高未生效 | 给 video 设置明确的 width/height 或 aspect-ratio |
Audio 音频处理
// ===== 移动端音频播放 =====
// ① 预加载音频池(解决播放延迟)
const audioPool = {};
function preloadAudio(name, src) {
const audio = new Audio();
audio.src = src;
audio.preload = 'auto';
audio.load();
audioPool[name] = audio;
}
// ② 播放音效(短音频)
function playSound(name) {
const audio = audioPool[name];
if (audio) {
audio.currentTime = 0; // 重置到开头
audio.play().catch(() => {}); // 忽略自动播放限制(静默失败)
}
}
// ③ 微信环境音频自动播放
document.addEventListener('WeixinJSBridgeReady', () => {
// 预加载背景音乐
const bgm = new Audio('bgm.mp3');
bgm.loop = true;
bgm.play();
});
// ④ 音频上下文恢复(iOS)
// iOS Safari 在页面不可见时会暂停 Web Audio API
document.addEventListener('visibilitychange', () => {
if (!document.hidden && audioContext?.state === 'suspended') {
audioContext.resume();
}
});
- 封面图必设:poster 属性必须有,部分 Android 无封面会黑屏或显示异常
- 格式选择:视频优先 MP4 (H.264),兼容性最好;音频优先 MP3/AAC
- 清晰度适配:根据网络类型(4G/WiFi)动态切换清晰度,节省流量
- 预加载控制:移动端不要设置
preload="auto",浪费用户流量 - 播放状态管理:页面隐藏时暂停播放,页面恢复时检查是否需要恢复
- 进度保存:使用 localStorage 保存播放进度,用户体验更好
⚡ PWA 渐进式 Web 应用
PWA(Progressive Web App)让 Web 应用具备类似原生 App 的能力:离线访问、桌面图标、推送通知、后台同步。在移动端,iOS 从 11.3 开始支持 Service Worker。
PWA 核心技术栈
| 技术 | 作用 | 兼容性 |
|---|---|---|
| Service Worker | 离线缓存、后台同步、推送通知 | iOS 11.3+, Android Chrome 全支持 |
| Web App Manifest | 添加到桌面、图标、启动画面、全屏模式 | Android Chrome 完整支持,iOS Safari 部分支持 |
| HTTPS | PWA 强制要求 | Service Worker 只能在 HTTPS 或 localhost 下注册 |
| Cache API | 编程式缓存管理 | 与 Service Worker 兼容性相同 |
| IndexedDB | 结构化离线数据存储 | iOS 8+, Android 全支持 |
1. Web App Manifest(添加到桌面)
<!-- ===== manifest.json(放在项目根目录)===== -->
{
"name": "我的应用",
"short_name": "MyApp",
"description": "一个移动端 Web 应用",
"start_url": "/?utm_source=pwa",
"scope": "/",
"display": "standalone", // standalone: 隐藏浏览器UI
// fullscreen: 完全全屏
// minimal-ui: 最小浏览器UI
// browser: 普通浏览器
"orientation": "portrait", // 锁定屏幕方向
"background_color": "#ffffff",
"theme_color": "#4A90D9",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable" // maskable: 适配 Android 自适应图标
}
],
"screenshots": [ // 应用商店展示图
{
"src": "/screenshots/home.png",
"sizes": "750x1334",
"type": "image/png"
}
],
"categories": ["utilities"],
"lang": "zh-CN"
}
<!-- HTML 中引入 -->
<link rel="manifest" href="/manifest.json">
<!-- iOS 特殊 meta 标签(iOS 不读取 manifest.json) -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="我的应用">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png">
<link rel="apple-touch-startup-image" href="/splash/splash-750x1334.png">
2. Service Worker(离线缓存)
// ===== sw.js(Service Worker 文件)=====
const CACHE_NAME = 'my-app-v1';
const urlsToCache = [
'/',
'/css/style.css',
'/js/main.js',
'/offline.html', // 离线兜底页
];
// ① install: 预缓存关键资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(urlsToCache);
})
);
// 立即激活,不等待旧 SW 释放
self.skipWaiting();
});
// ② activate: 清理旧版本缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
// 立即接管所有页面
self.clients.claim();
});
// ③ fetch: 缓存策略 - 网络优先,缓存兜底
self.addEventListener('fetch', (event) => {
// 只处理 GET 请求
if (event.request.method !== 'GET') return;
event.respondWith(
fetch(event.request)
.then((response) => {
// 网络成功:缓存响应副本
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
// 网络失败:返回缓存
return caches.match(event.request).then((cachedResponse) => {
return cachedResponse || caches.match('/offline.html');
});
})
);
});
// ===== 注册 Service Worker(在主 JS 中)=====
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then((registration) => {
console.log('SW 注册成功:', registration.scope);
// 监听更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新版本可用,提示用户刷新
if (confirm('有新版本可用,是否刷新?')) {
window.location.reload();
}
}
});
});
}).catch((err) => {
console.error('SW 注册失败:', err);
});
});
}
3. 缓存策略对比
| 策略 | 模式 | 适用场景 |
|---|---|---|
| Cache First(缓存优先) | 先查缓存,未命中再网络 | 静态资源(CSS/JS/图片/字体),版本号控制更新 |
| Network First(网络优先) | 先请求网络,失败用缓存 | API 数据、需要实时性的内容 |
| Stale While Revalidate | 返回缓存,后台更新 | 不要求即时性的内容,用户体验最好 |
| Network Only | 仅网络请求 | 不需要缓存的请求(如上报、统计) |
| Cache Only | 仅从缓存获取 | 离线应用的纯本地资源 |
Stale While Revalidate 策略实现
// ===== 最佳用户体验策略:先返回缓存,后台更新 =====
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cachedResponse) => {
// 后台发起网络请求更新缓存
const fetchPromise = fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// 立即返回缓存(如果有),否则等网络
return cachedResponse || fetchPromise;
});
})
);
});
4. iOS 添加到桌面引导
// ===== iOS Safari 添加到桌面引导 =====
// iOS 不支持自动弹出安装提示,需要手动引导用户
function showInstallGuide() {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isStandalone = window.navigator.standalone; // iOS 已添加到桌面
if (isIOS && !isStandalone) {
// 显示引导浮层
const guide = document.createElement('div');
guide.className = 'ios-install-guide';
guide.innerHTML = `
<div class="guide-content">
<p>点击下方 <strong>分享按钮</strong></p>
<p>选择 <strong>添加到主屏幕</strong></p>
<span class="guide-arrow">↓</span>
<button onclick="this.parentElement.parentElement.remove()">知道了</button>
</div>
`;
document.body.appendChild(guide);
}
}
// CSS 样式
.ios-install-guide {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.85);
color: #fff;
padding: 16px 24px;
border-radius: 12px;
z-index: 9999;
text-align: center;
}
.guide-arrow {
font-size: 24px;
display: block;
animation: bounce 1s infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(8px); }
}
5. PWA 离线检测与提示
// ===== 网络状态监听 =====
function initNetworkListener() {
const updateOnlineStatus = () => {
const isOnline = navigator.onLine;
const toast = document.getElementById('network-toast');
if (!isOnline) {
toast.textContent = '📡 当前处于离线状态,部分功能可能不可用';
toast.classList.add('show');
} else {
toast.textContent = '✅ 网络已恢复';
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2000);
}
};
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
}
// HTML 结构
<div id="network-toast" style="
position:fixed; top:0; left:0; right:0;
background:#ff6b6b; color:#fff; text-align:center;
padding:8px; transform:translateY(-100%);
transition:transform 0.3s; z-index:9999;
"></div>
<style>
#network-toast.show { transform: translateY(0); }
</style>
- iOS 限制:iOS 的 Service Worker 缓存上限约 50MB,超出会被系统清理
- 缓存策略分层:HTML 用 Network First,CSS/JS/字体用 Cache First(带版本号),API 用 Stale While Revalidate
- App 壳方案:PWA 在 iOS 体验不如 Android,可考虑 Trusted Web Activity(TWA)上架 Google Play
- 渐进增强:先确保无 SW 时页面正常工作,PWA 能力作为增强项
- 推送通知:iOS 16.4+ 才开始支持 Web Push,Android 完整支持
- Workbox:生产环境推荐使用 Google Workbox 库管理 SW,避免手动处理缓存细节
🐦 Flutter 移动端开发
Flutter 是 Google 推出的跨平台 UI 框架,使用 Dart 语言,一套代码可同时运行在 iOS 和 Android 平台。与 Web 技术栈不同,Flutter 自绘引擎提供接近原生的高性能体验。
Flutter vs React Native vs Web 对比
| 维度 | Flutter | React Native | 移动端 Web (H5) |
|---|---|---|---|
| 渲染方式 | Skia 自绘引擎 | 桥接到原生组件 | 浏览器 WebView 渲染 |
| 性能 | ⭐⭐⭐⭐⭐ 接近原生 | ⭐⭐⭐⭐ 需优化 Bridge | ⭐⭐⭐ 受限于 WebView |
| 开发语言 | Dart | JavaScript / TypeScript | HTML + CSS + JavaScript |
| 热重载 | ✅ 秒级 Hot Reload | ✅ Fast Refresh | ✅ 浏览器自动刷新 |
| UI 一致性 | ⭐⭐⭐⭐⭐ 像素级一致 | ⭐⭐⭐ 依赖原生组件 | ⭐⭐ 浏览器差异大 |
| 包管理 | pub.dev (30,000+) | npm (海量) | npm (海量) |
| 学习曲线 | 中等(需学 Dart + Widget) | 低(前端开发者友好) | 低(前端基础即可) |
| 适用场景 | 高性能跨平台 App | 快速迭代的跨平台 App | 营销页、内嵌 H5、混合 App |
Flutter 与 WebView 混合开发
在实际项目中,经常需要在 Flutter 中嵌入 H5 页面,或 H5 页面调用 Flutter 原生能力。
// ===== Flutter 中嵌入 H5(WebView)=====
import 'package:webview_flutter/webview_flutter.dart';
class H5Page extends StatefulWidget {
@override
State<H5Page> createState() => _H5PageState();
}
class _H5PageState extends State<H5Page> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (url) {
// 页面加载完成后注入 JS
_controller.runJavaScript('console.log("Flutter 注入的代码")');
},
),
)
// Flutter ↔ H5 双向通信
..addJavaScriptChannel('FlutterBridge',
onMessageReceived: (message) {
// 接收 H5 发来的消息
print('H5 发来消息: ${message.message}');
},
)
..loadRequest(Uri.parse('https://your-h5-page.com'));
}
// 原生调用 H5
void callJSFunction(String data) {
_controller.runJavaScript('window.onFlutterMessage($data)');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('H5 页面')),
body: SafeArea(
child: WebViewWidget(controller: _controller),
),
);
}
}
// ===== H5 端调用 Flutter(JavaScript)=====
// 在 H5 页面中发送消息给 Flutter
function sendToFlutter(action, params) {
if (window.FlutterBridge) {
FlutterBridge.postMessage(JSON.stringify({ action, params }));
}
}
// 接收 Flutter 发来的消息
window.onFlutterMessage = function(data) {
console.log('Flutter 发来消息:', data);
};
Flutter ↔ WebView 混合开发避坑指南
| 坑点 | 问题描述 | 解决方案 |
|---|---|---|
| JS Bridge 未就绪 | H5 页面过早调用 FlutterBridge.postMessage(),但 Bridge 还未注入完成,导致消息丢失 |
H5 端轮询等待 Bridge 就绪,或 Flutter 在 onPageFinished 后主动通知 H5 |
| 键盘遮挡 WebView | H5 内输入框聚焦时键盘弹出,WebView 不会自动调整,导致输入框被遮挡 | 设置 resizeToAvoidBottomInset: true(Scaffold 默认),或手动监听 viewInsets 调整 padding |
| Android 与 iOS WebView 差异 | webview_flutter 在 Android 用 Chromium,iOS 用 WKWebView,部分 JS/CSS 行为不一致 |
关键功能两端测试;避免使用 WebKit 私有 API;使用 flutter_inappwebview 替代可获得更统一的行为 |
| 返回键拦截 | 用户在 WebView 中浏览多页后按返回键,直接退出了整个页面而非返回 H5 上一页 | 使用 WillPopScope(或 PopScope)拦截返回事件,先判断 _controller.canGoBack(),有历史则 goBack() |
| Cookie 同步 | Flutter 原生登录态(Token)需要同步到 WebView,否则 H5 需要重新登录 | 通过 JS Bridge 传递 Token,H5 写入 localStorage;或加载 URL 时在 Header 中注入 Cookie |
| 白屏/加载慢 | H5 页面网络慢或资源大时,WebView 长时间白屏,体验差 | Flutter 端先展示骨架屏/Native Loading;设置 onPageFinished 后切换;H5 端启用资源预加载和离线缓存 |
| 内存泄漏 | WebView 未正确释放,反复进出页面后内存持续增长,导致 App 卡顿或 Crash | 在 dispose() 中调用 _controller.clearCache() 等清理;避免在多个页面中重复创建 WebView;及时移除 JS Channel |
| HTTPS 混合内容 | H5 页面为 HTTPS,但内部引用了 HTTP 资源(图片、接口),在 iOS WKWebView 中默认被拦截 | H5 端所有资源统一使用 HTTPS;Flutter 端可配置 App Transport Security 例外(仅开发调试用) |
| 文件上传/下载 | H5 中的 <input type="file"> 在 WebView 中默认无法触发 |
使用 flutter_inappwebview 处理文件选择回调;或通过 JS Bridge 由 Flutter 原生端实现文件选择和上传 |
| 调试困难 | WebView 中的 H5 控制台、网络请求无法直接查看,排查问题效率低 | Android: Chrome chrome://inspect 远程调试;iOS: Safari 开发菜单中连接 WebView;Flutter 端开启 onConsoleMessage 回调打印日志 |
✅ 移动端 Web 开发检查清单
以下是移动端 H5 页面上线前的完整检查清单,逐项确认避免踩坑。
HTML / Meta 检查
- 已添加
viewportmeta 标签,且width=device-width - 设置了
viewport-fit=cover(如需适配全面屏) - 页面设置了正确的
lang属性 <input>的font-size不小于 16px(防 iOS 缩放)- 已配置
format-detection禁止数字识别为电话
CSS 样式检查
- 使用 Flexbox 或 Grid 布局,避免固定宽度
- 字体和间距使用 rem/vw 或百分比,不使用固定 px
- 图片设置了
max-width: 100%防止溢出 - 已添加
-webkit-tap-highlight-color: transparent - 滚动容器添加了
-webkit-overflow-scrolling: touch - 动画使用
transform/opacity,避免触发重排 - 已适配全面屏安全区域(safe-area-inset)
JavaScript 检查
- 使用
touch或pointer事件处理交互 - 已处理 300ms 点击延迟(viewport 或 touch-action)
- 弹窗/遮罩打开时禁止背景滚动(防穿透)
- 使用
requestAnimationFrame做动画循环 - 使用
IntersectionObserver做懒加载 - 事件监听添加了
{ passive: true }优化滚动 - 微信环境下的特殊逻辑已处理(如分享、授权)
- JSBridge 调用有超时兜底和降级方案
- 视频添加了
playsinline和x5-video-player-type - 音视频自动播放有用户手势兜底方案
性能检查
- 图片使用 WebP 格式(带 JPG 兜底),已做懒加载
- CSS/JS 已压缩,启用 Gzip 或 Brotli
- 非首屏 JS 使用
defer或动态 import - 首屏关键 CSS 已内联
- 合理使用浏览器缓存策略
- 静态资源使用 CDN
- 已注册 Service Worker 实现离线缓存(PWA)
- 配置了
manifest.json支持添加到桌面
真机测试检查
- iPhone(Safari + 微信)和 Android(Chrome + 微信)均已测试
- 全面屏(刘海屏)显示正常
- 横竖屏切换布局正常
- 软键盘弹出/收起无布局异常
- 弱网环境下页面可用(loading 状态正常)
- 页面滚动流畅,无卡顿感
- PWA 离线模式下页面可正常访问
- JSBridge 在目标 App WebView 中功能正常
- 音视频在各平台(iOS/Android/微信)播放正常