惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

G
Google Developers Blog
Google DeepMind News
Google DeepMind News
Hugging Face - Blog
Hugging Face - Blog
D
Docker
F
Fortinet All Blogs
博客园 - 三生石上(FineUI控件)
Project Zero
Project Zero
Engineering at Meta
Engineering at Meta
J
Java Code Geeks
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Simon Willison's Weblog
Simon Willison's Weblog
S
Security Affairs
NISL@THU
NISL@THU
T
Tor Project blog
A
About on SuperTechFans
宝玉的分享
宝玉的分享
腾讯CDC
S
Schneier on Security
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
P
Privacy & Cybersecurity Law Blog
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Stack Overflow Blog
Stack Overflow Blog
P
Privacy International News Feed
雷峰网
雷峰网
C
Cyber Attacks, Cyber Crime and Cyber Security
Vercel News
Vercel News
Cisco Talos Blog
Cisco Talos Blog
D
DataBreaches.Net
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Google Online Security Blog
Google Online Security Blog
Recorded Future
Recorded Future
L
LINUX DO - 热门话题
Microsoft Security Blog
Microsoft Security Blog
Latest news
Latest news
C
Check Point Blog
有赞技术团队
有赞技术团队
T
The Exploit Database - CXSecurity.com
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
云风的 BLOG
云风的 BLOG
SecWiki News
SecWiki News
Application and Cybersecurity Blog
Application and Cybersecurity Blog
爱范儿
爱范儿
月光博客
月光博客
V
Vulnerabilities – Threatpost
T
Threat Research - Cisco Blogs
P
Palo Alto Networks Blog
T
The Blog of Author Tim Ferriss
C
Cisco Blogs
Webroot Blog
Webroot Blog
S
Security @ Cisco Blogs

博客园 - kunyashaw

基于langgraph的智能问答工作流 langgraph 基础使用(条件/循环/嵌套子图) 渗透与常见服务配置 LangChain教程-4、构建简易智能 PPT 生成器 LangChain教程-3、Langchain进阶 LangChain教程-2、Langchain基础 LangChain教程-1、python基础 openclaw skill--一键生成项目宣讲介绍网页及长截图 openclaw新手skill推荐: openclaw-newbie-faq 用opencode和minimax给娃搭了一个raz学习站点 clawdbot(新名字:moltbot、OpenClaw)折腾过程 angualr基础 node基础 vue基础 某业务技术架构 漏洞治理 堡垒机方案 linux常见软件的环境搭建 linux运维基础
vue3Crush以及对比vue2
kunyashaw · 2026-04-05 · via 博客园 - kunyashaw

Vue 3 + Vite + TypeScript 实战手册

这份手册基于当前 companyDashboard 项目整理。除明确标注“可选扩展”的部分外,文中的目录、命令和代码都以当前项目为准,可直接对照工程阅读。

GitHub 仓库:https://github.com/kunyashaw/vue3Crush

目录


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.$routerthis.$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 最值钱的升级是什么

  1. Composition API 让业务逻辑真正可以抽走复用。
  2. Pinia 让全局状态写起来比传统 Vuex 更轻。
  3. script setup 把组件样板代码砍掉了一大截。
  4. TeleportSuspense、Fragments 这些能力,让模板组织更自然。
  5. 对 TypeScript 的支持成熟得多,特别适合中大型项目。

1.2 那 Vue 2 的经验是不是就作废了

不是。下面这些经验在 Vue 3 里仍然成立:

  • 组件设计仍然要遵守单向数据流。
  • 页面仍然要拆业务层和展示层。
  • 状态管理仍然要克制,不要什么都塞全局。
  • 测试、路由、权限、接口封装这些工程化能力依然重要。

核心 API 速查表

先把 refreactivecomposablePinia 放在一起看,后面读页面代码时会更顺。

名称 一句话理解 怎么定义 怎么用
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 当前项目在做什么

这是一个典型的后台控制台示例项目,页面流转很清晰:

  1. 访问 /,先进入 Splash 闪屏页。
  2. 2 秒后自动跳转到 /login
  3. 用户登录成功后进入 /dashboard
  4. 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 namecamelCase namedashCase 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

你会看到三个生成器:

  1. component
  2. view
  3. composable

4.8 使用 Plop 时的实战建议

  • view 生成器适合快速开页面骨架,但实际项目里通常还要补路由、布局和业务逻辑。
  • componentcomposable 自带的测试模板只是“起步模板”,生成后一定要按真实返回值和真实交互改断言。
  • 如果一个模板生成出来的测试始终只断言 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 容器接进去。

如果你准备启用 GlobalLoadingGlobalToastGlobalDialog,可以把 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 派生值 带缓存,依赖不变则不重算 “所有场景都比方法更好”

要特别修正两个常见误区:

  1. Vue 不是“整页刷新”,而是调度并 patch 受影响的 DOM。
  2. refreactive 虽然都能参与响应式系统,但底层表现不同,不能简单一句“它们都只是 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

这一套适合做三类全局交互:

  1. 全屏 Loading
  2. 轻提示 Toast
  3. 确认弹窗 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)
  })
})

这里的语法重点有三个:

  1. beforeEach / afterEach 用来包住定时器和 mock 的初始化与清理。
  2. vi.spyOn(Math, 'random') 让测试结果稳定,不会受随机数波动影响。
  3. 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')
  })
})

这里的语法重点:

  1. setActivePinia(createPinia()) 相当于给每条测试准备一个全新的 Store 容器。
  2. beforeEach 里清理 localStorage,避免前一条测试污染后一条。
  3. 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')
  })
})

这里的语法重点:

  1. vi.mock('vue-router', () => ...) 是模块替身,把真实路由替换成我们可控的假对象。
  2. createTestingPinia({ createSpy: vi.fn }) 会把 Pinia action 包成可断言的 spy。
  3. stubs 用来替换 Vuetify 组件,避免测试因为 UI 组件太重而失败。
  4. 组件里如果有 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 编码格式写死,优先断言 pathquery 字段本身。

10. 常见坑

  • @/ 路径别名不是 Vue 天生自带的,如果你只在代码里照抄 @/stores/useAuthStore,但没有配置 vite.config.tstsconfig.app.json,项目会直接报路径找不到。
  • createWebHistory() 生成的 URL 更干净,但部署到静态服务器时必须配置 history fallback,不然刷新 /dashboard 很容易直接 404。
  • storeToRefs() 只适合提取会变化的状态,不适合把 action 也一起包进去。像 loginlogout 这类函数,直接从 store 实例上拿就够了。
  • router.push()router.replace() 不是一回事。登录成功、权限拦截、退出登录这类场景通常更适合 replace,不然用户点浏览器返回可能会回到不该回去的页面。
  • 路由守卫和页面内校验最好区分职责:路由守卫负责“拦在进入页面前”,页面内校验负责“进入页面后的兜底和补充逻辑”。
  • localStorage 在普通前端项目里很好用,但如果未来把这套代码迁到 SSR 场景,直接在模块初始化阶段访问 localStorage 会报错,需要先判断运行环境。
  • Teleport 只改变 DOM 的挂载位置,不改变组件的逻辑归属。状态、事件、响应式依赖仍然归原组件管理。
  • 使用 Teleport 时,to="body" 这种目标最稳;如果你写的是 to="#modal-root",就必须确保目标节点已经真实存在于页面里。
  • refreactivecomputed 都属于响应式工具,但访问方式不同。尤其是 ref 在 JS / TS 中别忘了 .value
  • 如果启用了 noUncheckedIndexedAccess,像 performanceList.value[0] 这种访问会被 TypeScript 视作“可能为空”,需要先做空值保护。
  • setTimeoutsetInterval、事件监听这类副作用如果页面频繁切换,最好在卸载时清理。当前项目里的 SplashUserLogin 还是演示版写法,真实业务里建议补上清理逻辑。
  • createTestingPinia 需要额外安装 @pinia/testing,而且有些版本还要求你显式传 createSpy: vi.fn,不然测试会直接报错。
  • Vuetify 组件在单元测试里通常不能“裸挂载”就跑,很多时候要配合 stubs,否则测试会被 UI 依赖拖垮。
  • 组件测试里如果用了假定时器,记得在 afterEach 里恢复 vi.useRealTimers(),不然后续测试可能被污染。
  • 路由守卫测试不要把重定向后的 URL 编码细节写死,优先断言 pathquery 字段本身,测试会更稳。
  • Plop 模板适合统一项目骨架,但生成出来的测试文件只是起步模板,不要把占位断言直接带进正式业务。