Vue 3 入门指南
1. Vue 是什么(#vue-%E6%98%AF%E4%BB%80%E4%B9%88) 2. 安装与项目创建(#%E5%AE%89%E8%A3%85%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%88%9B%E5%BB%BA) 3. 项目结构(#%E9%A1%B9%E7%9B%AE%E7%BB%93%E6%9E%84) 4. .vue 文件结构(#vue-%E6%96%87%E4%BB%B6%E7%BB%93%E6%9E%84) 5. 响应式基础(#%E5%93%8D%E5%BA%94%E5%BC%8F%E5%9F%BA%E7%A1%80
官方文档:https://cn.vuejs.org/
最后更新:2026-03-05
目录
- Vue 是什么
- 安装与项目创建
- 项目结构
- .vue 文件结构
- 响应式基础
- 模板语法
- 组件系统
- 生命周期
- 响应式工具函数
- 内置组件
- Vue Router 基础
- Pinia 状态管理
- 综合实战:博客列表应用
Vue 是什么
Vue 是一个用于构建用户界面的渐进式 JavaScript 框架。"渐进式"意味着你可以只用它的一小部分功能,也可以将其作为完整的前端解决方案。
MVVM 概念
MVVM 是一种软件架构模式,分为三层:
| 层级 | 全称 | 作用 |
|---|---|---|
| M | Model(模型) | 应用的数据和业务逻辑 |
| V | View(视图) | 用户看到的界面(HTML/DOM) |
| VM | ViewModel(视图模型) | 连接 Model 和 View,Vue 实例充当此角色 |
Vue 的核心是数据驱动视图:你只需要修改数据,界面会自动更新,不需要手动操作 DOM。
数据(Model) <--双向绑定--> Vue 实例(ViewModel) <--自动渲染--> 界面(View)
虚拟 DOM
虚拟 DOM(Virtual DOM)是 Vue 在内存中维护的一棵 JavaScript 对象树,用来描述真实 DOM 的结构。
工作原理:
- 数据发生变化时,Vue 先生成新的虚拟 DOM 树
- 与旧的虚拟 DOM 树进行对比(diff 算法)
- 找出最小差异,只更新真实 DOM 中变化的部分
好处:避免不必要的 DOM 操作(DOM 操作开销大),提升性能。
安装与项目创建
方式一:CDN 引入(适合快速体验)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Vue 3 CDN 示例</title>
</head>
<body>
<div id="app">
<!-- 模板语法:双花括号插值 -->
<p>{{ message }}</p>
<button @click="changeMessage">点击修改</button>
</div>
<!-- 引入 Vue 3 CDN -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp, ref } = Vue
createApp({
setup() {
const message = ref('你好,Vue 3!')
function changeMessage() {
message.value = '消息已修改'
}
return { message, changeMessage }
}
}).mount('#app')
</script>
</body>
</html>
方式二:create-vue 脚手架(推荐,基于 Vite)
前提条件:已安装 Node.js(推荐 18.0 以上版本)。
# 创建项目,会交互式询问配置选项
npm create vue@latest
# 进入项目目录(my-vue-app 是你填写的项目名)
cd my-vue-app
# 安装依赖
npm install
# 启动开发服务器
npm run dev
运行 npm create vue@latest 后,会询问以下选项:
项目名称: my-vue-app
是否添加 TypeScript? No(初学者选 No)
是否添加 JSX 支持? No
是否添加 Vue Router? Yes
是否添加 Pinia? Yes
是否添加 Vitest 单元测试? No
是否添加端到端测试? No
是否添加 ESLint? Yes
项目结构
my-vue-app/
├── public/ # 静态资源,直接复制到构建输出,不经过处理
│ └── favicon.ico
├── src/ # 源代码目录
│ ├── assets/ # 需要处理的静态资源(图片、字体等)
│ ├── components/ # 可复用组件
│ │ └── HelloWorld.vue
│ ├── router/ # Vue Router 配置
│ │ └── index.js
│ ├── stores/ # Pinia 状态管理
│ │ └── counter.js
│ ├── views/ # 页面级组件(与路由对应)
│ │ └── HomeView.vue
│ ├── App.vue # 根组件,整个应用的入口组件
│ └── main.js # 应用入口文件,创建并挂载 Vue 实例
├── index.html # HTML 入口文件
├── package.json # 项目依赖与脚本配置
└── vite.config.js # Vite 构建工具配置
main.js 说明
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
// 创建 Vue 应用实例
const app = createApp(App)
// 安装插件
app.use(createPinia()) // 注册 Pinia 状态管理
app.use(router) // 注册路由
// 将应用挂载到 index.html 中 id 为 app 的元素上
app.mount('#app')
.vue 文件结构
.vue 文件是 Vue 的单文件组件(Single File Component,SFC),将模板、逻辑、样式写在同一个文件中。
<!-- template:组件的 HTML 模板,有且只有一个根元素(Vue 3 支持多根元素) -->
<template>
<div class="greeting">
<h1>{{ title }}</h1>
<p>计数:{{ count }}</p>
<button @click="increment">+1</button>
</div>
</template>
<!-- script setup:Composition API 写法,推荐 -->
<script setup>
import { ref } from 'vue'
// 在 <script setup> 中声明的变量和函数,可以直接在模板中使用
const title = ref('你好,Vue!')
const count = ref(0)
function increment() {
count.value++
}
</script>
<!-- style:组件样式,scoped 表示样式只作用于当前组件 -->
<style scoped>
.greeting {
text-align: center;
color: #42b883; /* Vue 的绿色 */
}
</style>
Composition API vs Options API 对比
Vue 3 推荐使用 Composition API,Vue 2 使用 Options API。两者都能完成同样的工作,但 Composition API 逻辑组织更灵活,适合复杂组件。
<!-- Options API 写法(Vue 2 风格,Vue 3 仍然支持) -->
<script>
export default {
data() {
return {
count: 0
}
},
computed: {
doubled() {
return this.count * 2
}
},
methods: {
increment() {
this.count++
}
}
}
</script>
<!-- Composition API 写法(Vue 3 推荐) -->
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
</script>
响应式基础
ref()
ref() 用于创建一个响应式的数据引用,适合基本类型(number、string、boolean)和单个值。
参数说明:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| value | any | 是 | 初始值,可以是任意类型 |
返回值:一个 Ref 对象,通过 .value 访问和修改内部值。
<template>
<div>
<!-- 在模板中使用 ref,不需要写 .value,Vue 会自动解包 -->
<p>姓名:{{ name }}</p>
<p>年龄:{{ age }}</p>
<p>是否登录:{{ isLoggedIn }}</p>
<button @click="birthday">过生日</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 基本类型
const name = ref('张三')
const age = ref(25)
const isLoggedIn = ref(false)
// 也可以包裹对象(但推荐用 reactive)
const user = ref({ name: '李四', age: 30 })
function birthday() {
age.value++ // 在 JS 中访问必须用 .value
console.log(age.value) // 26
}
// 访问 ref 包裹的对象内部属性
console.log(user.value.name) // 李四
</script>
reactive()
reactive() 用于创建一个响应式对象,适合对象和数组。
参数说明:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| object | object / array | 是 | 要变为响应式的对象或数组 |
返回值:原始对象的响应式代理(Proxy)。
<template>
<div>
<p>{{ user.name }} - {{ user.age }} 岁</p>
<ul>
<li v-for="item in list" :key="item.id">{{ item.text }}</li>
</ul>
<button @click="addItem">添加项目</button>
</div>
</template>
<script setup>
import { reactive } from 'vue'
// 创建响应式对象,访问时不需要 .value
const user = reactive({
name: '王五',
age: 28,
address: {
city: '北京'
}
})
const list = reactive([
{ id: 1, text: '第一项' },
{ id: 2, text: '第二项' }
])
function addItem() {
user.age++ // 直接修改,不需要 .value
list.push({ id: list.length + 1, text: `第${list.length + 1}项` })
}
</script>
reactive() 的局限性:
import { reactive } from 'vue'
const state = reactive({ count: 0 })
// 错误:解构会失去响应性
const { count } = state // count 不再是响应式的
// 错误:直接替换整个对象,响应性断开
// state = reactive({ count: 1 }) // 这样做不行
// 正确:使用 toRefs 解构,或直接通过 state.xxx 访问
import { toRefs } from 'vue'
const { count } = toRefs(state) // count 现在是 ref,保持响应性
computed()
computed() 用于声明派生自其他响应式数据的计算属性,具有缓存特性:只有依赖数据变化时才重新计算。
参数说明:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| getter | Function | 是(简写形式) | 返回计算值的函数 |
| options | { get: Function, set: Function } | 是(完整形式) | 包含 getter 和 setter |
<template>
<div>
<p>原始价格:{{ price }} 元</p>
<p>折后价格:{{ discountedPrice }} 元</p>
<p>购物车共 {{ cartCount }} 件,合计 {{ total }} 元</p>
<input v-model.number="customDiscount" type="number" min="1" max="10"> 折
<p>自定义折扣价:{{ customPrice }} 元</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const price = ref(100)
const cartCount = ref(3)
const customDiscount = ref(8) // 8 折
// 只读计算属性(最常用)
const discountedPrice = computed(() => {
return (price.value * 0.8).toFixed(2)
})
// 依赖多个响应式数据
const total = computed(() => {
return (price.value * cartCount.value).toFixed(2)
})
// 可写计算属性(getter + setter)
const customPrice = computed({
get() {
return (price.value * customDiscount.value / 10).toFixed(2)
},
set(newValue) {
// 根据新的折后价反推折扣
customDiscount.value = Math.round((newValue / price.value) * 10)
}
})
</script>
watch()
watch() 用于监听响应式数据的变化,执行副作用(如发起请求、操作 DOM)。
参数说明:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| source | Ref / reactive对象 / getter函数 / 数组 | 是 | 要监听的数据源 |
| callback | (newValue, oldValue, onCleanup) => void | 是 | 数据变化时执行的回调 |
| options | WatchOptions 对象 | 否 | 配置选项,见下表 |
options 配置项:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| immediate | boolean | false | 是否在创建时立即执行一次回调 |
| deep | boolean | false | 是否深度监听对象内部变化 |
| flush | 'pre' / 'post' / 'sync' | 'pre' | 回调执行时机,'post' 表示在 DOM 更新后执行 |
| once | boolean | false | 是否只触发一次 |
<script setup>
import { ref, reactive, watch } from 'vue'
const count = ref(0)
const user = reactive({ name: '张三', age: 25 })
const searchQuery = ref('')
// 监听单个 ref
watch(count, (newValue, oldValue) => {
console.log(`count 从 ${oldValue} 变为 ${newValue}`)
})
// 监听 ref,立即执行(immediate: true)
watch(searchQuery, async (newQuery) => {
if (newQuery.trim()) {
// 根据搜索词发起请求
const result = await fetchSearchResults(newQuery)
console.log(result)
}
}, { immediate: true })
// 监听 reactive 对象的某个属性,必须用 getter 函数
watch(
() => user.age, // getter 函数
(newAge, oldAge) => {
console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
}
)
// 深度监听整个 reactive 对象
watch(
user,
(newUser) => {
console.log('user 对象有任何变化:', newUser)
},
{ deep: true }
)
// 同时监听多个数据源
watch(
[count, () => user.name], // 数组形式
([newCount, newName], [oldCount, oldName]) => {
console.log('count 或 name 发生了变化')
}
)
// 停止监听
const stop = watch(count, () => {
// ...
})
// 调用返回函数来停止监听
stop()
</script>
watchEffect()
watchEffect() 自动追踪回调中用到的所有响应式数据,任意一个变化就重新执行。
参数说明:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| effect | (onCleanup: Function) => void | 是 | 副作用函数,会立即执行一次 |
| options | { flush, onTrack, onTrigger } | 否 | 配置选项 |
<script setup>
import { ref, watchEffect } from 'vue'
const userId = ref(1)
const userData = ref(null)
// 会立即执行,并自动追踪 userId
// userId 变化时自动重新执行
watchEffect(async (onCleanup) => {
// 取消请求的控制器
const controller = new AbortController()
// onCleanup 在下次执行前或组件卸载时调用,用于清理副作用
onCleanup(() => {
controller.abort()
})
const response = await fetch(`/api/user/${userId.value}`, {
signal: controller.signal
})
userData.value = await response.json()
})
</script>
watch() 与 watchEffect() 的区别:
| 对比项 | watch() | watchEffect() |
|---|---|---|
| 数据源声明 | 需要明确指定监听的数据源 | 自动追踪,无需声明 |
| 初次执行 | 默认不立即执行(immediate: false) | 立即执行 |
| 访问旧值 | 可以通过回调参数获取旧值 | 无法获取旧值 |
| 适用场景 | 需要对比新旧值,或懒执行的情况 | 依赖多个数据,只关心副作用 |
模板语法
文本插值
<template>
<div>
<!-- 双花括号:文本插值,将数据渲染为纯文本 -->
<p>{{ message }}</p>
<!-- 支持 JavaScript 表达式(不能是语句) -->
<p>{{ message.toUpperCase() }}</p>
<p>{{ count > 0 ? '正数' : '零或负数' }}</p>
<p>{{ list.join(', ') }}</p>
<!-- v-html:渲染 HTML(注意防止 XSS,不要渲染用户输入的内容) -->
<p v-html="rawHtml"></p>
</div>
</template>
<script setup>
import { ref } from 'vue'
const message = ref('你好,世界')
const count = ref(5)
const list = ref(['苹果', '香蕉', '橙子'])
const rawHtml = ref('<strong>加粗文字</strong>')
</script>
v-bind(绑定属性)
<template>
<div>
<!-- 绑定单个属性 -->
<a :href="url">访问链接</a>
<img :src="imageSrc" :alt="imageAlt">
<!-- 绑定 class(对象语法):key 是类名,value 是是否添加 -->
<div :class="{ active: isActive, 'has-error': hasError }">对象语法</div>
<!-- 绑定 class(数组语法) -->
<div :class="[baseClass, isActive ? 'active' : '']">数组语法</div>
<!-- 绑定 style(对象语法):驼峰命名或字符串 -->
<div :style="{ color: textColor, fontSize: fontSize + 'px' }">内联样式</div>
<!-- 绑定 style(数组语法,合并多个样式对象) -->
<div :style="[baseStyles, extraStyles]">多个样式对象</div>
<!-- 一次性绑定多个属性(使用对象) -->
<input v-bind="inputAttrs">
<!-- 动态属性名 -->
<a :[dynamicAttr]="dynamicValue">动态属性名</a>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const url = ref('https://cn.vuejs.org/')
const imageSrc = ref('/images/logo.png')
const imageAlt = ref('Vue logo')
const isActive = ref(true)
const hasError = ref(false)
const baseClass = ref('btn')
const textColor = ref('#42b883')
const fontSize = ref(16)
const baseStyles = reactive({ margin: '0', padding: '8px' })
const extraStyles = reactive({ border: '1px solid #ccc' })
// 一次性绑定多个属性
const inputAttrs = reactive({
type: 'text',
placeholder: '请输入...',
maxlength: 100
})
const dynamicAttr = ref('href')
const dynamicValue = ref('https://cn.vuejs.org/')
</script>
v-if / v-else-if / v-else 与 v-show
<template>
<div>
<!-- v-if 系列:条件为 false 时,元素从 DOM 中移除 -->
<div v-if="score >= 90">优秀</div>
<div v-else-if="score >= 70">良好</div>
<div v-else-if="score >= 60">及格</div>
<div v-else>不及格</div>
<!-- v-show:条件为 false 时,元素仍在 DOM 中,只是 display: none -->
<div v-show="isVisible">这个元素始终在 DOM 中</div>
<!-- 使用 template 包裹多个元素,不会渲染额外节点 -->
<template v-if="isLoggedIn">
<p>欢迎回来,{{ username }}</p>
<button @click="logout">退出登录</button>
</template>
</div>
</template>
<script setup>
import { ref } from 'vue'
const score = ref(85)
const isVisible = ref(true)
const isLoggedIn = ref(true)
const username = ref('张三')
function logout() {
isLoggedIn.value = false
}
</script>
v-if 与 v-show 的区别:
| 对比项 | v-if | v-show |
|---|---|---|
| DOM 行为 | false 时从 DOM 移除 | false 时 display: none |
| 初始渲染成本 | 低(false 时不渲染) | 高(总是渲染) |
| 切换成本 | 高(需要销毁/重建) | 低(只改 CSS) |
| 适用场景 | 很少切换的条件显示 | 频繁切换的显示隐藏 |
v-for(列表渲染)
<template>
<div>
<!-- 遍历数组,key 必须是唯一值,帮助 Vue 高效更新 DOM -->
<ul>
<li v-for="item in fruits" :key="item">{{ item }}</li>
</ul>
<!-- 遍历对象数组,带索引 -->
<ul>
<li v-for="(user, index) in users" :key="user.id">
{{ index + 1 }}. {{ user.name }} - {{ user.age }} 岁
</li>
</ul>
<!-- 遍历对象的属性 -->
<ul>
<li v-for="(value, key, index) in person" :key="key">
{{ index }}. {{ key }}: {{ value }}
</li>
</ul>
<!-- 遍历数字范围(1 到 5) -->
<span v-for="n in 5" :key="n">{{ n }} </span>
<!-- 嵌套 v-for -->
<div v-for="category in categories" :key="category.id">
<h3>{{ category.name }}</h3>
<ul>
<li v-for="product in category.products" :key="product.id">
{{ product.name }}
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const fruits = reactive(['苹果', '香蕉', '橙子'])
const users = reactive([
{ id: 1, name: '张三', age: 25 },
{ id: 2, name: '李四', age: 30 },
{ id: 3, name: '王五', age: 28 }
])
const person = reactive({
name: '张三',
age: 25,
city: '北京'
})
const categories = reactive([
{
id: 1,
name: '水果',
products: [{ id: 101, name: '苹果' }, { id: 102, name: '香蕉' }]
},
{
id: 2,
name: '蔬菜',
products: [{ id: 201, name: '白菜' }, { id: 202, name: '萝卜' }]
}
])
</script>
key 的作用:
key 是 Vue 用来识别 v-for 中每个节点的唯一标识。有了 key,Vue 在更新列表时可以复用现有 DOM 节点(移动、更新),而不是全部重新创建,性能更好。key 应使用稳定的唯一 ID,不推荐用数组索引(index),因为删除/排序后索引会变化,导致意外行为。
v-on(事件绑定)
<template>
<div>
<!-- 基本事件绑定 -->
<button @click="handleClick">点击</button>
<!-- 内联处理器(简单逻辑) -->
<button @click="count++">计数:{{ count }}</button>
<!-- 传递参数 -->
<button @click="greet('张三')">打招呼</button>
<!-- 访问原生事件对象 -->
<button @click="handleWithEvent($event)">获取事件</button>
<!-- 修饰符:阻止默认行为 -->
<form @submit.prevent="handleSubmit">
<button type="submit">提交(不刷新页面)</button>
</form>
<!-- 修饰符:阻止冒泡 -->
<div @click="outerClick">
<button @click.stop="innerClick">内部按钮(不触发外层)</button>
</div>
<!-- 修饰符:只触发一次 -->
<button @click.once="onceHandler">只触发一次</button>
<!-- 键盘修饰符 -->
<input @keyup.enter="submitOnEnter" placeholder="按回车提交">
<input @keyup.ctrl.enter="submitCtrlEnter" placeholder="Ctrl+回车提交">
<!-- 多个修饰符可以链式使用 -->
<a @click.prevent.stop="handleLink">链接(阻止默认+阻止冒泡)</a>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
function handleClick() {
console.log('按钮被点击')
}
function greet(name) {
alert(`你好,${name}!`)
}
function handleWithEvent(event) {
console.log('事件对象:', event)
console.log('点击位置:', event.clientX, event.clientY)
}
function handleSubmit() {
console.log('表单提交(页面不刷新)')
}
function outerClick() {
console.log('外层点击')
}
function innerClick() {
console.log('内层点击(不会触发外层)')
}
function onceHandler() {
console.log('只执行一次')
}
function submitOnEnter() {
console.log('回车提交')
}
function submitCtrlEnter() {
console.log('Ctrl+回车提交')
}
function handleLink() {
console.log('处理链接点击')
}
</script>
常用事件修饰符:
| 修饰符 | 说明 |
|---|---|
| .prevent | event.preventDefault(),阻止默认行为(如表单提交刷新页面) |
| .stop | event.stopPropagation(),阻止事件冒泡 |
| .once | 事件只触发一次 |
| .self | 只有事件在元素本身触发时才处理(不包括子元素触发的冒泡) |
| .capture | 使用事件捕获模式 |
常用键盘修饰符:
| 修饰符 | 说明 |
|---|---|
| .enter | Enter 键 |
| .tab | Tab 键 |
| .delete | Delete 或 Backspace 键 |
| .esc | Escape 键 |
| .space | 空格键 |
| .up / .down / .left / .right | 方向键 |
| .ctrl / .alt / .shift / .meta | 系统修饰键 |
v-model(双向绑定)
<template>
<div>
<!-- 文本输入框 -->
<input v-model="text" placeholder="输入文本">
<p>输入内容:{{ text }}</p>
<!-- 多行文本框 -->
<textarea v-model="description" rows="4"></textarea>
<!-- 复选框(单个,绑定 boolean) -->
<label>
<input type="checkbox" v-model="agreed">
同意用户协议
</label>
<p>是否同意:{{ agreed }}</p>
<!-- 复选框(多个,绑定数组) -->
<label v-for="fruit in fruitOptions" :key="fruit">
<input type="checkbox" v-model="selectedFruits" :value="fruit">
{{ fruit }}
</label>
<p>已选:{{ selectedFruits.join(', ') }}</p>
<!-- 单选按钮 -->
<label v-for="option in genderOptions" :key="option.value">
<input type="radio" v-model="gender" :value="option.value">
{{ option.label }}
</label>
<p>性别:{{ gender }}</p>
<!-- 下拉选择框 -->
<select v-model="city">
<option value="">请选择城市</option>
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
<option value="guangzhou">广州</option>
</select>
<p>城市:{{ city }}</p>
<!-- v-model 修饰符 -->
<!-- .trim:自动去除首尾空格 -->
<input v-model.trim="trimmedText" placeholder="自动去除空格">
<!-- .number:自动转为数字类型 -->
<input v-model.number="numValue" type="number" placeholder="数字输入">
<p>类型:{{ typeof numValue }}</p>
<!-- .lazy:在失去焦点时同步(而不是每次输入) -->
<input v-model.lazy="lazyText" placeholder="失去焦点时同步">
</div>
</template>
<script setup>
import { ref } from 'vue'
const text = ref('')
const description = ref('')
const agreed = ref(false)
const selectedFruits = ref([])
const fruitOptions = ref(['苹果', '香蕉', '橙子', '葡萄'])
const gender = ref('')
const genderOptions = ref([
{ value: 'male', label: '男' },
{ value: 'female', label: '女' }
])
const city = ref('')
const trimmedText = ref('')
const numValue = ref(0)
const lazyText = ref('')
</script>
v-slot(插槽)
插槽用于向子组件传递模板内容,在"组件系统"章节有完整示例。
<!-- 父组件中使用具名插槽 -->
<template>
<MyCard>
<!-- 默认插槽内容 -->
<p>这是默认插槽内容</p>
<!-- 具名插槽,v-slot:header 可简写为 #header -->
<template #header>
<h2>卡片标题</h2>
</template>
<template #footer>
<button>确认</button>
</template>
</MyCard>
</template>
组件系统
创建与局部注册组件
<!-- src/components/UserCard.vue -->
<template>
<div class="user-card">
<img :src="avatar" :alt="name">
<h3>{{ name }}</h3>
<p>{{ bio }}</p>
<slot></slot><!-- 默认插槽,父组件可以插入内容 -->
</div>
</template>
<script setup>
// 使用 defineProps 接收父组件传来的数据
const props = defineProps({
name: {
type: String,
required: true // 必填
},
avatar: {
type: String,
default: '/default-avatar.png' // 默认值
},
bio: {
type: String,
default: '暂无简介'
}
})
</script>
<style scoped>
.user-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
</style>
<!-- 父组件中局部注册并使用 UserCard -->
<template>
<div>
<!-- 使用组件,传递 props -->
<UserCard
name="张三"
avatar="/images/zhangsan.jpg"
bio="前端开发工程师"
>
<!-- 传入默认插槽内容 -->
<button @click="follow">关注</button>
</UserCard>
<!-- 动态绑定 props -->
<UserCard v-bind="userInfo" />
</div>
</template>
<script setup>
// 局部注册:直接 import 即可,在 <script setup> 中无需 components 选项
import UserCard from '@/components/UserCard.vue'
import { reactive } from 'vue'
const userInfo = reactive({
name: '李四',
bio: '后端开发工程师'
})
function follow() {
alert('已关注')
}
</script>
props(父传子)
defineProps() 参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
| type | 构造函数或数组 | 期望的类型,如 String、Number、Boolean、Array、Object、Function |
| required | boolean | 是否必填,默认 false |
| default | any / Function | 默认值(对象/数组类型必须用工厂函数返回) |
| validator | Function | 自定义验证函数,返回 true/false |
<script setup>
const props = defineProps({
// 只指定类型
title: String,
// 完整配置
count: {
type: Number,
default: 0
},
// 多种类型
id: {
type: [String, Number],
required: true
},
// 对象类型,默认值必须用工厂函数
config: {
type: Object,
default: () => ({ theme: 'light' })
},
// 数组类型,默认值必须用工厂函数
tags: {
type: Array,
default: () => []
},
// 自定义验证
status: {
type: String,
validator(value) {
// 只允许这三个值
return ['pending', 'active', 'inactive'].includes(value)
}
}
})
// 访问 props
console.log(props.title)
console.log(props.count)
</script>
emit(子传父)
<!-- 子组件:src/components/SearchBar.vue -->
<template>
<div class="search-bar">
<input
v-model="inputValue"
@keyup.enter="handleSearch"
placeholder="搜索..."
>
<button @click="handleSearch">搜索</button>
<button @click="handleClear">清空</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 声明组件可以触发的事件
const emit = defineEmits({
// 带验证的事件声明(参数是验证函数,返回 true/false)
search: (query) => {
if (typeof query !== 'string') return false
return query.length > 0
},
// 简单声明(无验证)
clear: null
})
const inputValue = ref('')
function handleSearch() {
if (inputValue.value.trim()) {
// 触发事件,传递数据给父组件
emit('search', inputValue.value.trim())
}
}
function handleClear() {
inputValue.value = ''
emit('clear')
}
</script>
<!-- 父组件中监听子组件事件 -->
<template>
<div>
<SearchBar
@search="handleSearch"
@clear="handleClear"
/>
<p v-if="searchQuery">搜索词:{{ searchQuery }}</p>
</div>
</template>
<script setup>
import SearchBar from '@/components/SearchBar.vue'
import { ref } from 'vue'
const searchQuery = ref('')
function handleSearch(query) {
searchQuery.value = query
console.log('收到搜索词:', query)
}
function handleClear() {
searchQuery.value = ''
console.log('搜索已清空')
}
</script>
插槽(slot)
<!-- 子组件:src/components/BaseCard.vue -->
<template>
<div class="base-card">
<!-- 具名插槽:header -->
<div class="card-header" v-if="$slots.header">
<slot name="header"></slot>
</div>
<!-- 默认插槽(匿名插槽) -->
<div class="card-body">
<slot>
<!-- 默认内容:父组件没有提供内容时显示 -->
<p>暂无内容</p>
</slot>
</div>
<!-- 具名插槽:footer -->
<div class="card-footer" v-if="$slots.footer">
<slot name="footer"></slot>
</div>
<!-- 作用域插槽:将子组件数据暴露给父组件 -->
<div class="card-extra">
<slot name="extra" :item="internalData" :index="0"></slot>
</div>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const internalData = reactive({ text: '来自子组件的数据', count: 42 })
</script>
<!-- 父组件使用插槽 -->
<template>
<BaseCard>
<!-- 具名插槽:#header 是 v-slot:header 的简写 -->
<template #header>
<h2>卡片标题</h2>
</template>
<!-- 默认插槽内容 -->
<p>这是卡片的主要内容</p>
<!-- 具名插槽 footer -->
<template #footer>
<button>确认</button>
<button>取消</button>
</template>
<!-- 作用域插槽:接收子组件传来的数据 -->
<template #extra="{ item, index }">
<p>{{ index }}: {{ item.text }} ({{ item.count }})</p>
</template>
</BaseCard>
</template>
<script setup>
import BaseCard from '@/components/BaseCard.vue'
</script>
provide / inject(跨层级传值)
<!-- 祖先组件(App.vue 或某个父组件) -->
<script setup>
import { provide, ref, readonly } from 'vue'
const theme = ref('light')
const currentUser = ref({ name: '张三', role: 'admin' })
// provide(key, value)
// 使用 readonly 防止子孙组件意外修改
provide('theme', readonly(theme))
provide('currentUser', readonly(currentUser))
// 提供修改方法,让子孙组件通过方法修改数据(而不是直接修改)
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide('toggleTheme', toggleTheme)
</script>
<!-- 任意深度的子孙组件 -->
<template>
<div :class="['page', theme]">
<p>当前用户:{{ currentUser.name }}({{ currentUser.role }})</p>
<p>当前主题:{{ theme }}</p>
<button @click="toggleTheme">切换主题</button>
</div>
</template>
<script setup>
import { inject } from 'vue'
// inject(key, defaultValue)
const theme = inject('theme', 'light') // 第二个参数是默认值
const currentUser = inject('currentUser')
const toggleTheme = inject('toggleTheme')
</script>
生命周期
生命周期钩子
每个 Vue 组件实例在创建和销毁过程中会经历一系列阶段,每个阶段都有对应的钩子函数可以执行自定义代码。
<script setup>
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onErrorCaptured,
onActivated,
onDeactivated
} from 'vue'
// 组件挂载到 DOM 之前(此时 DOM 还不存在)
onBeforeMount(() => {
console.log('beforeMount:组件即将挂载,DOM 还没有创建')
})
// 组件挂载到 DOM 之后(最常用,可以操作 DOM、发起请求)
onMounted(() => {
console.log('mounted:组件已挂载,可以访问 DOM')
// 适合:发起初始数据请求、初始化第三方库、操作 DOM
fetchInitialData()
})
// 组件数据变化,DOM 更新之前
onBeforeUpdate(() => {
console.log('beforeUpdate:数据已变化,DOM 即将更新')
})
// 组件数据变化,DOM 更新之后
onUpdated(() => {
console.log('updated:DOM 已更新')
// 注意:不要在这里修改响应式数据,否则会造成无限循环
})
// 组件卸载之前(此时组件实例还可用)
onBeforeUnmount(() => {
console.log('beforeUnmount:组件即将卸载')
// 适合:清除定时器、取消网络请求、移除事件监听
clearInterval(timer)
})
// 组件卸载之后
onUnmounted(() => {
console.log('unmounted:组件已卸载')
})
// 捕获子孙组件抛出的错误
onErrorCaptured((error, instance, info) => {
console.error('捕获到错误:', error)
return false // 返回 false 阻止错误继续传播
})
// 被 KeepAlive 缓存的组件激活时
onActivated(() => {
console.log('activated:从缓存中激活')
})
// 被 KeepAlive 缓存的组件停用时
onDeactivated(() => {
console.log('deactivated:被缓存停用')
})
async function fetchInitialData() {
// 发起初始请求
}
let timer = null
onMounted(() => {
timer = setInterval(() => {
console.log('定时器执行')
}, 1000)
})
</script>
与 Options API 生命周期对应关系
| Composition API | Options API | 触发时机 |
|---|---|---|
| onBeforeMount | beforeMount | 组件挂载前,DOM 不存在 |
| onMounted | mounted | 组件挂载后,可访问 DOM |
| onBeforeUpdate | beforeUpdate | 数据变化,DOM 更新前 |
| onUpdated | updated | 数据变化,DOM 更新后 |
| onBeforeUnmount | beforeDestroy | 组件卸载前 |
| onUnmounted | destroyed | 组件卸载后 |
| onErrorCaptured | errorCaptured | 子孙组件报错时 |
| onActivated | activated | KeepAlive 激活时 |
| onDeactivated | deactivated | KeepAlive 停用时 |
| (无,用 setup() 本身) | created | 实例创建后,响应式数据已初始化 |
| (无,用 setup() 本身) | beforeCreate | 实例创建前 |
注意:在 <script setup> 中,顶层代码相当于在 created 钩子中执行。
响应式工具函数
toRef / toRefs
<script setup>
import { reactive, toRef, toRefs } from 'vue'
const state = reactive({
name: '张三',
age: 25,
city: '北京'
})
// toRef:为响应式对象的某个属性创建 ref,保持响应性
const nameRef = toRef(state, 'name')
// 修改 nameRef.value 会同步修改 state.name,反之亦然
nameRef.value = '李四'
console.log(state.name) // 李四
// toRefs:将整个响应式对象转为一组 ref,用于解构
const { name, age, city } = toRefs(state)
// 修改 name.value 会同步修改 state.name
name.value = '王五'
console.log(state.name) // 王五
</script>
isRef / isReactive / unref
<script setup>
import { ref, reactive, isRef, isReactive, unref } from 'vue'
const count = ref(0)
const user = reactive({ name: '张三' })
const normalValue = 42
// isRef:检查是否是 ref
console.log(isRef(count)) // true
console.log(isRef(user)) // false
console.log(isRef(normalValue)) // false
// isReactive:检查是否是 reactive 对象
console.log(isReactive(user)) // true
console.log(isReactive(count)) // false
// unref:如果是 ref 则返回 .value,否则原样返回
// 等价于:isRef(value) ? value.value : value
console.log(unref(count)) // 0
console.log(unref(normalValue)) // 42
</script>
readonly
<script setup>
import { reactive, readonly, ref } from 'vue'
const original = reactive({ count: 0, name: '张三' })
// 创建只读版本
const readonlyState = readonly(original)
// 尝试修改会在开发模式下发出警告,且修改无效
// readonlyState.count++ // 警告:Set operation on key "count" failed
// 修改原始对象,只读副本会自动同步
original.count++
console.log(readonlyState.count) // 1(已同步)
// ref 也可以用 readonly
const countRef = ref(0)
const readonlyCount = readonly(countRef)
</script>
内置组件
Transition(过渡动画)
Transition 参数说明:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| name | string | 'v' | 过渡类名前缀 |
| mode | 'in-out' / 'out-in' | 无 | 过渡模式,out-in 表示先出后入 |
| appear | boolean | false | 初始渲染时是否应用过渡 |
| type | 'transition' / 'animation' | 自动 | 指定监听哪种 CSS 事件 |
<template>
<div>
<button @click="show = !show">切换</button>
<!-- name="fade" 会生成 fade-enter-active、fade-leave-active 等类名 -->
<Transition name="fade" mode="out-in">
<p v-if="show">这段文字会有淡入淡出效果</p>
<p v-else>这是另一段文字</p>
</Transition>
</div>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>
<style>
/* 进入的起始状态 和 离开的结束状态 */
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 进入过渡激活期间 和 离开过渡激活期间 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
/* 进入的结束状态 和 离开的起始状态(通常不需要显式设置,保持默认) */
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
</style>
CSS 过渡类名说明:
| 类名 | 触发时机 |
|---|---|
| v-enter-from | 进入动画的起始帧 |
| v-enter-active | 进入动画的整个过程 |
| v-enter-to | 进入动画的结束帧 |
| v-leave-from | 离开动画的起始帧 |
| v-leave-active | 离开动画的整个过程 |
| v-leave-to | 离开动画的结束帧 |
注:v- 是默认前缀,使用 name="fade" 时变为 fade-。
TransitionGroup(列表动画)
<template>
<div>
<button @click="addItem">添加</button>
<button @click="removeItem">删除</button>
<!-- tag 指定渲染的包裹元素,默认不渲染包裹元素 -->
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id" class="list-item">
{{ item.text }}
</li>
</TransitionGroup>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const items = reactive([
{ id: 1, text: '项目一' },
{ id: 2, text: '项目二' },
{ id: 3, text: '项目三' }
])
let nextId = 4
function addItem() {
items.push({ id: nextId++, text: `项目${nextId - 1}` })
}
function removeItem() {
if (items.length > 0) items.pop()
}
</script>
<style>
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
/* 列表排序移动动画(TransitionGroup 特有) */
.list-move {
transition: transform 0.3s ease;
}
</style>
KeepAlive(组件缓存)
KeepAlive 可以缓存组件实例,避免组件切换时重新创建和销毁,保留组件的状态。
KeepAlive 参数说明:
| 属性 | 类型 | 说明 |
|---|---|---|
| include | string / RegExp / Array | 只缓存名字匹配的组件 |
| exclude | string / RegExp / Array | 不缓存名字匹配的组件 |
| max | number | 最多缓存多少个组件实例,超出时销毁最久未访问的 |
<template>
<div>
<button @click="current = 'TabA'">标签 A</button>
<button @click="current = 'TabB'">标签 B</button>
<!-- 只缓存 TabA,TabB 不缓存 -->
<KeepAlive include="TabA" :max="5">
<component :is="current === 'TabA' ? TabA : TabB" />
</KeepAlive>
</div>
</template>
<script setup>
import { ref } from 'vue'
import TabA from '@/components/TabA.vue'
import TabB from '@/components/TabB.vue'
const current = ref('TabA')
</script>
被 KeepAlive 缓存的组件需要通过 defineOptions 设置组件名,才能被 include/exclude 识别:
<!-- TabA.vue -->
<script setup>
import { defineOptions } from 'vue'
// 设置组件名,供 KeepAlive 的 include/exclude 匹配
defineOptions({
name: 'TabA'
})
</script>
Teleport(传送门)
Teleport 可以将组件模板的一部分"传送"到 DOM 中的其他位置(比如 body),常用于模态框、提示框等。
Teleport 参数说明:
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
| to | string / HTMLElement | 是 | 目标 DOM 元素的 CSS 选择器或 DOM 节点 |
| disabled | boolean | 否 | 是否禁用传送(禁用时渲染在原位置) |
<template>
<div class="component">
<p>这段内容渲染在组件内部</p>
<!-- 模态框被传送到 body 下,避免被父元素的 overflow:hidden 裁剪 -->
<Teleport to="body">
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
<div class="modal">
<h2>模态框标题</h2>
<p>这个模态框被渲染到 body 下,而不是组件内部</p>
<button @click="showModal = false">关闭</button>
</div>
</div>
</Teleport>
<button @click="showModal = true">打开模态框</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const showModal = ref(false)
</script>
Vue Router 基础
安装与配置
npm install vue-router@4
// src/router/index.js
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
const routes = [
{
path: '/', // URL 路径
name: 'home', // 路由名称(可选,但推荐)
component: HomeView // 对应的组件
},
{
path: '/about',
name: 'about',
// 懒加载:只在访问此路由时才加载组件,减小初始包体积
component: () => import('@/views/AboutView.vue')
},
{
// 动态路由参数,:id 会匹配任意值
path: '/post/:id',
name: 'post-detail',
component: () => import('@/views/PostDetail.vue')
},
{
// 嵌套路由
path: '/user',
component: () => import('@/views/UserLayout.vue'),
children: [
{
path: '', // 空路径匹配 /user
name: 'user-home',
component: () => import('@/views/UserHome.vue')
},
{
path: 'profile', // 匹配 /user/profile
name: 'user-profile',
component: () => import('@/views/UserProfile.vue')
}
]
},
{
// 重定向
path: '/old-path',
redirect: '/new-path'
},
{
// 404 页面,匹配所有未匹配的路径
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFound.vue')
}
]
const router = createRouter({
// createWebHistory:使用 HTML5 History 模式,URL 没有 # 号(需要服务器配置)
// createWebHashHistory:使用 Hash 模式,URL 有 # 号,不需要服务器配置
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
export default router
History 模式对比:
| 模式 | URL 示例 | 优点 | 缺点 |
|---|---|---|---|
| createWebHistory | /about | URL 简洁美观 | 需要服务器配置,直接访问子路径时不能返回 404 |
| createWebHashHistory | /#/about | 无需服务器配置,兼容性好 | URL 不美观,SEO 较差 |
RouterView 与 RouterLink
<!-- App.vue -->
<template>
<div>
<!-- 导航链接,会自动添加 active-class -->
<nav>
<!-- RouterLink 会渲染为 <a> 标签 -->
<RouterLink to="/">首页</RouterLink>
<RouterLink to="/about">关于</RouterLink>
<!-- 使用命名路由 -->
<RouterLink :to="{ name: 'post-detail', params: { id: 1 } }">文章详情</RouterLink>
<!-- 带查询参数 -->
<RouterLink :to="{ path: '/search', query: { q: 'vue' } }">
搜索
</RouterLink>
</nav>
<!-- 路由组件渲染的位置 -->
<RouterView />
</div>
</template>
useRouter / useRoute
<script setup>
import { useRouter, useRoute } from 'vue-router'
import { computed } from 'vue'
// useRouter:获取路由器实例,用于编程式导航
const router = useRouter()
// useRoute:获取当前路由信息(响应式)
const route = useRoute()
// 访问路由信息
console.log(route.path) // 当前路径,如 '/post/123'
console.log(route.name) // 路由名称,如 'post-detail'
console.log(route.params) // 动态路由参数,如 { id: '123' }
console.log(route.query) // 查询参数,如 { page: '1', sort: 'date' }
console.log(route.meta) // 路由元信息
// 动态路由参数
const postId = computed(() => route.params.id)
// 编程式导航
function goToHome() {
router.push('/') // 导航到路径
router.push({ name: 'home' }) // 使用命名路由
router.push({ path: '/post/1' }) // 带路径
router.push({ name: 'post-detail', params: { id: 2 } }) // 带参数
router.push({ path: '/search', query: { q: 'vue' } }) // 带查询参数
}
function replaceRoute() {
// replace:替换当前历史记录(无法后退)
router.replace('/login')
}
function goBack() {
router.go(-1) // 后退一步,等同于浏览器后退
router.go(1) // 前进一步
router.back() // 同 router.go(-1)
router.forward() // 同 router.go(1)
}
</script>
路由守卫
// src/router/index.js
// 全局前置守卫:每次路由跳转前执行
router.beforeEach((to, from, next) => {
// to:即将进入的路由
// from:正在离开的路由
// next:调用此函数来 resolve 守卫
const isLoggedIn = localStorage.getItem('token')
// 路由元信息 meta.requiresAuth 标记需要登录的路由
if (to.meta.requiresAuth && !isLoggedIn) {
// 未登录,跳转到登录页,并记录来源页
next({ name: 'login', query: { redirect: to.fullPath } })
} else {
next() // 正常放行
}
})
// 全局后置守卫:路由跳转完成后执行(没有 next 参数)
router.afterEach((to, from) => {
// 修改页面标题
document.title = to.meta.title || '默认标题'
})
在路由配置中添加 meta:
const routes = [
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: {
requiresAuth: true, // 需要登录
title: '控制台'
}
}
]
params vs query
// params:动态路由参数,是路径的一部分
// 路由配置:{ path: '/post/:id' }
router.push({ name: 'post-detail', params: { id: 123 } })
// URL:/post/123
// 组件中获取:route.params.id → '123'(字符串)
// query:查询字符串,附加在 URL 后面
router.push({ path: '/search', query: { keyword: 'vue', page: 2 } })
// URL:/search?keyword=vue&page=2
// 组件中获取:route.query.keyword → 'vue',route.query.page → '2'
Pinia 状态管理
Pinia 是 Vue 官方推荐的状态管理库,用于在多个组件之间共享状态。
安装与配置
npm install pinia
已在 main.js 中配置(见"项目结构"章节)。
defineStore
// src/stores/userStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// defineStore 参数:
// 第一个参数:store 的唯一 ID(字符串),用于 devtools 识别
// 第二个参数:setup 函数(Composition API 风格,推荐)
export const useUserStore = defineStore('user', () => {
// state:响应式数据
const currentUser = ref(null)
const token = ref(localStorage.getItem('token') || '')
const loading = ref(false)
// getters:计算属性
const isLoggedIn = computed(() => !!token.value)
const username = computed(() => currentUser.value?.name || '游客')
// actions:方法(可以是异步的)
async function login(username, password) {
loading.value = true
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
})
const data = await response.json()
token.value = data.token
currentUser.value = data.user
localStorage.setItem('token', data.token)
} catch (error) {
console.error('登录失败:', error)
throw error
} finally {
loading.value = false
}
}
function logout() {
token.value = ''
currentUser.value = null
localStorage.removeItem('token')
}
// 返回所有需要暴露的内容
return {
currentUser,
token,
loading,
isLoggedIn,
username,
login,
logout
}
})
也可以用 Options 风格定义(与 Vue 2 的 Vuex 更接近):
// Options 风格(了解即可)
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
getters: {
doubled: (state) => state.count * 2
},
actions: {
increment() {
this.count++
}
}
})
在组件中使用 Store
<template>
<div>
<p v-if="userStore.isLoggedIn">
欢迎,{{ userStore.username }}
</p>
<p v-else>请先登录</p>
<!-- 使用 storeToRefs 解构的响应式属性 -->
<p>登录状态:{{ isLoggedIn }}</p>
<p>当前用户:{{ username }}</p>
<button @click="handleLogout" v-if="isLoggedIn">退出登录</button>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/userStore'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// storeToRefs:解构 store 的 state 和 getters,保持响应性
// 注意:actions 不需要解构,直接从 store 调用
const { isLoggedIn, username, loading } = storeToRefs(userStore)
// actions 直接从 store 实例解构(不用 storeToRefs)
const { login, logout } = userStore
function handleLogout() {
logout()
}
</script>
与 Vuex 的对比
| 对比项 | Pinia | Vuex 4(Vue 3 版) |
|---|---|---|
| 模板文件结构 | store、getters、actions 扁平结构 | state、getters、mutations、actions 四层 |
| 修改状态 | 直接修改或在 action 中修改 | 必须通过 mutation(同步)才能修改 |
| TypeScript 支持 | 原生支持,类型推断好 | 需要额外配置 |
| 模块化 | 每个 store 文件是独立模块 | 需要显式 modules 配置 |
| devtools | 支持 | 支持 |
| 代码量 | 更少 | 更多(需要写 mutation) |
综合实战:博客列表应用
本节实现一个包含路由、Pinia 状态管理、父子组件通信、数据请求的完整应用。
项目结构
src/
├── components/
│ ├── PostCard.vue # 文章卡片组件(子组件)
│ └── LoadingSpinner.vue # 加载动画组件
├── views/
│ ├── HomeView.vue # 首页(文章列表)
│ └── PostDetail.vue # 文章详情页
├── stores/
│ └── postsStore.js # 文章状态管理
└── router/
└── index.js # 路由配置
路由配置
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
meta: { title: '博客列表' }
},
{
path: '/post/:id',
name: 'post-detail',
component: () => import('@/views/PostDetail.vue'),
meta: { title: '文章详情' }
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
router.afterEach((to) => {
document.title = to.meta.title || '我的博客'
})
export default router
Pinia Store
// src/stores/postsStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const usePostsStore = defineStore('posts', () => {
const posts = ref([])
const currentPost = ref(null)
const loading = ref(false)
const error = ref(null)
// 使用 JSONPlaceholder 提供的免费 API
const API_BASE = 'https://jsonplaceholder.typicode.com'
const postCount = computed(() => posts.value.length)
async function fetchPosts() {
loading.value = true
error.value = null
try {
const response = await fetch(`${API_BASE}/posts?_limit=10`)
if (!response.ok) throw new Error('请求失败')
posts.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
async function fetchPostById(id) {
loading.value = true
error.value = null
currentPost.value = null
try {
const [postResponse, commentsResponse] = await Promise.all([
fetch(`${API_BASE}/posts/${id}`),
fetch(`${API_BASE}/posts/${id}/comments`)
])
const post = await postResponse.json()
const comments = await commentsResponse.json()
currentPost.value = { ...post, comments }
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
return {
posts,
currentPost,
loading,
error,
postCount,
fetchPosts,
fetchPostById
}
})
文章卡片组件(子组件)
<!-- src/components/PostCard.vue -->
<template>
<div class="post-card" @click="handleClick">
<div class="post-meta">
<span class="post-id">文章 #{{ post.id }}</span>
<span class="user-id">作者 ID:{{ post.userId }}</span>
</div>
<h2 class="post-title">{{ post.title }}</h2>
<p class="post-body">{{ truncatedBody }}</p>
<div class="post-actions">
<!-- 触发自定义事件,通知父组件 -->
<button @click.stop="handleClick" class="read-btn">阅读全文</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
// 接收父组件传来的数据
const props = defineProps({
post: {
type: Object,
required: true
}
})
// 声明要触发的事件
const emit = defineEmits({
select: (post) => post && typeof post.id === 'number'
})
// 截断正文,只显示前 80 个字符
const truncatedBody = computed(() => {
if (props.post.body.length > 80) {
return props.post.body.substring(0, 80) + '...'
}
return props.post.body
})
function handleClick() {
// 将文章数据传给父组件
emit('select', props.post)
}
</script>
<style scoped>
.post-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
cursor: pointer;
transition: box-shadow 0.2s ease;
}
.post-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.post-meta {
display: flex;
gap: 12px;
margin-bottom: 8px;
font-size: 12px;
color: #888;
}
.post-title {
font-size: 18px;
margin-bottom: 8px;
color: #333;
text-transform: capitalize;
}
.post-body {
color: #666;
line-height: 1.6;
margin-bottom: 12px;
}
.read-btn {
padding: 6px 16px;
background-color: #42b883;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
加载动画组件
<!-- src/components/LoadingSpinner.vue -->
<template>
<div class="loading-wrapper">
<div class="spinner"></div>
<p>{{ text }}</p>
</div>
</template>
<script setup>
defineProps({
text: {
type: String,
default: '加载中...'
}
})
</script>
<style scoped>
.loading-wrapper {
text-align: center;
padding: 40px;
color: #888;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #e0e0e0;
border-top-color: #42b883;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
首页(文章列表)
<!-- src/views/HomeView.vue -->
<template>
<div class="home">
<header class="page-header">
<h1>我的博客</h1>
<p>共 {{ postCount }} 篇文章</p>
</header>
<!-- 加载状态 -->
<LoadingSpinner v-if="loading" text="正在加载文章..." />
<!-- 错误状态 -->
<div v-else-if="error" class="error-message">
<p>加载失败:{{ error }}</p>
<button @click="store.fetchPosts()">重试</button>
</div>
<!-- 文章列表 -->
<div v-else class="post-list">
<PostCard
v-for="post in posts"
:key="post.id"
:post="post"
@select="handlePostSelect"
/>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import PostCard from '@/components/PostCard.vue'
import LoadingSpinner from '@/components/LoadingSpinner.vue'
import { usePostsStore } from '@/stores/postsStore'
const router = useRouter()
const store = usePostsStore()
// 解构响应式数据
const { posts, loading, error, postCount } = storeToRefs(store)
// 组件挂载后发起请求
onMounted(async () => {
// 如果已有数据,不重复请求
if (posts.value.length === 0) {
await store.fetchPosts()
}
})
// 接收子组件传来的文章,跳转到详情页
function handlePostSelect(post) {
router.push({ name: 'post-detail', params: { id: post.id } })
}
</script>
<style scoped>
.home {
max-width: 800px;
margin: 0 auto;
padding: 24px 16px;
}
.page-header {
margin-bottom: 24px;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 16px;
}
.page-header h1 {
color: #42b883;
margin-bottom: 4px;
}
.error-message {
text-align: center;
padding: 40px;
color: #e74c3c;
}
</style>
文章详情页
<!-- src/views/PostDetail.vue -->
<template>
<div class="post-detail">
<button @click="router.back()" class="back-btn">返回列表</button>
<LoadingSpinner v-if="loading" text="正在加载文章..." />
<div v-else-if="error" class="error-message">
<p>加载失败:{{ error }}</p>
</div>
<article v-else-if="currentPost" class="article">
<header>
<div class="meta">
<span>文章 #{{ currentPost.id }}</span>
<span>作者 ID:{{ currentPost.userId }}</span>
</div>
<h1>{{ currentPost.title }}</h1>
</header>
<div class="body">
<p>{{ currentPost.body }}</p>
</div>
<!-- 评论区 -->
<section class="comments">
<h2>评论({{ currentPost.comments.length }})</h2>
<div
v-for="comment in currentPost.comments"
:key="comment.id"
class="comment"
>
<div class="comment-header">
<strong>{{ comment.name }}</strong>
<span>{{ comment.email }}</span>
</div>
<p>{{ comment.body }}</p>
</div>
</section>
</article>
</div>
</template>
<script setup>
import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import LoadingSpinner from '@/components/LoadingSpinner.vue'
import { usePostsStore } from '@/stores/postsStore'
const route = useRoute()
const router = useRouter()
const store = usePostsStore()
const { currentPost, loading, error } = storeToRefs(store)
// 根据路由参数加载文章
async function loadPost(id) {
await store.fetchPostById(id)
}
// 组件挂载时加载
onMounted(() => {
loadPost(route.params.id)
})
// 路由参数变化时重新加载(从一篇文章直接跳到另一篇)
watch(() => route.params.id, (newId) => {
if (newId) {
loadPost(newId)
}
})
</script>
<style scoped>
.post-detail {
max-width: 800px;
margin: 0 auto;
padding: 24px 16px;
}
.back-btn {
margin-bottom: 24px;
padding: 8px 16px;
background: none;
border: 1px solid #42b883;
color: #42b883;
border-radius: 4px;
cursor: pointer;
}
.back-btn:hover {
background-color: #42b883;
color: white;
}
.article header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e0e0e0;
}
.meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #888;
margin-bottom: 8px;
}
.article h1 {
font-size: 24px;
color: #333;
text-transform: capitalize;
}
.body {
line-height: 1.8;
color: #555;
margin-bottom: 32px;
}
.comments h2 {
font-size: 18px;
margin-bottom: 16px;
color: #333;
}
.comment {
border-left: 3px solid #42b883;
padding: 12px;
margin-bottom: 12px;
background-color: #f9f9f9;
}
.comment-header {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.comment-header strong {
font-size: 14px;
}
.comment-header span {
font-size: 12px;
color: #888;
}
.comment p {
font-size: 14px;
color: #666;
}
</style>
注意事项与常见误区
ref 相关
- 在
<script>中访问 ref 值必须用.value;在模板中不需要。 - 将 ref 传给函数时,如果函数内修改的是
.value,响应性保持;如果用解构赋值,响应性断开。
const count = ref(0)
// 正确:传引用,函数内通过 .value 修改
function increment(countRef) {
countRef.value++
}
increment(count)
// 错误:解构丢失响应性
function wrong({ value }) {
value++ // 这里修改的只是局部变量,不会触发视图更新
}
reactive 相关
- 不能解构 reactive 对象,解构后的值失去响应性,用
toRefs解决。 - 不能直接替换 reactive 对象(如
state = reactive({...})),会断开响应性。 - 只支持对象类型(object、array、Map、Set),不支持基本类型。
v-for 与 key
- 始终为
v-for提供:key,且使用稳定的唯一 ID,不要用 index。 - 不要在同一元素上同时使用
v-for和v-if,用<template>分层处理。
生命周期
- 在
onMounted中才能操作 DOM,不要在setup顶层操作 DOM。 - 在
onBeforeUnmount中清理副作用(定时器、事件监听、WebSocket 等)。
路由
route.params.id是字符串类型,和数字比较时注意类型转换。- 使用命名路由(
name)而不是硬编码路径,路径变化时只需修改路由配置。
最佳实践
用 ref 存储基本类型,用 reactive 存储对象,不混用:ref 通过 .value 访问,全场景适用;reactive 只支持对象,但访问字段时无需 .value,适合复杂表单对象。混用导致规则不一致,维护成本高。
将复杂状态逻辑提取为 Composable(useXxx):超过 50 行的组件 setup 逻辑应拆分为独立的 use*.ts 文件,每个 Composable 关注一个关切点,便于测试和复用。
// composables/useCounter.ts
export function useCounter(initial = 0) {
const count = ref(initial)
const increment = () => count.value++
return { count, increment }
}
使用 computed 缓存派生状态,不要在模板中写复杂表达式:computed 只在依赖变化时重新计算,模板中的方法调用每次渲染都执行,性能差且难以调试。
在 onBeforeUnmount 中清理所有副作用:setInterval、addEventListener、WebSocket 连接等若不清理,组件卸载后仍在运行,造成内存泄漏和意外行为。
v-for 始终绑定稳定的业务 ID 作为 :key,不用 index:用 index 作为 key 时,列表项删除或插入会导致 Vue 复用错误的 DOM 节点,产生数据显示错误,尤其是含有表单控件的列表。
常见陷阱
陷阱:解构 reactive 对象后失去响应性
现象: 从 reactive 对象中解构的变量修改后,视图没有更新。
原因: reactive 的响应性依赖对对象属性的 Proxy 拦截,解构后变量持有的是原始值(基本类型)或原始引用(对象),与 Proxy 断开连接,无法追踪变化。
解决: 使用 toRefs 将 reactive 对象的属性转为 ref,或直接使用 ref 而非 reactive。
const state = reactive({ count: 0, name: 'Alice' })
// 错误:解构丢失响应性
const { count } = state
// 正确:toRefs 保持响应性
const { count, name } = toRefs(state)
陷阱:在模板中对列表用 index 作为 key
现象: 删除列表中间的某一项后,其他项的显示出现错位,或输入框的内容跑到错误的行。
原因: Vue 使用 key 来识别哪些节点需要复用。用 index 作 key 时,删除中间项会导致后续所有项的 key 全部变化,Vue 无法正确复用 DOM,会错误地将旧节点内容保留在新位置。
解决: 始终用稳定的唯一业务 ID(如数据库主键)作为 :key。
<!-- 错误 -->
<div v-for="(item, index) in list" :key="index">
<!-- 正确 -->
<div v-for="item in list" :key="item.id">
陷阱:在 Composition API 中访问 this
现象: 在 setup() 中使用 this 访问组件实例的属性或方法,返回 undefined。
原因: setup() 在组件实例创建前执行,this 不指向组件实例。Composition API 的设计就是为了摆脱对 this 的依赖。
解决: 用 ref、reactive、Composable 等替代,通过 getCurrentInstance() 获取实例(仅在特殊场景使用)。