Vue 3 移动端开发实战:从 PWA 到原生应用的跨平台解决方案
Orion K Lv6

随着移动互联网的快速发展,Vue 3 在移动端开发领域也展现出了强大的能力。本文将深入探讨 Vue 3 移动端开发的完整解决方案,从 PWA 到原生应用,涵盖性能优化、用户体验和跨平台开发的最佳实践。

移动端开发技术栈对比

1. 技术方案选择

移动端开发方案对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
// types/mobile-development.ts

/**
* 移动端开发方案枚举
*/
export enum MobileDevelopmentApproach {
PWA = 'pwa', // 渐进式 Web 应用
HYBRID = 'hybrid', // 混合应用
NATIVE = 'native', // 原生应用
CROSS_PLATFORM = 'cross-platform' // 跨平台应用
}

/**
* 开发方案特性对比
*/
export const developmentApproachComparison = {
[MobileDevelopmentApproach.PWA]: {
name: '渐进式 Web 应用',
description: '基于 Web 技术的类原生应用体验',
technologies: ['Vue 3', 'Vite PWA', 'Workbox', 'Web APIs'],
advantages: [
'开发成本低',
'跨平台兼容',
'无需应用商店',
'自动更新',
'SEO 友好'
],
disadvantages: [
'功能受限',
'性能不如原生',
'依赖浏览器',
'离线能力有限'
],
useCases: [
'内容展示应用',
'电商网站',
'新闻媒体',
'社交平台'
],
performance: {
developmentSpeed: 'fast',
runtime: 'good',
userExperience: 'good',
maintenance: 'easy'
}
},

[MobileDevelopmentApproach.HYBRID]: {
name: '混合应用',
description: 'Web 技术 + 原生容器',
technologies: ['Vue 3', 'Ionic', 'Capacitor', 'Cordova'],
advantages: [
'接近原生体验',
'访问设备功能',
'代码复用',
'快速开发',
'应用商店分发'
],
disadvantages: [
'性能开销',
'包体积大',
'调试复杂',
'平台差异'
],
useCases: [
'企业应用',
'工具类应用',
'中等复杂度应用',
'快速原型'
],
performance: {
developmentSpeed: 'fast',
runtime: 'medium',
userExperience: 'good',
maintenance: 'medium'
}
},

[MobileDevelopmentApproach.CROSS_PLATFORM]: {
name: '跨平台应用',
description: '统一代码库,多平台编译',
technologies: ['Vue Native', 'NativeScript-Vue', 'Quasar'],
advantages: [
'原生性能',
'平台特性支持',
'代码共享',
'统一开发体验'
],
disadvantages: [
'学习成本高',
'平台特定优化',
'框架依赖',
'调试复杂'
],
useCases: [
'复杂业务应用',
'高性能要求',
'多平台发布',
'长期维护项目'
],
performance: {
developmentSpeed: 'medium',
runtime: 'excellent',
userExperience: 'excellent',
maintenance: 'medium'
}
}
}

/**
* 技术选型决策器
*/
export class MobileTechSelector {
/**
* 根据项目需求选择技术方案
*/
static selectApproach(requirements: {
budget: 'low' | 'medium' | 'high'
timeline: 'short' | 'medium' | 'long'
performance: 'basic' | 'good' | 'excellent'
platformCount: number
deviceFeatures: 'none' | 'basic' | 'advanced'
teamExperience: 'beginner' | 'intermediate' | 'expert'
}): MobileDevelopmentApproach {
const {
budget,
timeline,
performance,
platformCount,
deviceFeatures,
teamExperience
} = requirements

// 预算和时间紧张,选择 PWA
if (budget === 'low' && timeline === 'short') {
return MobileDevelopmentApproach.PWA
}

// 需要高性能和原生体验
if (performance === 'excellent' && deviceFeatures === 'advanced') {
return MobileDevelopmentApproach.CROSS_PLATFORM
}

// 需要设备功能但预算有限
if (deviceFeatures !== 'none' && budget !== 'high') {
return MobileDevelopmentApproach.HYBRID
}

// 多平台发布
if (platformCount > 2) {
return teamExperience === 'expert'
? MobileDevelopmentApproach.CROSS_PLATFORM
: MobileDevelopmentApproach.HYBRID
}

// 默认推荐 PWA
return MobileDevelopmentApproach.PWA
}

/**
* 获取推荐技术栈
*/
static getRecommendedStack(approach: MobileDevelopmentApproach) {
const stacks = {
[MobileDevelopmentApproach.PWA]: {
framework: 'Vue 3 + Vite',
ui: 'Quasar / Vuetify',
pwa: 'Vite PWA Plugin',
state: 'Pinia',
routing: 'Vue Router',
build: 'Vite',
testing: 'Vitest + Cypress'
},
[MobileDevelopmentApproach.HYBRID]: {
framework: 'Vue 3 + Ionic',
ui: 'Ionic Components',
native: 'Capacitor',
state: 'Pinia',
routing: 'Vue Router',
build: 'Vite',
testing: 'Vitest + Cypress'
},
[MobileDevelopmentApproach.CROSS_PLATFORM]: {
framework: 'Vue 3 + Quasar',
ui: 'Quasar Components',
native: 'Quasar Native',
state: 'Pinia',
routing: 'Vue Router',
build: 'Quasar CLI',
testing: 'Jest + Cypress'
}
}

return stacks[approach]
}
}

2. 移动端适配策略

响应式设计系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
// composables/useResponsive.ts

/**
* 设备类型枚举
*/
export enum DeviceType {
MOBILE = 'mobile',
TABLET = 'tablet',
DESKTOP = 'desktop'
}

/**
* 屏幕方向枚举
*/
export enum ScreenOrientation {
PORTRAIT = 'portrait',
LANDSCAPE = 'landscape'
}

/**
* 断点配置
*/
export const breakpoints = {
xs: 0,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
xxl: 1400
} as const

/**
* 响应式设计 Hook
*/
export function useResponsive() {
const windowWidth = ref(0)
const windowHeight = ref(0)
const devicePixelRatio = ref(1)

// 设备类型
const deviceType = computed<DeviceType>(() => {
if (windowWidth.value < breakpoints.md) {
return DeviceType.MOBILE
} else if (windowWidth.value < breakpoints.lg) {
return DeviceType.TABLET
} else {
return DeviceType.DESKTOP
}
})

// 屏幕方向
const orientation = computed<ScreenOrientation>(() => {
return windowWidth.value > windowHeight.value
? ScreenOrientation.LANDSCAPE
: ScreenOrientation.PORTRAIT
})

// 是否为移动设备
const isMobile = computed(() => deviceType.value === DeviceType.MOBILE)
const isTablet = computed(() => deviceType.value === DeviceType.TABLET)
const isDesktop = computed(() => deviceType.value === DeviceType.DESKTOP)

// 是否为高分辨率屏幕
const isRetina = computed(() => devicePixelRatio.value > 1)

// 断点匹配
const breakpointMatches = computed(() => ({
xs: windowWidth.value >= breakpoints.xs,
sm: windowWidth.value >= breakpoints.sm,
md: windowWidth.value >= breakpoints.md,
lg: windowWidth.value >= breakpoints.lg,
xl: windowWidth.value >= breakpoints.xl,
xxl: windowWidth.value >= breakpoints.xxl
}))

/**
* 更新窗口尺寸
*/
const updateWindowSize = () => {
windowWidth.value = window.innerWidth
windowHeight.value = window.innerHeight
devicePixelRatio.value = window.devicePixelRatio || 1
}

/**
* 监听窗口变化
*/
const setupWindowListener = () => {
updateWindowSize()

const debouncedUpdate = debounce(updateWindowSize, 100)
window.addEventListener('resize', debouncedUpdate)
window.addEventListener('orientationchange', debouncedUpdate)

return () => {
window.removeEventListener('resize', debouncedUpdate)
window.removeEventListener('orientationchange', debouncedUpdate)
}
}

/**
* 获取安全区域
*/
const getSafeArea = () => {
const style = getComputedStyle(document.documentElement)

return {
top: parseInt(style.getPropertyValue('--safe-area-inset-top') || '0'),
right: parseInt(style.getPropertyValue('--safe-area-inset-right') || '0'),
bottom: parseInt(style.getPropertyValue('--safe-area-inset-bottom') || '0'),
left: parseInt(style.getPropertyValue('--safe-area-inset-left') || '0')
}
}

/**
* 检测触摸支持
*/
const hasTouchSupport = computed(() => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
})

/**
* 检测网络状态
*/
const networkInfo = ref({
online: navigator.onLine,
effectiveType: '4g',
downlink: 10,
rtt: 100
})

const updateNetworkInfo = () => {
networkInfo.value.online = navigator.onLine

if ('connection' in navigator) {
const connection = (navigator as any).connection
networkInfo.value.effectiveType = connection.effectiveType || '4g'
networkInfo.value.downlink = connection.downlink || 10
networkInfo.value.rtt = connection.rtt || 100
}
}

// 初始化
onMounted(() => {
const cleanup = setupWindowListener()
updateNetworkInfo()

window.addEventListener('online', updateNetworkInfo)
window.addEventListener('offline', updateNetworkInfo)

if ('connection' in navigator) {
(navigator as any).connection.addEventListener('change', updateNetworkInfo)
}

onUnmounted(() => {
cleanup()
window.removeEventListener('online', updateNetworkInfo)
window.removeEventListener('offline', updateNetworkInfo)
})
})

return {
// 尺寸信息
windowWidth: readonly(windowWidth),
windowHeight: readonly(windowHeight),
devicePixelRatio: readonly(devicePixelRatio),

// 设备信息
deviceType,
orientation,
isMobile,
isTablet,
isDesktop,
isRetina,
hasTouchSupport,

// 断点匹配
breakpointMatches,

// 网络信息
networkInfo: readonly(networkInfo),

// 工具方法
getSafeArea,
updateWindowSize
}
}

/**
* 防抖函数
*/
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout

return (...args: Parameters<T>) => {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}

PWA 开发实战

1. PWA 基础配置

Vite PWA 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
// 缓存策略
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 // 24 小时
},
cacheKeyWillBeUsed: async ({ request }) => {
return `${request.url}?v=${Date.now()}`
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 天
}
}
},
{
urlPattern: /\.(?:js|css)$/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-resources'
}
}
],

// 预缓存文件
globPatterns: [
'**/*.{js,css,html,ico,png,svg,webp}'
],

// 忽略文件
globIgnores: [
'**/node_modules/**/*',
'sw.js',
'workbox-*.js'
],

// 清理过期缓存
cleanupOutdatedCaches: true,

// 跳过等待
skipWaiting: true,

// 客户端声明
clientsClaim: true
},

// 应用清单
manifest: {
name: 'Vue 3 移动端应用',
short_name: 'Vue3Mobile',
description: 'Vue 3 移动端开发实战应用',
theme_color: '#4f46e5',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
scope: '/',
start_url: '/',

icons: [
{
src: '/icons/icon-72x72.png',
sizes: '72x72',
type: 'image/png'
},
{
src: '/icons/icon-96x96.png',
sizes: '96x96',
type: 'image/png'
},
{
src: '/icons/icon-128x128.png',
sizes: '128x128',
type: 'image/png'
},
{
src: '/icons/icon-144x144.png',
sizes: '144x144',
type: 'image/png'
},
{
src: '/icons/icon-152x152.png',
sizes: '152x152',
type: 'image/png'
},
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icons/icon-384x384.png',
sizes: '384x384',
type: 'image/png'
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png'
}
],

// 快捷方式
shortcuts: [
{
name: '新建文档',
short_name: '新建',
description: '创建新文档',
url: '/create',
icons: [{ src: '/icons/create.png', sizes: '96x96' }]
},
{
name: '搜索',
short_name: '搜索',
description: '搜索内容',
url: '/search',
icons: [{ src: '/icons/search.png', sizes: '96x96' }]
}
],

// 分享目标
share_target: {
action: '/share',
method: 'POST',
enctype: 'multipart/form-data',
params: {
title: 'title',
text: 'text',
url: 'url',
files: [
{
name: 'files',
accept: ['image/*', 'text/*']
}
]
}
}
},

// 开发选项
devOptions: {
enabled: true,
type: 'module'
}
})
],

// 构建配置
build: {
target: 'esnext',
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
}
})

2. Service Worker 管理

PWA 更新管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
// composables/usePWA.ts

/**
* PWA 状态接口
*/
export interface PWAState {
isInstallable: boolean
isInstalled: boolean
isUpdateAvailable: boolean
isOffline: boolean
installPrompt: any
}

/**
* PWA 管理 Hook
*/
export function usePWA() {
const state = reactive<PWAState>({
isInstallable: false,
isInstalled: false,
isUpdateAvailable: false,
isOffline: !navigator.onLine,
installPrompt: null
})

const { updateServiceWorker } = useRegisterSW({
onNeedRefresh() {
state.isUpdateAvailable = true
},
onOfflineReady() {
console.log('App ready to work offline')
}
})

/**
* 安装 PWA
*/
const installPWA = async (): Promise<boolean> => {
if (!state.installPrompt) {
return false
}

try {
const result = await state.installPrompt.prompt()
const outcome = await result.userChoice

if (outcome === 'accepted') {
state.isInstalled = true
state.installPrompt = null
return true
}

return false
} catch (error) {
console.error('PWA installation failed:', error)
return false
}
}

/**
* 更新应用
*/
const updateApp = async (): Promise<void> => {
try {
await updateServiceWorker(true)
state.isUpdateAvailable = false

// 刷新页面
window.location.reload()
} catch (error) {
console.error('App update failed:', error)
}
}

/**
* 检查安装状态
*/
const checkInstallStatus = (): void => {
// 检查是否在 PWA 模式下运行
state.isInstalled = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true
}

/**
* 监听网络状态
*/
const setupNetworkListener = (): void => {
const updateOnlineStatus = () => {
state.isOffline = !navigator.onLine
}

window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)

onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
})
}

/**
* 监听安装提示
*/
const setupInstallListener = (): void => {
const handleBeforeInstallPrompt = (event: Event) => {
event.preventDefault()
state.installPrompt = event
state.isInstallable = true
}

const handleAppInstalled = () => {
state.isInstalled = true
state.isInstallable = false
state.installPrompt = null
}

window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)

onUnmounted(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
})
}

/**
* 显示安装横幅
*/
const showInstallBanner = (): void => {
if (!state.isInstallable || state.isInstalled) {
return
}

// 这里可以显示自定义的安装提示 UI
console.log('Show install banner')
}

/**
* 获取缓存信息
*/
const getCacheInfo = async (): Promise<{
caches: string[]
totalSize: number
}> => {
try {
const cacheNames = await caches.keys()
let totalSize = 0

for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName)
const requests = await cache.keys()

for (const request of requests) {
const response = await cache.match(request)
if (response) {
const blob = await response.blob()
totalSize += blob.size
}
}
}

return {
caches: cacheNames,
totalSize
}
} catch (error) {
console.error('Failed to get cache info:', error)
return {
caches: [],
totalSize: 0
}
}
}

/**
* 清理缓存
*/
const clearCache = async (): Promise<void> => {
try {
const cacheNames = await caches.keys()

await Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
)

console.log('All caches cleared')
} catch (error) {
console.error('Failed to clear cache:', error)
}
}

// 初始化
onMounted(() => {
checkInstallStatus()
setupNetworkListener()
setupInstallListener()
})

return {
// 状态
state: readonly(state),

// 方法
installPWA,
updateApp,
showInstallBanner,
getCacheInfo,
clearCache
}
}

3. 离线功能实现

离线数据管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
// composables/useOffline.ts

/**
* 离线存储接口
*/
export interface OfflineStorage {
get<T>(key: string): Promise<T | null>
set<T>(key: string, value: T): Promise<void>
remove(key: string): Promise<void>
clear(): Promise<void>
keys(): Promise<string[]>
}

/**
* IndexedDB 存储实现
*/
export class IndexedDBStorage implements OfflineStorage {
private dbName: string
private storeName: string
private version: number
private db: IDBDatabase | null = null

constructor(
dbName = 'vue-mobile-app',
storeName = 'offline-data',
version = 1
) {
this.dbName = dbName
this.storeName = storeName
this.version = version
}

/**
* 初始化数据库
*/
private async initDB(): Promise<IDBDatabase> {
if (this.db) {
return this.db
}

return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)

request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve(this.db)
}

request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result

if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'key' })
}
}
})
}

/**
* 获取数据
*/
async get<T>(key: string): Promise<T | null> {
try {
const db = await this.initDB()
const transaction = db.transaction([this.storeName], 'readonly')
const store = transaction.objectStore(this.storeName)

return new Promise((resolve, reject) => {
const request = store.get(key)

request.onerror = () => reject(request.error)
request.onsuccess = () => {
const result = request.result
resolve(result ? result.value : null)
}
})
} catch (error) {
console.error('IndexedDB get error:', error)
return null
}
}

/**
* 存储数据
*/
async set<T>(key: string, value: T): Promise<void> {
try {
const db = await this.initDB()
const transaction = db.transaction([this.storeName], 'readwrite')
const store = transaction.objectStore(this.storeName)

return new Promise((resolve, reject) => {
const request = store.put({
key,
value,
timestamp: Date.now()
})

request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
})
} catch (error) {
console.error('IndexedDB set error:', error)
throw error
}
}

/**
* 删除数据
*/
async remove(key: string): Promise<void> {
try {
const db = await this.initDB()
const transaction = db.transaction([this.storeName], 'readwrite')
const store = transaction.objectStore(this.storeName)

return new Promise((resolve, reject) => {
const request = store.delete(key)

request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
})
} catch (error) {
console.error('IndexedDB remove error:', error)
throw error
}
}

/**
* 清空数据
*/
async clear(): Promise<void> {
try {
const db = await this.initDB()
const transaction = db.transaction([this.storeName], 'readwrite')
const store = transaction.objectStore(this.storeName)

return new Promise((resolve, reject) => {
const request = store.clear()

request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
})
} catch (error) {
console.error('IndexedDB clear error:', error)
throw error
}
}

/**
* 获取所有键
*/
async keys(): Promise<string[]> {
try {
const db = await this.initDB()
const transaction = db.transaction([this.storeName], 'readonly')
const store = transaction.objectStore(this.storeName)

return new Promise((resolve, reject) => {
const request = store.getAllKeys()

request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result as string[])
})
} catch (error) {
console.error('IndexedDB keys error:', error)
return []
}
}
}

/**
* 离线功能 Hook
*/
export function useOffline() {
const storage = new IndexedDBStorage()
const isOnline = ref(navigator.onLine)
const syncQueue = ref<Array<{
id: string
action: 'create' | 'update' | 'delete'
data: any
timestamp: number
}>>([])

/**
* 监听网络状态
*/
const setupNetworkListener = () => {
const updateOnlineStatus = () => {
isOnline.value = navigator.onLine

if (isOnline.value) {
syncOfflineData()
}
}

window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)

onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
})
}

/**
* 缓存数据
*/
const cacheData = async <T>(key: string, data: T): Promise<void> => {
try {
await storage.set(key, data)
} catch (error) {
console.error('Failed to cache data:', error)
}
}

/**
* 获取缓存数据
*/
const getCachedData = async <T>(key: string): Promise<T | null> => {
try {
return await storage.get<T>(key)
} catch (error) {
console.error('Failed to get cached data:', error)
return null
}
}

/**
* 添加到同步队列
*/
const addToSyncQueue = async (item: {
id: string
action: 'create' | 'update' | 'delete'
data: any
}): Promise<void> => {
const queueItem = {
...item,
timestamp: Date.now()
}

syncQueue.value.push(queueItem)

// 持久化同步队列
await storage.set('sync-queue', syncQueue.value)
}

/**
* 同步离线数据
*/
const syncOfflineData = async (): Promise<void> => {
if (!isOnline.value || syncQueue.value.length === 0) {
return
}

const queue = [...syncQueue.value]

for (const item of queue) {
try {
// 这里实现具体的同步逻辑
await syncItem(item)

// 从队列中移除已同步的项
const index = syncQueue.value.findIndex(q => q.id === item.id)
if (index > -1) {
syncQueue.value.splice(index, 1)
}
} catch (error) {
console.error('Failed to sync item:', item, error)
}
}

// 更新持久化队列
await storage.set('sync-queue', syncQueue.value)
}

/**
* 同步单个项目
*/
const syncItem = async (item: {
id: string
action: 'create' | 'update' | 'delete'
data: any
timestamp: number
}): Promise<void> => {
// 这里根据具体业务实现同步逻辑
const { action, data } = item

switch (action) {
case 'create':
// 发送创建请求
break
case 'update':
// 发送更新请求
break
case 'delete':
// 发送删除请求
break
}
}

/**
* 加载同步队列
*/
const loadSyncQueue = async (): Promise<void> => {
try {
const queue = await storage.get<typeof syncQueue.value>('sync-queue')
if (queue) {
syncQueue.value = queue
}
} catch (error) {
console.error('Failed to load sync queue:', error)
}
}

/**
* 清理过期缓存
*/
const cleanupExpiredCache = async (maxAge = 7 * 24 * 60 * 60 * 1000): Promise<void> => {
try {
const keys = await storage.keys()
const now = Date.now()

for (const key of keys) {
const item = await storage.get<{ timestamp: number }>(key)

if (item && item.timestamp && (now - item.timestamp) > maxAge) {
await storage.remove(key)
}
}
} catch (error) {
console.error('Failed to cleanup expired cache:', error)
}
}

// 初始化
onMounted(() => {
setupNetworkListener()
loadSyncQueue()

// 定期清理过期缓存
const cleanupInterval = setInterval(cleanupExpiredCache, 60 * 60 * 1000) // 每小时

onUnmounted(() => {
clearInterval(cleanupInterval)
})
})

return {
// 状态
isOnline: readonly(isOnline),
syncQueue: readonly(syncQueue),

// 方法
cacheData,
getCachedData,
addToSyncQueue,
syncOfflineData,
cleanupExpiredCache
}
}

Ionic + Capacitor 混合应用开发

1. 项目配置

Ionic Vue 项目设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { IonicVue } from '@ionic/vue'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import routes from './router/routes'

// Ionic CSS
import '@ionic/vue/css/core.css'
import '@ionic/vue/css/normalize.css'
import '@ionic/vue/css/structure.css'
import '@ionic/vue/css/typography.css'
import '@ionic/vue/css/utilities.css'
import '@ionic/vue/css/flex-utils.css'
import '@ionic/vue/css/display.css'

// 主题变量
import './theme/variables.css'

// 创建路由
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})

// 创建应用
const app = createApp(App)
.use(IonicVue)
.use(createPinia())
.use(router)

// 路由准备就绪后挂载
router.isReady().then(() => {
app.mount('#app')
})

Capacitor 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// capacitor.config.ts
import { CapacitorConfig } from '@capacitor/cli'

const config: CapacitorConfig = {
appId: 'com.example.vue3mobile',
appName: 'Vue 3 Mobile App',
webDir: 'dist',
server: {
androidScheme: 'https'
},
plugins: {
SplashScreen: {
launchShowDuration: 2000,
launchAutoHide: true,
backgroundColor: '#4f46e5',
androidSplashResourceName: 'splash',
androidScaleType: 'CENTER_CROP',
showSpinner: false,
androidSpinnerStyle: 'large',
iosSpinnerStyle: 'small',
spinnerColor: '#ffffff',
splashFullScreen: true,
splashImmersive: true
},
StatusBar: {
style: 'DARK',
backgroundColor: '#4f46e5'
},
Keyboard: {
resize: 'body',
style: 'DARK',
resizeOnFullScreen: true
},
Camera: {
permissions: {
camera: 'Camera access is required for taking photos',
photos: 'Photo library access is required for selecting images'
}
},
Geolocation: {
permissions: {
location: 'Location access is required for this feature'
}
},
PushNotifications: {
presentationOptions: ['badge', 'sound', 'alert']
}
}
}

export default config

2. 原生功能集成

设备功能访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
// composables/useNativeFeatures.ts
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'
import { Geolocation } from '@capacitor/geolocation'
import { Device } from '@capacitor/device'
import { StatusBar, Style } from '@capacitor/status-bar'
import { Haptics, ImpactStyle } from '@capacitor/haptics'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Share } from '@capacitor/share'
import { Filesystem, Directory } from '@capacitor/filesystem'

/**
* 原生功能 Hook
*/
export function useNativeFeatures() {
/**
* 拍照或选择图片
*/
const takePicture = async (options: {
source?: CameraSource
quality?: number
allowEditing?: boolean
} = {}): Promise<string | null> => {
try {
const image = await Camera.getPhoto({
quality: options.quality || 90,
allowEditing: options.allowEditing || false,
resultType: CameraResultType.DataUrl,
source: options.source || CameraSource.Prompt
})

return image.dataUrl || null
} catch (error) {
console.error('Camera error:', error)
return null
}
}

/**
* 获取当前位置
*/
const getCurrentPosition = async (options: {
enableHighAccuracy?: boolean
timeout?: number
} = {}): Promise<{
latitude: number
longitude: number
accuracy: number
} | null> => {
try {
const coordinates = await Geolocation.getCurrentPosition({
enableHighAccuracy: options.enableHighAccuracy || true,
timeout: options.timeout || 10000
})

return {
latitude: coordinates.coords.latitude,
longitude: coordinates.coords.longitude,
accuracy: coordinates.coords.accuracy
}
} catch (error) {
console.error('Geolocation error:', error)
return null
}
}

/**
* 监听位置变化
*/
const watchPosition = (callback: (position: {
latitude: number
longitude: number
accuracy: number
}) => void): string => {
return Geolocation.watchPosition({
enableHighAccuracy: true,
timeout: 10000
}, (position, err) => {
if (err) {
console.error('Position watch error:', err)
return
}

if (position) {
callback({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy
})
}
})
}

/**
* 清除位置监听
*/
const clearWatch = (watchId: string): void => {
Geolocation.clearWatch({ id: watchId })
}

/**
* 获取设备信息
*/
const getDeviceInfo = async () => {
try {
const info = await Device.getInfo()
return {
platform: info.platform,
model: info.model,
operatingSystem: info.operatingSystem,
osVersion: info.osVersion,
manufacturer: info.manufacturer,
isVirtual: info.isVirtual,
webViewVersion: info.webViewVersion
}
} catch (error) {
console.error('Device info error:', error)
return null
}
}

/**
* 设置状态栏样式
*/
const setStatusBarStyle = async (style: 'LIGHT' | 'DARK'): Promise<void> => {
try {
await StatusBar.setStyle({
style: style === 'LIGHT' ? Style.Light : Style.Dark
})
} catch (error) {
console.error('Status bar style error:', error)
}
}

/**
* 触发震动反馈
*/
const triggerHaptic = async (style: 'LIGHT' | 'MEDIUM' | 'HEAVY' = 'MEDIUM'): Promise<void> => {
try {
const impactStyle = {
LIGHT: ImpactStyle.Light,
MEDIUM: ImpactStyle.Medium,
HEAVY: ImpactStyle.Heavy
}[style]

await Haptics.impact({ style: impactStyle })
} catch (error) {
console.error('Haptic feedback error:', error)
}
}

/**
* 发送本地通知
*/
const sendLocalNotification = async (options: {
title: string
body: string
id?: number
schedule?: Date
}): Promise<void> => {
try {
await LocalNotifications.schedule({
notifications: [
{
title: options.title,
body: options.body,
id: options.id || Date.now(),
schedule: options.schedule ? { at: options.schedule } : undefined
}
]
})
} catch (error) {
console.error('Local notification error:', error)
}
}

/**
* 分享内容
*/
const shareContent = async (options: {
title?: string
text?: string
url?: string
dialogTitle?: string
}): Promise<void> => {
try {
await Share.share({
title: options.title,
text: options.text,
url: options.url,
dialogTitle: options.dialogTitle || '分享到'
})
} catch (error) {
console.error('Share error:', error)
}
}

/**
* 保存文件
*/
const saveFile = async (options: {
data: string
path: string
directory?: Directory
}): Promise<boolean> => {
try {
await Filesystem.writeFile({
path: options.path,
data: options.data,
directory: options.directory || Directory.Documents
})

return true
} catch (error) {
console.error('File save error:', error)
return false
}
}

/**
* 读取文件
*/
const readFile = async (options: {
path: string
directory?: Directory
}): Promise<string | null> => {
try {
const result = await Filesystem.readFile({
path: options.path,
directory: options.directory || Directory.Documents
})

return result.data as string
} catch (error) {
console.error('File read error:', error)
return null
}
}

return {
// 相机功能
takePicture,

// 地理位置
getCurrentPosition,
watchPosition,
clearWatch,

// 设备信息
getDeviceInfo,

// UI 控制
setStatusBarStyle,
triggerHaptic,

// 通知
sendLocalNotification,

// 分享
shareContent,

// 文件系统
saveFile,
readFile
}
}

性能优化与最佳实践

1. 移动端性能优化

性能监控与优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
// utils/mobilePerformance.ts

/**
* 移动端性能监控器
*/
export class MobilePerformanceMonitor {
private metrics: Map<string, number> = new Map()
private observers: PerformanceObserver[] = []

/**
* 初始化性能监控
*/
init(): void {
this.observeResourceTiming()
this.observeUserTiming()
this.observeLongTasks()
this.observeMemoryUsage()
}

/**
* 监控资源加载时间
*/
private observeResourceTiming(): void {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()

entries.forEach((entry) => {
if (entry.entryType === 'resource') {
const resourceEntry = entry as PerformanceResourceTiming

// 记录关键资源加载时间
if (resourceEntry.name.includes('.js') ||
resourceEntry.name.includes('.css') ||
resourceEntry.name.includes('.woff')) {
this.metrics.set(
`resource_${resourceEntry.name.split('/').pop()}`,
resourceEntry.duration
)
}
}
})
})

observer.observe({ entryTypes: ['resource'] })
this.observers.push(observer)
}

/**
* 监控用户自定义时间
*/
private observeUserTiming(): void {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()

entries.forEach((entry) => {
this.metrics.set(entry.name, entry.duration || entry.startTime)
})
})

observer.observe({ entryTypes: ['measure', 'mark'] })
this.observers.push(observer)
}

/**
* 监控长任务
*/
private observeLongTasks(): void {
if ('PerformanceObserver' in window && 'PerformanceLongTaskTiming' in window) {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()

entries.forEach((entry) => {
console.warn(`Long task detected: ${entry.duration}ms`)
this.metrics.set('long_task_count',
(this.metrics.get('long_task_count') || 0) + 1
)
})
})

observer.observe({ entryTypes: ['longtask'] })
this.observers.push(observer)
}
}

/**
* 监控内存使用
*/
private observeMemoryUsage(): void {
if ('memory' in performance) {
const memory = (performance as any).memory

setInterval(() => {
this.metrics.set('memory_used', memory.usedJSHeapSize)
this.metrics.set('memory_total', memory.totalJSHeapSize)
this.metrics.set('memory_limit', memory.jsHeapSizeLimit)
}, 5000)
}
}

/**
* 标记时间点
*/
mark(name: string): void {
performance.mark(name)
}

/**
* 测量时间间隔
*/
measure(name: string, startMark: string, endMark?: string): void {
if (endMark) {
performance.measure(name, startMark, endMark)
} else {
performance.measure(name, startMark)
}
}

/**
* 获取性能指标
*/
getMetrics(): Record<string, number> {
return Object.fromEntries(this.metrics)
}

/**
* 获取关键性能指标
*/
getVitalMetrics(): {
fcp: number
lcp: number
fid: number
cls: number
ttfb: number
} {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming

return {
fcp: this.metrics.get('first-contentful-paint') || 0,
lcp: this.metrics.get('largest-contentful-paint') || 0,
fid: this.metrics.get('first-input-delay') || 0,
cls: this.metrics.get('cumulative-layout-shift') || 0,
ttfb: navigation ? navigation.responseStart - navigation.requestStart : 0
}
}

/**
* 清理监控器
*/
destroy(): void {
this.observers.forEach(observer => observer.disconnect())
this.observers = []
this.metrics.clear()
}
}

/**
* 移动端优化工具
*/
export class MobileOptimizer {
/**
* 图片懒加载
*/
static setupImageLazyLoading(): void {
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement
const src = img.dataset.src

if (src) {
img.src = src
img.removeAttribute('data-src')
imageObserver.unobserve(img)
}
}
})
})

document.querySelectorAll('img[data-src]').forEach((img) => {
imageObserver.observe(img)
})
}
}

/**
* 预加载关键资源
*/
static preloadCriticalResources(resources: string[]): void {
resources.forEach((resource) => {
const link = document.createElement('link')
link.rel = 'preload'
link.href = resource

if (resource.endsWith('.js')) {
link.as = 'script'
} else if (resource.endsWith('.css')) {
link.as = 'style'
} else if (resource.match(/\.(woff|woff2)$/)) {
link.as = 'font'
link.crossOrigin = 'anonymous'
}

document.head.appendChild(link)
})
}

/**
* 优化滚动性能
*/
static optimizeScrolling(): void {
// 使用 passive 事件监听器
const passiveSupported = this.checkPassiveSupport()

if (passiveSupported) {
document.addEventListener('touchstart', () => {}, { passive: true })
document.addEventListener('touchmove', () => {}, { passive: true })
document.addEventListener('wheel', () => {}, { passive: true })
}

// 添加 CSS 优化
const style = document.createElement('style')
style.textContent = `
* {
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}

.scroll-container {
transform: translateZ(0);
will-change: scroll-position;
}
`
document.head.appendChild(style)
}

/**
* 检查 passive 事件支持
*/
private static checkPassiveSupport(): boolean {
let passiveSupported = false

try {
const options = {
get passive() {
passiveSupported = true
return false
}
}

window.addEventListener('test', () => {}, options)
window.removeEventListener('test', () => {}, options)
} catch (err) {
passiveSupported = false
}

return passiveSupported
}

/**
* 减少重排重绘
*/
static reduceReflowRepaint(): void {
// 批量 DOM 操作
const fragment = document.createDocumentFragment()

// 使用 CSS containment
const style = document.createElement('style')
style.textContent = `
.contain-layout {
contain: layout;
}

.contain-paint {
contain: paint;
}

.contain-strict {
contain: strict;
}
`
document.head.appendChild(style)
}

/**
* 内存优化
*/
static optimizeMemory(): void {
// 清理定时器
const intervals: number[] = []
const timeouts: number[] = []

const originalSetInterval = window.setInterval
const originalSetTimeout = window.setTimeout

window.setInterval = function(callback, delay) {
const id = originalSetInterval(callback, delay)
intervals.push(id)
return id
}

window.setTimeout = function(callback, delay) {
const id = originalSetTimeout(callback, delay)
timeouts.push(id)
return id
}

// 页面卸载时清理
window.addEventListener('beforeunload', () => {
intervals.forEach(id => clearInterval(id))
timeouts.forEach(id => clearTimeout(id))
})
}
}

/**
* 移动端手势处理
*/
export class MobileGestureHandler {
private element: HTMLElement
private startX = 0
private startY = 0
private currentX = 0
private currentY = 0
private isDragging = false

constructor(element: HTMLElement) {
this.element = element
this.setupEventListeners()
}

/**
* 设置事件监听器
*/
private setupEventListeners(): void {
// 触摸事件
this.element.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false })
this.element.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false })
this.element.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: false })

// 鼠标事件(用于桌面测试)
this.element.addEventListener('mousedown', this.handleMouseDown.bind(this))
this.element.addEventListener('mousemove', this.handleMouseMove.bind(this))
this.element.addEventListener('mouseup', this.handleMouseUp.bind(this))
}

/**
* 处理触摸开始
*/
private handleTouchStart(event: TouchEvent): void {
const touch = event.touches[0]
this.startX = touch.clientX
this.startY = touch.clientY
this.currentX = touch.clientX
this.currentY = touch.clientY
this.isDragging = true

this.onGestureStart?.({
x: this.startX,
y: this.startY,
type: 'touch'
})
}

/**
* 处理触摸移动
*/
private handleTouchMove(event: TouchEvent): void {
if (!this.isDragging) return

const touch = event.touches[0]
this.currentX = touch.clientX
this.currentY = touch.clientY

const deltaX = this.currentX - this.startX
const deltaY = this.currentY - this.startY

this.onGestureMove?.({
x: this.currentX,
y: this.currentY,
deltaX,
deltaY,
type: 'touch'
})

// 检测滑动方向
if (Math.abs(deltaX) > 50 || Math.abs(deltaY) > 50) {
const direction = this.getSwipeDirection(deltaX, deltaY)
this.onSwipe?.(direction, { deltaX, deltaY })
}
}

/**
* 处理触摸结束
*/
private handleTouchEnd(event: TouchEvent): void {
this.isDragging = false

const deltaX = this.currentX - this.startX
const deltaY = this.currentY - this.startY

this.onGestureEnd?.({
x: this.currentX,
y: this.currentY,
deltaX,
deltaY,
type: 'touch'
})
}

/**
* 处理鼠标按下
*/
private handleMouseDown(event: MouseEvent): void {
this.startX = event.clientX
this.startY = event.clientY
this.currentX = event.clientX
this.currentY = event.clientY
this.isDragging = true

this.onGestureStart?.({
x: this.startX,
y: this.startY,
type: 'mouse'
})
}

/**
* 处理鼠标移动
*/
private handleMouseMove(event: MouseEvent): void {
if (!this.isDragging) return

this.currentX = event.clientX
this.currentY = event.clientY

const deltaX = this.currentX - this.startX
const deltaY = this.currentY - this.startY

this.onGestureMove?.({
x: this.currentX,
y: this.currentY,
deltaX,
deltaY,
type: 'mouse'
})
}

/**
* 处理鼠标释放
*/
private handleMouseUp(event: MouseEvent): void {
this.isDragging = false

const deltaX = this.currentX - this.startX
const deltaY = this.currentY - this.startY

this.onGestureEnd?.({
x: this.currentX,
y: this.currentY,
deltaX,
deltaY,
type: 'mouse'
})
}

/**
* 获取滑动方向
*/
private getSwipeDirection(deltaX: number, deltaY: number): 'left' | 'right' | 'up' | 'down' {
if (Math.abs(deltaX) > Math.abs(deltaY)) {
return deltaX > 0 ? 'right' : 'left'
} else {
return deltaY > 0 ? 'down' : 'up'
}
}

// 事件回调
onGestureStart?: (data: { x: number; y: number; type: string }) => void
onGestureMove?: (data: { x: number; y: number; deltaX: number; deltaY: number; type: string }) => void
onGestureEnd?: (data: { x: number; y: number; deltaX: number; deltaY: number; type: string }) => void
onSwipe?: (direction: 'left' | 'right' | 'up' | 'down', data: { deltaX: number; deltaY: number }) => void

/**
* 销毁手势处理器
*/
destroy(): void {
this.element.removeEventListener('touchstart', this.handleTouchStart.bind(this))
this.element.removeEventListener('touchmove', this.handleTouchMove.bind(this))
this.element.removeEventListener('touchend', this.handleTouchEnd.bind(this))
this.element.removeEventListener('mousedown', this.handleMouseDown.bind(this))
this.element.removeEventListener('mousemove', this.handleMouseMove.bind(this))
this.element.removeEventListener('mouseup', this.handleMouseUp.bind(this))
}
}

2. 移动端 UI 组件

移动端优化组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
<!-- components/MobileOptimized/VirtualList.vue -->
<template>
<div
ref="containerRef"
class="virtual-list"
@scroll="handleScroll"
>
<div
class="virtual-list-phantom"
:style="{ height: totalHeight + 'px' }"
></div>

<div
class="virtual-list-content"
:style="{
transform: `translateY(${offsetY}px)`
}"
>
<div
v-for="item in visibleItems"
:key="item.id"
class="virtual-list-item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="item.index"></slot>
</div>
</div>
</div>
</template>

<script setup lang="ts">
interface ListItem {
id: string | number
index: number
[key: string]: any
}

interface Props {
items: any[]
itemHeight: number
containerHeight: number
buffer?: number
}

const props = withDefaults(defineProps<Props>(), {
buffer: 5
})

const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)

// 计算总高度
const totalHeight = computed(() => props.items.length * props.itemHeight)

// 计算可见区域的起始和结束索引
const visibleRange = computed(() => {
const start = Math.floor(scrollTop.value / props.itemHeight)
const end = Math.min(
start + Math.ceil(props.containerHeight / props.itemHeight),
props.items.length
)

return {
start: Math.max(0, start - props.buffer),
end: Math.min(props.items.length, end + props.buffer)
}
})

// 可见项目
const visibleItems = computed(() => {
const { start, end } = visibleRange.value
return props.items.slice(start, end).map((item, index) => ({
...item,
id: item.id || start + index,
index: start + index
}))
})

// 偏移量
const offsetY = computed(() => {
return visibleRange.value.start * props.itemHeight
})

/**
* 处理滚动事件
*/
const handleScroll = throttle((event: Event) => {
const target = event.target as HTMLElement
scrollTop.value = target.scrollTop
}, 16)

/**
* 节流函数
*/
function throttle<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
let previous = 0

return (...args: Parameters<T>) => {
const now = Date.now()
const remaining = wait - (now - previous)

if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
func(...args)
} else if (!timeout) {
timeout = setTimeout(() => {
previous = Date.now()
timeout = null
func(...args)
}, remaining)
}
}
}

/**
* 滚动到指定项目
*/
const scrollToItem = (index: number): void => {
if (containerRef.value) {
const targetScrollTop = index * props.itemHeight
containerRef.value.scrollTop = targetScrollTop
}
}

/**
* 滚动到顶部
*/
const scrollToTop = (): void => {
scrollToItem(0)
}

/**
* 滚动到底部
*/
const scrollToBottom = (): void => {
scrollToItem(props.items.length - 1)
}

defineExpose({
scrollToItem,
scrollToTop,
scrollToBottom
})
</script>

<style scoped>
.virtual-list {
position: relative;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}

.virtual-list-phantom {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1;
}

.virtual-list-content {
position: absolute;
top: 0;
left: 0;
right: 0;
}

.virtual-list-item {
box-sizing: border-box;
}
</style>

下拉刷新组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
<!-- components/MobileOptimized/PullToRefresh.vue -->
<template>
<div
ref="containerRef"
class="pull-to-refresh"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<div
class="pull-to-refresh-indicator"
:style="{
transform: `translateY(${indicatorOffset}px)`,
opacity: indicatorOpacity
}"
>
<div class="indicator-content">
<div v-if="status === 'pulling'" class="pulling-text">
{{ pullText }}
</div>
<div v-else-if="status === 'releasing'" class="releasing-text">
{{ releaseText }}
</div>
<div v-else-if="status === 'refreshing'" class="refreshing-spinner">
<div class="spinner"></div>
{{ refreshingText }}
</div>
</div>
</div>

<div
class="pull-to-refresh-content"
:style="{
transform: `translateY(${contentOffset}px)`,
transition: isTransitioning ? 'transform 0.3s ease' : 'none'
}"
>
<slot></slot>
</div>
</div>
</template>

<script setup lang="ts">
type RefreshStatus = 'idle' | 'pulling' | 'releasing' | 'refreshing'

interface Props {
pullText?: string
releaseText?: string
refreshingText?: string
threshold?: number
disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
pullText: '下拉刷新',
releaseText: '释放刷新',
refreshingText: '刷新中...',
threshold: 60,
disabled: false
})

const emit = defineEmits<{
refresh: []
}>()

const containerRef = ref<HTMLElement>()
const status = ref<RefreshStatus>('idle')
const startY = ref(0)
const currentY = ref(0)
const distance = ref(0)
const isTransitioning = ref(false)

// 指示器偏移量
const indicatorOffset = computed(() => {
return Math.max(0, distance.value - props.threshold)
})

// 指示器透明度
const indicatorOpacity = computed(() => {
return Math.min(1, distance.value / props.threshold)
})

// 内容偏移量
const contentOffset = computed(() => {
if (status.value === 'refreshing') {
return props.threshold
}
return Math.max(0, distance.value)
})

/**
* 处理触摸开始
*/
const handleTouchStart = (event: TouchEvent): void => {
if (props.disabled || status.value === 'refreshing') {
return
}

const touch = event.touches[0]
startY.value = touch.clientY
currentY.value = touch.clientY
isTransitioning.value = false
}

/**
* 处理触摸移动
*/
const handleTouchMove = (event: TouchEvent): void => {
if (props.disabled || status.value === 'refreshing') {
return
}

const touch = event.touches[0]
currentY.value = touch.clientY

const deltaY = currentY.value - startY.value

// 只有在顶部且向下拉时才触发
if (deltaY > 0 && isAtTop()) {
event.preventDefault()

// 使用阻尼效果
distance.value = deltaY * 0.5

if (distance.value >= props.threshold) {
status.value = 'releasing'
} else {
status.value = 'pulling'
}
}
}

/**
* 处理触摸结束
*/
const handleTouchEnd = (): void => {
if (props.disabled || status.value === 'refreshing') {
return
}

isTransitioning.value = true

if (status.value === 'releasing' && distance.value >= props.threshold) {
status.value = 'refreshing'
emit('refresh')
} else {
status.value = 'idle'
distance.value = 0
}
}

/**
* 检查是否在顶部
*/
const isAtTop = (): boolean => {
if (!containerRef.value) return false
return containerRef.value.scrollTop === 0
}

/**
* 完成刷新
*/
const finishRefresh = (): void => {
status.value = 'idle'
distance.value = 0
isTransitioning.value = true

setTimeout(() => {
isTransitioning.value = false
}, 300)
}

defineExpose({
finishRefresh
})
</script>

<style scoped>
.pull-to-refresh {
position: relative;
overflow: hidden;
}

.pull-to-refresh-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
z-index: 1;
transform: translateY(-100%);
}

.indicator-content {
display: flex;
align-items: center;
gap: 8px;
color: #666;
font-size: 14px;
}

.spinner {
width: 16px;
height: 16px;
border: 2px solid #e0e0e0;
border-top: 2px solid #4f46e5;
border-radius: 50%;
animation: spin 1s linear infinite;
}

@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

.pull-to-refresh-content {
position: relative;
z-index: 2;
}
</style>

总结

Vue 3 移动端开发提供了丰富的解决方案,从 PWA 到原生应用,每种方案都有其适用场景:

技术选型建议

  1. PWA 适用场景:内容展示、电商、新闻媒体等轻量级应用
  2. 混合应用适用场景:需要设备功能但开发成本有限的中等复杂度应用
  3. 跨平台应用适用场景:高性能要求、复杂业务逻辑的企业级应用

性能优化要点

  1. 响应式设计:使用断点系统和设备适配
  2. 资源优化:图片懒加载、代码分割、缓存策略
  3. 交互优化:手势处理、虚拟滚动、下拉刷新
  4. 内存管理:及时清理定时器、优化 DOM 操作

最佳实践

  1. 渐进增强:从基础功能开始,逐步添加高级特性
  2. 性能监控:实时监控关键性能指标
  3. 用户体验:注重加载状态、错误处理、离线体验
  4. 测试策略:多设备测试、性能测试、用户体验测试

通过合理的技术选型和优化策略,Vue 3 能够为移动端开发提供出色的开发体验和用户体验。

本站由 提供部署服务