移动端适配与 PWA

移动端流量早已超过桌面端。一个没有做好移动端适配的 Web 应用,相当于丢掉了一半以上的用户。而 PWA(Progressive Web App)让 Web 应用获得接近原生的体验——可安装、离线可用、推送通知。本章从响应式布局讲到 PWA 完整配置,覆盖移动端开发中的关键技术和常见陷阱。

1. 响应式布局

1.1 Viewport Meta

Nuxt4 默认在 <head> 中注入 viewport meta 标签,但理解其含义很重要:

<meta name="viewport" content="width=device-width, initial-scale=1" />
  • width=device-width:页面宽度等于设备宽度(而非默认的 980px)
  • initial-scale=1:初始缩放比例 1:1

不要禁用用户缩放user-scalable=no),这违反无障碍标准——视力不好的用户需要放大页面。

1.2 移动优先

Tailwind 的默认策略是移动优先——无前缀的类用于移动端,加断点前缀用于更大的屏幕:

<!-- 移动端:1 列 → 平板:2 列 → 桌面:3 列 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">

移动优先不只是 CSS 的写法习惯,更是思维方式:先在最小屏幕上设计核心交互,再逐步增强大屏幕的体验。

1.3 安全区域

iPhone 的刘海屏和底部指示条会遮挡页面内容。使用 env() 安全区域变量:

.bottom-bar {
  padding-bottom: env(safe-area-inset-bottom, 0);
}
 
.header {
  padding-top: env(safe-area-inset-top, 0);
}

需要在 viewport meta 中添加 viewport-fit=cover

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      meta: [
        {
          name: 'viewport',
          content: 'width=device-width, initial-scale=1, viewport-fit=cover',
        },
      ],
    },
  },
})

1.4 触摸优化

要素推荐值原因
点击目标大小≥ 44×44pxApple HIG 推荐的最小触摸区域
点击间距≥ 8px防止误触
hover 效果避免依赖 hover触摸屏没有 hover
300ms 延迟无需处理现代浏览器已消除
/* 移动端不显示 hover 效果 */
@media (hover: hover) {
  .button:hover {
    background-color: var(--color-primary-600);
  }
}

@media (hover: hover) 只在支持真正 hover 的设备(鼠标/触控板)上生效,触摸屏上不会触发。

2. PWA 配置

2.1 什么是 PWA

PWA 是一组 Web 技术的集合,让 Web 应用具备原生应用的能力:

能力实现技术
可安装Web App Manifest
离线可用Service Worker + Cache
推送通知Push API + Notification API
后台同步Background Sync API
全屏运行display: standalone

2.2 Nuxt4 PWA 配置

npx nuxi module add @vite-pwa/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@vite-pwa/nuxt'],
  pwa: {
    registerType: 'autoUpdate',
    manifest: {
      name: 'My App',
      short_name: 'MyApp',
      theme_color: '#3b82f6',
      background_color: '#ffffff',
      display: 'standalone',
      icons: [
        { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
        { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
        { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
      ],
    },
    workbox: {
      navigateFallback: '/',
      globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
      runtimeCaching: [
        {
          urlPattern: /^https:\/\/api\.example\.com\/.*/i,
          handler: 'NetworkFirst',
          options: {
            cacheName: 'api-cache',
            expiration: { maxEntries: 50, maxAgeSeconds: 300 },
          },
        },
      ],
    },
  },
})

2.3 缓存策略

策略行为适用场景
CacheFirst先查缓存,没有才请求网络不常变化的静态资源
NetworkFirst先请求网络,失败用缓存API 数据
StaleWhileRevalidate返回缓存,后台更新频繁但可延迟更新的资源
NetworkOnly只用网络实时性要求高的数据
CacheOnly只用缓存完全离线的资源

2.4 更新策略

registerType: 'autoUpdate' 会在检测到新版本时自动更新 Service Worker。如果需要用户确认:

pwa: {
  registerType: 'prompt',
  client: {
    installPrompt: true,
  },
}
<!-- components/PwaUpdatePrompt.vue -->
<script setup>
const { needRefresh, updateServiceWorker } = useRegisterSW()
</script>
 
<template>
  <div v-if="needRefresh" class="fixed bottom-4 right-4 bg-white p-4 rounded-lg shadow-lg">
    <p>有新版本可用</p>
    <button @click="updateServiceWorker()">立即更新</button>
  </div>
</template>

3. 推送通知

3.1 工作流程

1. 用户授权通知权限
2. 浏览器生成 Push Subscription(包含 endpoint + keys)
3. 将 Subscription 发送到你的服务器
4. 服务器通过 Web Push 协议发送消息
5. Service Worker 收到推送,显示通知

3.2 请求权限

// composables/usePushNotification.ts
export function usePushNotification() {
  const isSupported = ref(false)
  const permission = ref<NotificationPermission>('default')
 
  if (import.meta.client) {
    isSupported.value = 'Notification' in window && 'PushManager' in window
    permission.value = Notification.permission
  }
 
  async function requestPermission() {
    if (!isSupported.value) return false
    const result = await Notification.requestPermission()
    permission.value = result
    return result === 'granted'
  }
 
  return { isSupported, permission, requestPermission }
}

最佳实践:不要在页面加载时立即请求通知权限——用户会条件反射地拒绝。应该在用户执行相关操作时(如"订阅更新")再请求。

3.3 Service Worker 接收推送

// public/sw.js(由 @vite-pwa/nuxt 自动生成,这里展示自定义部分)
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {}
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url },
    })
  )
})
 
self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  event.waitUntil(
    clients.openWindow(event.notification.data.url)
  )
})

4. 移动端手势

4.1 基础触摸事件

// composables/useSwipe.ts
export function useSwipe(
  target: Ref<HTMLElement | null>,
  options: { threshold?: number } = {}
) {
  const { threshold = 50 } = options
  const direction = ref<'left' | 'right' | 'up' | 'down' | null>(null)
 
  let startX = 0
  let startY = 0
 
  function onTouchStart(e: TouchEvent) {
    startX = e.touches[0].clientX
    startY = e.touches[0].clientY
  }
 
  function onTouchEnd(e: TouchEvent) {
    const endX = e.changedTouches[0].clientX
    const endY = e.changedTouches[0].clientY
    const diffX = endX - startX
    const diffY = endY - startY
 
    if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > threshold) {
      direction.value = diffX > 0 ? 'right' : 'left'
    } else if (Math.abs(diffY) > threshold) {
      direction.value = diffY > 0 ? 'down' : 'up'
    }
  }
 
  onMounted(() => {
    const el = target.value
    if (!el) return
    el.addEventListener('touchstart', onTouchStart, { passive: true })
    el.addEventListener('touchend', onTouchEnd, { passive: true })
  })
 
  return { direction }
}

4.2 使用 VueUse

VueUse 提供了开箱即用的手势 Composable:

import { useSwipe } from '@vueuse/core'
 
const target = useTemplateRef<HTMLElement>('swipeArea')
const { direction, isSwiping, lengthX, lengthY } = useSwipe(target)
 
watch(direction, (dir) => {
  if (dir === 'left') goNext()
  if (dir === 'right') goPrev()
})

4.3 下拉刷新

// composables/usePullToRefresh.ts
export function usePullToRefresh(onRefresh: () => Promise<void>) {
  const isRefreshing = ref(false)
  const pullDistance = ref(0)
  const threshold = 80
 
  let startY = 0
 
  onMounted(() => {
    document.addEventListener('touchstart', (e) => {
      if (window.scrollY === 0) {
        startY = e.touches[0].clientY
      }
    }, { passive: true })
 
    document.addEventListener('touchmove', (e) => {
      if (startY > 0) {
        pullDistance.value = Math.max(0, e.touches[0].clientY - startY)
      }
    }, { passive: true })
 
    document.addEventListener('touchend', async () => {
      if (pullDistance.value > threshold) {
        isRefreshing.value = true
        await onRefresh()
        isRefreshing.value = false
      }
      pullDistance.value = 0
      startY = 0
    })
  })
 
  return { isRefreshing, pullDistance }
}

5. iOS Safari 兼容

5.1 常见问题

问题原因解决方案
100vh 包含地址栏Safari 的 100vh 包含隐藏的地址栏高度使用 100dvh(Dynamic Viewport Height)
滚动穿透弹窗打开时背景仍可滚动overflow: hidden on &lt;body&gt;
橡皮筋效果过度滚动时的回弹overscroll-behavior: none
input 自动缩放字号 < 16px 时 Safari 自动缩放页面保持 input 字号 ≥ 16px
PWA 状态栏standalone 模式下状态栏样式apple-mobile-web-app-status-bar-style

5.2 100dvh

.full-screen {
  height: 100dvh;  /* 动态视口高度,排除地址栏 */
}
 
/* 兼容不支持 dvh 的浏览器 */
@supports not (height: 100dvh) {
  .full-screen {
    height: 100vh;
  }
}

5.3 iOS PWA Meta 标签

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      meta: [
        { name: 'apple-mobile-web-app-capable', content: 'yes' },
        { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
      ],
      link: [
        { rel: 'apple-touch-icon', href: '/icons/apple-touch-icon.png' },
      ],
    },
  },
})

本章小结

  • 响应式布局:移动优先策略 + env(safe-area-inset-*) 安全区域 + @media (hover: hover) 区分触摸/鼠标
  • PWA 配置@vite-pwa/nuxt 一站式配置 Manifest + Service Worker + 缓存策略
  • 缓存策略:静态资源 CacheFirst,API 数据 NetworkFirst,频繁资源 StaleWhileRevalidate
  • 推送通知:不在加载时请求权限,在用户主动操作时再请求
  • 手势操作:VueUse useSwipe 开箱即用,自定义下拉刷新组合式函数
  • iOS 兼容100dvh 替代 100vh,input 字号 ≥ 16px 防止缩放,PWA Apple Meta 标签