Vue 3 完整学习教程
Vue 3 完整学习教程
版本: Vue 3.5+ | 语言: JavaScript / TypeScript | 更新日期: 2026-06-08
基于官方文档: https://cn.vuejs.org/
目录
第一部分:基础入门
第二部分:组件系统
第三部分:逻辑复用
第四部分:内置组件
第五部分:状态管理与路由
第六部分:工程化
第七部分:进阶实战
1. Vue 3 概述与环境搭建
1.1 Vue 3 核心变化
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 响应式系统 | Object.defineProperty |
Proxy(支持数组索引、属性新增) |
| API 风格 | Options API(data/methods/computed) |
Composition API(setup/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) | vFocus → v-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: 推荐统一使用 ref。ref 可整体替换、解构不丢响应性(配合 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 切换等场景。
学习路线建议:
- 掌握响应式基础(ref/reactive、computed、watch)
- 熟悉模板语法与组件通信(props/emits/slots)
- 学会使用
<script setup>+ TypeScript- 编写自己的 Composables 复用逻辑
- 集成 Pinia + Vue Router 构建完整应用
- 了解内置组件(Teleport/Suspense/KeepAlive/Transition)
- 学习 Vite 配置与项目工程化
- 探索 Nuxt 3 实现 SSR
本文档基于 Vue 3.5+ 编写,持续更新中。
更多推荐

所有评论(0)