vue3.2+vite+vant企业实战-阅读app 2023最新前端实战 持续更新中(APP开发/起点/项目实战/小说/vue)

36 阅读4分钟

t04ca4d7b760f3552a0.jpg

vue3.2+vite+vant企业实战-阅读app 2023最新前端实战 持续更新中(APP开发/起点/项目实战/小说/vue)---youkeit.xyz/13919/

跨端与性能优化:Vue3.2 攻克阅读 APP 的多设备适配难题

引言

在移动互联网时代,阅读类 APP 需要适配从手机、平板到桌面端的各种设备,同时还要保证流畅的性能体验。Vue3.2 凭借其 Composition API、响应式优化和更好的 TypeScript 支持,成为解决这一难题的理想选择。本文将深入探讨如何利用 Vue3.2 实现阅读 APP 的高效跨端适配与性能优化。

一、响应式布局方案

1.1 基于 CSS 变量的响应式设计

<template>
  <div class="reader-app" :style="cssVars">
    <!-- 阅读器内容 -->
  </div>
</template>

<script setup>
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'

const windowWidth = ref(window.innerWidth)

const updateWindowWidth = () => {
  windowWidth.value = window.innerWidth
}

onMounted(() => {
  window.addEventListener('resize', updateWindowWidth)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', updateWindowWidth)
})

const cssVars = computed(() => {
  const isMobile = windowWidth.value < 768
  const isTablet = windowWidth.value >= 768 && windowWidth.value < 1024
  
  return {
    '--font-size': isMobile ? '16px' : isTablet ? '18px' : '20px',
    '--line-height': isMobile ? '1.6' : '1.8',
    '--content-width': isMobile ? '90%' : isTablet ? '80%' : '60%',
    '--padding': isMobile ? '10px' : '20px'
  }
})
</script>

<style>
.reader-app {
  font-size: var(--font-size);
  line-height: var(--line-height);
  width: var(--content-width);
  padding: var(--padding);
  margin: 0 auto;
  transition: all 0.3s ease;
}
</style>

1.2 组合式 API 封装响应式逻辑

// useResponsive.ts
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'

export function useResponsive() {
  const windowWidth = ref(window.innerWidth)
  
  const updateWindowWidth = () => {
    windowWidth.value = window.innerWidth
  }
  
  onMounted(() => {
    window.addEventListener('resize', updateWindowWidth)
  })
  
  onBeforeUnmount(() => {
    window.removeEventListener('resize', updateWindowWidth)
  })
  
  const isMobile = computed(() => windowWidth.value < 768)
  const isTablet = computed(() => windowWidth.value >= 768 && windowWidth.value < 1024)
  const isDesktop = computed(() => windowWidth.value >= 1024)
  
  return {
    windowWidth,
    isMobile,
    isTablet,
    isDesktop
  }
}

二、跨端组件设计

2.1 自适应阅读器组件

<template>
  <div class="reader-container" :class="{ 'mobile': isMobile, 'tablet': isTablet }">
    <div class="reader-content" ref="contentRef">
      <!-- 内容渲染 -->
    </div>
    <ReaderControls 
      :is-mobile="isMobile"
      @font-size-change="handleFontSizeChange"
      @theme-change="handleThemeChange"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useResponsive } from '@/composables/useResponsive'
import ReaderControls from './ReaderControls.vue'

const { isMobile, isTablet } = useResponsive()
const contentRef = ref<HTMLElement | null>(null)

const fontSize = ref(16)
const currentTheme = ref('light')

const handleFontSizeChange = (newSize: number) => {
  fontSize.value = newSize
  if (contentRef.value) {
    contentRef.value.style.fontSize = `${newSize}px`
  }
}

const handleThemeChange = (theme: string) => {
  currentTheme.value = theme
  document.documentElement.setAttribute('data-theme', theme)
}
</script>

<style scoped>
.reader-container {
  height: 100%;
  display: flex;
  flex-direction: column;
  
  &.mobile {
    padding: 8px;
  }
  
  &.tablet {
    padding: 16px;
    max-width: 800px;
    margin: 0 auto;
  }
  
  :deep(.reader-content) {
    flex: 1;
    overflow-y: auto;
    line-height: 1.8;
    text-align: justify;
    hyphens: auto;
  }
}
</style>

2.2 条件渲染与设备特定功能

<template>
  <div>
    <DesktopNavigation v-if="isDesktop" />
    <MobileNavigation v-else />
    
    <template v-if="isMobile">
      <FloatingActionButton @click="showMobileMenu" />
      <BottomNavigationBar />
    </template>
    
    <SplitView v-if="isDesktop || isTablet" />
  </div>
</template>

<script setup>
import { useResponsive } from '@/composables/useResponsive'

const { isDesktop, isTablet, isMobile } = useResponsive()

const showMobileMenu = () => {
  // 移动端菜单逻辑
}
</script>

三、性能优化策略

3.1 虚拟滚动实现长列表优化

<template>
  <div class="virtual-scroll-container" ref="scrollContainer" @scroll="handleScroll">
    <div class="virtual-scroll-content" :style="contentStyle">
      <div 
        v-for="item in visibleItems" 
        :key="item.id"
        class="virtual-item"
        :style="getItemStyle(item)"
      >
        {{ item.content }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

interface BookItem {
  id: number
  content: string
  height: number
}

const props = defineProps<{
  items: BookItem[]
  itemHeight?: number
  buffer?: number
}>()

const scrollContainer = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const containerHeight = ref(0)

const handleScroll = () => {
  if (scrollContainer.value) {
    scrollTop.value = scrollContainer.value.scrollTop
  }
}

onMounted(() => {
  if (scrollContainer.value) {
    containerHeight.value = scrollContainer.value.clientHeight
  }
})

const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / (props.itemHeight || 50)) - (props.buffer || 3))
})

const endIndex = computed(() => {
  return Math.min(
    props.items.length - 1,
    Math.floor((scrollTop.value + containerHeight.value) / (props.itemHeight || 50)) + (props.buffer || 3)
  )
})

const visibleItems = computed(() => {
  return props.items.slice(startIndex.value, endIndex.value + 1)
})

const contentStyle = computed(() => {
  const totalHeight = props.items.reduce((sum, item) => sum + (item.height || props.itemHeight || 50), 0)
  return {
    height: `${totalHeight}px`,
    position: 'relative'
  }
})

const getItemStyle = (item: BookItem) => {
  const offset = props.items
    .slice(0, props.items.indexOf(item))
    .reduce((sum, i) => sum + (i.height || props.itemHeight || 50), 0)
  
  return {
    position: 'absolute',
    top: `${offset}px`,
    width: '100%',
    height: `${item.height || props.itemHeight || 50}px`
  }
}
</script>

<style scoped>
.virtual-scroll-container {
  height: 100%;
  overflow-y: auto;
}

.virtual-scroll-content {
  width: 100%;
}

.virtual-item {
  padding: 12px;
  border-bottom: 1px solid #eee;
  box-sizing: border-box;
}
</style>

3.2 图片懒加载与自适应

<template>
  <img
    v-for="(image, index) in visibleImages"
    :key="image.id"
    :src="isInViewport[index] ? image.url : placeholder"
    :alt="image.alt"
    :style="imageStyle"
    @load="handleImageLoad"
    ref="imageRefs"
  />
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'

interface BookImage {
  id: string
  url: string
  alt: string
  width?: number
  height?: number
}

const props = defineProps<{
  images: BookImage[]
  placeholder?: string
}>()

const imageRefs = ref<(HTMLImageElement | null)[]>([])
const isInViewport = ref<boolean[]>([])
const loadedImages = ref<Set<string>>(new Set())

const placeholder = props.placeholder || 'data:image/svg+xml;base64,...'

const imageStyle = computed(() => ({
  maxWidth: '100%',
  height: 'auto',
  display: 'block',
  margin: '0 auto'
}))

const handleImageLoad = (event: Event) => {
  const img = event.target as HTMLImageElement
  const imageId = img.getAttribute('data-id')
  if (imageId) {
    loadedImages.value.add(imageId)
  }
}

onMounted(() => {
  imageRefs.value.forEach((img, index) => {
    if (img) {
      useIntersectionObserver(
        img,
        ([{ isIntersecting }]) => {
          isInViewport.value[index] = isIntersecting
        },
        { threshold: 0.1 }
      )
    }
  })
})
</script>

四、状态管理与缓存策略

4.1 使用 Pinia 管理阅读状态

// stores/readerStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useStorage } from '@vueuse/core'

export const useReaderStore = defineStore('reader', () => {
  // 响应式本地存储
  const fontSize = useStorage('reader-font-size', 16)
  const theme = useStorage('reader-theme', 'light')
  const lineHeight = useStorage('reader-line-height', 1.6)
  const currentBookId = ref<string | null>(null)
  const readingProgress = ref<Record<string, number>>({})
  
  // 计算属性
  const readerSettings = computed(() => ({
    fontSize: fontSize.value,
    theme: theme.value,
    lineHeight: lineHeight.value
  }))
  
  // 操作方法
  const updateFontSize = (size: number) => {
    fontSize.value = size
  }
  
  const toggleTheme = () => {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }
  
  const saveProgress = (bookId: string, progress: number) => {
    readingProgress.value[bookId] = progress
  }
  
  const getProgress = (bookId: string) => {
    return readingProgress.value[bookId] || 0
  }
  
  return {
    fontSize,
    theme,
    lineHeight,
    currentBookId,
    readingProgress,
    readerSettings,
    updateFontSize,
    toggleTheme,
    saveProgress,
    getProgress
  }
})

4.2 服务端数据缓存与离线阅读

// composables/useBookCache.ts
import { ref, onMounted } from 'vue'
import { useReaderStore } from '@/stores/readerStore'

export function useBookCache(bookId: string) {
  const readerStore = useReaderStore()
  const cachedBook = ref<any>(null)
  const isCached = ref(false)
  const isLoading = ref(false)
  const error = ref<Error | null>(null)
  
  const checkCache = async () => {
    try {
      if ('caches' in window) {
        const cache = await caches.open('books-cache')
        const response = await cache.match(`/api/books/${bookId}`)
        
        if (response) {
          cachedBook.value = await response.json()
          isCached.value = true
        }
      }
    } catch (err) {
      console.error('Cache check failed:', err)
    }
  }
  
  const fetchBook = async () => {
    isLoading.value = true
    error.value = null
    
    try {
      const response = await fetch(`/api/books/${bookId}`)
      
      if (!response.ok) {
        throw new Error('Failed to fetch book')
      }
      
      const data = await response.json()
      cachedBook.value = data
      
      // 缓存响应
      if ('caches' in window) {
        const cache = await caches.open('books-cache')
        const cacheResponse = new Response(JSON.stringify(data), {
          headers: { 'Content-Type': 'application/json' }
        })
        await cache.put(`/api/books/${bookId}`, cacheResponse)
      }
      
      // 保存到 IndexedDB 供离线使用
      if ('indexedDB' in window) {
        await saveToIndexedDB(data)
      }
      
      isCached.value = true
      readerStore.currentBookId = bookId
    } catch (err) {
      error.value = err as Error
    } finally {
      isLoading.value = false
    }
  }
  
  const saveToIndexedDB = async (bookData: any) => {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open('BooksDatabase', 1)
      
      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result
        if (!db.objectStoreNames.contains('books')) {
          db.createObjectStore('books', { keyPath: 'id' })
        }
      }
      
      request.onsuccess = (event) => {
        const db = (event.target as IDBOpenDBRequest).result
        const transaction = db.transaction('books', 'readwrite')
        const store = transaction.objectStore('books')
        const putRequest = store.put(bookData)
        
        putRequest.onsuccess = () => resolve(true)
        putRequest.onerror = () => reject(putRequest.error)
      }
      
      request.onerror = () => reject(request.error)
    })
  }
  
  onMounted(() => {
    checkCache()
  })
  
  return {
    cachedBook,
    isCached,
    isLoading,
    error,
    fetchBook
  }
}

五、测试与监控

5.1 性能监控组件

<template>
  <div v-if="showMetrics" class="performance-metrics">
    <div>FPS: {{ fps }}</div>
    <div>Memory: {{ memoryUsage }} MB</div>
    <div>Load Time: {{ loadTime }}ms</div>
    <div>DOM Nodes: {{ domNodes }}</div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

const props = defineProps<{
  showMetrics?: boolean
}>()

const fps = ref(0)
const memoryUsage = ref(0)
const loadTime = ref(0)
const domNodes = ref(0)

let frameCount = 0
let lastTime = performance.now()
let animationFrameId: number

const calculateFPS = () => {
  const now = performance.now()
  frameCount++
  
  if (now > lastTime + 1000) {
    fps.value = Math.round((frameCount * 1000) / (now - lastTime))
    frameCount = 0
    lastTime = now
    
    // 获取内存使用情况 (Chrome only)
    if ('memory' in performance) {
      // @ts-ignore
      memoryUsage.value = Math.round(performance.memory.usedJSHeapSize / 1024 / 1024)
    }
    
    // 计算DOM节点数
    domNodes.value = document.getElementsByTagName('*').length
  }
  
  animationFrameId = requestAnimationFrame(calculateFPS)
}

onMounted(() => {
  loadTime.value = Math.round(performance.now())
  calculateFPS()
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<style scoped>
.performance-metrics {
  position: fixed;
  bottom: 10px;
  right: 10px;
  background: rgba(0, 0, 0, 0.7);
  color: white;
  padding: 8px;
  border-radius: 4px;
  font-family: monospace;
  font-size: 12px;
  z-index: 1000;
}
</style>

5.2 设备特性检测与降级方案

// utils/deviceCapabilities.ts
export class DeviceCapabilities {
  static isTouchDevice(): boolean {
    return 'ontouchstart' in window || navigator.maxTouchPoints > 0
  }
  
  static supportsWebP(): boolean {
    const elem = document.createElement('canvas')
    return elem.toDataURL('image/webp').indexOf('data:image/webp') === 0
  }
  
  static supportsIntersectionObserver(): boolean {
    return 'IntersectionObserver' in window && 
           'IntersectionObserverEntry' in window && 
           'intersectionRatio' in window.IntersectionObserverEntry.prototype
  }
  
  static supportsServiceWorker(): boolean {
    return 'serviceWorker' in navigator
  }
  
  static supportsIndexedDB(): boolean {
    return 'indexedDB' in window
  }
  
  static getNetworkStatus(): 'slow-2g' | '2g' | '3g' | '4g' | 'unknown' {
    // @ts-ignore
    const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection
    if (connection) {
      return connection.effectiveType || 'unknown'
    }