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'
}