移动端适配与 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×44px | Apple 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 <body> |
| 橡皮筋效果 | 过度滚动时的回弹 | 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 标签