移动端开发与PWA专题
掌握现代移动端开发技术,打造原生级别的Web应用体验
📚 专题目标
通过本专题学习,你将掌握:
- 移动端适配与响应式设计
- PWA 开发与离线功能
- 触摸交互与手势识别
- 移动端性能优化
- 跨平台开发方案
📱 移动端适配策略
视口配置
<!-- 基础视口配置 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<!-- 高级视口配置 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover">
响应式断点系统
/* 移动端优先的断点系统 */
:root {
--breakpoint-xs: 0;
--breakpoint-sm: 576px;
--breakpoint-md: 768px;
--breakpoint-lg: 992px;
--breakpoint-xl: 1200px;
--breakpoint-xxl: 1400px;
}
/* 移动端样式 */
.container {
padding: 16px;
max-width: 100%;
}
/* 平板样式 */
@media (min-width: 768px) {
.container {
padding: 24px;
max-width: 750px;
margin: 0 auto;
}
}
/* 桌面样式 */
@media (min-width: 1200px) {
.container {
padding: 32px;
max-width: 1140px;
}
}
触摸友好的设计
/* 触摸目标最小尺寸 */
.touch-target {
min-width: 44px;
min-height: 44px;
padding: 12px;
}
/* 触摸反馈 */
.touch-feedback {
transition: all 0.1s ease;
-webkit-tap-highlight-color: transparent;
}
.touch-feedback:active {
transform: scale(0.95);
background-color: rgba(0, 0, 0, 0.1);
}
/* 防止双击缩放 */
.no-zoom {
touch-action: manipulation;
}
/* 滚动优化 */
.smooth-scroll {
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
🎨 移动端UI组件
底部导航组件
<template>
<nav class="bottom-nav">
<div
v-for="item in navItems"
:key="item.id"
:class="['nav-item', { active: activeItem === item.id }]"
@click="setActiveItem(item.id)"
>
<Icon :name="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.label }}</span>
</div>
</nav>
</template>
<script setup lang="ts">
interface NavItem {
id: string
label: string
icon: string
route: string
}
const navItems: NavItem[] = [
{ id: 'home', label: '首页', icon: 'home', route: '/' },
{ id: 'search', label: '搜索', icon: 'search', route: '/search' },
{ id: 'favorites', label: '收藏', icon: 'heart', route: '/favorites' },
{ id: 'profile', label: '我的', icon: 'user', route: '/profile' }
]
const activeItem = ref('home')
const setActiveItem = (id: string) => {
activeItem.value = id
const item = navItems.find(item => item.id === id)
if (item) {
navigateTo(item.route)
}
}
</script>
<style scoped>
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: white;
border-top: 1px solid #e5e5e5;
padding: 8px 0;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
z-index: 1000;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 4px;
cursor: pointer;
transition: all 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.nav-item:active {
transform: scale(0.95);
}
.nav-item.active {
color: #007bff;
}
.nav-icon {
width: 24px;
height: 24px;
margin-bottom: 4px;
}
.nav-label {
font-size: 12px;
font-weight: 500;
}
/* 安全区域适配 */
@supports (padding: max(0px)) {
.bottom-nav {
padding-bottom: max(8px, env(safe-area-inset-bottom));
}
}
</style>
滑动卡片组件
<template>
<div class="swipe-container" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd">
<div
class="swipe-card"
:style="cardStyle"
@click="handleCardClick"
>
<slot />
</div>
<!-- 操作按钮 -->
<div class="swipe-actions">
<button class="action-btn action-btn--left" @click="swipeLeft">
<Icon name="x" />
</button>
<button class="action-btn action-btn--right" @click="swipeRight">
<Icon name="heart" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
onSwipeLeft?: () => void
onSwipeRight?: () => void
onTap?: () => void
}
const props = defineProps<Props>()
const startX = ref(0)
const currentX = ref(0)
const isDragging = ref(false)
const cardStyle = ref({})
const handleTouchStart = (e: TouchEvent) => {
startX.value = e.touches[0].clientX
isDragging.value = true
}
const handleTouchMove = (e: TouchEvent) => {
if (!isDragging.value) return
currentX.value = e.touches[0].clientX - startX.value
const rotation = currentX.value * 0.1
const opacity = 1 - Math.abs(currentX.value) / 300
cardStyle.value = {
transform: `translateX(${currentX.value}px) rotate(${rotation}deg)`,
opacity: Math.max(0.3, opacity)
}
}
const handleTouchEnd = () => {
if (!isDragging.value) return
isDragging.value = false
const threshold = 100
if (Math.abs(currentX.value) > threshold) {
if (currentX.value > 0) {
swipeRight()
} else {
swipeLeft()
}
} else {
// 回弹
cardStyle.value = {
transform: 'translateX(0) rotate(0deg)',
opacity: 1,
transition: 'all 0.3s ease'
}
}
currentX.value = 0
}
const swipeLeft = () => {
cardStyle.value = {
transform: 'translateX(-100%) rotate(-30deg)',
opacity: 0,
transition: 'all 0.3s ease'
}
setTimeout(() => {
props.onSwipeLeft?.()
}, 300)
}
const swipeRight = () => {
cardStyle.value = {
transform: 'translateX(100%) rotate(30deg)',
opacity: 0,
transition: 'all 0.3s ease'
}
setTimeout(() => {
props.onSwipeRight?.()
}, 300)
}
const handleCardClick = () => {
if (!isDragging.value) {
props.onTap?.()
}
}
</script>
<style scoped>
.swipe-container {
position: relative;
width: 100%;
height: 400px;
perspective: 1000px;
}
.swipe-card {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.swipe-actions {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 20px;
}
.action-btn {
width: 60px;
height: 60px;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.action-btn:active {
transform: scale(0.9);
}
.action-btn--left {
background: #ff4757;
color: white;
}
.action-btn--right {
background: #2ed573;
color: white;
}
</style>
🔄 PWA 开发
Service Worker 配置
// sw.js
const CACHE_NAME = 'app-cache-v1'
const urlsToCache = [
'/',
'/static/js/bundle.js',
'/static/css/main.css',
'/manifest.json'
]
// 安装事件
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
return cache.addAll(urlsToCache)
})
)
})
// 激活事件
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName)
}
})
)
})
)
})
// 拦截请求
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// 缓存命中,返回缓存
if (response) {
return response
}
// 缓存未命中,发起网络请求
return fetch(event.request).then((response) => {
// 检查响应是否有效
if (!response || response.status !== 200 || response.type !== 'basic') {
return response
}
// 克隆响应
const responseToCache = response.clone()
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache)
})
return response
})
})
)
})
PWA 注册与更新
// pwa.ts
export class PWAManager {
private registration: ServiceWorkerRegistration | null = null
private updateAvailable = ref(false)
async register() {
if ('serviceWorker' in navigator) {
try {
this.registration = await navigator.serviceWorker.register('/sw.js')
console.log('Service Worker registered successfully')
// 监听更新
this.registration.addEventListener('updatefound', () => {
const newWorker = this.registration!.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
this.updateAvailable.value = true
}
})
}
})
// 监听消息
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'CACHE_UPDATED') {
this.showUpdateNotification()
}
})
} catch (error) {
console.error('Service Worker registration failed:', error)
}
}
}
async update() {
if (this.registration) {
await this.registration.update()
this.updateAvailable.value = false
}
}
private showUpdateNotification() {
// 显示更新通知
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('应用已更新', {
body: '点击重新加载以获取最新版本',
icon: '/icon-192.png'
})
}
}
async requestNotificationPermission() {
if ('Notification' in window) {
const permission = await Notification.requestPermission()
return permission === 'granted'
}
return false
}
async installPrompt() {
let deferredPrompt: any = null
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt = e
})
return deferredPrompt
}
}
export const pwaManager = new PWAManager()
离线功能实现
<template>
<div class="offline-indicator" :class="{ 'offline-indicator--visible': isOffline }">
<Icon name="wifi-off" />
<span>网络连接已断开</span>
</div>
<div class="online-indicator" :class="{ 'online-indicator--visible': isOnline && wasOffline }">
<Icon name="wifi" />
<span>网络连接已恢复</span>
</div>
</template>
<script setup lang="ts">
const isOnline = ref(navigator.onLine)
const isOffline = ref(!navigator.onLine)
const wasOffline = ref(false)
const handleOnline = () => {
isOnline.value = true
isOffline.value = false
wasOffline.value = true
setTimeout(() => {
wasOffline.value = false
}, 3000)
}
const handleOffline = () => {
isOnline.value = false
isOffline.value = true
}
onMounted(() => {
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
})
onUnmounted(() => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
})
</script>
<style scoped>
.offline-indicator,
.online-indicator {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #ff4757;
color: white;
padding: 12px;
text-align: center;
z-index: 1000;
transform: translateY(-100%);
transition: transform 0.3s ease;
}
.online-indicator {
background: #2ed573;
}
.offline-indicator--visible,
.online-indicator--visible {
transform: translateY(0);
}
</style>
📱 移动端性能优化
图片懒加载
<template>
<div class="lazy-image-container" ref="containerRef">
<img
v-if="loaded"
:src="src"
:alt="alt"
class="lazy-image"
@load="onLoad"
@error="onError"
/>
<div v-else class="lazy-image-placeholder">
<div class="loading-spinner"></div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
src: string
alt: string
threshold?: number
}
const props = withDefaults(defineProps<Props>(), {
threshold: 0.1
})
const containerRef = ref<HTMLElement>()
const loaded = ref(false)
const error = ref(false)
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loaded.value = true
observer.unobserve(entry.target)
}
})
},
{ threshold: props.threshold }
)
onMounted(() => {
if (containerRef.value) {
observer.observe(containerRef.value)
}
})
onUnmounted(() => {
observer.disconnect()
})
const onLoad = () => {
error.value = false
}
const onError = () => {
error.value = true
}
</script>
<style scoped>
.lazy-image-container {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
}
.lazy-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.3s ease;
}
.lazy-image-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e5e5e5;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
虚拟滚动
<template>
<div class="virtual-scroll-container" ref="containerRef" @scroll="handleScroll">
<div class="virtual-scroll-spacer" :style="{ height: totalHeight + 'px' }">
<div
class="virtual-scroll-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="item in visibleItems"
:key="item.id"
class="virtual-scroll-item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="item.index" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
items: any[]
itemHeight: number
containerHeight: number
}
const props = defineProps<Props>()
const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)
const totalHeight = computed(() => props.items.length * props.itemHeight)
const visibleCount = computed(() => Math.ceil(props.containerHeight / props.itemHeight) + 2)
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight))
const endIndex = computed(() => Math.min(startIndex.value + visibleCount.value, props.items.length))
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value).map((item, index) => ({
...item,
index: startIndex.value + index
}))
})
const offsetY = computed(() => startIndex.value * props.itemHeight)
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement
scrollTop.value = target.scrollTop
}
</script>
<style scoped>
.virtual-scroll-container {
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.virtual-scroll-spacer {
position: relative;
}
.virtual-scroll-content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.virtual-scroll-item {
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid #e5e5e5;
}
</style>
🎯 跨平台开发
Tauri 集成
// tauri.conf.json
{
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devPath": "http://localhost:3000",
"distDir": "../dist"
},
"package": {
"productName": "My App",
"version": "0.1.0"
},
"tauri": {
"allowlist": {
"all": false,
"shell": {
"all": false,
"open": true
},
"window": {
"all": false,
"close": true,
"hide": true,
"show": true,
"maximize": true,
"minimize": true,
"unmaximize": true,
"unminimize": true,
"startDragging": true
}
},
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.example.myapp",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"security": {
"csp": null
},
"windows": [
{
"fullscreen": false,
"resizable": true,
"title": "My App",
"width": 800,
"height": 600
}
]
}
}
Capacitor 集成
// capacitor.config.ts
import { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
appId: 'com.example.myapp',
appName: 'My App',
webDir: 'dist',
server: {
androidScheme: 'https'
},
plugins: {
SplashScreen: {
launchShowDuration: 2000,
backgroundColor: '#ffffff',
showSpinner: true,
spinnerColor: '#007bff'
},
StatusBar: {
style: 'dark'
}
}
}
export default config
🎯 专题总结
通过本专题学习,你掌握了:
- 移动端适配:视口配置、响应式设计、触摸交互
- PWA 开发:Service Worker、离线功能、安装体验
- 移动端组件:底部导航、滑动卡片、手势识别
- 性能优化:图片懒加载、虚拟滚动、缓存策略
- 跨平台开发:Tauri、Capacitor 集成
📝 练习题
- 实现一个完整的移动端应用界面
- 开发一个支持离线功能的PWA应用
- 创建一个手势识别组件库
- 实现移动端性能优化方案
- 集成跨平台开发框架