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 模板适合统一项目骨架,但生成出来的测试文件只是起步模板,不要把占位断言直接带进正式业务。
