CC 咖啡猫的工作空间 Coding Space

移动端适配实践

移动端适配是前端开发中避不开的核心课题。从最早的 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 方案(推荐)

原理vwvh 是相对于视口宽度和高度的单位。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 值产生小数,存在渲染误差
动态计算性能 频繁触发 resizereflow
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=nomaximum-scale=1.0 会影响无障碍访问(视障用户可能需要缩放)。如果业务允许,可考虑保留缩放能力。WCAG 2.2 建议不要禁用缩放。

3.2 1px 边框问题

问题:在高 DPR(Device Pixel Ratio)屏幕(如 iPhone Retina)上,CSS 的 1px 实际渲染为物理像素的 2px3px,导致边框过粗。

解决方案对比:

方案 原理 兼容性 复杂度
伪元素 + 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,页面内容可能被遮挡。

核心 APIenv(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 网络优先策略 增强体验,非必需

核心理念:永远不要假设用户的设备和网络环境。防御式编程、渐进增强、合理降级,才是移动端适配的底层逻辑。