Vue 3 完整学习教程

版本: Vue 3.5+ | 语言: JavaScript / TypeScript | 更新日期: 2026-06-08
基于官方文档: https://cn.vuejs.org/


目录

第一部分:基础入门

  1. Vue 3 概述与环境搭建
  2. 响应式基础:ref 与 reactive
  3. 计算属性与侦听器
  4. 模板语法与指令
  5. 条件渲染与列表渲染
  6. 事件处理与表单绑定

第二部分:组件系统

  1. 组件基础与注册
  2. Props 父传子通信
  3. Emits 子传父通信
  4. defineExpose 与组件暴露
  5. 插槽 Slots
  6. 依赖注入 provide / inject

第三部分:逻辑复用

  1. Composables 组合式函数
  2. 自定义指令
  3. 插件系统

第四部分:内置组件

  1. Transition 过渡动画
  2. TransitionGroup 列表过渡
  3. KeepAlive 组件缓存
  4. Teleport 传送门
  5. Suspense 异步渲染

第五部分:状态管理与路由

  1. Pinia 状态管理
  2. Vue Router 4 路由管理

第六部分:工程化

  1. Vite 构建工具
  2. TypeScript 集成
  3. 测试体系 (Vitest + Playwright)
  4. 项目结构与最佳实践
  5. VueUse 实用工具库

第七部分:进阶实战

  1. 异步组件与代码分割
  2. 渲染函数与 JSX
  3. 服务端渲染 SSR 与 Nuxt 3
  4. 性能优化
  5. 安全最佳实践

1. Vue 3 概述与环境搭建

1.1 Vue 3 核心变化

特性 Vue 2 Vue 3
响应式系统 Object.defineProperty Proxy(支持数组索引、属性新增)
API 风格 Options API(data/methods/computed Composition APIsetup/ref/reactive
组件写法 单文件组件 <script setup>(编译时语法糖)
状态管理 Vuex Pinia(官方推荐)
路由 Vue Router 3 Vue Router 4
构建工具 Vue CLI / Webpack Vite(极速开发)
TypeScript 支持较弱 原生 TypeScript 支持
包体积 较大 Tree-shaking 后更小
性能 基准 更快(编译优化、静态提升)

1.2 创建项目

# 方式一:使用 Vite(推荐)
npm create vite@latest my-vue-app -- --template vue
# 或带 TypeScript
npm create vite@latest my-vue-app -- --template vue-ts

cd my-vue-app
npm install
npm run dev

# 方式二:使用 create-vue 脚手架(包含 Router + Pinia 等选项)
npm create vue@latest

# 进入项目并启动
cd my-vue-app
npm install
npm run dev

1.3 单文件组件 (SFC) 结构

<!-- App.vue — Vue 3 SFC 标准结构 -->
<script setup>
// 逻辑代码 — Composition API
import { ref } from 'vue'

const message = ref('Hello Vue 3!')

function handleClick() {
  message.value = '你点击了按钮!'
}
</script>

<template>
  <!-- 模板 — HTML -->
  <div class="app">
    <h1>{{ message }}</h1>
    <button @click="handleClick">点击我</button>
  </div>
</template>

<style scoped>
/* 样式 — CSS(scoped 表示仅当前组件生效) */
.app {
  text-align: center;
  padding: 20px;
}
h1 {
  color: #42b983;
}
</style>

1.4 Options API vs Composition API

<!-- Options API 风格 -->
<script>
export default {
  data() {
    return { count: 0, name: 'Vue' }
  },
  computed: {
    doubleCount() { return this.count * 2 }
  },
  methods: {
    increment() { this.count++ }
  },
  watch: {
    count(newVal) { console.log(newVal) }
  },
  mounted() { console.log('组件已挂载') }
}
</script>

<!-- Composition API 风格(<script setup>) -->
<script setup>
import { ref, computed, watch, onMounted } from 'vue'

const count = ref(0)
const name = ref('Vue')
const doubleCount = computed(() => count.value * 2)
const increment = () => count.value++
watch(count, (newVal) => console.log(newVal))
onMounted(() => console.log('组件已挂载'))
</script>

1.5 应用实例与挂载

// main.js
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 全局注册
app.component('GlobalButton', GlobalButton)  // 全局组件
app.directive('focus', focusDirective)        // 全局指令
app.use(router)                                // 插件
app.use(createPinia())

app.mount('#app')

// 访问全局属性
app.config.globalProperties.$api = apiClient

2. 响应式基础:ref 与 reactive

2.1 ref — 包装基本类型

<script setup>
import { ref } from 'vue'

// ref 可包装任意类型,.value 访问/修改
const count = ref(0)
const name = ref('Vue')
const isActive = ref(true)
const items = ref([1, 2, 3])
const user = ref({ name: 'Alice', age: 25 })

// 在 JS 中访问/修改需要 .value
console.log(count.value)   // 0
count.value++               // 1
user.value.name = 'Bob'     // 'Bob'

// 在模板中自动解包,不需要 .value
// <p>{{ count }}</p>
// <p>{{ user.name }}</p>

// ref 可以整体替换
user.value = { name: 'Charlie', age: 30 }
</script>

2.2 reactive — 包装对象类型

<script setup>
import { reactive } from 'vue'

// reactive 适合表单、嵌套对象等
const state = reactive({
  count: 0,
  user: {
    name: 'Alice',
    profile: { age: 25, city: '北京' }
  },
  list: ['a', 'b', 'c']
})

// 直接访问,不需要 .value
state.count++
state.user.profile.city = '上海'
state.list.push('d')

// ⚠️ reactive 的限制:
// 1. 不能整体替换整个对象(会丢失响应性)
//    state = reactive({...}) // ❌ 无效
// 2. 解构会丢失响应性
//    const { count } = state   // ❌ 不是响应式的
//    需要用 toRefs:
import { toRefs } from 'vue'
const { count } = toRefs(state)  // ✅ 保持响应性
</script>

2.3 ref vs reactive 选择指南

// ✅ 推荐:大多数场景统一使用 ref
const count = ref(0)
const user = ref({ name: 'Alice' })
const list = ref([])

// ⚠️ reactive 仅在以下场景可能有优势:
// 1. 复杂的表单对象
const form = reactive({
  username: '',
  password: '',
  email: '',
  agreeTerms: false
})

// 2. 不需要整体替换的数据集合
const config = reactive({
  theme: 'light',
  locale: 'zh-CN',
  features: { darkMode: true, rtl: false }
})

2.4 响应式工具 API

<script setup>
import { ref, reactive, isRef, unref, toRef, toRefs, toRaw, markRaw } from 'vue'

const count = ref(0)

// isRef — 判断是否为 ref
console.log(isRef(count))  // true

// unref — 返回 ref 的值或原始值
console.log(unref(count))  // 0(等价于 isRef(val) ? val.value : val)

// toRef — 从 reactive 对象提取单个属性为 ref
const state = reactive({ name: 'Alice', age: 25 })
const name = toRef(state, 'name')  // 双向绑定
name.value = 'Bob'
console.log(state.name)  // 'Bob'

// toRefs — 将 reactive 对象的每个属性转为 ref
const { name: n, age } = toRefs(state)

// toRaw — 获取 reactive/ref 的原始对象(用于性能优化、临时操作)
const raw = toRaw(state)
console.log(raw === state)  // false(raw 不是响应式的)

// markRaw — 标记对象永不转为响应式(用于大型不变数据)
const hugeData = markRaw({ /* 大量只读配置 */ })
</script>

2.5 shallowRef 与 shallowReactive

<script setup>
import { shallowRef, shallowReactive, triggerRef } from 'vue'

// shallowRef — 仅 .value 本身是响应式的,内部属性不是
const state = shallowRef({ count: 0 })
state.value = { count: 1 }  // ✅ 触发更新(替换整个value)
state.value.count = 2       // ❌ 不触发更新(修改内部属性)

// 手动触发 shallowRef 的更新
state.value.count = 2
triggerRef(state)           // 强制触发更新

// shallowReactive — 仅第一层属性是响应式的
const obj = shallowReactive({
  a: 1,
  nested: { b: 2 }
})
obj.a = 10            // ✅ 触发更新
obj.nested.b = 20     // ❌ 不触发更新
</script>

3. 计算属性与侦听器

3.1 computed — 计算属性

<script setup>
import { ref, reactive, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

// 基础计算属性(只读)
const fullName = computed(() => `${firstName.value}${lastName.value}`)

console.log(fullName.value)  // '张三'
firstName.value = '李'
console.log(fullName.value)  // '李三'

// 购物车总价
const cart = reactive([
  { name: '苹果', price: 5, quantity: 3 },
  { name: '香蕉', price: 3, quantity: 5 },
  { name: '橘子', price: 4, quantity: 2 },
])

const totalPrice = computed(() =>
  cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
)

// 可写计算属性
const count = ref(0)
const double = computed({
  get: () => count.value * 2,
  set: (val) => { count.value = val / 2 }
})

double.value = 10
console.log(count.value)  // 5

// 计算属性 vs 方法
// computed: 基于响应式依赖缓存,依赖不变不重新计算
// 方法: 每次渲染都重新执行
</script>

3.2 watch — 侦听器

<script setup>
import { ref, reactive, watch } from 'vue'

// 侦听单个 ref
const count = ref(0)
watch(count, (newVal, oldVal) => {
  console.log(`count: ${oldVal} → ${newVal}`)
})

// 侦听多个 ref
const firstName = ref('')
const lastName = ref('')
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
  console.log(`名字: ${oldFirst}${oldLast} → ${newFirst}${newLast}`)
})

// 侦听 reactive 对象(默认深度侦听)
const user = reactive({ name: 'Alice', age: 25 })
watch(user, (newVal, oldVal) => {
  // 注意:reactive 的 oldVal 和 newVal 是同一个引用
  console.log('用户信息已变更', newVal)
})

// 侦听 reactive 对象的特定属性(需要用 getter 函数)
watch(
  () => user.name,
  (newName, oldName) => {
    console.log(`name: ${oldName} → ${newName}`)
  }
)

// 深度侦听 ref 包装的对象
const obj = ref({ a: { b: { c: 1 } } })
watch(obj, (newVal) => {
  console.log('深层属性变更', newVal)
}, { deep: true })

// 立即执行
watch(count, (val) => {
  console.log('立即执行:', val)
}, { immediate: true })

// 停止侦听
const stop = watch(count, (val) => console.log(val))
// 条件满足时停止
if (count.value > 10) stop()
</script>

3.3 watchEffect — 自动追踪依赖

<script setup>
import { ref, watchEffect, watchPostEffect, watchSyncEffect } from 'vue'

const count = ref(0)
const doubled = ref(0)

// watchEffect: 自动追踪回调中使用的所有响应式依赖
const stop = watchEffect(() => {
  // 自动追踪 count.value
  doubled.value = count.value * 2
  console.log(`count=${count.value}, doubled=${doubled.value}`)
})
// 初始立即执行一次
// 每次 count 变化时重新执行

// 清除副作用
const keyword = ref('')
watchEffect((onCleanup) => {
  const id = setTimeout(() => {
    // 模拟 API 请求
    fetchResults(keyword.value)
  }, 300)

  // 下一次执行前会调用上一次的清理函数
  onCleanup(() => clearTimeout(id))
})

// watchPostEffect: DOM 更新后执行(等价于 watchEffect + flush: 'post')
watchPostEffect(() => {
  // 这里可以安全地访问更新后的 DOM
  console.log('DOM 已更新')
})

// watchSyncEffect: 同步执行(不常用,需谨慎)
// watchSyncEffect(() => { ... })
</script>

3.4 watch vs watchEffect 对比

特性 watch watchEffect
依赖追踪 显式指定数据源 自动追踪
初始执行 默认不执行,需 immediate: true 立即执行一次
旧值获取 (newVal, oldVal) ❌ 无法获取
控制粒度 精确控制要监听的数据 自动监听所有使用的依赖
适用场景 监听特定数据变化做操作 自动追踪依赖执行副作用

4. 模板语法与指令

4.1 文本插值与表达式

<template>
  <!-- 文本插值 -->
  <p>{{ message }}</p>
  <p>{{ message.toUpperCase() }}</p>
  <p>{{ count + 1 }}</p>
  <p>{{ isActive ? '激活' : '未激活' }}</p>
  <p>{{ items.length > 0 ? `共${items.length}项` : '无数据' }}</p>

  <!-- v-text — 等价于 {{ }} -->
  <span v-text="message"></span>

  <!-- v-html — 渲染原始 HTML(注意 XSS 风险!) -->
  <div v-html="trustedHtml"></div>

  <!-- v-once — 只渲染一次,之后不再更新 -->
  <span v-once>{{ staticMessage }}</span>

  <!-- v-pre — 跳过编译,原样输出 -->
  <pre v-pre>{{ 这里的模板语法不会被编译 }}</pre>

  <!-- v-memo — 缓存模板片段,依赖不变则跳过更新 -->
  <div v-memo="[count]">
    <!-- 仅当 count 变化时才重新渲染 -->
    <ExpensiveComponent :count="count" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
const message = ref('Hello Vue!')
const count = ref(0)
const isActive = ref(true)
const items = ref([1, 2, 3])
const trustedHtml = ref('<strong>粗体文本</strong>')
const staticMessage = ref('这条消息不会改变')
</script>

4.2 指令速查

<template>
  <!-- v-bind — 动态绑定属性 -->
  <img :src="imageUrl" :alt="imageAlt" />
  <div :class="{ active: isActive, 'text-danger': hasError }"></div>
  <div :style="{ color: textColor, fontSize: fontSize + 'px' }"></div>
  <!-- 多属性绑定 -->
  <div v-bind="attrsObject"></div>

  <!-- v-on — 事件监听 -->
  <button @click="handleClick">点击</button>
  <button @click="count++">count++</button>
  <form @submit.prevent="onSubmit">提交</form>
  <input @keyup.enter="onEnter" @keyup.escape="onEscape" />

  <!-- v-model — 双向绑定 -->
  <input v-model="username" />
  <input v-model.trim="text" />         <!-- 自动去空格 -->
  <input v-model.number="age" />        <!-- 自动转数字 -->
  <input v-model.lazy="comment" />      <!-- change 时更新(非 input) -->
  <textarea v-model="bio"></textarea>
  <input type="checkbox" v-model="checked" />
  <select v-model="selected">
    <option value="">请选择</option>
    <option value="1">选项1</option>
  </select>

  <!-- v-if / v-else-if / v-else — 条件渲染 -->
  <div v-if="type === 'A'">类型A</div>
  <div v-else-if="type === 'B'">类型B</div>
  <div v-else>其他类型</div>

  <!-- v-show — 条件显示(始终渲染,只切换 display) -->
  <div v-show="isVisible">内容</div>

  <!-- v-for — 列表渲染 -->
  <li v-for="(item, index) in items" :key="item.id">
    {{ index + 1 }}. {{ item.name }}
  </li>
  <li v-for="(value, key, index) in myObject" :key="key">
    {{ key }}: {{ value }}
  </li>
</template>

4.3 动态参数与修饰符

<template>
  <!-- 动态参数:用方括号绑定动态的 attribute 名或事件名 -->
  <div :[dynamicAttr]="value"></div>
  <button @[dynamicEvent]="handler">动态事件</button>

  <!-- 事件修饰符 -->
  <a @click.stop="doThis">阻止冒泡</a>
  <form @submit.prevent="onSubmit">阻止默认行为</form>
  <div @click.self="doThat">仅自身触发</div>
  <button @click.once="doOnce">仅触发一次</button>
  <div @scroll.passive="onScroll">passive模式</div>

  <!-- 按键修饰符 -->
  <input @keyup.enter="submit" />
  <input @keyup.ctrl.s="save" />
  <input @keyup.alt.enter="quickSubmit" />

  <!-- 鼠标修饰符 -->
  <button @click.left="leftClick">左键</button>
  <button @click.right.prevent="rightClick">右键</button>
  <button @click.middle="middleClick">中键</button>
</template>

<script setup>
import { ref } from 'vue'
const dynamicAttr = ref('title')
const dynamicEvent = ref('click')
</script>

5. 条件渲染与列表渲染

5.1 v-if / v-else-if / v-else

<template>
  <!-- 基础条件渲染 -->
  <div v-if="isLoggedIn">
    <p>欢迎回来,{{ username }}!</p>
    <button @click="logout">退出登录</button>
  </div>
  <div v-else>
    <p>请登录以继续</p>
    <button @click="login">登录</button>
  </div>

  <!-- 多条件链 -->
  <div v-if="score >= 90">优秀</div>
  <div v-else-if="score >= 75">良好</div>
  <div v-else-if="score >= 60">及格</div>
  <div v-else>不及格</div>

  <!-- <template> 包裹多个元素(自身不会渲染到DOM) -->
  <template v-if="show">
    <h2>标题</h2>
    <p>段落1</p>
    <p>段落2</p>
  </template>

  <!-- v-if vs v-show 区别 -->
  <!-- v-if: 懒渲染,条件为false时不渲染任何DOM -->
  <!-- v-show: 始终渲染,通过CSS display切换 -->
  <div v-if="heavyComponent">开销大的组件(切换不频繁)</div>
  <div v-show="toggleFrequent">频繁切换的元素</div>
</template>

5.2 v-for 列表渲染

<template>
  <!-- 数组渲染 -->
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.name }} - {{ item.price }}元
    </li>
  </ul>

  <!-- 带索引 -->
  <li v-for="(item, index) in items" :key="item.id">
    {{ index + 1 }}. {{ item.name }}
  </li>

  <!-- 对象渲染 -->
  <li v-for="(value, key, index) in userInfo" :key="key">
    {{ index }}. {{ key }}: {{ value }}
  </li>

  <!-- 范围渲染 -->
  <span v-for="n in 10" :key="n">{{ n }}</span>

  <!-- v-for  + v-if 的正确写法 -->
  <!-- ❌ 不要在同一元素上同时使用 v-for 和 v-if -->
  <!-- ✅ 在 <template> 上使用 v-if -->
  <template v-for="item in items" :key="item.id">
    <li v-if="item.active">{{ item.name }}</li>
  </template>

  <!-- 或先过滤再渲染 -->
  <li v-for="item in activeItems" :key="item.id">{{ item.name }}</li>
</template>

<script setup>
import { ref, computed } from 'vue'

const items = ref([
  { id: 1, name: '商品A', price: 99, active: true },
  { id: 2, name: '商品B', price: 199, active: false },
  { id: 3, name: '商品C', price: 299, active: true },
])

const activeItems = computed(() =>
  items.value.filter(item => item.active)
)

const userInfo = ref({ name: '张三', age: 30, city: '北京' })
</script>

5.3 key 的作用

<template>
  <!-- key 帮助 Vue 识别每个节点,高效更新 DOM -->
  <!-- 必须用唯一且稳定的标识(如数据库 ID),不要用 index -->

  <!-- ✅ 正确:使用稳定的唯一 ID -->
  <li v-for="user in users" :key="user.id">{{ user.name }}</li>

  <!-- ❌ 错误:用 index 作为 key(列表顺序变化时会导致错误复用) -->
  <!-- <li v-for="(user, index) in users" :key="index">{{ user.name }}</li> -->

  <!-- key 也用于强制替换组件 -->
  <UserProfile :key="user.id" :user="user" />

  <!-- 强制重新渲染 -->
  <ExpensiveComponent :key="refreshKey" />
  <button @click="refreshKey++">刷新组件</button>
</template>

6. 事件处理与表单绑定

6.1 事件处理

<template>
  <!-- 内联处理器 -->
  <button @click="count++">count: {{ count }}</button>

  <!-- 方法处理器 -->
  <button @click="handleIncrement(5)">+5</button>

  <!-- 访问原生事件对象 -->
  <button @click="handleClick($event)">点击</button>
  <input @keyup="handleKeyup($event)" />

  <!-- 多个处理器 -->
  <button @click="handler1(), handler2()">执行多个</button>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

function handleIncrement(amount) {
  count.value += amount
}

function handleClick(event) {
  // event 是原生 DOM 事件
  console.log(event.target, event.clientX, event.clientY)
}

function handleKeyup(event) {
  if (event.key === 'Enter') {
    console.log('按下了回车')
  }
}
</script>

6.2 v-model 双向绑定

<template>
  <!-- 文本输入 -->
  <input v-model="text" placeholder="输入文本" />
  <p>输入的内容: {{ text }}</p>

  <!-- .trim — 自动去除首尾空格 -->
  <input v-model.trim="username" />

  <!-- .number — 自动转换为数字 -->
  <input v-model.number="age" type="text" />

  <!-- .lazy — 在 change 事件时更新(而非 input) -->
  <input v-model.lazy="comment" />

  <!-- 文本域 -->
  <textarea v-model="bio" rows="4"></textarea>

  <!-- 复选框(单个) -->
  <input type="checkbox" v-model="agreed" />
  <span>{{ agreed ? '已同意' : '未同意' }}</span>

  <!-- 复选框(多个 — 绑定到数组) -->
  <label><input type="checkbox" v-model="hobbies" value="reading" /> 阅读</label>
  <label><input type="checkbox" v-model="hobbies" value="coding" /> 编程</label>
  <label><input type="checkbox" v-model="hobbies" value="gaming" /> 游戏</label>
  <p>已选: {{ hobbies }}</p>

  <!-- 单选框 -->
  <label><input type="radio" v-model="gender" value="male" /> 男</label>
  <label><input type="radio" v-model="gender" value="female" /> 女</label>
  <p>性别: {{ gender }}</p>

  <!-- 下拉选择 -->
  <select v-model="selectedCity">
    <option value="">请选择城市</option>
    <option value="beijing">北京</option>
    <option value="shanghai">上海</option>
    <option value="shenzhen">深圳</option>
  </select>

  <!-- 多选下拉(绑定到数组) -->
  <select v-model="selectedTags" multiple>
    <option value="vue">Vue</option>
    <option value="react">React</option>
    <option value="angular">Angular</option>
  </select>
</template>

<script setup>
import { ref } from 'vue'

const text = ref('')
const username = ref('')
const age = ref(0)
const comment = ref('')
const bio = ref('')
const agreed = ref(false)
const hobbies = ref(['coding'])
const gender = ref('male')
const selectedCity = ref('beijing')
const selectedTags = ref(['vue'])
</script>

6.3 组件上的 v-model

<!-- 父组件 -->
<template>
  <!-- 单个 v-model(默认绑定 modelValue) -->
  <CustomInput v-model="searchText" />

  <!-- 带参数的 v-model(绑定指定 prop) -->
  <CustomInput
    v-model:title="title"
    v-model:content="content"
  />

  <!-- v-model 修饰符 -->
  <CustomInput v-model.capitalize="text" />
</template>

<!-- 子组件 CustomInput.vue -->
<script setup>
// 单个 v-model(Vue 3.4+ 推荐 defineModel)
const model = defineModel({ default: '' })

// 多个 v-model
const title = defineModel('title', { default: '' })
const content = defineModel('content', { default: '' })

// 修饰符处理
const model = defineModel()
const props = defineProps({
  modelModifiers: { type: Object, default: () => ({}) }
})
function emitValue(e) {
  let value = e.target.value
  if (props.modelModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  model.value = value
}
</script>

<template>
  <input :value="model" @input="emitValue" />
</template>

7. 组件基础与注册

7.1 组件注册

<script setup>
// 局部注册(推荐)— 直接导入即可使用
import MyButton from './components/MyButton.vue'
import UserCard from './components/UserCard.vue'
import { defineAsyncComponent } from 'vue'

// 异步组件(按需加载)
const HeavyChart = defineAsyncComponent(() =>
  import('./components/HeavyChart.vue')
)
</script>

<template>
  <MyButton />
  <UserCard :user="currentUser" />
  <HeavyChart v-if="showChart" />
</template>
// main.js — 全局注册(仅在真正需要全局使用时)
import { createApp } from 'vue'
import App from './App.vue'
import GlobalButton from './components/GlobalButton.vue'

const app = createApp(App)

// 全局注册
app.component('GlobalButton', GlobalButton)
// 之后在任何组件中都可以直接使用 <GlobalButton />

app.mount('#app')

7.2 生命周期钩子

<script setup>
import {
  onBeforeMount, onMounted,
  onBeforeUpdate, onUpdated,
  onBeforeUnmount, onUnmounted,
  onActivated, onDeactivated,
  onErrorCaptured,
  ref
} from 'vue'

const data = ref(null)

// 创建阶段(setup 本身等价于 beforeCreate + created)

// 挂载阶段
onBeforeMount(() => {
  console.log('组件即将挂载,DOM 尚未生成')
})

onMounted(async () => {
  console.log('组件已挂载,可以访问 DOM')
  // 适合:发起 API 请求、初始化第三方库
  data.value = await fetchData()
})

// 更新阶段
onBeforeUpdate(() => {
  console.log('数据变化,即将重新渲染')
})

onUpdated(() => {
  console.log('DOM 已更新')
})

// 卸载阶段
onBeforeUnmount(() => {
  console.log('组件即将卸载')
  // 适合:清除定时器、取消订阅、移除事件监听
})

onUnmounted(() => {
  console.log('组件已卸载')
})

// KeepAlive 特有
onActivated(() => { console.log('被 KeepAlive 缓存的组件激活') })
onDeactivated(() => { console.log('被 KeepAlive 缓存的组件失活') })

// 错误捕获
onErrorCaptured((err, instance, info) => {
  console.error('捕获到子组件错误:', err)
  return false  // 阻止错误继续向上传播
})
</script>

7.3 组件 v-model 进阶

<!-- 父组件 -->
<template>
  <div>
    <h2>{{ title }}</h2>
    <CustomInput v-model="username" v-model:email="email" />
    <CustomInput v-model.capitalize="name" />
  </div>
</template>

<!-- 子组件 CustomInput.vue -->
<script setup>
// Vue 3.4+ defineModel 写法
const modelValue = defineModel({ default: '' })
const email = defineModel('email', { default: '' })

// 获取修饰符
const props = defineProps({
  modelModifiers: { type: Object, default: () => ({}) }
})

function handleInput(e) {
  let value = e.target.value
  if (props.modelModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  modelValue.value = value
}
</script>

<template>
  <div>
    <input :value="modelValue" @input="handleInput" />
    <input :value="email" @input="email = $event.target.value" />
  </div>
</template>

7.4 defineOptions

<script setup>
// defineOptions — 定义组件选项(名称、inheritAttrs 等)
defineOptions({
  name: 'MyComponent',    // 组件名称(DevTools 显示、递归调用)
  inheritAttrs: false,    // 不自动将 $attrs 传给根元素
})
</script>

<template>
  <div>
    <!-- 手动传递 $attrs -->
    <input v-bind="$attrs" />
  </div>
</template>

8. Props 父传子通信

8.1 defineProps 基础

<!-- 子组件 Child.vue -->
<script setup>
// 方式一:运行时声明
const props = defineProps({
  title: { type: String, required: true },
  count: { type: Number, default: 0 },
  tags: { type: Array, default: () => [] },
  config: { type: Object, default: () => ({ theme: 'light' }) },
})

// 方式二:纯 TypeScript 类型声明(推荐)
// const props = defineProps<{
//   title: string
//   count?: number
//   tags?: string[]
//   config?: { theme: string }
// }>()

console.log(props.title)
console.log(props.count)
</script>

<template>
  <div>
    <h3>{{ title }}</h3>
    <p>数量: {{ count }}</p>
  </div>
</template>

8.2 withDefaults

<script setup lang="ts">
// withDefaults — 为 TS 类型的 Props 设置默认值
interface Props {
  title: string
  size?: 'small' | 'medium' | 'large'
  items?: string[]
  callback?: (val: string) => void
}

const props = withDefaults(defineProps<Props>(), {
  size: 'medium',
  items: () => ['默认项'],  // 数组/对象必须用函数返回
  callback: (val) => console.log(val),
})
</script>

8.3 Props 校验

<script setup>
const props = defineProps({
  // 基础类型
  name: String,

  // 多种类型
  id: [String, Number],

  // 必填
  title: { type: String, required: true },

  // 默认值
  count: { type: Number, default: 0 },

  // 自定义校验
  status: {
    type: String,
    validator: (value) => ['active', 'inactive', 'pending'].includes(value),
  },

  // 复杂默认值(函数返回)
  items: {
    type: Array,
    default: () => [],
  },
})
</script>

8.4 单向数据流

<script setup>
import { ref, computed } from 'vue'

const props = defineProps({
  initialCount: { type: Number, default: 0 }
})

const emit = defineEmits(['update:count'])

// ✅ 正确:基于 prop 创建本地状态
const localCount = ref(props.initialCount)

// ✅ 正确:使用计算属性转换 prop
const displayText = computed(() => `数量: ${props.initialCount}`)

// ❌ 错误:直接修改 prop
// props.initialCount++  // 会报错!

// ✅ 正确:通过事件通知父组件修改
function increment() {
  localCount.value++
  emit('update:count', localCount.value)
}
</script>

9. Emits 子传父通信

9.1 defineEmits 基础

<!-- 子组件 -->
<script setup>
// 方式一:数组声明
const emit = defineEmits(['update', 'delete', 'submit'])

// 触发事件
emit('update', 'new data')
emit('delete', 42)
emit('submit', { title: 'Hello', content: '...' })

// 方式二:TypeScript 类型声明(推荐)
// const emit = defineEmits<{
//   (e: 'update', value: string): void
//   (e: 'delete', id: number): void
//   (e: 'submit', data: { title: string; content: string }): void
// }>()
</script>

<template>
  <button @click="$emit('update', 'clicked')">更新</button>
</template>

9.2 父组件监听

<!-- 父组件 -->
<template>
  <ChildComponent
    @update="handleUpdate"
    @delete="handleDelete"
    @submit="handleSubmit"
  />
  <!-- 或使用 kebab-case(推荐) -->
  <ChildComponent
    @item-update="handleUpdate"
  />
</template>

<script setup>
function handleUpdate(value) {
  console.log('收到更新:', value)
}

function handleDelete(id) {
  console.log('删除项:', id)
}

function handleSubmit(data) {
  console.log('提交数据:', data)
}
</script>

9.3 Emits 校验

<script setup>
const emit = defineEmits({
  // 无校验
  click: null,

  // 带校验:返回 true/false
  submit: ({ email, password }) => {
    if (email && password) return true
    console.warn('提交数据不完整!')
    return false
  },
})

emit('submit', { email: 'test@test.com', password: '' })  // 控制台会打印警告
</script>

10. defineExpose 与组件暴露

10.1 暴露方法和属性

<!-- 子组件 Modal.vue -->
<script setup>
import { ref } from 'vue'

const visible = ref(false)
const title = ref('')

function open(modalTitle = '') {
  title.value = modalTitle
  visible.value = true
}

function close() {
  visible.value = false
}

function toggle() {
  visible.value = !visible.value
}

// 对外暴露的方法和属性
defineExpose({ open, close, toggle, visible })
</script>

<template>
  <Teleport to="body">
    <div v-if="visible" class="modal-overlay" @click="close">
      <div class="modal-content" @click.stop>
        <h2>{{ title }}</h2>
        <slot />
        <button @click="close">关闭</button>
      </div>
    </div>
  </Teleport>
</template>

10.2 父组件调用

<!-- 父组件 -->
<script setup>
import { ref } from 'vue'
import Modal from './Modal.vue'

const modalRef = ref(null)

function showModal() {
  modalRef.value?.open('用户信息')
}

// TypeScript 中获取类型
// const modalRef = ref<InstanceType<typeof Modal>>()
</script>

<template>
  <button @click="showModal">打开弹窗</button>

  <Modal ref="modalRef">
    <p>这里是弹窗内容</p>
  </Modal>
</template>

10.3 表单组件的 expose 模式

<!-- FormInput.vue — 暴露验证和重置方法 -->
<script setup>
import { ref } from 'vue'

const model = defineModel({ default: '' })
const error = ref('')

function validate() {
  if (!model.value) {
    error.value = '此字段不能为空'
    return false
  }
  error.value = ''
  return true
}

function reset() {
  model.value = ''
  error.value = ''
}

defineExpose({ validate, reset })
</script>

<template>
  <div>
    <input v-model="model" :class="{ error: error }" />
    <span v-if="error" class="error-msg">{{ error }}</span>
  </div>
</template>

11. 插槽 Slots

11.1 默认插槽与具名插槽

<!-- 子组件 Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header">默认标题</slot>
    </div>
    <div class="card-body">
      <slot>默认内容(当父组件不传内容时显示)</slot>
    </div>
    <div class="card-footer">
      <slot name="footer">
        <button>确定</button>
        <button>取消</button>
      </slot>
    </div>
  </div>
</template>

<!-- 父组件 -->
<template>
  <Card>
    <!-- 具名插槽 -->
    <template #header>
      <h2>用户管理</h2>
    </template>

    <!-- 默认插槽 -->
    <p>这里是卡片的主要内容区域</p>

    <!-- 具名插槽 -->
    <template #footer>
      <button @click="save">保存</button>
    </template>
  </Card>
</template>

11.2 作用域插槽

<!-- 子组件 DataList.vue -->
<script setup>
const props = defineProps({
  items: { type: Array, required: true },
  columns: { type: Array, required: true },
})
</script>

<template>
  <table>
    <thead>
      <tr>
        <th v-for="col in columns" :key="col.key">{{ col.title }}</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(item, index) in items" :key="item.id">
        <!-- 将每一行的数据通过插槽传给父组件 -->
        <slot name="row" :item="item" :index="index" :isFirst="index === 0">
          <!-- 默认渲染 -->
          <td v-for="col in columns" :key="col.key">
            {{ item[col.key] }}
          </td>
        </slot>
      </tr>
    </tbody>
  </table>
</template>

<!-- 父组件 — 使用作用域插槽自定义渲染 -->
<template>
  <DataList :items="users" :columns="columns">
    <template #row="{ item, index, isFirst }">
      <td>{{ index + 1 }}</td>
      <td :class="{ highlight: isFirst }">{{ item.name }}</td>
      <td>{{ item.email }}</td>
      <td>
        <button @click="editUser(item)">编辑</button>
        <button @click="deleteUser(item.id)">删除</button>
      </td>
    </template>
  </DataList>
</template>

11.3 动态插槽名

<template>
  <div>
    <!-- 动态插槽名:用方括号绑定 -->
    <slot :name="dynamicSlotName" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
const dynamicSlotName = ref('header')
</script>

11.4 defineSlots (TypeScript)

<script setup lang="ts">
// 声明插槽类型,为父组件提供 TS 类型检查
const slots = defineSlots<{
  default: (props: {}) => any
  header: (props: { title: string }) => any
  item: (props: { item: { id: number; name: string }; index: number }) => any
}>()
</script>

12. 依赖注入 provide / inject

12.1 基础用法

<!-- 祖先组件 -->
<script setup>
import { provide, ref, readonly } from 'vue'

const theme = ref('light')
const user = ref({ name: 'Alice', role: 'admin' })

// 提供数据
provide('theme', theme)
provide('user', readonly(user))  // 只读,防止子组件修改

// 提供修改方法
function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide('toggleTheme', toggleTheme)
</script>

<!-- 深层子组件 -->
<script setup>
import { inject } from 'vue'

// 注入数据
const theme = inject('theme', 'light')  // 第二个参数为默认值
const user = inject('user')
const toggleTheme = inject('toggleTheme')

// 安全访问
if (!user) {
  throw new Error('user 未提供!')
}
</script>

<template>
  <div :class="theme">
    <p>{{ user.name }}, 主题: {{ theme }}</p>
    <button @click="toggleTheme">切换主题</button>
  </div>
</template>

12.2 Symbol 键避免冲突

// keys.js
export const ThemeKey = Symbol('theme')
export const UserKey = Symbol('user')
<script setup>
import { provide, inject } from 'vue'
import { ThemeKey, UserKey } from './keys'

// 提供
provide(ThemeKey, ref('light'))

// 注入
const theme = inject(ThemeKey)
</script>

12.3 组合式封装 provide/inject

// composables/useAppContext.js
import { provide, inject, ref, readonly } from 'vue'

const ContextKey = Symbol('app-context')

function createAppContext() {
  const theme = ref('light')
  const locale = ref('zh-CN')
  const sidebarCollapsed = ref(false)

  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }

  function toggleSidebar() {
    sidebarCollapsed.value = !sidebarCollapsed.value
  }

  return {
    theme: readonly(theme),
    locale: readonly(locale),
    sidebarCollapsed,
    toggleTheme,
    toggleSidebar,
  }
}

// 在 App.vue 中调用
export function provideAppContext() {
  const context = createAppContext()
  provide(ContextKey, context)
  return context
}

// 在任意子组件中调用
export function useAppContext() {
  const context = inject(ContextKey)
  if (!context) throw new Error('useAppContext 必须在 provideAppContext 的下层使用')
  return context
}

13. Composables 组合式函数

13.1 什么是 Composable

Composable 是利用 Composition API 封装有状态可复用逻辑的函数,命名以 use 开头。

13.2 useMouse — 鼠标追踪

// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function onMouseMove(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => window.addEventListener('mousemove', onMouseMove))
  onUnmounted(() => window.removeEventListener('mousemove', onMouseMove))

  return { x, y }
}
<script setup>
import { useMouse } from './composables/useMouse'
const { x, y } = useMouse()
</script>

<template>
  <p>鼠标位置: ({{ x }}, {{ y }})</p>
</template>

13.3 useFetch — 数据请求

// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const isFetching = ref(true)

  async function fetchData() {
    isFetching.value = true
    error.value = null
    data.value = null

    try {
      const urlValue = toValue(url)  // 兼容 ref / getter / 普通字符串
      const response = await fetch(urlValue)
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json()
    } catch (err) {
      error.value = err
    } finally {
      isFetching.value = false
    }
  }

  watchEffect(() => { fetchData() })

  return { data, error, isFetching, refetch: fetchData }
}
<script setup>
import { ref, computed } from 'vue'
import { useFetch } from './composables/useFetch'

const postId = ref(1)
const url = computed(() =>
  `https://jsonplaceholder.typicode.com/posts/${postId.value}`
)
const { data, error, isFetching, refetch } = useFetch(url)
</script>

<template>
  <button @click="postId++">下一篇</button>
  <button @click="refetch()">刷新</button>

  <div v-if="isFetching">加载中...</div>
  <div v-else-if="error">出错了: {{ error.message }}</div>
  <div v-else>
    <h3>{{ data?.title }}</h3>
    <p>{{ data?.body }}</p>
  </div>
</template>

13.4 useLocalStorage — 响应式本地存储

// composables/useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const stored = localStorage.getItem(key)
  const value = ref(stored ? JSON.parse(stored) : defaultValue)

  watch(value, (newVal) => {
    if (newVal === null || newVal === undefined) {
      localStorage.removeItem(key)
    } else {
      localStorage.setItem(key, JSON.stringify(newVal))
    }
  }, { deep: true })

  // 跨标签页同步
  window.addEventListener('storage', (e) => {
    if (e.key === key) {
      value.value = e.newValue ? JSON.parse(e.newValue) : defaultValue
    }
  })

  return value
}
<script setup>
import { useLocalStorage } from './composables/useLocalStorage'

const username = useLocalStorage('username', '游客')
const settings = useLocalStorage('settings', {
  theme: 'light',
  fontSize: 16,
})
</script>

<template>
  <input v-model="username" />
  <select v-model="settings.theme">
    <option value="light">浅色</option>
    <option value="dark">深色</option>
  </select>
</template>

13.5 useDebounce — 防抖

// composables/useDebounce.js
import { ref, watch } from 'vue'

export function useDebounce(source, delay = 300) {
  const debounced = ref(source.value)
  let timer = null

  watch(source, (val) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      debounced.value = val
    }, delay)
  })

  return debounced
}
<script setup>
import { ref } from 'vue'
import { useDebounce } from './composables/useDebounce'

const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 500)

// 使用防抖后的值发起搜索
watch(debouncedKeyword, (val) => {
  if (val) searchAPI(val)
})
</script>

13.6 通用 useEventListener

// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback, options) {
  onMounted(() => target.addEventListener(event, callback, options))
  onUnmounted(() => target.removeEventListener(event, callback, options))
}

13.7 Composable 编写规范

// ✅ 好的 Composable 特征:
// 1. 以 use 开头命名
// 2. 返回包含 ref 的普通对象(不用 reactive)
// 3. 使用 toValue() / unref() 兼容 ref 和普通值
// 4. 在 onUnmounted 中清理副作用
// 5. 每个调用获得独立的状态副本

export function useCounter(initialValue = 0) {
  const count = ref(toValue(initialValue))

  function increment() { count.value++ }
  function decrement() { count.value-- }
  function reset() { count.value = toValue(initialValue) }

  return { count, increment, decrement, reset }
}

14. 自定义指令

14.1 局部自定义指令

<script setup>
// v-focus — 自动聚焦
const vFocus = {
  mounted: (el) => el.focus()
}

// v-color — 动态颜色
const vColor = {
  mounted: (el, binding) => {
    el.style.color = binding.value
  },
  updated: (el, binding) => {
    el.style.color = binding.value
  }
}

// v-click-outside — 点击外部
const vClickOutside = {
  mounted(el, binding) {
    el._clickOutside = (event) => {
      if (!(el === event.target || el.contains(event.target))) {
        binding.value(event)
      }
    }
    document.addEventListener('click', el._clickOutside)
  },
  unmounted(el) {
    document.removeEventListener('click', el._clickOutside)
  }
}
</script>

<template>
  <!-- 指令命名规则:vFocus → v-focus -->
  <input v-focus />
  <p v-color="'red'">红色文本</p>
  <p v-color="'blue'">蓝色文本</p>
  <div v-click-outside="() => show = false">
    <p v-if="show">点击外部关闭</p>
  </div>
</template>

14.2 全局自定义指令

// directives/focus.js
export const vFocus = {
  mounted(el) {
    el.focus()
  }
}

// directives/lazyLoad.js
export const vLazyLoad = {
  mounted(el, binding) {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        el.src = binding.value
        observer.unobserve(el)
      }
    })
    observer.observe(el)
  }
}
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { vFocus } from './directives/focus'
import { vLazyLoad } from './directives/lazyLoad'

const app = createApp(App)

app.directive('focus', vFocus)
app.directive('lazy-load', vLazyLoad)

app.mount('#app')

14.3 指令钩子详解

const vMyDirective = {
  // 元素创建时调用(只调用一次)
  created(el, binding, vnode, prevVnode) {},

  // 元素挂载到 DOM 时调用
  mounted(el, binding, vnode, prevVnode) {},

  // 父组件更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {},

  // 父组件和自身更新后调用
  updated(el, binding, vnode, prevVnode) {},

  // 元素卸载前调用
  beforeUnmount(el, binding, vnode, prevVnode) {},

  // 元素卸载后调用
  unmounted(el, binding, vnode, prevVnode) {},
}

// binding 对象:
// {
//   value: 传递给指令的值
//   oldValue: 之前的值(仅 updated 中可用)
//   arg: 指令参数,如 v-dir:foo 中的 'foo'
//   modifiers: 修饰符对象,如 v-dir.foo.bar 中的 { foo: true, bar: true }
// }

15. 插件系统

15.1 创建插件

// plugins/i18n.js
export function createI18nPlugin(options = {}) {
  const defaultLocale = options.defaultLocale || 'zh-CN'
  const messages = options.messages || {}

  return {
    install(app) {
      // 1. 提供全局方法
      app.config.globalProperties.$t = (key) => {
        const locale = app.config.globalProperties.$locale || defaultLocale
        return messages[locale]?.[key] || key
      }

      // 2. 提供全局属性
      app.config.globalProperties.$locale = defaultLocale

      // 3. provide/inject
      app.provide('i18n', { locale: defaultLocale, messages })

      // 4. 注册全局组件
      app.component('I18nText', {
        props: ['key'],
        template: '<span>{{ $t(key) }}</span>',
      })
    },
  }
}
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { createI18nPlugin } from './plugins/i18n'

const app = createApp(App)

app.use(createI18nPlugin({
  defaultLocale: 'zh-CN',
  messages: {
    'zh-CN': { hello: '你好', goodbye: '再见' },
    'en-US': { hello: 'Hello', goodbye: 'Goodbye' },
  },
}))

app.mount('#app')

15.2 插件中使用 Composition API

// plugins/auth.js
import { ref, inject, provide } from 'vue'

const AuthKey = Symbol('auth')

export function createAuthPlugin() {
  const user = ref(null)
  const isAuthenticated = ref(false)

  async function login(credentials) {
    const res = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials),
    })
    user.value = await res.json()
    isAuthenticated.value = true
  }

  function logout() {
    user.value = null
    isAuthenticated.value = false
  }

  function useAuth() {
    const auth = inject(AuthKey)
    if (!auth) throw new Error('useAuth 必须在 createAuthPlugin 之后使用')
    return auth
  }

  return {
    install(app) {
      const auth = { user, isAuthenticated, login, logout }
      app.provide(AuthKey, auth)
    },
    useAuth,  // 导出 composable
  }
}

16. Transition 过渡动画

16.1 CSS 过渡

<template>
  <div>
    <button @click="show = !show">切换</button>

    <Transition name="fade">
      <p v-if="show" class="box">淡入淡出效果</p>
    </Transition>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(false)
</script>

<style>
.fade-enter-from { opacity: 0; transform: translateY(-20px); }
.fade-enter-active { transition: all 0.3s ease; }
.fade-enter-to { opacity: 1; transform: translateY(0); }

.fade-leave-from { opacity: 1; }
.fade-leave-active { transition: all 0.3s ease; }
.fade-leave-to { opacity: 0; transform: translateY(20px); }

.box {
  padding: 20px;
  background: #42b983;
  color: white;
  border-radius: 8px;
  margin-top: 10px;
}
</style>

16.2 自定义过渡类名

<template>
  <!-- 使用第三方动画库(如 Animate.css) -->
  <Transition
    enter-active-class="animate__animated animate__bounceIn"
    leave-active-class="animate__animated animate__bounceOut"
  >
    <div v-if="show">使用 Animate.css 动画</div>
  </Transition>
</template>

16.3 JavaScript 钩子

<template>
  <Transition
    @before-enter="onBeforeEnter"
    @enter="onEnter"
    @after-enter="onAfterEnter"
    @before-leave="onBeforeLeave"
    @leave="onLeave"
    @after-leave="onAfterLeave"
    :css="false"  <!-- 禁用 CSS 过渡,纯 JS 动画 -->
  >
    <div v-if="show">JS 驱动动画</div>
  </Transition>
</template>

<script setup>
import gsap from 'gsap'

function onBeforeEnter(el) {
  gsap.set(el, { opacity: 0, scale: 0.5 })
}

function onEnter(el, done) {
  gsap.to(el, {
    opacity: 1, scale: 1, duration: 0.5,
    onComplete: done,  // 必须调用 done 表示动画结束
  })
}

function onLeave(el, done) {
  gsap.to(el, {
    opacity: 0, scale: 0.8, duration: 0.3,
    onComplete: done,
  })
}
</script>

16.4 mode 属性

<template>
  <!-- mode="out-in": 先离开再进入(避免两个元素同时存在) -->
  <Transition name="slide" mode="out-in">
    <component :is="currentTab" />
  </Transition>
</template>

17. TransitionGroup 列表过渡

<template>
  <div>
    <button @click="addItem">添加</button>
    <button @click="shuffle">打乱</button>

    <TransitionGroup name="list" tag="ul" class="list-container">
      <li v-for="item in items" :key="item.id" class="list-item">
        {{ item.text }}
        <button @click="removeItem(item.id)">×</button>
      </li>
    </TransitionGroup>
  </div>
</template>

<script setup>
import { ref } from 'vue'

let nextId = 4
const items = ref([
  { id: 1, text: '项目A' },
  { id: 2, text: '项目B' },
  { id: 3, text: '项目C' },
])

function addItem() {
  items.value.push({ id: nextId++, text: `项目${String.fromCharCode(64 + nextId)}` })
}

function removeItem(id) {
  items.value = items.value.filter(item => item.id !== id)
}

function shuffle() {
  items.value = [...items.value].sort(() => Math.random() - 0.5)
}
</script>

<style>
.list-container {
  list-style: none;
  padding: 0;
}

.list-item {
  padding: 10px 15px;
  margin: 5px 0;
  background: #f0f0f0;
  border-radius: 4px;
  display: flex;
  justify-content: space-between;
}

/* 进入/离开动画 */
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}
.list-enter-from {
  opacity: 0;
  transform: translateX(-30px);
}
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

/* 位置移动动画(FLIP) */
.list-move {
  transition: transform 0.5s ease;
}

/* 确保离开的元素脱离文档流,不影响其他元素 */
.list-leave-active {
  position: absolute;
}
</style>

18. KeepAlive 组件缓存

18.1 基础用法

<template>
  <!-- 缓存动态组件,切换时不销毁 -->
  <KeepAlive>
    <component :is="currentTab" />
  </KeepAlive>

  <!-- 路由缓存 -->
  <router-view v-slot="{ Component }">
    <KeepAlive>
      <component :is="Component" />
    </KeepAlive>
  </router-view>
</template>

18.2 include / exclude / max

<template>
  <!-- include: 只缓存匹配的组件 -->
  <KeepAlive include="UserList,UserDetail">
    <component :is="currentView" />
  </KeepAlive>

  <!-- exclude: 不缓存匹配的组件 -->
  <KeepAlive exclude="Login,Register">
    <component :is="currentView" />
  </KeepAlive>

  <!-- max: 最大缓存数(超出按 LRU 淘汰) -->
  <KeepAlive :max="10">
    <component :is="currentView" />
  </KeepAlive>
</template>

18.3 activated / deactivated 钩子

<script setup>
import { ref, onActivated, onDeactivated } from 'vue'

const data = ref(null)
const isActive = ref(false)

// 从缓存中激活时触发(等同于切回此页面)
onActivated(() => {
  console.log('组件被激活(从缓存中恢复)')
  isActive.value = true
  // 可以在此刷新数据
  if (!data.value) {
    fetchData()
  }
})

// 组件被缓存时触发(等同于切走此页面)
onDeactivated(() => {
  console.log('组件被缓存(进入后台)')
  isActive.value = false
  // 可以在此暂停轮询、websocket 等
})
</script>

19. Teleport 传送门

19.1 基础模态框

<template>
  <div class="app-content">
    <button @click="showModal = true">打开模态框</button>

    <!-- 传送到 body 底部,避免被父级样式影响 -->
    <Teleport to="body">
      <div v-if="showModal" class="modal-overlay" @click="showModal = false">
        <div class="modal-content" @click.stop>
          <h2>模态框标题</h2>
          <p>模态框内容...</p>
          <button @click="showModal = false">关闭</button>
        </div>
      </div>
    </Teleport>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const showModal = ref(false)
</script>

<style scoped>
.modal-overlay {
  position: fixed; inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex; align-items: center; justify-content: center;
  z-index: 9999;
}
.modal-content {
  background: #fff; padding: 30px; border-radius: 8px;
  min-width: 400px;
}
</style>

19.2 动态目标

<template>
  <Teleport :to="teleportTarget" :disabled="!shouldTeleport">
    <Notification :message="msg" />
  </Teleport>
</template>

<script setup>
import { ref, computed } from 'vue'

const isMobile = ref(false)
const teleportTarget = computed(() =>
  isMobile.value ? '#mobile-container' : '#desktop-container'
)
const shouldTeleport = computed(() => true)
</script>

19.3 全局通知系统

<!-- NotificationContainer.vue — 全局通知容器 -->
<script setup>
import { ref, provide } from 'vue'

const notifications = ref([])
let id = 0

function addNotification(message, type = 'info', duration = 3000) {
  const nid = ++id
  notifications.value.push({ id: nid, message, type })
  if (duration > 0) {
    setTimeout(() => removeNotification(nid), duration)
  }
  return nid
}

function removeNotification(nid) {
  notifications.value = notifications.value.filter(n => n.id !== nid)
}

provide('notify', addNotification)
</script>

<template>
  <Teleport to="body">
    <div class="notification-container">
      <TransitionGroup name="notif">
        <div
          v-for="n in notifications" :key="n.id"
          class="notification" :class="n.type"
          @click="removeNotification(n.id)"
        >
          {{ n.message }}
        </div>
      </TransitionGroup>
    </div>
  </Teleport>
</template>

<style>
.notification-container {
  position: fixed; top: 20px; right: 20px;
  z-index: 10000; display: flex; flex-direction: column; gap: 8px;
}
.notification {
  padding: 12px 20px; border-radius: 6px;
  color: #fff; cursor: pointer; min-width: 250px;
}
.notification.info    { background: #1890ff; }
.notification.success { background: #52c41a; }
.notification.warning { background: #faad14; }
.notification.error   { background: #ff4d4f; }

.notif-enter-active { transition: all 0.3s ease; }
.notif-leave-active { transition: all 0.3s ease; position: absolute; }
.notif-enter-from { opacity: 0; transform: translateX(50px); }
.notif-leave-to   { opacity: 0; transform: translateX(50px); }
</style>

20. Suspense 异步渲染

20.1 基础用法

<template>
  <Suspense>
    <!-- 默认内容:异步组件 -->
    <template #default>
      <AsyncDashboard />
    </template>

    <!-- 加载中:fallback 内容 -->
    <template #fallback>
      <div class="loading">
        <LoadingSpinner />
        <p>正在加载数据...</p>
      </div>
    </template>
  </Suspense>
</template>

<script setup>
import { defineAsyncComponent } from 'vue'

const AsyncDashboard = defineAsyncComponent(() =>
  import('./Dashboard.vue')
)
</script>

20.2 异步组件内使用 await

<!-- Dashboard.vue -->
<script setup>
// async setup:Suspense 自动捕获等待状态
const [userData, statsData, configData] = await Promise.all([
  fetch('/api/user').then(r => r.json()),
  fetch('/api/stats').then(r => r.json()),
  fetch('/api/config').then(r => r.json()),
])
</script>

<template>
  <div class="dashboard">
    <UserPanel :data="userData" />
    <StatsPanel :data="statsData" />
    <ConfigPanel :data="configData" />
  </div>
</template>

20.3 错误处理

<template>
  <Suspense @pending="onPending" @resolve="onResolve" @fallback="onFallback">
    <template #default>
      <AsyncComponent />
    </template>
    <template #fallback>
      <SkeletonLoader />
    </template>
  </Suspense>
</template>

<script setup>
// 结合 onErrorCaptured 处理异步组件的错误
import { onErrorCaptured, ref } from 'vue'

const error = ref(null)

onErrorCaptured((err) => {
  error.value = err
  return false  // 防止错误继续传播
})
</script>

20.4 Suspense + Transition + KeepAlive 组合

<template>
  <router-view v-slot="{ Component }">
    <Transition name="page" mode="out-in">
      <KeepAlive :max="5">
        <Suspense>
          <component :is="Component" />
          <template #fallback>
            <PageSkeleton />
          </template>
        </Suspense>
      </KeepAlive>
    </Transition>
  </router-view>
</template>

21. Pinia 状态管理

21.1 安装与配置

npm install pinia
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia())
app.mount('#app')

21.2 Setup Store(推荐写法)

// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // State — 用 ref 或 reactive
  const count = ref(0)
  const name = ref('计数器')

  // Getters — 用 computed
  const doubleCount = computed(() => count.value * 2)
  const displayName = computed(() => `${name.value}: ${count.value}`)

  // Actions — 普通函数(可以是异步的)
  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  function incrementBy(amount) {
    count.value += amount
  }

  async function fetchAndSet() {
    const res = await fetch('/api/counter')
    const data = await res.json()
    count.value = data.value
  }

  // 手动实现 $reset(Setup Store 必须手动实现)
  function $reset() {
    count.value = 0
  }

  // 导出所有公开的 state / getters / actions
  return { count, name, doubleCount, displayName, increment, decrement, incrementBy, fetchAndSet, $reset }
})

21.3 Options Store

// stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: '',
    isLoggedIn: false,
  }),

  getters: {
    userName: (state) => state.user?.name ?? '未登录',
    isAdmin: (state) => state.user?.role === 'admin',
  },

  actions: {
    async login(credentials) {
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials),
      })
      const data = await res.json()
      this.user = data.user
      this.token = data.token
      this.isLoggedIn = true
    },

    logout() {
      this.user = null
      this.token = ''
      this.isLoggedIn = false
    },
  },
})

21.4 在组件中使用

<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

const counter = useCounterStore()

// ✅ 使用 storeToRefs 保持响应式
const { count, doubleCount, name } = storeToRefs(counter)

// ✅ actions 直接解构
const { increment, decrement } = counter

// ❌ 直接解构会丢失响应式
// const { count } = counter  // 不响应式!
</script>

<template>
  <div>
    <p>{{ name }}: {{ count }}</p>
    <p>双倍: {{ doubleCount }}</p>
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
    <!-- 也可以直接 counter.increment() -->
    <button @click="counter.incrementBy(5)">+5</button>
    <button @click="counter.$reset()">重置</button>
  </div>
</template>

21.5 $patch 批量更新

const store = useUserStore()

// 对象形式 — 批量更新多个属性
store.$patch({
  user: { name: 'Alice' },
  token: 'new-token',
  isLoggedIn: true,
})

// 函数形式 — 适合数组操作等复杂逻辑
store.$patch((state) => {
  state.items.push({ id: 1, name: 'New' })
  state.items = state.items.filter(item => item.active)
})

21.6 $subscribe 监听变化

const store = useUserStore()

// 监听 state 变化
store.$subscribe((mutation, state) => {
  console.log('变更类型:', mutation.type)    // 'direct' | 'patch object' | 'patch function'
  console.log('变更store:', mutation.storeId)

  // 持久化到 localStorage
  localStorage.setItem('user-store', JSON.stringify(state))
}, { detached: true })  // detached: true  — 组件卸载后仍然监听

21.7 跨 Store 使用

// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useCartStore = defineStore('cart', () => {
  const userStore = useUserStore()
  const items = ref([])

  const canCheckout = computed(() =>
    items.value.length > 0 && userStore.isLoggedIn
  )

  async function checkout() {
    if (!userStore.isLoggedIn) {
      throw new Error('请先登录')
    }
    await fetch('/api/checkout', {
      method: 'POST',
      headers: { Authorization: `Bearer ${userStore.token}` },
      body: JSON.stringify({ items: items.value }),
    })
    items.value = []
  }

  return { items, canCheckout, checkout }
})

21.8 持久化插件

npm install pinia-plugin-persistedstate
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
// stores/user.js
export const useUserStore = defineStore('user', () => {
  // ... state, getters, actions
}, {
  persist: {
    key: 'user-store',    // localStorage 的 key
    storage: localStorage, // 存储方式
    paths: ['token', 'user'],  // 仅持久化这些字段
  },
})

22. Vue Router 4 路由管理

22.1 安装与配置

npm install vue-router@4
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/Home.vue'),
    meta: { title: '首页', requiresAuth: false },
  },
  {
    path: '/about',
    name: 'about',
    component: () => import('@/views/About.vue'),
    meta: { title: '关于', requiresAuth: false },
  },
  {
    path: '/user/:id',
    name: 'user-detail',
    component: () => import('@/views/UserDetail.vue'),
    props: true,  // 将 params 作为 props 传递
    meta: { requiresAuth: true },
  },
  {
    path: '/:pathMatch(.*)*',  // 404
    name: 'not-found',
    component: () => import('@/views/NotFound.vue'),
  },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition
    if (to.hash) return { el: to.hash, behavior: 'smooth' }
    return { top: 0 }
  },
})

export default router

22.2 使用 Router

<script setup>
import { useRouter, useRoute, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

const router = useRouter()
const route = useRoute()

// 编程式导航
function goToUser(id) {
  router.push({ name: 'user-detail', params: { id } })
}

function goBack() {
  router.back()
}

function goHome() {
  router.replace({ name: 'home' })
}

// 获取路由参数
console.log(route.params.id)       // 动态参数
console.log(route.query.search)    // 查询参数 ?search=xxx
console.log(route.hash)            // hash
console.log(route.meta.requiresAuth)

// 路由守卫
onBeforeRouteLeave((to, from, next) => {
  if (hasUnsavedChanges.value) {
    const answer = window.confirm('有未保存的更改,确定离开?')
    if (!answer) return false
  }
})
</script>

<template>
  <nav>
    <router-link to="/" exact-active-class="active">首页</router-link>
    <router-link :to="{ name: 'about' }">关于</router-link>
  </nav>

  <router-view v-slot="{ Component, route }">
    <Transition name="page" mode="out-in">
      <KeepAlive :include="['Home']">
        <component :is="Component" :key="route.path" />
      </KeepAlive>
    </Transition>
  </router-view>
</template>

22.3 导航守卫

// router/index.js — 全局守卫
router.beforeEach((to, from, next) => {
  // 设置页面标题
  document.title = to.meta.title || '默认标题'

  // 权限校验
  if (to.meta.requiresAuth && !isLoggedIn()) {
    next({ name: 'login', query: { redirect: to.fullPath } })
  } else {
    next()
  }
})

router.afterEach((to, from) => {
  // 页面切换后的分析统计
  console.log(`${from.path}${to.path}`)
})

22.4 嵌套路由

const routes = [
  {
    path: '/dashboard',
    component: () => import('@/layouts/DashboardLayout.vue'),
    children: [
      {
        path: '',  // /dashboard
        name: 'dashboard',
        component: () => import('@/views/dashboard/Overview.vue'),
      },
      {
        path: 'users',  // /dashboard/users
        name: 'dashboard-users',
        component: () => import('@/views/dashboard/Users.vue'),
      },
      {
        path: 'settings',  // /dashboard/settings
        name: 'dashboard-settings',
        component: () => import('@/views/dashboard/Settings.vue'),
      },
    ],
  },
]
<!-- DashboardLayout.vue -->
<template>
  <div class="dashboard-layout">
    <aside>
      <nav>
        <router-link to="/dashboard">概览</router-link>
        <router-link to="/dashboard/users">用户</router-link>
        <router-link to="/dashboard/settings">设置</router-link>
      </nav>
    </aside>
    <main>
      <router-view />  <!-- 嵌套路由出口 -->
    </main>
  </div>
</template>

23. Vite 构建工具

23.1 Vite 配置

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  // 插件
  plugins: [vue()],

  // 路径别名
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@components': resolve(__dirname, 'src/components'),
      '@composables': resolve(__dirname, 'src/composables'),
      '@stores': resolve(__dirname, 'src/stores'),
      '@assets': resolve(__dirname, 'src/assets'),
    },
  },

  // 开发服务器
  server: {
    port: 3000,
    open: true,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },

  // 构建配置
  build: {
    target: 'es2020',
    outDir: 'dist',
    assetsDir: 'assets',
    sourcemap: false,
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'ui-vendor': ['ant-design-vue', 'echarts'],
        },
      },
    },
  },
})

23.2 环境变量

# .env.development
VITE_API_BASE_URL=http://localhost:3000/api
VITE_APP_TITLE=开发环境

# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_APP_TITLE=生产环境
// 在代码中使用
console.log(import.meta.env.VITE_API_BASE_URL)
console.log(import.meta.env.MODE)       // 'development' | 'production'
console.log(import.meta.env.DEV)        // 是否开发模式
console.log(import.meta.env.PROD)       // 是否生产模式

24. TypeScript 集成

24.1 Props 类型声明

<script setup lang="ts">
interface Props {
  title: string
  count?: number
  status: 'active' | 'inactive' | 'pending'
  items: Array<{ id: number; name: string }>
  onUpdate?: (value: string) => void
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  status: 'pending',
  items: () => [],
})
</script>

24.2 Emits 类型声明

<script setup lang="ts">
const emit = defineEmits<{
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
  (e: 'submit', data: { title: string; content: string }): void
}>()

emit('submit', { title: '标题', content: '内容' })
</script>

24.3 Template Ref 类型

<script setup lang="ts">
import { ref, onMounted } from 'vue'

// DOM 元素 ref
const inputRef = ref<HTMLInputElement | null>(null)

onMounted(() => {
  inputRef.value?.focus()
})

// 组件 ref
import MyModal from './MyModal.vue'
const modalRef = ref<InstanceType<typeof MyModal> | null>(null)

function openModal() {
  modalRef.value?.open('标题')
}
</script>

<template>
  <input ref="inputRef" />
  <MyModal ref="modalRef" />
</template>

24.4 Provide/Inject 类型

// keys.ts
import type { InjectionKey, Ref } from 'vue'

interface ThemeContext {
  theme: Ref<'light' | 'dark'>
  toggleTheme: () => void
}

export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
<script setup lang="ts">
import { provide, ref } from 'vue'
import { ThemeKey } from './keys'

const theme = ref<'light' | 'dark'>('light')
function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide(ThemeKey, { theme, toggleTheme })
</script>

25. 测试体系 (Vitest + Playwright)

25.1 Vitest 安装与配置

npm install -D vitest @vue/test-utils jsdom
// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': resolve(__dirname, 'src') },
  },
  test: {
    environment: 'jsdom',
    globals: true,
    include: ['src/**/*.{test,spec}.{js,ts}'],
  },
})

25.2 组件单元测试

// Counter.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter.vue', () => {
  it('渲染初始值', () => {
    const wrapper = mount(Counter, {
      props: { initialCount: 5 },
    })
    expect(wrapper.text()).toContain('5')
  })

  it('点击按钮增加计数', async () => {
    const wrapper = mount(Counter)
    const button = wrapper.find('button.increment')

    await button.trigger('click')
    expect(wrapper.text()).toContain('1')

    await button.trigger('click')
    expect(wrapper.text()).toContain('2')
  })

  it('触发 emit 事件', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button.increment').trigger('click')
    expect(wrapper.emitted('update')).toBeTruthy()
    expect(wrapper.emitted('update')[0]).toEqual([1])
  })
})

25.3 Composable 测试

// useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('初始值正确', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })

  it('increment 增加计数', () => {
    const { count, increment } = useCounter(0)
    increment()
    expect(count.value).toBe(1)
  })

  it('decrement 减少计数', () => {
    const { count, decrement } = useCounter(5)
    decrement()
    expect(count.value).toBe(4)
  })
})

25.4 Pinia Store 测试

// counter.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from './counter'

describe('Counter Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('初始 count 为 0', () => {
    const store = useCounterStore()
    expect(store.count).toBe(0)
  })

  it('increment 增加 count', () => {
    const store = useCounterStore()
    store.increment()
    expect(store.count).toBe(1)
  })

  it('doubleCount 正确计算', () => {
    const store = useCounterStore()
    store.increment()
    store.increment()
    expect(store.doubleCount).toBe(4)
  })
})

25.5 Playwright E2E 测试

npm install -D @playwright/test
npx playwright install
// tests/e2e/home.spec.js
import { test, expect } from '@playwright/test'

test.describe('首页', () => {
  test('显示标题', async ({ page }) => {
    await page.goto('http://localhost:3000')
    await expect(page.locator('h1')).toHaveText('Vue 3 应用')
  })

  test('导航到关于页', async ({ page }) => {
    await page.goto('http://localhost:3000')
    await page.click('text=关于')
    await expect(page).toHaveURL(/\/about/)
    await expect(page.locator('h2')).toContainText('关于')
  })

  test('计数器交互', async ({ page }) => {
    await page.goto('http://localhost:3000')
    const button = page.locator('button.increment')
    const display = page.locator('.count')

    await expect(display).toHaveText('0')
    await button.click()
    await expect(display).toHaveText('1')
    await button.click()
    await button.click()
    await expect(display).toHaveText('3')
  })
})

26. 项目结构与最佳实践

26.1 推荐项目结构

my-vue-app/
├── public/
│   └── favicon.ico
├── src/
│   ├── assets/              # 静态资源(图片、字体、全局样式)
│   │   ├── images/
│   │   └── styles/
│   │       ├── variables.css
│   │       ├── reset.css
│   │       └── global.css
│   ├── components/          # 可复用组件
│   │   ├── common/          #   通用组件(Button, Modal, Table...)
│   │   ├── layout/          #   布局组件(Header, Sidebar, Footer...)
│   │   └── business/        #   业务组件(UserCard, OrderList...)
│   ├── composables/         # 组合式函数
│   │   ├── useAuth.js
│   │   ├── useFetch.js
│   │   └── useTheme.js
│   ├── directives/          # 自定义指令
│   │   ├── focus.js
│   │   └── lazyLoad.js
│   ├── layouts/             # 页面布局
│   │   ├── DefaultLayout.vue
│   │   └── AuthLayout.vue
│   ├── router/              # 路由配置
│   │   └── index.js
│   ├── stores/              # Pinia 状态管理
│   │   ├── user.js
│   │   ├── cart.js
│   │   └── app.js
│   ├── views/               # 页面组件
│   │   ├── Home.vue
│   │   ├── About.vue
│   │   └── dashboard/
│   │       ├── Overview.vue
│   │       └── Users.vue
│   ├── utils/               # 工具函数
│   │   ├── request.js       #   Axios 封装
│   │   ├── format.js        #   格式化工具
│   │   └── validators.js    #   校验工具
│   ├── App.vue
│   └── main.js
├── tests/
│   ├── unit/                # Vitest 单元测试
│   └── e2e/                 # Playwright E2E 测试
├── .env.development
├── .env.production
├── index.html
├── vite.config.js
├── package.json
└── README.md

26.2 最佳实践清单

<!-- ✅ 组件编写规则 -->
<script setup>
// 1. 导入顺序: Vue 核心 → 第三方库 → 项目内部
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import MyButton from '@/components/common/MyButton.vue'
import { useFetch } from '@/composables/useFetch'

// 2. Props/Emits 定义(放在最前面)
const props = defineProps({ /* ... */ })
const emit = defineEmits([/* ... */])

// 3. Composables
const router = useRouter()
const userStore = useUserStore()
const { data, isFetching } = useFetch('/api/data')

// 4. 响应式状态
const count = ref(0)
const name = ref('')

// 5. 计算属性
const doubleCount = computed(() => count.value * 2)

// 6. 方法(按功能分组)
function handleSubmit() { /* ... */ }
function handleReset() { /* ... */ }

// 7. 生命周期
onMounted(() => { /* ... */ })

// 8. defineExpose
defineExpose({ /* ... */ })
</script>

26.3 命名规范

类型 规范 示例
组件文件 PascalCase UserCard.vue, DataTable.vue
组件名 PascalCase <UserCard />
Composable use 前缀 + camelCase useAuth, useFetch
Store use 前缀 + PascalCase useUserStore, useCartStore
Props camelCase(模板中用 kebab-case) initialValue:initial-value
Events kebab-case @item-update, @form-submit
指令 camelCase(模板中用 kebab-case) vFocusv-focus
目录 kebab-case 或 camelCase user-profile, dashboardLayout

26.4 请求封装

// utils/request.js
import axios from 'axios'
import { useUserStore } from '@/stores/user'
import router from '@/router'

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000,
})

// 请求拦截器
request.interceptors.request.use((config) => {
  const userStore = useUserStore()
  if (userStore.token) {
    config.headers.Authorization = `Bearer ${userStore.token}`
  }
  return config
})

// 响应拦截器
request.interceptors.response.use(
  (response) => response.data,
  (error) => {
    if (error.response?.status === 401) {
      const userStore = useUserStore()
      userStore.logout()
      router.push('/login')
    }
    return Promise.reject(error)
  }
)

export default request

27. VueUse 实用工具库

27.1 安装与概述

npm install @vueuse/core

VueUse 提供 200+ 个经过测试和优化的 Composable,涵盖浏览器 API、状态管理、动画、传感器等。

27.2 常用 VueUse 函数

<script setup>
import {
  // 浏览器
  useTitle,          // 响应式页面标题
  useClipboard,      // 剪贴板
  useFullscreen,     // 全屏
  useDark,           // 暗黑模式
  useLocalStorage,   // localStorage
  useSessionStorage, // sessionStorage
  useStorage,        // 通用存储
  useMediaQuery,     // 媒体查询

  // 传感器
  useMouse,          // 鼠标位置
  useWindowSize,     // 窗口大小
  useWindowScroll,   // 滚动位置
  useIntersectionObserver,  // 交叉观察器
  useResizeObserver,       // 大小观察器

  // 状态
  useToggle,         // 布尔切换
  useDebounceFn,     // 防抖函数
  useThrottleFn,     // 节流函数

  // 网络
  useFetch,          // fetch 封装
  useWebSocket,      // WebSocket

  // 工具
  useDateFormat,     // 日期格式化
  useNow,            // 当前时间(响应式)
} from '@vueuse/core'

// === 使用示例 ===

// 页面标题自动同步
const title = useTitle('默认标题')
title.value = '新标题'  // 自动更新 document.title

// 暗黑模式
const isDark = useDark()
const toggleDark = useToggle(isDark)
// <button @click="toggleDark()">切换主题</button>

// 剪贴板
const { copy, copied, isSupported } = useClipboard()
// copy('要复制的文本')
// copied.value  // true 表示刚复制成功

// 全屏
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()

// 窗口尺寸
const { width, height } = useWindowSize()

// 滚动位置
const { x, y, isScrolling, arrivedState } = useWindowScroll()

// 防抖
const debouncedSearch = useDebounceFn((query) => {
  fetchSearchResults(query)
}, 500)

// 节流
const throttledScroll = useThrottleFn(() => {
  handleScroll()
}, 100)

// 媒体查询
const isLargeScreen = useMediaQuery('(min-width: 1024px)')
const isMobile = useMediaQuery('(max-width: 768px)')

// 交叉观察器(无限滚动 / 懒加载)
const target = ref(null)
const { isIntersecting } = useIntersectionObserver(target, {
  threshold: 0.1,
})
// 当 target 元素进入视口时 isIntersecting 变为 true

// 响应式 localStorage
const settings = useLocalStorage('app-settings', {
  theme: 'light',
  language: 'zh-CN',
})

// fetch
const { data, error, isFetching, execute } = useFetch(
  'https://api.example.com/posts'
).json()

// WebSocket
const { status, data, send, close } = useWebSocket(
  'wss://echo.example.com'
)

// 当前时间(每秒自动更新)
const now = useNow()
// {{ now }}  → 2026-06-08 15:30:45
</script>

27.3 创建 VueUse 风格 Composable

// 遵循 VueUse 的配置模式
import { ref, toValue, watch } from 'vue'
import { useEventListener } from '@vueuse/core'

export function useCounter(initial = 0, options = {}) {
  const { min = -Infinity, max = Infinity } = options

  const count = ref(toValue(initial))

  function inc(step = 1) {
    count.value = Math.min(max, count.value + step)
  }
  function dec(step = 1) {
    count.value = Math.max(min, count.value - step)
  }
  function set(val) {
    count.value = Math.min(max, Math.max(min, val))
  }
  function reset() {
    count.value = toValue(initial)
  }

  return { count, inc, dec, set, reset }
}

28. 异步组件与代码分割

28.1 defineAsyncComponent

<script setup>
import { defineAsyncComponent, ref } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorDisplay from './ErrorDisplay.vue'

const HeavyChart = defineAsyncComponent({
  loader: () => import('./HeavyChart.vue'),

  // 加载中显示的组件
  loadingComponent: LoadingSpinner,
  delay: 200,  // 延迟 200ms 再显示 loading(避免闪烁)

  // 加载失败显示的组件
  errorComponent: ErrorDisplay,
  timeout: 10000,  // 超时时间(ms)
})
</script>

<template>
  <HeavyChart :data="chartData" v-if="showChart" />
</template>

28.2 路由级代码分割(自动)

const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),  // 按路由拆分
  },
  {
    path: '/settings',
    component: () => import('@/views/Settings.vue'),
  },
]

28.3 条件加载

<script setup>
import { ref, shallowRef } from 'vue'

const ChartComponent = shallowRef(null)
const showChart = ref(false)

async function loadChart() {
  showChart.value = true
  const module = await import('./HeavyChart.vue')
  ChartComponent.value = module.default
}
</script>

<template>
  <button @click="loadChart">加载图表</button>
  <component :is="ChartComponent" v-if="ChartComponent" />
</template>

29. 渲染函数与 JSX

29.1 h() 渲染函数

<script setup>
import { h, ref } from 'vue'

const count = ref(0)

// 使用 h() 创建 VNode
const vnode = h('div', { class: 'counter' }, [
  h('p', `计数: ${count.value}`),
  h('button', {
    onClick: () => count.value++,
    class: 'btn',
  }, '增加'),
])

// 作为组件渲染
</script>

<template>
  <component :is="vnode" />
</template>

29.2 函数式组件

// FunctionalHeading.js — 纯函数组件(无状态)
import { h } from 'vue'

export default function FunctionalHeading(props, { slots, attrs }) {
  const tag = `h${props.level || 1}`
  return h(tag, attrs, slots.default?.())
}

29.3 JSX (需要 @vitejs/plugin-vue-jsx)

// Counter.jsx
import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const increment = () => count.value++

    return () => (
      <div class="counter">
        <p>计数: {count.value}</p>
        <button onClick={increment}>+1</button>
      </div>
    )
  },
}

30. 服务端渲染 SSR 与 Nuxt 3

30.1 Nuxt 3 快速入门

npx nuxi init my-nuxt-app
cd my-nuxt-app
npm install
npm run dev

30.2 Nuxt 核心特性

<!-- pages/index.vue — 自动路由 -->
<script setup>
// 自动导入 composables 和组件
const { data: posts } = await useFetch('/api/posts')
const count = ref(0)
</script>

<template>
  <div>
    <h1>Nuxt 3 App</h1>

    <!-- 服务端渲染的数据 -->
    <ul>
      <li v-for="post in posts" :key="post.id">
        {{ post.title }}
      </li>
    </ul>
  </div>
</template>
<!-- server/api/posts.get.ts — API Routes -->
export default defineEventHandler(async (event) => {
  const posts = await db.query('SELECT * FROM posts')
  return posts
})

30.3 SSR 注意事项

// 浏览器专用 API 需在 mounted 中调用
onMounted(() => {
  localStorage.setItem('key', 'value')
  window.addEventListener('resize', handler)
})

// 使用 ClientOnly 包裹浏览器专用组件
// <ClientOnly>
//   <ChartComponent />
// </ClientOnly>

31. 性能优化

31.1 编译时优化

<template>
  <!-- v-once: 只渲染一次 -->
  <div v-once>{{ expensiveStaticContent }}</div>

  <!-- v-memo: 依赖不变时跳过子树更新 -->
  <div v-memo="[item.id, item.selected]">
    <ExpensiveItem :item="item" />
  </div>

  <!-- 静态内容提升(编译器自动处理) -->
  <h1>静态标题 — 不会每次重新创建</h1>
</template>

31.2 shallowRef 避免深层响应

<script setup>
import { shallowRef } from 'vue'

// 大型只读数据用 shallowRef
const hugeList = shallowRef([])

// 获取数据时整体替换
async function loadData() {
  hugeList.value = await fetchHugeDataset()
}

// 不要这样写(会触发昂贵的深度响应式转换)
// const hugeList = ref([])
</script>

31.3 虚拟列表(长列表优化)

<script setup>
import { useVirtualList } from '@vueuse/core'

const allItems = ref(Array.from({ length: 100000 }, (_, i) => ({
  id: i, text: `Item #${i}`
})))

const { list, containerProps, wrapperProps } = useVirtualList(
  allItems,
  { itemHeight: 40, overscan: 5 }
)
</script>

<template>
  <div v-bind="containerProps" style="height: 400px; overflow-y: auto;">
    <div v-bind="wrapperProps">
      <div v-for="item in list" :key="item.index" style="height: 40px;">
        Row {{ item.data.text }}
      </div>
    </div>
  </div>
</template>

31.4 图片懒加载

<script setup>
import { useIntersectionObserver } from '@vueuse/core'

const imgRef = ref(null)
const imgSrc = ref('')

useIntersectionObserver(imgRef, ([{ isIntersecting }]) => {
  if (isIntersecting) {
    imgSrc.value = '/path/to/actual-image.jpg'
    stop()
  }
})
</script>

<template>
  <img ref="imgRef" :src="imgSrc || '/placeholder.jpg'" />
</template>

32. 安全最佳实践

32.1 XSS 防范

<template>
  <!-- ✅ Vue 默认转义所有插值,防止 XSS -->
  <p>{{ userInput }}</p>

  <!-- ❌ 除非绝对信任内容,否则不要使用 v-html -->
  <div v-html="sanitizedHtml"></div>
</template>

<script setup>
import DOMPurify from 'dompurify'

const rawHtml = ref('<p onclick="alert(1)">用户内容</p>')
// 先消毒再渲染
const sanitizedHtml = computed(() => DOMPurify.sanitize(rawHtml.value))
</script>

32.2 敏感信息保护

// ✅ 不要在客户端暴露敏感信息
// 使用环境变量但不要 VITE_ 前缀(Vite 中只有 VITE_ 前缀的才会暴露给客户端)

// 服务端 API Routes 中使用:
// process.env.DATABASE_URL    ✅ 服务端可用
// import.meta.env.VITE_API_URL ✅ 客户端可用(仅非敏感信息)

32.3 路由权限控制

router.beforeEach((to, from, next) => {
  const userStore = useUserStore()

  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    next({ name: 'login', query: { redirect: to.fullPath } })
  } else if (to.meta.roles && !to.meta.roles.includes(userStore.user?.role)) {
    next({ name: 'forbidden' })
  } else {
    next()
  }
})

附录 A:常用代码片段

A.1 防抖搜索

<script setup>
import { ref, watch } from 'vue'

const keyword = ref('')
const results = ref([])
let timer = null

watch(keyword, (val) => {
  clearTimeout(timer)
  timer = setTimeout(async () => {
    if (val) {
      results.value = await searchAPI(val)
    }
  }, 300)
})
</script>

<template>
  <input v-model="keyword" placeholder="搜索..." />
  <ul>
    <li v-for="item in results" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

A.2 无限滚动

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const items = ref([])
const page = ref(1)
const loading = ref(false)
const finished = ref(false)

async function loadMore() {
  if (loading.value || finished.value) return
  loading.value = true
  const newItems = await fetchItems(page.value)
  items.value.push(...newItems)
  page.value++
  if (newItems.length === 0) finished.value = true
  loading.value = false
}

function onScroll() {
  const { scrollTop, scrollHeight, clientHeight } = document.documentElement
  if (scrollTop + clientHeight >= scrollHeight - 100) {
    loadMore()
  }
}

onMounted(() => window.addEventListener('scroll', onScroll))
onUnmounted(() => window.removeEventListener('scroll', onScroll))

loadMore()
</script>

A.3 表单验证

<script setup>
import { ref, reactive, computed } from 'vue'

const form = reactive({
  username: '',
  email: '',
  password: '',
})

const errors = reactive({
  username: '',
  email: '',
  password: '',
})

const rules = {
  username: (v) => !v ? '用户名不能为空' : v.length < 3 ? '用户名至少3位' : '',
  email: (v) => !v ? '邮箱不能为空' : !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? '邮箱格式不正确' : '',
  password: (v) => !v ? '密码不能为空' : v.length < 6 ? '密码至少6位' : '',
}

function validateField(field) {
  errors[field] = rules[field](form[field])
}

function validateAll() {
  Object.keys(rules).forEach(validateField)
  return Object.values(errors).every(e => !e)
}

const isValid = computed(() =>
  Object.keys(rules).every(field => !rules[field](form[field]))
)

function handleSubmit() {
  if (validateAll()) {
    console.log('提交:', form)
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <input v-model="form.username" @blur="validateField('username')" placeholder="用户名" />
      <span v-if="errors.username" class="error">{{ errors.username }}</span>
    </div>
    <div>
      <input v-model="form.email" @blur="validateField('email')" placeholder="邮箱" />
      <span v-if="errors.email" class="error">{{ errors.email }}</span>
    </div>
    <div>
      <input v-model="form.password" type="password" @blur="validateField('password')" placeholder="密码" />
      <span v-if="errors.password" class="error">{{ errors.password }}</span>
    </div>
    <button type="submit" :disabled="!isValid">提交</button>
  </form>
</template>

附录 B:常见问题

Q: ref 和 reactive 如何选择?
A: 推荐统一使用 refref 可整体替换、解构不丢响应性(配合 toRefs),而 reactive 解构会丢失响应性、不能整体替换。仅在复杂表单对象等不需要整体替换的场景考虑用 reactive

Q: <script setup>setup() 函数有什么区别?
A: <script setup> 是编译时语法糖,代码更简洁、性能更好(编译器可做更多优化)。setup() 函数仅在需要与其他选项(如 inheritAttrs)放在一起时使用。2026 年推荐统一用 <script setup>

Q: watch 和 watchEffect 的区别?
A: watch 显式指定数据源,可获取旧值,默认不立即执行(需 immediate: true)。watchEffect 自动追踪依赖,立即执行,无法获取旧值。大多数场景用 watch 更精确。

Q: Pinia vs Vuex 如何选?
A: Vue 3 项目必须用 Pinia。Vuex 已不再更新,Pinia 是官方推荐。Pinia 的 Setup Store 语法与 Composition API 风格完全一致,TypeScript 支持更好。

Q: 什么时候用 defineExpose
A: 优先用 props/emits 进行父子通信。仅在确实需要父组件主动调用子组件的方法时使用(如弹窗的 open()、表单的 validate())。否则会导致组件耦合度过高。

Q: KeepAlive 和普通的 v-if 有什么区别?
A: v-if 切换时组件被完全销毁和重建,状态丢失。KeepAlive 缓存组件实例(不销毁),切换回来时保持之前的状态。用于路由缓存、Tab 切换等场景。


学习路线建议

  1. 掌握响应式基础(ref/reactive、computed、watch)
  2. 熟悉模板语法与组件通信(props/emits/slots)
  3. 学会使用 <script setup> + TypeScript
  4. 编写自己的 Composables 复用逻辑
  5. 集成 Pinia + Vue Router 构建完整应用
  6. 了解内置组件(Teleport/Suspense/KeepAlive/Transition)
  7. 学习 Vite 配置与项目工程化
  8. 探索 Nuxt 3 实现 SSR

本文档基于 Vue 3.5+ 编写,持续更新中。

更多推荐