Vue3Crush与Vue2相比,有哪些颠覆性亮点?

摘要:Vue 3 + Vite + TypeScript 实战手册 这份手册基于当前 companyDashboard 项目整理。除明确标注“可选扩展”的部分外,文中的目录、命令和代码都以当前项目为准,可直接对
Vue 3 + Vite + TypeScript 实战手册 这份手册基于当前 companyDashboard 项目整理。除明确标注“可选扩展”的部分外,文中的目录、命令和代码都以当前项目为准,可直接对照工程阅读。 GitHub 仓库:https://github.com/kunyashaw/vue3Crush 目录 1. Vue 2 与 Vue 3 特性对比总表 核心 API 速查表 2. 工程总览 3. 初始化与依赖安装 4. Plop 模块与 hbs 模板 5. 应用入口与路由骨架 6. Store 与 Composable 7. 各个 View 实战代码 8. Teleport 与全局 UI 模块 9. 单元测试实践 10. 常见坑 1. Vue 2 与 Vue 3 特性对比总表 先把这部分放在前面,方便对整个技术栈先建立一个整体判断。 对比维度 Vue 2 Vue 3 实战意义 框架入口 new Vue() createApp() 应用实例边界更清晰,适合多实例与插件隔离 核心 API 风格 Options API 为主 Composition API 与 Options API 并存 逻辑可按“业务能力”聚合,不再被 data / methods / computed 打散 响应式底层 Object.defineProperty Proxy + effect 调度体系 对数组、动态属性、新旧值追踪都更自然 单值响应式 状态通常统一放在 data / computed 中管理 ref() 在 JS / TS 中显式 .value,类型推导更直观 对象响应式 data() 返回对象统一管理 reactive() 更适合复杂状态容器,但解构时要注意丢响应式 逻辑复用 mixins、高阶组件、renderless component composables 复用逻辑来源更清晰,命名冲突更少 TypeScript 支持 支持一般,类型推导较吃力 官方支持明显更成熟 大型项目的开发体验提升很明显 多根节点 不支持 支持 Fragment 模板不必再被无意义的包裹 div 污染 Teleport 无原生能力 原生支持 弹窗、抽屉、全局提示更容易摆脱层级限制 Suspense 无 原生支持 异步组件和加载态处理更统一 Tree-shaking 效果有限 更友好 没用到的 API 更容易被摇掉,构建体积更可控 生命周期入口 beforeCreate / created setup() + 各类 hooks setup 不是传统意义生命周期钩子,而是组合式逻辑入口 组件通信 props / $emit / provide/inject / event bus props / emits / provide / inject 组件契约更清晰,Event Bus 不再是主流方案 全局状态 Vuex 常见 Pinia 成为官方推荐 API 更轻,TS 体验更好,心智负担更低 路由使用方式 this.$router、this.$route useRouter()、useRoute() 组合式函数更适合 script setup 模板语法 v-model 默认 value / input 语义 v-model 更统一,支持多个 v-model 自定义组件双向绑定更灵活 自定义事件声明 约定式为主 emits 显式声明 组件契约更清晰,利于 TS 和团队协作 过滤器 Filters 常见 已移除主流地位 推荐改用 computed、方法或格式化函数 this 使用习惯 大量依赖 this script setup 基本不依赖 this 代码更贴近原生 JS / TS 思维 性能优化 以运行时优化为主 编译期 + 运行时协同优化 静态提升、Patch Flag 等让渲染更高效 自定义渲染器 生态层面相对弱 提供更强 runtime-core 能力 更容易扩展到非 DOM 平台 生态主流 Vue CLI、Vuex Vite、Pinia、SFC <script setup> 新项目大多默认围绕 Vue 3 工具链构建 1.1 站在实际项目角度,Vue 3 最值钱的升级是什么 Composition API 让业务逻辑真正可以抽走复用。 Pinia 让全局状态写起来比传统 Vuex 更轻。 script setup 把组件样板代码砍掉了一大截。 Teleport、Suspense、Fragments 这些能力,让模板组织更自然。 对 TypeScript 的支持成熟得多,特别适合中大型项目。 1.2 那 Vue 2 的经验是不是就作废了 不是。下面这些经验在 Vue 3 里仍然成立: 组件设计仍然要遵守单向数据流。 页面仍然要拆业务层和展示层。 状态管理仍然要克制,不要什么都塞全局。 测试、路由、权限、接口封装这些工程化能力依然重要。 核心 API 速查表 先把 ref、reactive、composable、Pinia 放在一起看,后面读页面代码时会更顺。 名称 一句话理解 怎么定义 怎么用 ref 给单值或某个引用包一层响应式壳 const count = ref(0) 在 script setup 里通过 count.value 读写;在模板里直接写 {{ count }} reactive 让整个对象或数组变成响应式代理 const form = reactive({ name: '', age: 0 }) 直接写 form.name = 'Tom'、form.age++;不要随手普通解构 composable 把一组可复用的响应式逻辑抽成函数 export function useTodoList() { const list = ref([]); return { list, addTodo } } 在组件里调用 const { list, addTodo } = useTodoList(),像普通函数一样取回状态和方法 Pinia 管理跨组件、跨页面共享状态的全局 store export const useAuthStore = defineStore('auth', () => { const token = ref(''); return { token } }) 在组件里先 const authStore = useAuthStore();状态用 storeToRefs(authStore),方法直接 authStore.login() 怎么选更顺手 场景 更适合用什么 原因 一个数字、字符串、布尔值是否变化 ref 单值最直接,类型也更清晰 表单对象、筛选条件、复杂对象 reactive 写法更自然,改属性时不用一直补 .value 一个页面里多处复用同一套业务逻辑 composable 逻辑能抽走复用,但不必上升成全局状态 登录态、主题、全局弹窗、用户信息 Pinia 需要跨组件共享,而且希望统一维护 可以先记一个最简单的判断: 只有一个值要变,用 ref 一整个对象要改,用 reactive 一组逻辑想复用,写成 composable 多个页面都要共享,放进 Pinia 最小完整示例 下面这几段尽量写成可以直接照着理解和改造的版本。 ref 示例 <script setup lang="ts"> import { ref } from 'vue' const count = ref(0) function increment() { count.value += 1 } function reset() { count.value = 0 } </script> <template> <section class="counter-demo"> <h3>当前计数:{{ count }}</h3> <button type="button" @click="increment">+1</button> <button type="button" @click="reset">重置</button> </section> </template> reactive 示例 <script setup lang="ts"> import { reactive } from 'vue' const form = reactive({ name: '', age: 18, city: '' }) function fillDemoData() { form.name = 'Tom' form.age = 24 form.city = 'Shanghai' } function resetForm() { form.name = '' form.age = 18 form.city = '' } </script> <template> <section class="form-demo"> <input v-model="form.name" placeholder="请输入姓名" /> <input v-model.number="form.age" type="number" placeholder="请输入年龄" /> <input v-model="form.city" placeholder="请输入城市" /> <p>姓名:{{ form.name }}</p> <p>年龄:{{ form.age }}</p> <p>城市:{{ form.city }}</p> <button type="button" @click="fillDemoData">填充演示数据</button> <button type="button" @click="resetForm">重置</button> </section> </template> composable 示例 src/composables/useCounter.ts import { ref, computed } from 'vue' export function useCounter() { const count = ref(0) const doubleCount = computed(() => count.value * 2) function increment() { count.value += 1 } function decrement() { count.value -= 1 } function reset() { count.value = 0 } return { count, doubleCount, increment, decrement, reset } } 组件里这样用: <script setup lang="ts"> import { useCounter } from '@/composables/useCounter' const { count, doubleCount, increment, decrement, reset } = useCounter() </script> <template> <section> <p>当前值:{{ count }}</p> <p>双倍值:{{ doubleCount }}</p> <button type="button" @click="increment">增加</button> <button type="button" @click="decrement">减少</button> <button type="button" @click="reset">重置</button> </section> </template> Pinia 示例 src/stores/useAuthStore.ts import { defineStore } from 'pinia' import { computed, ref } from 'vue' type UserInfo = { id: string name: string } export const useAuthStore = defineStore('auth', () => { const token = ref<string | null>(null) const userInfo = ref<UserInfo | null>(null) const isLoggedIn = computed(() => !!token.value) function login(newToken: string, newUserInfo: UserInfo) { token.value = newToken userInfo.value = newUserInfo } function logout() { token.value = null userInfo.value = null } return { token, userInfo, isLoggedIn, login, logout } }) 组件里这样用: <script setup lang="ts"> import { storeToRefs } from 'pinia' import { useAuthStore } from '@/stores/useAuthStore' const authStore = useAuthStore() const { userInfo, isLoggedIn } = storeToRefs(authStore) function handleLogin() { authStore.login('token-001', { id: 'U-001', name: 'Admin' }) } function handleLogout() { authStore.logout() } </script> <template> <section> <p v-if="isLoggedIn">当前用户:{{ userInfo?.name }}</p> <p v-else>当前未登录</p> <button type="button" @click="handleLogin">登录</button> <button type="button" @click="handleLogout">退出登录</button> </section> </template> 2. 工程总览 2.1 当前项目在做什么 这是一个典型的后台控制台示例项目,页面流转很清晰: 访问 /,先进入 Splash 闪屏页。 2 秒后自动跳转到 /login。 用户登录成功后进入 /dashboard。 Dashboard 里同时演示了业务大盘、Pinia、Composable、Teleport 和生命周期钩子。 2.2 技术栈一览 模块 技术 作用 构建工具 Vite 本地开发、打包构建、模块热更新 核心框架 Vue 3 组件化开发与响应式渲染 语言 TypeScript 类型约束、编辑器提示、重构安全性 路由 Vue Router 页面切换与导航守卫 状态管理 Pinia 全局身份信息与 UI 状态管理 UI 框架 Vuetify 现成的后台组件与 Material 风格控件 图标 @mdi/font Vuetify 图标依赖 测试 Vitest + @vue/test-utils Store、Composable、组件测试 模板脚手架 Plop 自动生成 View / Component / Composable 2.3 推荐关注的工程结构 companyDashboard ├── public │ └── favicon.ico ├── src │ ├── App.vue │ ├── main.ts │ ├── router │ │ └── index.ts │ ├── views │ │ ├── Splash/index.vue │ │ ├── UserLogin/index.vue │ │ └── Dashboard/index.vue │ ├── components │ │ ├── TeleportDemo.vue │ │ ├── GlobalLoading.vue │ │ ├── GlobalToast.vue │ │ └── GlobalDialog.vue │ ├── stores │ │ ├── useAuthStore.ts │ │ ├── useUiStore.ts │ │ └── counter.ts │ ├── composables │ │ └── useInfoList.ts │ └── __tests__ │ ├── router.spec.ts │ ├── UserLogin.spec.ts │ ├── useAuthStore.spec.ts │ └── useInfoList.spec.ts ├── plop-templates │ ├── component │ │ ├── index.vue.hbs │ │ └── component.spec.ts.hbs │ ├── composable │ │ ├── index.ts.hbs │ │ └── composable.spec.ts.hbs │ └── view │ └── index.vue.hbs ├── plopfile.js ├── package.json └── vue3Crash.md 2.4 这个项目最值得学习的点 Router + Pinia + Composable 的职责划分很典型。 Dashboard 页面把「局部状态」和「全局状态」拆得比较明确。 Teleport 的演示很适合用来理解“代码写在哪”和“DOM 渲染在哪”是两件事。 Plop 把重复劳动自动化了,适合团队约束目录结构。 useUiStore + GlobalLoading/Toast/Dialog 这一套很适合作为全局交互层的基础设施。 3. 初始化与依赖安装 3.1 环境要求 在开始之前,最好先把本地环境对齐,否则你可能会在安装依赖或运行脚本时遇到版本问题。 Node.js:^20.19.0 || >=22.12.0 npm:跟随 Node.js 自带版本即可 包管理器:以下命令统一使用 npm 上面的 Node 版本要求来自当前项目 package.json 里的 engines.node 配置。如果你的 Node 版本偏旧,建议先升级再继续。 3.2 创建项目 # 1) 创建工程 npm create vue@latest companyDashboard # 2) 按提示勾选 # - TypeScript # - Router # - Pinia # - Vitest # - ESLint / Prettier # 3) 进入项目 cd companyDashboard # 4) 安装脚手架生成的依赖 npm install # 5) 安装 UI 相关依赖 npm install vuetify @mdi/font # 6) 安装 Plop npm install -D plop # 7) 如果测试中需要 createTestingPinia npm install -D @pinia/testing 3.3 为什么这里单独提到 @pinia/testing 很多教程里会直接在测试代码中写 createTestingPinia(),但如果没有安装 @pinia/testing,测试会直接报模块找不到。 所以只要你准备写组件测试,并且想方便地 mock Pinia,就把它作为测试依赖补上。 3.4 推荐命令清单 { "scripts": { "dev": "vite", "build": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", "test:unit": "vitest", "build-only": "vite build", "type-check": "vue-tsc --build", "lint": "run-s lint:*", "lint:oxlint": "oxlint . --fix", "lint:eslint": "eslint . --fix --cache", "format": "prettier --write --experimental-cli src/", "plop": "plop" } } 4. Plop 模块与 hbs 模板 Plop 的价值很简单:把“新建一个 View / Component / Composable 时重复复制粘贴”的动作交给脚手架。 为了让模板路径和命名规则更直观,下面的展示代码统一用 __PROPER_CASE_NAME__、__CAMEL_CASE_NAME__ 这类占位符代替。 真实模板文件里仍然是 Handlebars helper 写法,也就是把 properCase name、camelCase name、dashCase name 这些 helper 放在双花括号里。 4.1 plopfile.js export default function (plop) { plop.setWelcomeMessage('🚀 欢迎使用自动化模板生成工具!'); // 组件生成器 plop.setGenerator('component', { description: '📦 生成一个 Vue 3 基础组件 (含单元测试)', prompts: [ { type: 'input', name: 'name', message: '请输入组件名称 (如 button、user-profile):', validate: (value) => { if (!value) return '组件名称不能为空'; return true; } } ], actions: [ { type: 'add', path: 'src/components/__PROPER_CASE_NAME__/index.vue', templateFile: 'plop-templates/component/index.vue.hbs' }, { type: 'add', path: 'src/components/__PROPER_CASE_NAME__/index.spec.ts', templateFile: 'plop-templates/component/component.spec.ts.hbs' } ] }); // 视图生成器 plop.setGenerator('view', { description: '📄 生成一个 Vue 3 页面 (View)', prompts: [ { type: 'input', name: 'name', message: '请输入页面名称 (如 login、user-detail):', validate: (value) => { if (!value) return '页面名称不能为空'; return true; } } ], actions: [ { type: 'add', path: 'src/views/__PROPER_CASE_NAME__/index.vue', templateFile: 'plop-templates/view/index.vue.hbs' } ] }); // Composable 生成器 plop.setGenerator('composable', { description: '🧲 生成一个 Vue 3 Composable (组合式函数)', prompts: [ { type: 'input', name: 'name', message: '请输入 Composable 名称 (通常以 use 开头,如 useAuth):', validate: (value) => { if (!value) return '名称不能为空'; if (!value.startsWith('use')) return '推荐以 use 开头命名'; return true; } } ], actions: [ { type: 'add', path: 'src/composables/__CAMEL_CASE_NAME__.ts', templateFile: 'plop-templates/composable/index.ts.hbs' }, { type: 'add', path: 'src/composables/__CAMEL_CASE_NAME__.spec.ts', templateFile: 'plop-templates/composable/composable.spec.ts.hbs' } ] }); } 4.2 View 模板:plop-templates/view/index.vue.hbs <template> <div class="__DASH_CASE_NAME__-page"> <h2>__TITLE_CASE_NAME__ 页面</h2> </div> </template> <script setup lang="ts"> /** * __PROPER_CASE_NAME__ View */ defineOptions({ name: '__PROPER_CASE_NAME__View' }) </script> <style scoped> .__DASH_CASE_NAME__-page { /* 页面级通用布局属性可在此定义 */ } </style> 4.3 Component 模板:plop-templates/component/index.vue.hbs <template> <div class="__DASH_CASE_NAME__-wrapper"> <slot>__PROPER_CASE_NAME__ Component</slot> </div> </template> <script setup lang="ts"> /** * __PROPER_CASE_NAME__ * @description Automatically generated by Plop */ defineOptions({ name: '__PROPER_CASE_NAME__' }) // const props = withDefaults(defineProps<{}>(), {}) // const emit = defineEmits<{}>() </script> <style scoped> .__DASH_CASE_NAME__-wrapper { /* Write your styles here */ } </style> 4.4 Component 测试模板:plop-templates/component/component.spec.ts.hbs import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' import __PROPER_CASE_NAME__ from './index.vue' describe('__PROPER_CASE_NAME__ component', () => { it('正确渲染基础内容', () => { const wrapper = mount(__PROPER_CASE_NAME__, { props: {}, slots: { default: '__PROPER_CASE_NAME__ Component' } }) expect(wrapper.exists()).toBe(true) expect(wrapper.text()).toContain('__PROPER_CASE_NAME__ Component') }) }) 4.5 Composable 模板:plop-templates/composable/index.ts.hbs import { ref, computed } from 'vue' /** * __CAMEL_CASE_NAME__ * @description Automatically generated Composable */ export function __CAMEL_CASE_NAME__() { // state const data = ref(null) // getters const hasData = computed(() => data.value !== null) // actions function updateData(newData: any) { data.value = newData } return { data, hasData, updateData } } 4.6 Composable 测试模板:plop-templates/composable/composable.spec.ts.hbs import { describe, it, expect } from 'vitest' import { __CAMEL_CASE_NAME__ } from './__CAMEL_CASE_NAME__' describe('__CAMEL_CASE_NAME__ composable', () => { it('初始化状态应符合预期', () => { const { data, hasData } = __CAMEL_CASE_NAME__() expect(data.value).toBeNull() expect(hasData.value).toBe(false) }) it('updateData 应该正确更新状态', () => { const { data, hasData, updateData } = __CAMEL_CASE_NAME__() updateData('test value') expect(data.value).toBe('test value') expect(hasData.value).toBe(true) }) }) 4.7 如何使用 npm run plop 你会看到三个生成器: component view composable 4.8 使用 Plop 时的实战建议 view 生成器适合快速开页面骨架,但实际项目里通常还要补路由、布局和业务逻辑。 component 和 composable 自带的测试模板只是“起步模板”,生成后一定要按真实返回值和真实交互改断言。 如果一个模板生成出来的测试始终只断言 data / hasData / updateData,而你的真实 composable 根本没有这几个字段,那这个测试就应该立刻重写,而不是保留占位代码。 5. 应用入口与路由骨架 这一部分是整个应用真正“跑起来”的地方。 5.1 src/main.ts import { createApp } from 'vue' import { createPinia } from 'pinia' // 引入 Vuetify import 'vuetify/styles' import '@mdi/font/css/materialdesignicons.css' import { createVuetify } from 'vuetify' import * as components from 'vuetify/components' import * as directives from 'vuetify/directives' import App from './App.vue' import router from './router' // 创建 Vuetify 实例,集中注册组件和指令 const vuetify = createVuetify({ components, directives, }) // 创建 Vue 应用 const app = createApp(App) // 注入全局能力 app.use(createPinia()) app.use(router) app.use(vuetify) // 挂载到页面根节点 app.mount('#app') 5.2 src/App.vue <script setup lang="ts"> /** * App.vue 是应用的顶层入口组件。 * 这里本身不放业务,只负责承载 router-view 和全局容器。 */ </script> <template> <!-- v-app 是 Vuetify 的根容器 --> <v-app> <!-- 页面会根据当前路由动态切换 --> <router-view /> </v-app> </template> <style> #app { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } body { margin: 0; padding: 0; } </style> 5.3 可选增强:在根组件挂全局 UI 容器 这一段是“可选扩展”,不是当前仓库默认状态。 当前项目里的 src/App.vue 只挂了 router-view,还没有把全局 UI 容器接进去。 如果你准备启用 GlobalLoading、GlobalToast、GlobalDialog,可以把 App.vue 扩成下面这样: <script setup lang="ts"> import GlobalLoading from '@/components/GlobalLoading.vue' import GlobalToast from '@/components/GlobalToast.vue' import GlobalDialog from '@/components/GlobalDialog.vue' </script> <template> <v-app> <router-view /> <!-- 全局交互层 --> <GlobalLoading /> <GlobalToast /> <GlobalDialog /> </v-app> </template> 5.4 src/router/index.ts import { createRouter, createWebHistory } from 'vue-router' function hasAuthToken() { return typeof window !== 'undefined' && !!window.localStorage.getItem('token') } const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'splash', component: () => import('../views/Splash/index.vue'), meta: { public: true } }, { path: '/login', name: 'login', component: () => import('../views/UserLogin/index.vue'), meta: { public: true, guestOnly: true } }, { path: '/dashboard', name: 'dashboard', component: () => import('../views/Dashboard/index.vue'), meta: { requiresAuth: true } } ], }) router.beforeEach((to) => { const isAuthenticated = hasAuthToken() if (to.meta.requiresAuth && !isAuthenticated) { return { path: '/login', query: { redirect: to.fullPath, }, } } if (to.meta.guestOnly && isAuthenticated) { return { path: '/dashboard' } } return true }) export default router 5.5 这套路由守卫解决了什么 场景 处理方式 结果 未登录访问 /dashboard meta.requiresAuth + beforeEach 自动重定向到 /login 已登录访问 /login meta.guestOnly + beforeEach 自动重定向到 /dashboard 登录后想回原页面 query 里带上 redirect 后续可继续扩展“登录后返回原地址” 这样登录拦截就不再只靠页面内的 onMounted 兜底,而是前置到了路由层。页面里的二次校验仍然可以保留,作为双保险。 5.6 为什么这里用 createWebHistory 模式 URL 表现 说明 createWebHistory() /login 更干净,适合正式项目 createWebHashHistory() /#/login 部署最省心,但 URL 带 # 当前项目选的是 createWebHistory(),所以如果未来部署到静态服务器,需要服务端支持 history fallback。 5.7 为什么示例里可以直接写 @/ 文中的很多 import 都用了 @/,这是因为当前项目已经提前配好了路径别名。 Vite 侧的配置来自 vite.config.ts: resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, }, TypeScript 侧的配置来自 tsconfig.app.json: { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } } 如果你把这里的代码片段复制到自己的项目里,但没有配置这两处,就会遇到“找不到 @/xxx”的问题。 6. Store 与 Composable 这一部分是 Vue 3 项目里最容易混淆、但也最能拉开架构差距的地方。 6.1 什么时候用 Pinia,什么时候用 Composable 场景 推荐方案 原因 登录态、主题、全局弹窗 Pinia 跨页面、跨组件都要用 单页面内的业务列表、局部 loading Composable 只在局部复用,不必升到全局 深层祖孙传值 provide / inject 解决 prop drilling 纯展示组件数据下发 props / emits 最直接、最可维护 6.2 身份认证 Store:src/stores/useAuthStore.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' /** * useAuthStore * 负责管理登录凭证和当前用户信息。 */ export const useAuthStore = defineStore('auth', () => { // token 为 null 代表未登录 const token = ref<string | null>(localStorage.getItem('token') || null) // 当前登录用户信息 const userInfo = ref({ id: '', name: '', age: 0 }) // 只要 token 存在,就视为已登录 const isLoggedIn = computed(() => !!token.value) /** * 登录成功后写入 token 和用户信息 */ function login(newToken: string, userData: { id: string; name: string; age: number }) { token.value = newToken userInfo.value = userData localStorage.setItem('token', newToken) } /** * 退出登录时清空状态和本地缓存 */ function logout() { token.value = null userInfo.value = { id: '', name: '', age: 0 } localStorage.removeItem('token') } return { token, userInfo, isLoggedIn, login, logout } }) 6.3 在组件里如何安全取出 Store 数据 import { storeToRefs } from 'pinia' import { useAuthStore } from '@/stores/useAuthStore' const authStore = useAuthStore() // 会变化的状态,使用 storeToRefs 保持响应式 const { userInfo, isLoggedIn } = storeToRefs(authStore) // 行为函数直接从 store 拿即可 const { login, logout } = authStore 不要把会变化的 Store 状态直接普通解构成 const { isLoggedIn } = authStore,否则你拿到的可能不是一个还能继续追踪更新的响应式引用。 6.4 业务 Composable:src/composables/useInfoList.ts 下面这个版本和当前工程结构一致,同时顺手补了数组下标保护,避免 performanceList.value[0] 为空时触发 TypeScript 警告: import { ref, computed } from 'vue' /** * useInfoList * 负责 Dashboard 中的业务大盘数据和局部 loading 状态。 */ export function useInfoList() { // 仪表盘卡片数据 const performanceList = ref([ { id: '1', projectName: '年度企业签约', quarter: 'Q1', revenue: 345800.5, growth: 12.5, trend: 'up', title: '季度总签约额', value: '345,800', unit: '元', trendRate: '+12.5%' }, { id: '2', projectName: '云产品续约率', quarter: 'Q2', revenue: 128400.0, growth: -2.3, trend: 'down', title: '客户留存率', value: '98.2', unit: '%', trendRate: '-2.3%' }, { id: '3', projectName: '研发成本投入', quarter: 'Q1', revenue: 67200.0, growth: 5.8, trend: 'up', title: '新增潜客数', value: '2,450', unit: '人', trendRate: '+5.8%' }, { id: '4', projectName: '大客户增购', quarter: 'Q3', revenue: 254100.2, growth: 0.0, trend: 'none', title: '在线用户波动', value: '1,280', unit: '人/时', trendRate: '持平' }, ]) // 按钮 loading const isLoading = ref(false) // 汇总所有项目 revenue 字段 const totalRevenue = computed(() => { return performanceList.value.reduce((acc, cur) => acc + cur.revenue, 0) }) /** * 模拟异步拉取最新数据 */ async function fetchLatestPerformance() { isLoading.value = true return new Promise((resolve) => { setTimeout(() => { // 防止数组为空时报错 const firstItem = performanceList.value[0] if (firstItem) { firstItem.revenue += Math.random() * 1000 } isLoading.value = false resolve(true) }, 1500) }) } return { performanceList, isLoading, totalRevenue, fetchLatestPerformance } } 6.5 ref / reactive / computed 的准确理解 API 用途 关键点 不要误解成 ref 单值或对象引用包装 JS/TS 中通过 .value 访问 “整个页面整页刷新” reactive 对象 / 数组响应式代理 适合复杂对象结构 “可以随便解构不丢响应式” computed 派生值 带缓存,依赖不变则不重算 “所有场景都比方法更好” 要特别修正两个常见误区: Vue 不是“整页刷新”,而是调度并 patch 受影响的 DOM。 ref 和 reactive 虽然都能参与响应式系统,但底层表现不同,不能简单一句“它们都只是 Proxy”带过。 7. 各个 View 实战代码 这一节直接贴当前仓库中的 View 代码,并保留关键注释。 代码尽量与当前项目保持一致,方便直接对照。 7.1 src/views/Splash/index.vue <template> <div class="splash-page"> <v-container class="fill-height d-flex flex-column justify-center align-center"> <v-progress-circular indeterminate color="primary" size="64" width="6"></v-progress-circular> <h2 class="mt-6 text-h4 font-weight-bold text-primary">Company Dashboard</h2> <p class="text-subtitle-1 text-grey mt-2">正在初始化系统资源...</p> </v-container> </div> </template> <script setup lang="ts"> import { onMounted } from 'vue' import { useRouter } from 'vue-router' defineOptions({ name: 'SplashView' }) const router = useRouter() onMounted(() => { // 模拟2秒的开场白停留,之后直接被路由推倒登录页! setTimeout(() => { router.replace('/login') }, 2000) }) </script> <style scoped> .splash-page { height: 100vh; background-color: #f5f7fa; } </style> 7.2 src/views/UserLogin/index.vue <template> <div class="user-login-page d-flex align-center justify-center"> <!-- Vuetify 卡片组件:制作一个漂亮的纯白浮雕背景框 --> <v-card class="pa-8 elevation-8" width="450" rounded="xl"> <div class="text-center mb-8"> <v-icon icon="mdi-shield-lock" color="primary" size="64" class="mb-4"></v-icon> <h2 class="text-h4 font-weight-bold text-grey-darken-3">系统身份认证</h2> <p class="text-body-2 text-grey mt-2">欢迎回来,请输入您的管理员账户以继续</p> </div> <!-- 表单区域:绑定了 handleLogin 控制逻辑 --> <v-form @submit.prevent="handleLogin"> <!-- 用户名输入框:双向绑定到 username 变量 --> <v-text-field v-model="username" label="用户名" prepend-inner-icon="mdi-account" variant="outlined" color="primary" required ></v-text-field> <!-- 密码输入框:双向绑定到 password 变量 --> <v-text-field v-model="password" label="安全密码" prepend-inner-icon="mdi-lock" type="password" variant="outlined" color="primary" class="mt-2" required ></v-text-field> <!-- 登录按钮:loading 状态由异步函数控制 --> <v-btn type="submit" color="primary" block size="x-large" class="mt-6 text-h6 font-weight-bold text-none" :loading="isLoggingIn" elevation="4" rounded="lg" > 登 录 </v-btn> </v-form> </v-card> </div> </template> <script setup lang="ts"> /** * 登录页面核心逻辑 * 涉及技术:Vue 3 响应式 ref、Pinia 全局状态存储、Vue Router 编程式跳转 */ import { ref } from 'vue' import { useRouter } from 'vue-router' import { useAuthStore } from '@/stores/useAuthStore' // 1. 初始化路由:router 用于执行指令式的跳转(如登录成功后跳到首页) const router = useRouter() // 2. 初始化全局认证 Store:用于在登录成功后,持久化存储用户身份 const authStore = useAuthStore() // 3. 定义模版中使用的响应式变量 const username = ref('admin') // 初始值默认填好 admin 方便测试 const password = ref('123456') // 初始值默认填好密码 const isLoggingIn = ref(false) // 控制按钮的加载动画状态 /** * 处理登录逻辑:模拟真实的后端接口调用 */ async function handleLogin() { if (!username.value || !password.value) return isLoggingIn.value = true // 2. 模拟网络延迟(后端接口调用通常需要 0.5s - 2s) setTimeout(() => { // 3. 调用 Pinia Action:将登录凭证(Token)和用户信息存入全局内存 // 这里的 id, name, age 是模拟从后端返回的数据 authStore.login('super-secret-token-10086', { id: 'U-001', name: username.value, age: 26 }) isLoggingIn.value = false // 5. 核心:编程式路由跳转 // 将用户重定向到 Dashboard 页面,这样用户就不能通过物理返回键回到登录页了 router.replace('/dashboard') }, 1200) } </script> <style scoped> .user-login-page { height: 100vh; /* 使用一段垂直镜面的渐变色作为背景,提升视觉档次 */ background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); } </style> 7.3 src/views/Dashboard/index.vue <template> <div class="dashboard-page bg-grey-lighten-4"> <!-- 1. 顶部导航栏 (Global Header) --> <v-app-bar color="primary" elevation="2"> <v-app-bar-title class="font-weight-bold text-h5"> <v-icon icon="mdi-chart-line-variant" class="mr-2 pb-1"></v-icon> 企业数据看板 </v-app-bar-title> <v-spacer></v-spacer> <!-- 展示当前登录用户信息 --> <div v-if="isLoggedIn" class="d-flex align-center mr-4"> <v-avatar color="indigo-lighten-1" size="40" class="mr-3 elevation-2"> <span class="text-white text-h6 font-weight-bold">{{ userInfo.name.charAt(0).toUpperCase() }}</span> </v-avatar> <span class="text-body-1 font-weight-medium mr-6 text-white">您好,{{ userInfo.name }}</span> <!-- 退出登录按钮:编程式路由跳转演示 --> <v-btn variant="outlined" color="white" rounded="pill" @click="handleLogout"> <v-icon icon="mdi-logout" start></v-icon> 退出登录 </v-btn> </div> </v-app-bar> <v-main> <!-- 2. 选项卡切换 (Tabs Control) --> <v-tabs v-model="activeTab" bg-color="white" color="primary" class="elevation-1"> <v-tab value="dashboard"> <v-icon start icon="mdi-view-dashboard" />业务大盘 </v-tab> <v-tab value="teleport"> <v-icon start icon="mdi-rocket-launch" />Teleport 演示 </v-tab> </v-tabs> <!-- 3. 内容展示区 (Main Content) --> <v-window v-model="activeTab"> <!-- 页面一:业务大盘 (演示响应式数据绑定与 Composable 抽离) --> <v-window-item value="dashboard"> <v-container class="pa-8"> <div class="d-flex align-center justify-space-between mb-8"> <div> <h1 class="text-h3 font-weight-black text-grey-darken-4 mb-2">公司业务概览</h1> <p class="text-subtitle-1 text-grey">实时追踪今日的所有核心考核指标动态</p> </div> <!-- 按钮点击触发:异步逻辑封装在 Composable 中 --> <v-btn color="primary" variant="flat" size="large" prepend-icon="mdi-refresh" :loading="isLoading" @click="fetchLatestPerformance" rounded="xl" class="elevation-2 px-6" > 刷新数据 </v-btn> </div> <!-- 数据卡片网格 --> <v-row> <v-col v-for="item in performanceList" :key="item.id" cols="12" sm="6" md="3"> <v-card class="elevation-2 rounded-xl pa-5 h-100 d-flex flex-column hover-card"> <div class="text-subtitle-1 text-grey-darken-1 mb-3 d-flex align-center"> {{ item.title }} <v-spacer></v-spacer> <v-icon icon="mdi-information-outline" color="grey-lighten-1" size="small"></v-icon> </div> <div class="text-h3 font-weight-black text-primary mb-auto"> {{ item.value }} <span class="text-subtitle-1 text-grey-darken-1">{{ item.unit }}</span> </div> <!-- 计算属性演示:根据趋势动态绑定颜色 --> <div class="d-flex align-center mt-5 pt-3 border-t"> <v-chip :color="item.trend === 'up' ? 'success' : item.trend === 'down' ? 'error' : 'grey'" size="small" class="font-weight-bold mr-2" variant="flat" > <v-icon :icon="item.trend === 'up' ? 'mdi-trending-up' : item.trend === 'down' ? 'mdi-trending-down' : 'mdi-minus'" start size="small"></v-icon> {{ item.trendRate }} </v-chip> <span class="text-caption text-grey">环比昨日</span> </div> </v-card> </v-col> </v-row> <!-- 底部看板汇总 --> <v-row class="mt-8"> <v-col cols="12"> <v-card class="pa-8 rounded-xl bg-indigo-darken-2 elevation-6"> <div class="d-flex align-center"> <v-icon icon="mdi-robot-outline" size="48" color="yellow-accent-2" class="mr-6"></v-icon> <div> <h3 class="text-h4 font-weight-bold mb-3 text-white">AI 智能辅助洞察</h3> <p class="text-h6 font-weight-regular text-indigo-lighten-4 mb-0" style="line-height: 1.6;"> 监测到今日总营收已达到 <strong class="text-yellow-accent-2 text-h5 px-1">{{ totalRevenue.toLocaleString() }} 元</strong>。 数据表现强劲,系统预计明日可能会出现客诉服务峰值,请提前做好准备。 </p> </div> </div> </v-card> </v-col> </v-row> </v-container> </v-window-item> <!-- 页面二:Teleport 实战演示 (演示跨 DOM 挂载逻辑) --> <v-window-item value="teleport"> <v-container class="pa-8"> <h2 class="text-h4 font-weight-bold mb-6">传送门 (Teleport) 边缘场景演示</h2> <TeleportDemo /> </v-container> </v-window-item> </v-window> </v-main> </div> </template> <script setup lang="ts"> /** * Vue 3 仪表盘页面核心逻辑 * 集成了:响应式变量、Pinia Store、Composable、生命周期钩子、路由跳转 */ import { ref, onBeforeMount, onMounted, onBeforeUnmount, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import { storeToRefs } from 'pinia' import { useAuthStore } from '@/stores/useAuthStore' import { useInfoList } from '@/composables/useInfoList' import TeleportDemo from '@/components/TeleportDemo.vue' // 1. 初始化路由工具:用于页面跳转 const router = useRouter() // 2. 初始化全局认证 Store:包含 Token 和用户信息 const authStore = useAuthStore() // ⚠️ 重要:解构解构 Store 数据时必须包裹 storeToRefs,否则数据变动时页面不会自动刷新(响应式丢失) const { userInfo, isLoggedIn } = storeToRefs(authStore) // 3. 引入业务逻辑 Composable:将沉重的业务代码(数据拉取、算法)从 UI 文件中剥离 const { performanceList, isLoading, totalRevenue, fetchLatestPerformance } = useInfoList() // 4. 定义本地响应式变量 const activeTab = ref('dashboard') // ══════════════════════════════════════════════════════════════ // Vue 3 生命周期钩子演示 — 它们是组件在执行不同阶段的“报警器” // ══════════════════════════════════════════════════════════════ onBeforeMount(() => { // 🔧 挂载前:组件加载的第一个环节 console.log('%c[Lifecycle] 1. onBeforeMount: 数据就绪,DOM 还未挂载', 'color: orange') }) onMounted(async () => { // ✅ 挂载完成:最常用的钩子,此处可以安全地操作 DOM console.log('%c[Lifecycle] 2. onMounted: DOM 已就绪!执行任务...', 'color: green') // 拦截校验:如果未登录,利用编程式导航 router.replace 踢回登录页 if (!isLoggedIn.value) { console.warn('检测到未登录,正在拦截并重定向到登录页') router.replace('/login') return } // 页面加载完成后,自动拉取一次最新业务数据 await fetchLatestPerformance() }) onBeforeUnmount(() => { // 🧹 卸载前:最后的清理时机 console.log('%c[Lifecycle] 5. onBeforeUnmount: 即将离开页面,正在清理定时器和连接...', 'color: red') }) onUnmounted(() => { // 💀 卸载完成:组件彻底消失 console.log('%c[Lifecycle] 6. onUnmounted: 销毁完成', 'color: grey') }) // ══════════════════════════════════════════════════════════════ /** * 退出登录 */ function handleLogout() { // 1. 调用 Pinia Action 清除 Token authStore.logout() // 2. 路由跳转:replace 的作用是跳转后“替换”当前历史记录,防止用户按返回键又回到 Dashboard router.replace('/login') } </script> <style scoped> .dashboard-page { min-height: 100vh; } .border-t { border-top: 1px solid rgba(0,0,0,0.06); } /* 卡片悬浮动画特效 */ .hover-card { transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); } .hover-card:hover { transform: translateY(-8px); box-shadow: 0 14px 28px rgba(0,0,0,0.1), 0 10px 10px rgba(0,0,0,0.08) !important; } </style> 7.4 这三个 View 各自承担的职责 View 职责 重点技术 Splash 过渡页 onMounted、定时器、router.replace UserLogin 登录页 ref、Pinia Action、表单提交 Dashboard 主控制台 storeToRefs、Composable、Teleport、路由拦截 8. Teleport 与全局 UI 模块 这一块是当前工程里很适合继续扩展的部分。 8.1 为什么需要 Teleport 当弹窗、抽屉、全屏遮罩写在一个受限容器里时,最常见的三个 CSS 问题是: 父级样式 结果 overflow: hidden 超出区域被裁切 transform position: fixed 坐标系被改变 z-index 层叠上下文 弹层可能被更高层元素压住 Teleport 的核心作用就是: 代码仍归当前组件管理,但 DOM 物理节点可以挂到 body 下。 8.2 src/components/TeleportDemo.vue <template> <v-container class="pa-8"> <div class="mb-8"> <h1 class="text-h4 font-weight-black text-grey-darken-4 mb-2"> Teleport 传送门实战演示 </h1> <p class="text-body-1 text-grey-darken-1"> 左边是不使用 Teleport 的情况,右边是使用 Teleport 的情况。 </p> </div> <v-row> <!-- 左侧:不使用 Teleport --> <v-col cols="12" md="6"> <v-card class="rounded-xl elevation-4" style="overflow: visible;"> <div class="pa-4 bg-red-darken-1 d-flex align-center"> <v-icon icon="mdi-close-circle" color="white" class="mr-2" /> <span class="text-white font-weight-bold">不用 Teleport(会被裁切)</span> </div> <div class="pa-5"> <v-btn color="error" variant="flat" prepend-icon="mdi-alert" class="mb-4" @click="showBrokenDialog = true" > 点我触发弹窗 </v-btn> <div style="overflow: hidden; height: 120px; border: 2px dashed #ef5350; border-radius: 8px; position: relative; background: #fff8f8;" > <p class="text-caption text-red pa-2 ma-0"> 这里是一个有 overflow: hidden 的容器 </p> <div v-if="showBrokenDialog" class="broken-popup"> <p class="font-weight-bold mb-1 text-error">我被裁切了</p> <p class="text-body-2">因为我的 DOM 还在这个容器里面。</p> <v-btn size="x-small" color="error" variant="flat" class="mt-1" @click="showBrokenDialog = false" > 关闭 </v-btn> </div> </div> </div> </v-card> </v-col> <!-- 右侧:使用 Teleport --> <v-col cols="12" md="6"> <v-card class="rounded-xl elevation-4" style="overflow: visible;"> <div class="pa-4 bg-green-darken-1 d-flex align-center"> <v-icon icon="mdi-check-circle" color="white" class="mr-2" /> <span class="text-white font-weight-bold">使用 Teleport(正常显示)</span> </div> <div class="pa-5"> <v-btn color="success" variant="flat" prepend-icon="mdi-rocket-launch" class="mb-4" @click="showFixedDialog = true" > 点我触发弹窗 </v-btn> <div style="overflow: hidden; height: 120px; border: 2px dashed #66bb6a; border-radius: 8px; position: relative; background: #f8fff8;" > <p class="text-caption text-green-darken-2 pa-2 ma-0"> 代码还写在这里,但 DOM 会被投送到 body 下 </p> <Teleport to="body"> <v-dialog v-model="showFixedDialog" max-width="460"> <v-card rounded="xl" class="pa-4"> <v-card-item> <template #prepend> <v-icon icon="mdi-rocket-launch" color="success" size="x-large" /> </template> <v-card-title class="font-weight-black"> 我逃出来了 </v-card-title> </v-card-item> <v-card-text class="text-body-1 text-grey-darken-2" style="line-height: 1.8;"> 现在我的 DOM 在 body 下,不再受 overflow: hidden 影响。 </v-card-text> <v-card-actions class="justify-end"> <v-btn color="success" variant="elevated" @click="showFixedDialog = false"> 关闭 </v-btn> </v-card-actions> </v-card> </v-dialog> </Teleport> </div> </div> </v-card> </v-col> </v-row> </v-container> </template> <script setup lang="ts"> import { ref } from 'vue' defineOptions({ name: 'TeleportDemo' }) const showBrokenDialog = ref(false) const showFixedDialog = ref(false) </script> <style scoped> .broken-popup { position: absolute; top: 10px; left: 50%; transform: translateX(-50%); width: 280px; background: white; border-radius: 10px; padding: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); border: 2px solid #ef5350; } </style> 8.3 全局 UI Store:src/stores/useUiStore.ts 这一套适合做三类全局交互: 全屏 Loading 轻提示 Toast 确认弹窗 Dialog import { defineStore } from 'pinia' import { reactive } from 'vue' export const useUiStore = defineStore('ui', () => { const loading = reactive({ visible: false, text: '加载中...' }) function showLoading(text = '加载中...') { loading.text = text loading.visible = true } function hideLoading() { loading.visible = false } const dialog = reactive({ visible: false, title: '', message: '', confirmText: '确定', cancelText: '取消', type: 'info' as 'info' | 'warning' | 'danger', onConfirm: null as (() => void | Promise<void>) | null }) function showDialog(options: { title: string message: string type?: 'info' | 'warning' | 'danger' confirmText?: string cancelText?: string onConfirm?: () => void | Promise<void> }) { Object.assign(dialog, { visible: true, title: options.title, message: options.message, type: options.type ?? 'info', confirmText: options.confirmText ?? '确定', cancelText: options.cancelText ?? '取消', onConfirm: options.onConfirm ?? null }) } function hideDialog() { dialog.visible = false } const toast = reactive({ visible: false, message: '', type: 'success' as 'success' | 'error' | 'warning' | 'info', duration: 3000 }) let toastTimer: ReturnType<typeof setTimeout> | null = null function showToast(options: { message: string type?: 'success' | 'error' | 'warning' | 'info' duration?: number }) { if (toastTimer) clearTimeout(toastTimer) Object.assign(toast, { visible: true, message: options.message, type: options.type ?? 'success', duration: options.duration ?? 3000 }) toastTimer = setTimeout(() => { toast.visible = false }, toast.duration) } function hideToast() { toast.visible = false } return { loading, showLoading, hideLoading, dialog, showDialog, hideDialog, toast, showToast, hideToast, } }) 8.4 全局 UI 的推荐接入方式 组件 用途 挂载位置 GlobalLoading.vue 全屏等待遮罩 App.vue GlobalToast.vue 底部轻提示 App.vue GlobalDialog.vue 通用确认弹窗 App.vue 调用方式示例: import { useUiStore } from '@/stores/useUiStore' const uiStore = useUiStore() uiStore.showLoading('正在同步企业数据...') uiStore.hideLoading() uiStore.showToast({ message: '保存成功', type: 'success' }) uiStore.showDialog({ title: '删除确认', message: '该操作不可撤销,确定继续吗?', type: 'danger', onConfirm: async () => { console.log('执行删除逻辑') } }) 9. 单元测试实践 9.1 为什么 Vue 项目要测这三层 测试层 目标 例子 Composable 测试 验证业务逻辑 useInfoList 的总收入计算 Store 测试 验证状态变化 login / logout 是否正确修改状态 组件测试 验证渲染和交互 登录表单提交后是否调用跳转 9.2 Vitest 常用语法速览 如果你是第一次接触 Vitest,可以先把这些关键字记住: 语法 作用 你可以把它理解成 describe('模块名', () => {}) 把一组相关测试包在一起 一个测试分组 it('行为描述', () => {}) 定义一个具体测试用例 一条断言场景 expect(value) 断言入口 “我要检查这个值” .toBe() 严格相等 适合布尔、字符串、数字 .toEqual() 深比较 适合对象和数组 .toContain() 包含关系判断 常用于字符串和数组 .toHaveLength() 检查长度 常用于数组、字符串 .toBeCloseTo() 浮点数近似比较 适合金额、比例、计算结果 .toHaveBeenCalled() 检查 mock 是否被调用 常用于 spy / mock 函数 .toHaveBeenCalledWith() 检查 mock 的调用参数 常用于断言函数调用入参 beforeEach(() => {}) 每条测试前执行 重置状态、清缓存 afterEach(() => {}) 每条测试后执行 恢复 mock、还原定时器 vi.fn() 创建一个假函数 用来记录是否被调用 vi.mock() 模块级 mock 替换 router、接口模块等 vi.useFakeTimers() 接管定时器 测 setTimeout / setInterval 特别好用 vi.runAllTimersAsync() 快进所有定时器 让异步延时立即执行 vi.spyOn(obj, 'method') 监听已有方法 比如监控 Math.random 一个最小测试长这样: import { describe, it, expect } from 'vitest' describe('sum', () => { it('1 + 1 应该等于 2', () => { expect(1 + 1).toBe(2) }) }) 9.3 测试 Composable import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { useInfoList } from '@/composables/useInfoList' describe('useInfoList', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() }) it('初始时应当有 4 条卡片数据', () => { const { performanceList, isLoading } = useInfoList() expect(performanceList.value).toHaveLength(4) expect(isLoading.value).toBe(false) }) it('totalRevenue 应当汇总所有 revenue', () => { const { totalRevenue } = useInfoList() expect(totalRevenue.value).toBeCloseTo(795500.7) }) it('fetchLatestPerformance 应当切换 loading 并更新第一条数据', async () => { vi.spyOn(Math, 'random').mockReturnValue(0.5) const { performanceList, isLoading, fetchLatestPerformance } = useInfoList() const initialRevenue = performanceList.value[0]?.revenue ?? 0 const promise = fetchLatestPerformance() expect(isLoading.value).toBe(true) await vi.runAllTimersAsync() await promise expect(isLoading.value).toBe(false) expect(performanceList.value[0]?.revenue).toBeCloseTo(initialRevenue + 500) }) }) 这里的语法重点有三个: beforeEach / afterEach 用来包住定时器和 mock 的初始化与清理。 vi.spyOn(Math, 'random') 让测试结果稳定,不会受随机数波动影响。 vi.runAllTimersAsync() 可以直接把 setTimeout 的 1.5 秒等待快进掉。 9.4 测试 Store import { describe, it, expect, beforeEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useAuthStore } from '@/stores/useAuthStore' describe('useAuthStore', () => { beforeEach(() => { localStorage.clear() setActivePinia(createPinia()) }) it('login 后应当写入 token 和用户信息', () => { const authStore = useAuthStore() authStore.login('token-001', { id: 'U-001', name: 'admin', age: 26 }) expect(authStore.token).toBe('token-001') expect(authStore.userInfo.name).toBe('admin') expect(authStore.isLoggedIn).toBe(true) expect(localStorage.getItem('token')).toBe('token-001') }) }) 这里的语法重点: setActivePinia(createPinia()) 相当于给每条测试准备一个全新的 Store 容器。 beforeEach 里清理 localStorage,避免前一条测试污染后一条。 Store 测试尽量直接断言“状态是否真的被改了”,而不是只测函数有没有被调用。 9.5 测试组件 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount } from '@vue/test-utils' import { createTestingPinia } from '@pinia/testing' import { useAuthStore } from '@/stores/useAuthStore' import UserLogin from '@/views/UserLogin/index.vue' const mockReplace = vi.fn() vi.mock('vue-router', () => ({ useRouter: () => ({ replace: mockReplace }) })) describe('UserLogin', () => { beforeEach(() => { localStorage.clear() vi.clearAllMocks() vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('提交表单后应当跳转到 dashboard', async () => { const wrapper = mount(UserLogin, { global: { plugins: [createTestingPinia({ createSpy: vi.fn, stubActions: false })], stubs: { 'v-card': { template: '<div><slot /></div>' }, 'v-icon': { template: '<i />' }, 'v-form': { template: '<form @submit.prevent="$emit(\'submit\', $event)"><slot /></form>' }, 'v-text-field': { template: '<input />' }, 'v-btn': { template: '<button type="submit"><slot /></button>' } } } }) const authStore = useAuthStore() await wrapper.find('form').trigger('submit') await vi.runAllTimersAsync() expect(authStore.login).toHaveBeenCalled() expect(mockReplace).toHaveBeenCalledWith('/dashboard') }) }) 这里的语法重点: vi.mock('vue-router', () => ...) 是模块替身,把真实路由替换成我们可控的假对象。 createTestingPinia({ createSpy: vi.fn }) 会把 Pinia action 包成可断言的 spy。 stubs 用来替换 Vuetify 组件,避免测试因为 UI 组件太重而失败。 组件里如果有 setTimeout,记得配合 vi.useFakeTimers() 和 vi.runAllTimersAsync()。 9.6 路由守卫也值得测 既然我们已经把登录拦截放到了 Router 层,那这一层也值得单独测: import { beforeEach, describe, expect, it } from 'vitest' import router from '@/router' describe('router auth guards', () => { beforeEach(async () => { localStorage.clear() await router.replace('/') }) it('未登录访问 dashboard 时,应当被重定向到 login', async () => { await router.replace('/dashboard') expect(router.currentRoute.value.path).toBe('/login') expect(router.currentRoute.value.query.redirect).toBe('/dashboard') }) it('已登录访问 login 时,应当被重定向到 dashboard', async () => { localStorage.setItem('token', 'guard-token') await router.replace('/login') expect(router.currentRoute.value.fullPath).toBe('/dashboard') }) }) 9.7 写测试时最容易踩的坑 如果组件依赖 Router、Pinia、Vuetify,而你没有 stub 或 mock,对应测试很容易直接挂掉。 createTestingPinia 来自 @pinia/testing,别忘记安装。 默认模板生成的测试文件是“脚手架占位用”,不是业务最终版本。 如果测试里用了假的定时器,记得在 afterEach 里调用 vi.useRealTimers() 还原。 路由守卫测试不要把 query 编码格式写死,优先断言 path 和 query 字段本身。 10. 常见坑 @/ 路径别名不是 Vue 天生自带的,如果你只在代码里照抄 @/stores/useAuthStore,但没有配置 vite.config.ts 和 tsconfig.app.json,项目会直接报路径找不到。 createWebHistory() 生成的 URL 更干净,但部署到静态服务器时必须配置 history fallback,不然刷新 /dashboard 很容易直接 404。 storeToRefs() 只适合提取会变化的状态,不适合把 action 也一起包进去。像 login、logout 这类函数,直接从 store 实例上拿就够了。 router.push() 和 router.replace() 不是一回事。登录成功、权限拦截、退出登录这类场景通常更适合 replace,不然用户点浏览器返回可能会回到不该回去的页面。 路由守卫和页面内校验最好区分职责:路由守卫负责“拦在进入页面前”,页面内校验负责“进入页面后的兜底和补充逻辑”。 localStorage 在普通前端项目里很好用,但如果未来把这套代码迁到 SSR 场景,直接在模块初始化阶段访问 localStorage 会报错,需要先判断运行环境。 Teleport 只改变 DOM 的挂载位置,不改变组件的逻辑归属。状态、事件、响应式依赖仍然归原组件管理。 使用 Teleport 时,to="body" 这种目标最稳;如果你写的是 to="#modal-root",就必须确保目标节点已经真实存在于页面里。 ref、reactive、computed 都属于响应式工具,但访问方式不同。尤其是 ref 在 JS / TS 中别忘了 .value。 如果启用了 noUncheckedIndexedAccess,像 performanceList.value[0] 这种访问会被 TypeScript 视作“可能为空”,需要先做空值保护。 setTimeout、setInterval、事件监听这类副作用如果页面频繁切换,最好在卸载时清理。当前项目里的 Splash 和 UserLogin 还是演示版写法,真实业务里建议补上清理逻辑。 createTestingPinia 需要额外安装 @pinia/testing,而且有些版本还要求你显式传 createSpy: vi.fn,不然测试会直接报错。 Vuetify 组件在单元测试里通常不能“裸挂载”就跑,很多时候要配合 stubs,否则测试会被 UI 依赖拖垮。 组件测试里如果用了假定时器,记得在 afterEach 里恢复 vi.useRealTimers(),不然后续测试可能被污染。 路由守卫测试不要把重定向后的 URL 编码细节写死,优先断言 path 和 query 字段本身,测试会更稳。 Plop 模板适合统一项目骨架,但生成出来的测试文件只是起步模板,不要把占位断言直接带进正式业务。