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

推荐订阅源

酷 壳 – CoolShell
酷 壳 – CoolShell
H
Hacker News: Front Page
P
Palo Alto Networks Blog
T
ThreatConnect
Apple Machine Learning Research
Apple Machine Learning Research
博客园_首页
T
True Tiger Recordings
P
Privacy & Cybersecurity Law Blog
B
Blog
IT之家
IT之家
Last Week in AI
Last Week in AI
F
Full Disclosure
Hacker News: Ask HN
Hacker News: Ask HN
C
Comments on: Blog
Microsoft Azure Blog
Microsoft Azure Blog
C
Cybersecurity and Infrastructure Security Agency CISA
Microsoft Security Blog
Microsoft Security Blog
博客园 - 【当耐特】
N
News and Events Feed by Topic
NISL@THU
NISL@THU
腾讯CDC
雷峰网
雷峰网
Security Latest
Security Latest
李成银的技术随笔
M
Microsoft Research Blog - Microsoft Research
L
LangChain Blog
L
Lohrmann on Cybersecurity
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
C
Check Point Blog
Y
Y Combinator Blog
Recent Announcements
Recent Announcements
博客园 - Franky
N
News | PayPal Newsroom
V
V2EX
A
About on SuperTechFans
The Register - Security
The Register - Security
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Google Online Security Blog
Google Online Security Blog
MyScale Blog
MyScale Blog
Cisco Talos Blog
Cisco Talos Blog
Vercel News
Vercel News
WordPress大学
WordPress大学
C
Cyber Attacks, Cyber Crime and Cyber Security
The Hacker News
The Hacker News
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
爱范儿
爱范儿
A
Arctic Wolf
L
LINUX DO - 最新话题
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More

博客园 - 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 特性对比总表

先把这部分放在前面,方便对整个技术栈先建立一个整体判断。

框架入口 new Vue()createApp() 应用实例边界更清晰,适合多实例与插件隔离 核心 API 风格 Options API 为主 Composition API 与 Options API 并存 逻辑可按“业务能力”聚合,不再被 data / methods / computed 打散 响应式底层 Object.definePropertyProxy + effect 调度体系 对数组、动态属性、新旧值追踪都更自然 单值响应式 状态通常统一放在 data / computed 中管理 ref() 在 JS / TS 中显式 .value,类型推导更直观 对象响应式 data() 返回对象统一管理 reactive() 更适合复杂状态容器,但解构时要注意丢响应式 逻辑复用 mixins、高阶组件、renderless component composables 复用逻辑来源更清晰,命名冲突更少 TypeScript 支持 支持一般,类型推导较吃力 官方支持明显更成熟 大型项目的开发体验提升很明显 多根节点 不支持 支持 Fragment 模板不必再被无意义的包裹 div 污染 Teleport 无原生能力 原生支持 弹窗、抽屉、全局提示更容易摆脱层级限制 Suspense 无 原生支持 异步组件和加载态处理更统一 Tree-shaking 效果有限 更友好 没用到的 API 更容易被摇掉,构建体积更可控 生命周期入口 beforeCreate / createdsetup() + 各类 hooks setup 不是传统意义生命周期钩子,而是组合式逻辑入口 组件通信 props / $emit / provide/inject / event busprops / emits / provide / inject 组件契约更清晰,Event Bus 不再是主流方案 全局状态 Vuex 常见 Pinia 成为官方推荐 API 更轻,TS 体验更好,心智负担更低 路由使用方式 this.$routerthis.$routeuseRouter()useRoute() 组合式函数更适合 script setup 模板语法 v-model 默认 value / input 语义 v-model 更统一,支持多个 v-model 自定义组件双向绑定更灵活 自定义事件声明 约定式为主 emits 显式声明 组件契约更清晰,利于 TS 和团队协作 过滤器 Filters 常见 已移除主流地位 推荐改用 computed、方法或格式化函数 this 使用习惯 大量依赖 thisscript setup 基本不依赖 this 代码更贴近原生 JS / TS 思维 性能优化 以运行时优化为主 编译期 + 运行时协同优化 静态提升、Patch Flag 等让渲染更高效 自定义渲染器 生态层面相对弱 提供更强 runtime-core 能力 更容易扩展到非 DOM 平台 生态主流 Vue CLI、Vuex Vite、Pinia、SFC <script setup> 新项目大多默认围绕 Vue 3 工具链构建
对比维度 Vue 2 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 这套路由守卫解决了什么

未登录访问 /dashboardmeta.requiresAuth + beforeEach 自动重定向到 /login 已登录访问 /loginmeta.guestOnly + beforeEach 自动重定向到 /dashboard 登录后想回原页面 query 里带上 redirect 后续可继续扩展“登录后返回原地址”
场景 处理方式 结果

这样登录拦截就不再只靠页面内的 onMounted 兜底,而是前置到了路由层。页面里的二次校验仍然可以保留,作为双保险。

5.6 为什么这里用 createWebHistory

createWebHistory()/login 更干净,适合正式项目 createWebHashHistory()/#/login 部署最省心,但 URL 带 #
模式 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 的准确理解

ref 单值或对象引用包装 JS/TS 中通过 .value 访问 “整个页面整页刷新” reactive 对象 / 数组响应式代理 适合复杂对象结构 “可以随便解构不丢响应式” computed 派生值 带缓存,依赖不变则不重算 “所有场景都比方法更好”
API 用途 关键点 不要误解成

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

  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 各自承担的职责

Splash 过渡页 onMounted、定时器、router.replaceUserLogin 登录页 ref、Pinia Action、表单提交 Dashboard 主控制台 storeToRefs、Composable、Teleport、路由拦截
View 职责 重点技术

8. Teleport 与全局 UI 模块

这一块是当前工程里很适合继续扩展的部分。

8.1 为什么需要 Teleport

当弹窗、抽屉、全屏遮罩写在一个受限容器里时,最常见的三个 CSS 问题是:

overflow: hidden 超出区域被裁切 transformposition: 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.vueGlobalToast.vue 底部轻提示 App.vueGlobalDialog.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 模板适合统一项目骨架,但生成出来的测试文件只是起步模板,不要把占位断言直接带进正式业务。