HiHuo
首页
博客
手册
工具
首页
博客
手册
工具
  • Vue & CSS 进阶

    • 第0章:工具链与开发体验
    • 第1章:TypeScript 核心
    • 第2章:CSS 现代能力
    • 第3章:Vue3 核心与原理
    • 第4章:路由与状态管理
    • 第5章:工程化与性能
    • 第6章:测试与质量
    • 第7章:CSS 进阶专题
    • 第8章:项目实战A - SaaS仪表盘
    • 第9章:项目实战B - 内容社区
    • 第10章:Vue 内核深入
    • 第11章:微前端与部署
    • CSS 变量体系与主题系统深度指南
    • 前端安全与防护专题
    • CSS 专题:动效优化与微交互
    • CSS 专题:图片与图形处理
    • 实时通信与WebSocket专题
    • 开发工具与调试技巧专题
    • 新兴技术与未来趋势专题
    • 移动端开发与PWA专题
    • 附录A:代码片段库
    • 附录B:练习题集

移动端开发与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

🎯 专题总结

通过本专题学习,你掌握了:

  1. 移动端适配:视口配置、响应式设计、触摸交互
  2. PWA 开发:Service Worker、离线功能、安装体验
  3. 移动端组件:底部导航、滑动卡片、手势识别
  4. 性能优化:图片懒加载、虚拟滚动、缓存策略
  5. 跨平台开发:Tauri、Capacitor 集成

📝 练习题

  1. 实现一个完整的移动端应用界面
  2. 开发一个支持离线功能的PWA应用
  3. 创建一个手势识别组件库
  4. 实现移动端性能优化方案
  5. 集成跨平台开发框架

🔗 相关资源

  • PWA 官方文档
  • Service Worker API
  • Tauri 文档
  • Capacitor 文档
  • 移动端性能优化
Prev
新兴技术与未来趋势专题
Next
附录A:代码片段库