喵吱App 从零开发实战经验 — Vue 3 + Vant 4 + Capacitor 全栈实录

喵吱App 从零开发实战经验

Vue 3 + Vant 4 + Capacitor 全栈移动应用开发实录

基于实际项目 /Users/ann/Desktop/miaozhi-app 真实代码v1.8.0 · 2026年6月

目录

一、项目概述

二、技术栈选型

三、项目初始化与工程搭建

四、前端架构设计

五、状态管理(Pinia)实践

六、组件设计与复用

七、路由与页面导航

八、API 层封装(Axios 拦截器)

九、热更新机制(CapacitorUpdater)

十、SVG 图标系统

十一、构建与部署

十二、踩坑记录与经验总结

一、项目概述

喵吱App 是一款知识卡片分享应用,涵盖知识点学习、社区交流、积分成长体系等功能。项目采用前后端分离架构,前端为 Vue 3 单页应用(SPA),后端为 Express.js + MySQL。应用同时支持 Web 浏览器(PWA)和原生移动端(Android/iOS,通过 Capacitor 封装)。

以下从实际代码出发,完整记录从零搭建该项目的技术决策、实现细节和踩坑经验。

项目基本信息(取自 package.json 和 config.js):

// package.json(真实)
{
"name": "miaozhi-app",
"version": "1.8.0",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"build:apk": "npm run build && cd android && ./gradlew assembleRelease"
}
}
// src/config.js(真实)
// APP_VERSION 由 Vite define 从 package.json 自动注入
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0-dev'

二、技术栈选型

项目的依赖选择基于实际需求,以下是 package.json 中的关键依赖及其选型理由:

前端核心:

Vue 3.4 + Composition API() — 函数式组件编写方式,Tree-shaking 友好

Vant 4(移动端 UI 库) — 专为移动端设计的组件库,支持按需引入,适合微信风格 UI

Pinia 2.1(状态管理) — Vue 官方推荐的状态管理方案,比 Vuex 更轻量,完整的 TypeScript 支持

Vue Router 4(Hash 路由) — SPA 路由,hash 模式避免服务器配置问题

Axios(HTTP 客户端) — 拦截器机制强大,支持请求/响应拦截,适合统一处理 Token 刷新和错误

跨平台移动端:

@capacitor/core ^6.2.1 — 将 Web 应用包装为原生 App 的核心库,提供原生 API 调用能力

@capgo/capacitor-updater ^6.45.10 — 热更新方案,允许不经过应用商店直接更新 Web 资源

后端:

Express.js — Node.js 最成熟的 Web 框架,中间件生态完善

mysql2/promise — MySQL 数据库驱动,支持 Promise 和连接池

jsonwebtoken + bcryptjs — JWT 认证 + 密码哈希

构建工具:

Vite 5 — 极速冷启动和 HMR,Rollup 生产打包

三、项目初始化与工程搭建

项目使用 Vite 创建 Vue 3 项目模板,然后逐步集成各项依赖。以下是实际的项目配置文件:

3.1 Vite 配置

实际 vite.config.js(取自真实项目文件):

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import { copyFileSync, writeFileSync, existsSync } from 'fs'
export default defineConfig({
plugins: [
vue(),
{
name: 'copy-capacitor-config',
closeBundle() {
// 构建完成后复制 capacitor.config.json 到 dist/
if (existsSync('capacitor.config.json')) {
copyFileSync('capacitor.config.json', 'dist/capacitor.config.json')
}
// 生成 version.json(CapacitorUpdater 校验 bundle 版本需要)
const versionInfo = {
version: APP_VERSION,
timestamp: new Date().toISOString(),
app_id: 'com.miaozhi.app'
}
writeFileSync('dist/version.json', JSON.stringify(versionInfo, null, 2))
}
}
],
resolve: {
alias: { '@': resolve(__dirname, 'src') }
},
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api': { target: 'http://localhost:3001', changeOrigin: true },
'/bundle': { target: 'http://localhost:3001', changeOrigin: true }
}
}
})
关键点说明:
自定义 closeBundle 插件:在构建完成后自动复制 capacitor.config.json 和生成 version.json。这是 CapacitorUpdater 校验 bundle 版本的必需文件
@ 路径别名:避免深层相对路径引用(如 ../../../utils/request)
开发代理:将 /api 和 /bundle 请求代理到后端 3001 端口,跨域问题在开发环境解决
3.2 Capacitor 配置
实际 capacitor.config.json:
{
"appId": "com.miaozhi.app",
"appName": "喵吱",
"webDir": "dist",
"server": {
"androidScheme": "https",
"iosScheme": "capacitor"
},
"plugins": {
"CapacitorUpdater": {
"autoUpdate": true,
"appId": "com.miaozhi.app"
}
}
}
注意 webDir 指向 dist 目录,构建后的产物直接作为 Capacitor 的 Web 资源。

四、前端架构设计

项目的 src/ 目录结构按照功能模块划分,以下是真实目录结构:

src/

├── main.js # 应用入口

├── config.js # APP_VERSION 常量

├── App.vue # 根组件(更新弹窗、隐私协议、下载横幅)

├── router/index.js # 路由配置(17 个页面)

├── api/ # API 接口(7 个模块)

│ ├── community.js # 社区/帖子

│ ├── knowledge.js # 知识卡片

│ ├── checkin.js # 签到

│ ├── favorites.js # 收藏

│ ├── notifications.js # 通知

│ ├── points.js # 积分

│ └── preferences.js # 偏好设置

├── components/ # 公共组件

│ ├── DefaultAvatar.vue

│ └── BackToTop.vue

├── composables/ # 组合式函数

│ ├── useAppUpdate.js # 版本更新

│ ├── useNative.js # 原生能力

│ └── useShare.js # 分享

├── store/ # Pinia 状态管理

│ ├── user.js # 用户状态

│ ├── points.js # 积分/等级

│ ├── favorites.js # 收藏

│ └── studyPlan.js # 学科领域数据

├── styles/

│ ├── global.css # 全局样式

│ └── icons.js # SVG 图标注入

├── utils/ # 工具函数

│ ├── request.js # Axios 实例

│ ├── avatar.js # 头像工具

│ ├── auth.js # 认证持久化

│ ├── helpers.js # 通用函数

│ ├── imageCompress.js # 图片压缩

│ └── navigation.js # 导航映射

└── views/ # 页面组件(17 个)

架构设计原则:

关注点分离:页面(views)只负责组合,业务逻辑在 composables 和 stores 中,API 调用在 api/ 中

组件复用:公共 UI 组件放在 components/,如 DefaultAvatar 被 8 个页面共享

单向数据流:页面 → 用户操作 → API 调用 → Store 更新 → 视图响应

4.1 应用入口(main.js)

实际 main.js:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import Vant from 'vant'
import 'vant/lib/index.css'
import './styles/icons.js'
import './styles/global.css'
import { initStatusBar, hideSplashScreen, hapticLight } from './composables/useNative.js'
import { APP_VERSION } from './config.js'
// ⚠️ 必须 import 插件,触发 registerPlugin()
import '@capgo/capacitor-updater'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(Vant)
app.mount('#app')
// 原生平台初始化
initStatusBar().then(() => hideSplashScreen())
// 路由切换触觉反馈
router.afterEach((to, from) => {
if (from.name) hapticLight()
})
// 启动时检查版本变化,清除过期 dismiss 记录
setTimeout(() => {
try {
const dismissed = localStorage.getItem('miaozhi_update_dismissed')
if (dismissed) {
const record = JSON.parse(dismissed)
if (record.version && record.version !== APP_VERSION) {
localStorage.removeItem('miaozhi_update_dismissed')
}
}
} catch (e) {}
}, 1000)
// 注册 Service Worker(PWA 离线缓存)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(err => {
console.warn('SW registration failed:', err)
})
})
}
⚠️ 关键踩坑:@capgo/capacitor-updater 必须通过 import 显式导入以触发 registerPlugin()。如果只写在 main.js 中而不 import,window.Capacitor.Plugins.CapacitorUpdater 会是 undefined,热更新完全无法工作。

五、状态管理(Pinia)实践

项目使用 Pinia 的 setup store 语法(Composition API 风格),共 4 个 store。

5.1 User Store(用户认证)

实际 store/user.js:

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { saveAuth, getAuthUser, logout as authLogout } from '../utils/auth.js'
import request from '../utils/request.js'
export const useUserStore = defineStore('user', () => {
const user = ref(getAuthUser())   // 从 localStorage 恢复
const token = ref(null)
const refreshToken = ref(null)
const isLoggedIn = computed(() => !!user.value)
async function login(username, password) {
const res = await request({
url: '/auth/login',
method: 'post',
data: { username, password }
})
if (res.code === 0) {
saveAuth(res.result)
user.value = res.result.user
token.value = res.result.token
refreshToken.value = res.result.refresh_token
}
return res
}
async function register(username, password) {
const res = await request({
url: '/auth/register',
method: 'post',
data: { username, password }
})
if (res.code === 0) {
saveAuth(res.result)
user.value = res.result.user
token.value = res.result.token
refreshToken.value = res.result.refresh_token
}
return res
}
function logout() {
user.value = null
token.value = null
refreshToken.value = null
authLogout()
}
function updateUser(userData) {
user.value = userData
// 同步更新 localStorage
const auth = localStorage.getItem('tuanzi_auth')
if (auth) {
const authData = JSON.parse(auth)
authData.user = userData
localStorage.setItem('tuanzi_auth', JSON.stringify(authData))
}
}
return { user, token, isLoggedIn, login, register, logout, updateUser }
})
设计要点:
使用 getAuthUser() 从 localStorage 初始化,保证刷新后状态不丢失
login/register 成功后同时更新 Pinia 状态和 localStorage
updateUser 同时更新 store 和 localStorage,避免刷新后数据回滚
5.2 Points Store(积分等级系统)
项目的积分体系是一个完整的成长系统,共 6 个等级(取自 store/points.js 的常量定义):
// 等级定义(实际代码)
const LEVELS = [
{ name: '知识萌新', minPoints: 0 },
{ name: '知识学徒', minPoints: 100 },
{ name: '知识达人', minPoints: 300 },
{ name: '知识专家', minPoints: 600 },
{ name: '知识大师', minPoints: 1000 },
{ name: '知识传奇', minPoints: 2000 }
]
// computed 属性
const currentLevel = computed(() => { ... })
const nextLevel = computed(() => { ... })
const progressPercent = computed(() => { ... })
程序计算当前等级和进度百分比,驱动 UI 的等级徽章和进度条。

六、组件设计与复用

本节以 DefaultAvatar 组件为案例,展示从代码冗余到公共组件的重构过程。

6.1 背景:8 个页面的重复代码

最初,头像逻辑在 8 个页面中重复编写:每个页面都包含头像 img 标签、加载失败时的 SVG 回退、getAvatarUrl() 拼接、独立的 loadError 状态管理、独立的 .icon-svg CSS 定义。不仅代码重复,样式也不一致——列表页 36px 和详情页 80px 的 SVG 图标比例不同。

6.2 DefaultAvatar 公共组件

实际 components/DefaultAvatar.vue:

<div

class=”default-avatar”

:class=”[clickable ? ‘is-clickable’ : ”]”

:style=”avatarStyle”

@click=”$emit(‘click’)”

>

<img

v-if=”avatarSrc && !loadError”

:src=”avatarSrc”

class=”avatar-img”

@error=”loadError = true”

/>

<svg

v-if=”!avatarSrc || loadError”

class=”default-avatar-icon”

:fill=”iconFill”

:stroke=”iconStroke”

>

import { ref, computed, watch } from 'vue'
import { getAvatarUrl, getAvatarColor } from '../utils/avatar.js'
const props = defineProps({
avatar: { type: String, default: '' },
avatarColor: { type: String, default: '' },
username: { type: String, default: '' },
size: { type: [Number, String], default: 40 },
clickable: { type: Boolean, default: false },
variant: { type: String, default: 'default' }
})
defineEmits(['click'])
const loadError = ref(false)
watch(() => props.avatar, () => { loadError.value = false })
const avatarSrc = computed(() => getAvatarUrl(props.avatar))
const bgColor = computed(() => props.avatarColor || getAvatarColor(props.username))
const avatarStyle = computed(() => {
const sizePx = typeof props.size === 'number' ? props.size + 'px' : props.size
return {
width: sizePx,
height: sizePx,
backgroundColor: bgColor.value,
fontSize: sizePx    // SVG 的 em 基于此
}
})
const iconFill = computed(() => props.variant === 'gray' ? '#ccc' : 'none')
const iconStroke = computed(() => props.variant === 'gray' ? '#ccc' : 'currentColor')

组件的设计要点:
Props 驱动:外部传入 avatar 路径、用户名、尺寸、是否可点击、颜色变体
内部状态封装:loadError 在每个组件实例内部独立管理,不再污染页面
响应式重置:watch props.avatar 自动重置 loadError,无需页面手动设置
智能降级:有头像且加载成功 → 显示图片;无头像或加载失败 → 显示 SVG 默认图标
6.3 全局 CSS 合并
实际 styles/global.css(合并自 8 个页面的重复定义):
/* SVG 图标基类 */
.icon-svg {
display: inline-block;
width: 1em; height: 1em;
vertical-align: -0.1em;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
/* 头像通用 */
.default-avatar {
position: relative;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
color: white;
}
.default-avatar .avatar-img {
width: 100%; height: 100%;
object-fit: cover;
border-radius: 50%;
}
.default-avatar .default-avatar-icon {
width: 0.45em;    /* 关键比例:图标占容器的 0.45 */
height: 0.45em;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
⚠️ 关键踩坑:SVG 的 stroke 属性必须在 CSS 中显式设置。最初 .default-avatar-icon 缺少 stroke-width、stroke-linecap、stroke-linejoin,导致图标线条粗细不统一。另外图标比例 0.45em 是经验值——40px 容器显示 18px 图标、80px 容器显示 36px 图标,大小头像视觉一致。
6.4 头像 URL 工具
实际 utils/avatar.js:
const SERVER_BASE = 'https://app.ann.hi.cn'
const AVATAR_COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1',
'#96CEB4', '#DDA0DD', '#98D8C8', '#F7DC6F'
]
export function getAvatarUrl(avatar) {
if (!avatar) return ''
if (avatar.startsWith('http')) return avatar  // 第三方登录
const path = avatar.startsWith('/') ? avatar : '/' + avatar
return SERVER_BASE + path
}
export function getAvatarColor(username) {
if (!username) return '#667eea'
let hash = 0
for (let i = 0; i < username.length; i++) {
hash = username.charCodeAt(i) + ((hash << 5) - hash)
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]
}
getAvatarColor 使用 DJB2 哈希算法,确保同一用户名始终得到相同颜色,不同用户名大概率颜色不同。7 种颜色循环使用。

七、路由与页面导航

实际 router/index.js:共 17 个页面路由,使用 Hash 模式(createWebHashHistory),原因是在 Capacitor WebView 中无需服务器 URL 重写。

import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
{ path: '/', redirect: '/learn' },
{ path: '/learn', name: 'Learn', component: () => import('../views/Learn.vue'), meta: { direction: 'none' } },
{ path: '/community', name: 'Community', component: () => import('../views/Community.vue'), meta: { direction: 'slide-left' } },
{ path: '/knowledge', name: 'KnowledgeBase', component: () => import('../views/KnowledgeBase.vue') },
{ path: '/profile', name: 'Profile', component: () => import('../views/Profile.vue') },
{ path: '/edit-profile', name: 'EditProfile', component: () => import('../views/EditProfile.vue') },
{ path: '/auth', name: 'Auth', component: () => import('../views/Auth.vue') },
{ path: '/onboarding', name: 'Onboarding', component: () => import('../views/Onboarding.vue') },
{ path: '/create-card', name: 'CreateCard', component: () => import('../views/CreateCard.vue') },
{ path: '/my-posts', name: 'MyPosts', component: () => import('../views/MyPosts.vue') },
{ path: '/my-cards', name: 'MyCards', component: () => import('../views/MyCards.vue') },
{ path: '/points-log', name: 'PointsLog', component: () => import('../views/PointsLog.vue') },
{ path: '/edit-card/:id', name: 'EditCard', component: () => import('../views/CreateCard.vue') },
{ path: '/user/:id', name: 'UserProfile', component: () => import('../views/UserProfile.vue') },
{ path: '/notifications', name: 'Notifications', component: () => import('../views/Notifications.vue') },
{ path: '/preferences', name: 'Preferences', component: () => import('../views/Preferences.vue') },
{ path: '/privacy-policy', name: 'PrivacyPolicy', component: () => import('../views/PrivacyPolicy.vue') },
{ path: '/user-agreement', name: 'UserAgreement', component: () => import('../views/UserAgreement.vue') },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('../views/NotFound.vue') }
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
// 自动检测转场方向
router.beforeEach((to, from) => {
if (to.meta?.direction && to.meta.direction !== 'none') return
const toDepth = routeDepth(to.path)
const fromDepth = routeDepth(from.path)
to.meta.direction = toDepth >= fromDepth ? 'slide-left' : 'slide-right'
})
路由设计要点:
Hash 模式:避免在生产部署时需要配置服务器 URL 重写
懒加载:所有页面使用 () => import() 动态导入,按需加载代码块
自动转场方向:根据路由深度自动判断滑动方向(更深 → left,更浅 → right),实现原生 App 的导航感
编辑页复用:EditCard 路由重用 CreateCard 组件,通过路由参数区分新增/编辑

八、API 层封装(Axios 拦截器)

项目的 API 层基于 Axios,封装在 utils/request.js 中,是所有 HTTP 请求的统一入口。

8.1 请求拦截器

请求拦截器的工作:从 localStorage 读取 Token 注入 Authorization 头,并添加防篡改头部。

request.interceptors.request.use(config => {
// 1. 注入 Authorization
const authStr = localStorage.getItem('tuanzi_auth')
if (authStr) {
const parsed = JSON.parse(authStr)
if (parsed.token) {
config.headers.Authorization = `Bearer ${parsed.token}`
}
}
// 2. 防篡改头部
config.headers['X-Request-Timestamp'] = String(Date.now())
config.headers['X-Request-Nonce'] = crypto.randomUUID()
// 3. 平台标识
if (window.Capacitor?.isNativePlatform?.()) {
config.headers['X-App-Platform'] = 'native-' + window.Capacitor.getPlatform()
} else {
config.headers['X-App-Platform'] = 'web'
}
return config
})
8.2 响应拦截器与自动刷新 Token
响应拦截器中最核心的逻辑是 401 自动刷新 Token,这是确保用户无感续期登录的关键。
request.interceptors.response.use(
response => response.data,  // 直接解包 data
async error => {
if (!error.response) {
return Promise.reject(error)  // 网络错误
}
if (error.response.status === 401 && !isHandling401) {
return handle401(error.config)  // 自动刷新 Token
}
return Promise.reject(error)
}
)
// 401 处理策略
async function handle401(config) {
if (isHandling401) return null  // 已有请求在处理
isHandling401 = true
try {
// 1. 尝试刷新 Token
const newToken = await doRefresh()
if (!newToken) {
// 刷新失败 → 清除认证信息,跳转登录
localStorage.removeItem('tuanzi_auth')
showToast('登录已过期,请重新登录')
router.push('/auth')
return null
}
// 2. 用新 Token 重试原始请求
config.headers.Authorization = `Bearer ${newToken}`
const response = await request(config)
return response.data
} finally {
isHandling401 = false
}
}
设计要点:
isHandling401 互斥锁防止并发 401 重复刷新 Token
刷新成功后自动重试原始请求,对业务代码完全透明
刷新失败后清除 localStorage 中所有认证信息,防止僵尸 Token 持续尝试

九、热更新机制(CapacitorUpdater)

这是项目中最核心的架构特性之一。喵吱App 使用 @capgo/capacitor-updater 实现无需应用商店审核的热更新。

9.1 整体策略

取自 composables/useAppUpdate.js 的注释和实际逻辑:

/**

* useAppUpdate — 统一版本更新管理器

*

* 策略:

* 1. 优先使用 @capgo/capacitor-updater 做 Web 热更新(无需重装 APK)

* 2. 原生变更(新增插件、SDK 升级等)需要完整 APK 安装

*

* 流程:

* checkForUpdates() → 查询服务器最新版本

* ├─ 已有最新 bundle → 无需操作

* ├─ bundle 有新版本 → downloadBundle() → 透明热更新

* └─ native 需要更新 → showApkUpdateDialog() → 下载 APK → 安装

*/

9.2 版本检测

客户端每次启动(只在原生平台)检查更新:

export async function checkForUpdates() {
// Web 浏览器不检查更新
if (!window.Capacitor || !window.Capacitor.isNativePlatform()) return
const updateRes = await fetch(
`${BASE_URL}/api/app/update?version=${APP_VERSION}&platform=android`
)
const updateData = await updateRes.json()
if (!updateData.needs_update) {
// 版本一致但 bundle 不同 → 静默预下载
trySilentBundleDownload(updateData)
return
}
// 24 小时内弹过不重复弹
const dismissed = localStorage.getItem('miaozhi_update_dismissed')
// ... 检查 dismiss 记录 ...
// 优先尝试 bundle 热更新
const bundleUpdated = await tryBundleUpdate(updateData)
if (bundleUpdated) return
// bundle 不可用,降级到 APK
await promptApkUpdate(serverLatest, updateData)
}
9.3 Bundle 热更新流程
实际的 bundle 下载与生效逻辑:
async function tryBundleUpdate(updateData) {
// 1. 获取当前 bundle 版本
const current = await CapacitorUpdater.current()
const currentVersion = current.version || APP_VERSION
// 2. 下载新 bundle
isBundleDownloading.value = true
// 注册下载进度监听
bundleDownloadListenerHandle = await CapacitorUpdater.addListener(
'download', (state) => {
bundleDownloadProgress.value = Math.round(state.percent)
}
)
// 原生 HTTP 下载 bundle ZIP
const bundleId = await CapacitorUpdater.download({
version: bundleVersion,
url: downloadUrl
})
// 3. ⚠️ 使用 next() 而非 set()
// set() 是终端操作,立即销毁 JS 上下文
// next() 只是排队,等 App 进入后台或 reload() 才生效
await CapacitorUpdater.next({ id: bundleId.id || bundleId })
// 4. 提示用户重启
updateMessage.value = `新版本 ${bundleVersion} 已就绪,重启应用后生效。`
showUpdateDialog.value = true
}
⚠️ 关键踩坑一:必须使用 CapacitorUpdater.next() 而非 set()。set() 会立即销毁 JS 上下文,后面的代码(包括弹窗提示)都不会执行。next() 只是排队,调用 reload() 时才生效。
⚠️ 关键踩坑二:@capgo/capacitor-updater 插件必须通过 import 显式导入(import '@capgo/capacitor-updater'),确保 registerPlugin() 被调用。如果只用变量引用,原生插件不会注册,CapacitorUpdater.download() 会抛出 undefined 错误。
⚠️ 关键踩坑三:版本号必须递增!CapacitorUpdater 的 isVersionNewer() 判断依赖版本号比较。如果只改代码不改版本号,Native App 不会触发热更新。Web 端(浏览器 PWA)是实时从服务器加载的,不需要版本号变更。这是双部署模式的核心区别。
9.4 服务器端更新接口
取自 server/routes/update.js 的实际代码:
const APP_INFO = {
latest_version: APP_VERSION,
min_version: '1.0.0',
apk_url: 'https://app.ann.hi.cn/apk/miaozhi.apk',
bundle_version: APP_VERSION,
bundle_url: 'https://app.ann.hi.cn/api/app/bundle/download',
release_notes: 'V1.8.0 更新:CSS 变量体系重构、品牌色统一、公共 CSS 类去重',
force_update: false
}
function buildUpdateResponse(clientVersion) {
const response = {
version: effectiveVersion,
url: bundleUrl,
latest: APP_INFO.latest_version,
min: APP_INFO.min_version,
release_notes: APP_INFO.release_notes
}
// needs_update 必须存在,否则客户端的 needs_update === undefined → 跳过
response.needs_update = isVersionNewer(effectiveClientVersion, APP_INFO.latest_version)
return response
}
// GET 和 POST 都支持
router.get('/update', (req, res) => {
const clientVersion = req.query.version
res.json(buildUpdateResponse(clientVersion))
})
router.post('/update', (req, res) => {
const clientVersion = req.body?.version_name || req.body?.version
res.json(buildUpdateResponse(clientVersion))
})

十、SVG 图标系统

项目使用 SVG sprite 技术管理图标,所有图标定义在 styles/icons.js 中,在应用启动时注入 DOM。

// styles/icons.js(实际代码片段)
const svgSprite = `







...



`
// 注入到 body 开头
document.body.insertAdjacentHTML('afterbegin', svgSprite)
页面使用时只需:

优势:
仅一次 HTTP 请求(内联在 JS 中),后续使用零网络开销
CSS 统一控制颜色(stroke/currentColor)和大小(1em)
可复用性强,40+ 图标在 17 个页面中任意使用

十一、构建与部署

11.1 构建流程

实际的构建命令(package.json scripts):

“scripts”: {

“dev”: “vite”,

“build”: “vite build”,

“preview”: “vite preview”,

“build:apk”: “npm run build && cd android && ./gradlew assembleRelease”

}
构建时 Vite 的 closeBundle 钩子自动执行:
复制 capacitor.config.json 到 dist/
生成 version.json(包含 version、timestamp、app_id)
11.2 双部署模式
喵吱App 有两种部署方式:
Web 端(PWA 浏览器):直接更新服务器 dist/ 目录即可,用户下次访问时 Service Worker 会更新缓存。无需版本号变更,实时生效。
Native App(Capacitor 打包):需要经过以下流程:
增加版本号(config.js 的 APP_VERSION)
执行 npm run build 生成 dist/
打包 dist/ 为 ZIP bundle
上传 ZIP 到服务器 bundles/ 目录
更新服务器 update.js 中的 latest_version 和 bundle_version
重启后端进程(node server/index.js)
客户端启动时 checkForUpdates() 检测到 needs_update: true → 下载 bundle → 重启生效
实际服务器部署结构(取自服务器 /data/wwwroot/miaozhi-app/):
server/bundles/
├── bundle-v1.7.8.zip
├── bundle-v1.7.9.zip
├── bundle-v1.8.0.zip
├── latest.zip -> bundle-v1.8.0.zip  # 软链接
生产环境运行方式(直接 node 启动):
node server/index.js

十二、踩坑记录与经验总结

以下是项目开发中实际遇到并解决的问题汇总:

12.1 CapacitorUpdater 插件注册

问题:CapacitorUpdater.download() 报 undefined 错误。原因是 import ‘@capgo/capacitor-updater’ 必须在 main.js 中显式导入,否则 registerPlugin() 不会执行,原生插件不会注册。

解决:在 main.js 中加入 import ‘@capgo/capacitor-updater’,确保在 createApp() 之前注册。

12.2 set() vs next() 的选择

问题:使用 CapacitorUpdater.set() 后,后续代码不执行,用户看不到”更新完成”的提示。

解决:改用 CapacitorUpdater.next() + CapacitorUpdater.reload() 两步走。next() 只排队,reload() 时生效,中间可以执行弹窗提示。

12.3 版本号必须递增

问题:修改了代码但 Native App 不触发更新。原因是 isVersionNewer(APP_VERSION, serverLatest) 判断版本号必须严格递增。Web 端实时更新不受影响。

解决:每次热更新前必须递增 APP_VERSION(如 1.7.8 → 1.7.9 → 1.8.0),同时更新服务器 update.js 中 latest_version 和 bundle_version。

12.4 SVG 图标描边属性缺失

问题:DefaultAvatar 组件的 SVG 默认图标线条粗细不一致,80px 大头像和 40px 小头像的图标比例不统一。

解决:在 global.css 中统一设置 stroke-width: 1.8、stroke-linecap: round、stroke-linejoin: round,图标大小使用 0.45em 相对比例,确保任何尺寸的容器视觉一致。

12.5 合并 CSS 导致的样式冲突

问题:将 8 个页面的独立 .icon-svg 定义合并到 global.css 后,某些页面的子选择器(如 .header-back .icon-svg)不受影响,但根选择器 .icon-svg 被统一后,个别页面的定制样式丢失。

解决:保留每个页面特有的子选择器样式(如 .header-back .icon-svg),只合并完全相同的根 .icon-svg 定义。页面的覆盖样式通过 scoped CSS 或更具体的选择器实现。

12.6 构建产物旧文件残留

问题:Vite 增量构建后,dist/assets/ 中同时存在新旧 hash 的 CSS 文件,导致 zip 打包时包含重复文件。

解决:执行 rm -rf dist/ 后重新 npm run build,确保干净构建。生产部署脚本中应包含清理步骤。

12.7 头像路径在 Capacitor 中 404

问题:服务端返回的头像是相对路径(如 /uploads/avatars/xxx.jpg),在 Capacitor WebView 中请求到 https://localhost 导致 404。

解决:统一使用 getAvatarUrl() 函数拼接完整域名(https://app.ann.hi.cn + 路径),确保在任何 WebView 中都能正确加载。

12.8 双部署模式的理解

PWA 浏览器端:资源直接从服务器加载,每次访问都是最新代码。不需要版本号变更——修改 dist/ 文件后用户下次访问即生效。

Native App(Capacitor):资源打包在 APK 中,只能通过 CapacitorUpdater 热更新或发新 APK 版本更新。版本号是触发热更新的开关,必须递增。

这个区别在开发中容易被忽略——修改代码后只在浏览器测试通过,却忽略了 Native App 需要版本号递增才能获取更新。


← 返回首页