Vue 3核心面试题
1. Vue 的设计哲学 & 核心理念
Vue 的设计初衷是为了让 Web 开发变得更简单、更高效,同时又不失灵活性。它的核心理念可以概括为以下几点:
-
渐进式框架 (Progressive Framework):这是 Vue 最核心的特点。它意味着你可以在项目中逐步采用 Vue 的功能,而不是必须全盘接受一个庞大的框架。
- 起步:你可以像使用 jQuery 一样,只用 Vue 的核心库来处理页面的数据绑定和交互。
- 进阶:当应用变得复杂时,你可以引入 Vue Router 来管理路由,引入 Pinia (或 Vuex) 来进行状态管理。
- 完整:配合构建工具 (Vite/Webpack),你可以使用单文件组件 (SFC) 构建大型、可维护的单页面应用 (SPA)。
- 比喻:就像搭乐高,你可以从几块基础积木开始,根据需要逐步添加更复杂的组件和套装。
-
平易近人 (Approachable):Vue 的学习曲线非常平缓,因为它基于标准的 HTML、CSS 和 JavaScript。如果你已经了解这些基础,就可以很快上手 Vue。它的 API 设计简洁直观,文档也极其出色。
-
高性能 (Performant):Vue 在内部做了大量优化。它使用虚拟 DOM (Virtual DOM) 来最小化实际的 DOM 操作。在编译阶段,它会尽可能多地分析模板,进行静态节点提升等优化,从而在运行时获得极佳的性能。
-
灵活性与通用性 (Versatile):Vue 不仅限于构建复杂的 SPA。你可以用它来增强现有的静态页面,构建 Web Components,甚至通过 Electron 或 NativeScript 开发桌面和移动应用。
核心思想总结:
- 声明式渲染:你只需要关心状态 (State) 和视图 (View) 之间的关系,当状态改变时,Vue 会自动、高效地更新视图。
- 组件化系统:将复杂的 UI 拆分成一个个独立、可复用、可组合的组件 (Component),极大地提高了代码的可维护性和开发效率。
2. Composition API 与响应式系统整体介绍
Composition API (组合式 API)
这是 Vue 3 引入的最重要的特性,旨在解决 Vue 2 中 Options API (选项式 API) 在大型组件中难以维护的问题。
-
Options API 的痛点:在复杂的组件中,同一个逻辑关注点(比如一个用户认证功能)的代码,会被分散到
data、methods、computed、watch等不同的选项中,导致代码碎片化,难以阅读和维护。 -
Composition API 的解决方案:它允许我们将与某个逻辑关注点相关的代码组织在一起。你不再按“选项类型”组织代码,而是按“功能”来组织。这使得逻辑的复用和提取变得极其简单。
- 比喻:Options API 像是把所有书按“颜色”分类(红色的放一堆,蓝色的放一堆),而 Composition API 则是按“主题”分类(所有关于历史的书放一起,所有关于科学的书放一起),后者显然更有条理。
响应式系统 (Reactivity System)
这是 Vue 的“魔法”所在。它是一个能让数据变化自动驱动视图更新的系统。
- 核心流程:
- 你将一个普通的 JavaScript 对象传递给 Vue 的响应式 API (如
ref或reactive)。 - Vue 会将这个对象转换成一个响应式对象。
- 当你在模板中访问这个对象的属性时,Vue 会追踪 (Track) 这个依赖关系,记录下“这段 UI 依赖于这个数据”。
- 当你修改这个对象的属性时,Vue 会触发 (Trigger) 更新,找到所有依赖于该数据的 UI 部分,并高效地重新渲染它们。
- 你将一个普通的 JavaScript 对象传递给 Vue 的响应式 API (如
Vue 3 的响应式系统是基于 ES6 的 Proxy 实现的,相比 Vue 2 的 Object.defineProperty 更加强大和高效。
3. Composition API 基础语法讲解
所有 Composition API 的逻辑都写在 setup 函数(或 <script setup> 语法糖)中。
setup函数:是组件的入口点,在组件实例创建之前执行。
核心 API
-
ref:- 用途:用于创建基本类型(如
String,Number,Boolean)的响应式数据。当然也可以用于对象。 - 原理:它将值包装在一个带有
.value属性的对象中。 - 原因:JavaScript 的基本类型是按值传递的。为了让它们在函数间传递时保持响应性,必须用一个对象来“包裹”它们。
- 示例:
<script setup> import { ref } from 'vue'; // 创建一个响应式的 count,初始值为 0 const count = ref(0); function increment() { // 在 <script> 中,必须通过 .value 访问或修改 count.value++; } </script> <template> <!-- 在 <template> 中,Vue 会自动解包,无需 .value --> <button @click="increment">Count is: {{ count }}</button> </template>
- 用途:用于创建基本类型(如
-
reactive:- 用途:用于创建对象或数组的响应式代理。
- 原理:返回一个对象的响应式代理(基于
Proxy)。 - 示例:
<script setup> import { reactive } from 'vue'; const state = reactive({ user: { name: 'Alice', age: 30 }, hobbies: ['reading', 'coding'] }); function changeName() { state.user.name = 'Bob'; } </script>
-
computed:- 用途:创建基于其他响应式数据计算得出的计算属性。
- 特点:结果会被缓存,只有当其依赖的响应式数据改变时才会重新计算。
- 示例:
<script setup> import { ref, computed } from 'vue'; const firstName = ref('John'); const lastName = ref('Doe'); // fullName 会在 firstName 或 lastName 改变时自动更新 const fullName = computed(() => `${firstName.value} ${lastName.value}`); </script>
-
watch&watchEffect:watch:明确地侦听一个或多个响应式数据源,并在其变化时执行回调函数。- 特点:懒执行(默认不立即执行)、更具体地控制侦听的目标。
- 示例:
import { ref, watch } from 'vue'; const question = ref(''); // 侦听 question 的变化 watch(question, (newVal, oldVal) => { console.log(`Question changed from "${oldVal}" to "${newVal}"`); });
watchEffect:自动地追踪其回调函数中所有响应式依赖,并在任何依赖变化时重新运行该函数。- 特点:立即执行、自动追踪依赖。
- 示例:
import { ref, watchEffect } from 'vue'; const userID = ref(1); // 只要 userID.value 变化,这个函数就会重新运行 watchEffect(() => { console.log(`Fetching data for user: ${userID.value}`); // fetchData(userID.value); });
4. Vue 的响应式陷阱
在使用响应式系统时,有一些常见的错误会导致数据失去响应性。
-
解构
reactive对象:- 陷阱:当你使用解构赋值从一个
reactive对象中提取属性时,提取出的变量会失去与原始对象的响应式链接。import { reactive } from 'vue'; const state = reactive({ count: 0, name: 'Vue' }); // 错误!count 和 name 只是普通的数字和字符串,不再是响应式的 let { count, name } = state; count++; // state.count 不会改变,视图也不会更新 - 解决方案:使用
toRefs或toRef将reactive对象的属性转换成ref。import { reactive, toRefs } from 'vue'; const state = reactive({ count: 0, name: 'Vue' }); // 正确!count 和 name 现在是 ref,保持了响应性 const { count, name } = toRefs(state); count.value++; // OK! state.count 会更新,视图也会更新
- 陷阱:当你使用解构赋值从一个
-
直接替换整个
reactive对象:- 陷阱:如果你直接给一个
reactive变量赋一个新的非响应式对象,会断开原来的响应式连接。let state = reactive({ count: 0 }); // 错误!state 不再是响应式代理,变成了普通对象 state = { count: 1 }; - 解决方案:不要替换整个对象,而是修改其属性。如果确实需要替换,可以逐个赋值,或者用
Object.assign。let state = reactive({ count: 0 }); // 正确 state.count = 1; // 或者对于整个对象 const newObj = { count: 1, name: 'New' }; Object.assign(state, newObj);
- 陷阱:如果你直接给一个
-
忘记
.value:在<script>(特别是<script setup>的 JS 部分) 中操作ref时,忘记使用.value是最常见的错误之一。
5. Vue 响应式底层原理 - Proxy
Vue 3 的响应式系统是基于 ES6 Proxy 的,它解决了 Vue 2 中 Object.defineProperty 的诸多限制。
Object.defineProperty (Vue 2) 的缺陷
- 无法检测属性的新增/删除:你不能直接通过
obj.newProp = value添加一个响应式属性,必须使用Vue.set()。 - 无法直接监听数组索引和
length属性的变化:Vue 2 通过重写数组的部分方法(如push,pop)来hack式地实现数组响应性。 - 初始化时需要深度遍历:必须在组件初始化时遍历对象的所有属性,并为每个属性设置 getter/setter,开销较大。
Proxy (Vue 3) 的优势
Proxy 是在对象层面创建一个代理,而不是在属性层面。它像一个拦截器,可以“代理”对一个对象的所有操作。
- 比喻:
Proxy就像一个大楼的保安。无论你想进入哪个房间(get属性),或者想在哪个房间里改变陈设(set属性),都必须先经过保安。保安可以记录你的行为,并通知其他人。
Vue 如何利用 Proxy 实现响应式
Vue 的 reactive() 函数内部就做着类似的事情:
-
创建代理:当你调用
reactive(originalObject)时,Vue 会返回new Proxy(originalObject, handler)。 -
handler对象:这个对象定义了代理的行为,其中最重要的两个是get和set。-
get陷阱 - 依赖追踪 (Track)- 当你的代码(如模板渲染函数或
computed)读取代理对象的属性时(例如state.user.name),get陷阱会被触发。 - 在
get内部,Vue 会执行依赖收集:它会记录下“当前正在运行的这段代码(称为 Effect)依赖于state.user.name这个属性”。
- 当你的代码(如模板渲染函数或
-
set陷阱 - 触发更新 (Trigger)- 当你修改代理对象的属性时(例如
state.user.name = 'Bob'),set陷阱会被触发。 - 在
set内部,Vue 会执行派发更新:它会查找所有依赖于state.user.name的 Effect,并通知它们重新执行。 - 由于模板渲染函数是一个 Effect,它就会被重新执行,从而更新视图。
- 当你修改代理对象的属性时(例如
-
简化版伪代码:
// 全局变量,用于存储当前正在运行的 Effect
let activeEffect = null;
// 依赖收集
function track(target, key) {
if (activeEffect) {
// 将 activeEffect 与 target[key] 关联起来
// ... (复杂的依赖地图存储逻辑)
}
}
// 派发更新
function trigger(target, key) {
// 找到所有依赖于 target[key] 的 Effect
// ... (从依赖地图中查找)
// 依次执行它们
}
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
// 1. 依赖追踪
track(target, key);
// 2. 返回原始值
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
// 1. 设置新值
const result = Reflect.set(target, key, value, receiver);
// 2. 触发更新
trigger(target, key);
return result;
}
});
}
Proxy 的强大之处在于它能原生支持对整个对象的监听,包括属性的新增、删除、以及对数组的各种操作,使得 Vue 3 的响应式系统更加健壮、完整且高效。
6. Vue 响应式底层原理 - 依赖追踪机制
这是 Vue 响应式系统最精妙的部分,解释了 Vue 如何精确地知道“当这个数据变了,我应该更新哪部分 UI”。这个机制可以分解为三个核心角色:Effect (副作用)、Track (追踪) 和 Trigger (触发)。
比喻:订阅报纸
- 你 (Effect):一个对特定新闻感兴趣的读者。
- 报纸的某个版块 (响应式数据属性):比如“体育版”。
- 订阅动作 (Track):你去报社订阅“体育版”,报社把你加入了“体育版读者列表”。
- 发布新报纸 (Trigger):当“体育版”有新内容时,报社就会根据“体育版读者列表”,给所有订阅者(包括你)送去最新的报纸。
具体工作流程
-
Effect (副作用)
- 在 Vue 中,任何需要被响应式数据驱动的操作,都可以被包装成一个副作用函数 (Effect)。最典型的 Effect 就是组件的渲染函数。
computed的 getter 函数和watch的回调函数也是 Effect。 - 当一个 Effect 首次执行时,Vue 会将其设置为全局唯一的
activeEffect,表示“当前正在运行的副作用是这个”。
- 在 Vue 中,任何需要被响应式数据驱动的操作,都可以被包装成一个副作用函数 (Effect)。最典型的 Effect 就是组件的渲染函数。
-
Track (追踪 / 依赖收集)
- 当
activeEffect正在执行时,如果它访问了任何一个响应式对象的属性(例如,在模板中用到了{{ state.count }}),就会触发该属性的Proxy的get陷阱。 - 在
get陷阱内部,Vue 执行追踪操作:- 它会查看当前是否存在
activeEffect。 - 如果存在,它就会建立一个双向的映射关系:“
state.count这个属性,被activeEffect(即组件渲染函数)依赖了”。
- 它会查看当前是否存在
- 这个映射关系通常是用一个复杂的全局数据结构(如
WeakMap<target, Map<key, Set<effect>>>)来存储的,可以高效地记录哪个对象 -> 哪个属性 -> 被哪些 Effect 依赖。
- 当
-
Trigger (触发 / 派发更新)
- 当你修改一个响应式对象的属性时(例如,
state.count++),就会触发该属性的Proxy的set陷阱。 - 在
set陷阱内部,Vue 执行触发操作:- 它会根据之前建立的映射关系,找到所有依赖于
state.count这个属性的 Effect。 - 然后,它会将这些 Effect 放入一个调度队列中,准备在下一个合适的时机(通常是微任务 tick)重新执行它们。
- 它会根据之前建立的映射关系,找到所有依赖于
- 当组件的渲染函数 Effect 被重新执行时,它会生成新的 VNode,然后 Vue 会通过 Diff 算法,高效地将变化更新到真实 DOM 上。
- 当你修改一个响应式对象的属性时(例如,
总结:Vue 通过 activeEffect 这个“探针”,在 get 时精准地收集依赖,然后在 set 时精准地通知所有相关的依赖进行更新。这种精确到属性级别的追踪,是 Vue 高效更新的基础。
7. Vue 响应式更新 vs React 状态更新
这是一个非常经典的前端面试题,它揭示了两个框架在设计哲学上的根本不同。
Vue: 精确追踪,自动更新 (Surgical Updates)
- 核心机制:基于可变数据 (Mutable Data) 和依赖追踪。
- 工作流程:
- 通过
Proxy实现细粒度的依赖追踪,精确到组件使用了哪个对象的哪个属性。 - 当数据变化时(例如
state.user.name = 'new name'),Vue 知道只有依赖于state.user.name的组件需要更新。 - Vue 自动地、精确地触发这些组件的重新渲染。
- 通过
- 开发者体验:开发者只需要直接修改数据即可,心智负担小。你只需要相信 Vue 会帮你搞定一切。
- 优点:
- 性能优化更自动化:由于更新是精确的,通常不需要开发者手动进行性能优化(如 React 中的
memo或useCallback)。 - 心智模型简单:直接修改状态,符合直觉。
- 性能优化更自动化:由于更新是精确的,通常不需要开发者手动进行性能优化(如 React 中的
- 比喻:Vue 就像一个智能的管家,你只需要告诉他哪个家具换了,他就会自动找到需要调整的房间,并只调整那个房间。
React: 不可变状态,手动优化 (Immutable State & Manual Optimization)
- 核心机制:基于不可变数据 (Immutable Data) 和手动控制。
- 工作流程:
- 状态是不可变的,你不能直接修改它,必须通过
setState(或useState的 setter) 来创建一个新的状态对象。 - 当调用
setState时,React 会将该组件标记为“脏” (dirty),并从该组件开始,自上而下地重新渲染其整个子组件树(除非被优化)。 - React 使用虚拟 DOM 的 Diff 算法来找出实际需要更新的 DOM 部分。
- 状态是不可变的,你不能直接修改它,必须通过
- 开发者体验:开发者需要明确地调用状态更新函数,并且为了避免不必要的子组件渲染,通常需要手动使用
React.memo,useMemo,useCallback等工具进行性能优化。 - 优点:
- 数据流清晰:单向数据流和不可变性使得状态的变更更容易追溯和调试。
- 函数式编程思想:与函数式编程范式结合得很好。
- 比喻:React 就像一个建筑承包商,你告诉他“设计图纸有变动”(创建了新状态),他会重新检查从变动点开始的整个楼层和下面的所有楼层(重新渲染子树),然后决定哪些地方需要真正地重建(更新 DOM)。如果你想让他跳过某些没变化的房间,你需要提前给他一份“免检清单”(
memo)。
核心差异总结:
| 特性 | Vue | React |
|---|---|---|
| 范式 | 响应式编程 | 函数式编程 |
| 更新粒度 | 组件属性级别 | 组件级别 |
| 触发方式 | 自动 (修改数据即可) | 手动 (setState) |
| 性能优化 | 默认高效,通常无需手动优化 | 需要开发者手动优化 (memo 等) |
| 心智模型 | “数据变了,UI 自动变” | “创建新状态,触发自上而下的重新渲染” |
8. Vue 常用的生命周期函数
生命周期钩子(函数)是在组件从创建到销毁的整个过程中,在特定阶段自动执行的函数。在 Composition API 中,它们需要从 vue 中导入,并以 onXxx 的形式使用。
核心生命周期钩子(按执行顺序):
-
onBeforeMount:- 时机:在组件被挂载到 DOM 之前调用。
- 状态:模板已经编译完成,但尚未创建真实的 DOM 节点。
- 用途:可以在这里执行一些不会操作 DOM 的初始化操作。
-
onMounted:- 时机:在组件被挂载到 DOM 之后调用。
- 状态:组件已经拥有了真实的 DOM 元素,可以通过
ref访问到它们。 - 用途:最常用的钩子之一。非常适合执行需要访问 DOM 的操作,如初始化第三方库(图表、地图等)、发送网络请求获取数据。
-
onBeforeUpdate:- 时机:当响应式数据发生变化,导致组件即将重新渲染之前调用。
- 状态:数据已经更新,但 DOM 尚未更新。
- 用途:可以在 DOM 更新前获取一些状态,比如更新前的滚动位置。
-
onUpdated:- 时机:在组件因数据变化而重新渲染之后调用。
- 状态:数据和 DOM 都已是最新状态。
- 用途:执行依赖于更新后 DOM 的操作。但要注意避免在此钩子中修改状态,否则可能导致无限更新循环。
-
onBeforeUnmount:- 时机:在组件实例被卸载之前调用。
- 状态:组件实例、指令、事件监听器等仍然完全可用。
- 用途:非常重要的钩子,用于执行副作用清理工作。比如清除定时器、取消订阅、解绑手动添加的 DOM 事件监听器等。
-
onUnmounted:- 时机:在组件实例被卸载之后调用。
- 状态:组件的所有内容都已被清理。
- 用途:用于执行最终的清理工作。
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
const myElement = ref(null);
let timer = null;
onMounted(() => {
// 组件已挂载,可以访问 DOM
console.log('Component is mounted!', myElement.value);
// 启动一个定时器 (副作用)
timer = setInterval(() => {
console.log('tick');
}, 1000);
});
onUnmounted(() => {
// 组件将要卸载,清理副作用
console.log('Component is unmounted, clearing timer.');
clearInterval(timer);
});
</script>
<template>
<div ref="myElement">Hello, Lifecycle!</div>
</template>
9. Vue 的副作用清理逻辑
副作用 (Side Effect) 是指那些在组件之外产生影响的操作,比如:
setInterval/setTimeout定时器- 手动的 DOM 事件监听 (
window.addEventListener) - 第三方库的实例化(如图表库)
- WebSocket 连接或其他订阅
如果这些副作用不被清理,它们会在组件销毁后继续存在,导致内存泄漏和意外的 bug。
清理副作用的最佳实践:
onBeforeUnmount/onUnmounted:这是清理副作用的标准位置。在组件即将销毁时,执行与副作用创建时相反的操作。watch/watchEffect的清理函数:watch和watchEffect可以返回一个函数,这个函数会在副作用即将重新执行之前或者侦听器停止时被调用。这对于处理可能频繁变化的异步操作非常有用。
示例:使用 watch 清理过期的网络请求
import { ref, watch } from 'vue';
const searchTerm = ref('');
watch(searchTerm, (newVal, oldVal, onCleanup) => {
// 使用 AbortController 来取消请求
const controller = new AbortController();
// 注册一个清理函数
// 当 searchTerm 再次变化时,这个函数会先被调用
onCleanup(() => {
console.log('Search term changed, aborting previous request.');
controller.abort();
});
// 发起可被取消的网络请求
fetch(`/api/search?q=${newVal}`, { signal: controller.signal })
.then(res => res.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch aborted');
}
});
});
这个例子完美地展示了如何在用户快速输入时,自动取消上一次尚未完成的搜索请求,避免了数据错乱和资源浪费。
10. 模板语法 - v-if, v-for, v-bind, v-model
这些是 Vue 模板中最常用、最核心的指令 (Directives)。指令是带有 v- 前缀的特殊 attribute。
-
v-if/v-else-if/v-else- 条件渲染- 用途:根据表达式的真假值,来条件性地创建或销毁元素。
- 特点:是“真正”的条件渲染,如果条件为假,元素及其子组件会被完全从 DOM 中移除。开销比
v-show大。 v-showvsv-if:v-show只是通过 CSS 的display: none来切换元素的可见性,元素始终存在于 DOM 中。v-if适用于条件不常改变的场景,v-show适用于需要频繁切换的场景。<div v-if="type === 'A'">Type A</div> <div v-else-if="type === 'B'">Type B</div> <div v-else>Other</div>
-
v-for- 列表渲染- 用途:基于一个数组或对象,渲染一个列表。
- 语法:
item in items或(item, index) in items。 key的重要性:必须为每个v-for的项提供一个唯一且稳定的keyattribute。key帮助 Vue 识别每个节点,从而在列表数据变化时,能够高效地复用和重新排序现有元素,而不是暴力地重新渲染整个列表。<ul> <li v-for="user in users" :key="user.id"> {{ user.name }} </li> </ul>
-
v-bind- 动态绑定 Attribute- 用途:将一个或多个 attribute,或者一个组件 prop,动态地绑定到表达式。
- 语法糖:
:(冒号)。v-bind:href可以简写为:href。<!-- 动态绑定 class --> <div :class="{ active: isActive }"></div> <!-- 动态绑定 style --> <div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div> <!-- 动态绑定 href --> <a :href="url">Link</a>
-
v-model- 双向数据绑定- 用途:在表单控件(如
<input>,<textarea>,<select>)或组件上创建双向数据绑定。 - 原理:它本质上是一个语法糖,背后做了两件事:
- 使用
v-bind绑定了一个value(或checked,selected) attribute。 - 使用
v-on监听了一个input(或change) 事件,并在事件触发时更新绑定的数据。
- 使用
- 语法糖:
v-model="searchText"等价于:value="searchText" @input="searchText = $event.target.value"。<input v-model="message" placeholder="edit me"> <p>Message is: {{ message }}</p>
- 用途:在表单控件(如
这套指令系统是 Vue 声明式渲染的核心,让开发者能够以非常直观的方式将数据状态映射到 DOM 结构和属性上。
11. Vue 的通信基础
组件是 Vue 应用的基本单元,如何让这些独立的组件之间有效地传递信息,是构建复杂应用的关键。以下是几种最常用的通信方式,从简单到复杂排列。
1. Props (父 -> 子)
- 用途:父组件向子组件传递数据。这是最常用、最基础的单向数据流方式。
- 工作方式:
- 父组件:通过
v-bind(或简写:) 将数据绑定到子组件标签的 attribute 上。 - 子组件:通过
defineProps(在<script setup>中) 或props选项来声明它期望接收的 props。
- 父组件:通过
- 特点:数据是单向的。子组件不应该直接修改 props 的值,这样做会破环数据流的清晰性,Vue 也会在控制台给出警告。如果需要修改,子组件应该
emit一个事件通知父组件去更改。
示例:
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue';
import Child from './Child.vue';
const messageToChild = ref('Hello from Parent!');
</script>
<template>
<Child :message="messageToChild" :count="123" />
</template>
<!-- Child.vue -->
<script setup>
// 声明接收的 props,可以带类型校验
const props = defineProps({
message: String,
count: {
type: Number,
required: true
}
});
// 在模板或 script 中可以直接使用 props.message
</script>
<template>
<h2>{{ props.message }}</h2>
<p>Count is: {{ props.count }}</p>
</template>
2. Emit (子 -> 父)
- 用途:子组件向父组件发送消息或触发事件。
- 工作方式:
- 子组件:通过
defineEmits声明它可能触发的事件,然后使用返回的emit函数来触发事件,可以携带数据。 - 父组件:通过
v-on(或简写@) 在子组件标签上监听这些自定义事件。
- 子组件:通过
- 特点:这是子组件与父组件通信的标准方式,完美地补充了 props 的单向数据流。
示例:
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['updateMessage']);
function sendMessageToParent() {
// 触发 updateMessage 事件,并传递一个字符串作为参数
emit('updateMessage', 'A new message from child!');
}
</script>
<template>
<button @click="sendMessageToParent">Update Parent's Message</button>
</template>
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue';
import Child from './Child.vue';
const childMessage = ref('Waiting for message...');
function handleUpdate(newMessage) {
childMessage.value = newMessage;
}
</script>
<template>
<p>Message from child: {{ childMessage }}</p>
<Child @update-message="handleUpdate" />
</template>
3. 全局状态管理 (Pinia/Vuex)
- 用途:用于管理跨多个组件、非父子关系的共享状态。适用于大型应用。
- 工作方式:将共享的状态抽离到一个全局的 Store 中。任何组件都可以读取 Store 中的状态,或者通过调用 Store 中定义的 actions/mutations 来修改状态。
- 特点:解决了“props drilling”(多层组件透传 props)的问题,让状态管理变得集中、可预测。
- 推荐:Vue 3 官方推荐的状态管理库是 Pinia,它比 Vuex 更简洁、类型支持更好。
4. v-model (双向绑定)
- 我们之前讲过
v-model用于表单,但它也可以用在组件上,实现父子组件之间的“双向绑定”。 - 原理:它也是
props+emit的语法糖。默认情况下,v-model会:- 向子组件传递一个名为
modelValue的 prop。 - 监听子组件触发的
update:modelValue事件。
- 向子组件传递一个名为
- 示例:
<!-- CustomInput.vue (子组件) -->
<script setup>
defineProps(['modelValue']);
defineEmits(['update:modelValue']);
</script>
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue';
import CustomInput from './CustomInput.vue';
const searchText = ref('');
</script>
<template>
<CustomInput v-model="searchText" />
<p>Search text is: {{ searchText }}</p>
</template>
12. Provide / Inject 的用法
provide 和 inject 是一对用于解决深层组件通信问题的 API。
- 用途:当一个父组件需要为它的所有后代组件(无论层级多深)提供数据或方法时,使用
provide/inject可以避免逐层传递 props(即“props drilling”)。 - 工作方式:
- 祖先组件:使用
provide函数来“提供”数据。provide接收两个参数:一个键 (key)(通常是String或Symbol)和一个值 (value)。 - 后代组件:使用
inject函数来“注入”由祖先提供的数据。inject使用相同的键来查找数据。
- 祖先组件:使用
- 特点:
- 非响应式:默认情况下,
provide提供的数据不是响应式的。如果希望后代组件能够响应数据的变化,你需要提供一个响应式对象(如ref或computed)。 - 推荐使用
Symbol作为键:为了避免在大型应用中可能出现的键名冲突,推荐使用Symbol来作为provide的键。
- 非响应式:默认情况下,
示例:
<!-- Ancestor.vue -->
<script setup>
import { provide, ref, readonly } from 'vue';
// 1. 创建一个唯一的 Symbol 作为 key
export const ThemeKey = Symbol();
const theme = ref('light');
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light';
}
// 2. 提供响应式数据和方法
// 使用 readonly() 包装,防止后代组件意外修改数据
provide(ThemeKey, {
theme: readonly(theme),
toggleTheme
});
</script>
<!-- Descendant.vue (任意深度的后代) -->
<script setup>
import { inject } from 'vue';
import { ThemeKey } from './Ancestor.vue'; // 导入 key
// 3. 注入数据,可以提供一个默认值
const { theme, toggleTheme } = inject(ThemeKey, { theme: 'default', toggleTheme: () => {} });
</script>
<template>
<div :class="theme">
<p>Current theme is: {{ theme }}</p>
<button @click="toggleTheme">Toggle Theme</button>
</div>
</template>
13. 插槽 (Slot) 与具名插槽
插槽是 Vue 组件中一个非常强大的内容分发机制。它允许父组件向子组件的特定位置“插入”内容。
- 用途:创建可复用的、布局灵活的组件,如对话框、卡片、布局框架等。子组件定义了“坑”(插槽),父组件负责往“坑”里填东西(内容)。
1. 默认插槽 (Default Slot)
- 子组件中使用
<slot></slot>标签来创建一个插槽出口。 - 父组件在使用该子组件时,放在其标签内部的内容,都会被插入到这个
<slot>的位置。
示例:
<!-- FancyButton.vue (子组件) -->
<template>
<button class="fancy-btn">
<slot></slot> <!-- 内容将会被插入到这里 -->
</button>
</template>
<!-- Parent.vue -->
<template>
<FancyButton>
<!-- 👇 这部分内容会被插入到子组件的 <slot> 中 -->
Click me!
</FancyButton>
<FancyButton>
<span style="color: red;">Icon</span> Submit
</FancyButton>
</template>
2. 具名插槽 (Named Slots)
- 当一个组件需要接收多个位置的内容时,可以使用具名插槽。
- 子组件通过
nameattribute 为<slot>命名,如<slot name="header"></slot>。 - 父组件使用
<template>元素和v-slot指令(简写#)来指定内容要插入到哪个插槽。
示例:
<!-- BaseLayout.vue (子组件) -->
<template>
<div class="container">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot> <!-- 这是一个默认插槽 -->
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>
<!-- Parent.vue -->
<template>
<BaseLayout>
<template v-slot:header> <!-- 或简写 #header -->
<h1>Here might be a page title</h1>
</template>
<!-- 默认内容,可以写在没有 v-slot 的 <template> 中,或直接写 -->
<p>A paragraph for the main content.</p>
<template #footer>
<p>Here's some contact info</p>
</template>
</BaseLayout>
</template>
14. 作用域插槽 (Scoped Slots)
这是插槽最高级、最强大的用法。它允许子组件在渲染插槽时,将一些数据传递给父组件,让父组件可以根据这些数据来决定插槽的内容。
- 用途:创建高度可定制的列表渲染组件或其他数据驱动的 UI 组件。
- 工作方式:
- 子组件:在
<slot>标签上像绑定 props 一样绑定数据,如<slot :item="data"></slot>。 - 父组件:在使用
v-slot指令时,接收这些数据,如v-slot:default="slotProps"。slotProps就是一个包含了子组件传递的所有数据的对象。
- 子组件:在
示例:
<!-- MyList.vue (子组件) -->
<script setup>
import { ref } from 'vue';
const items = ref([{ id: 1, text: 'A' }, { id: 2, text: 'B' }]);
</script>
<template>
<ul>
<!-- 遍历列表,并通过插槽将每个 item 数据暴露出去 -->
<li v-for="item in items" :key="item.id">
<slot :item-data="item"></slot>
</li>
</ul>
</template>
<!-- Parent.vue -->
<template>
<MyList>
<!-- 接收子组件通过插槽传递的数据 -->
<template #default="slotProps">
<!-- 我们可以自定义如何渲染每个列表项 -->
<strong>{{ slotProps.itemData.text }}</strong> (ID: {{ slotProps.itemData.id }})
</template>
</MyList>
</template>
在这个例子中,MyList 组件只负责数据的遍历和 li 标签的渲染,而如何渲染 li 内部的具体内容,则完全由父组件通过作用域插槽来决定,实现了极高的灵活性。
15. Vue-Router 的基本配置与使用
Vue Router 是 Vue.js 的官方路由管理器。它能让你构建单页面应用 (SPA) 变得轻而易举。
1. 安装
npm install vue-router@4
2. 创建和配置路由
通常在 src/router/index.js 文件中配置。
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import Home from '../views/Home.vue';
import About from '../views/About.vue';
// 1. 定义路由
// 每个路由都需要映射到一个组件
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: About
}
];
// 2. 创建路由实例
const router = createRouter({
// 使用 history 模式,URL 更美观,需要服务器配置支持
history: createWebHistory(),
routes, // `routes: routes` 的缩写
});
export default router;
3. 在主应用中挂载路由
在 src/main.js 中。
// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import router from './router'; // 导入我们创建的路由实例
const app = createApp(App);
app.use(router); // 让整个应用支持路由功能
app.mount('#app');
4. 在组件中使用路由
在 App.vue 或其他组件中,使用 <router-link> 和 <router-view>。
<router-link>: 这是一个用于声明式导航的组件,它会被渲染成一个<a>标签。toprop 指定了目标路径。<router-view>: 这是一个占位符,当前路由匹配到的组件将会被渲染在这里。
<!-- App.vue -->
<template>
<div id="nav">
<!-- 使用 router-link 组件进行导航 -->
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<!-- 路由出口 -->
<!-- 路由匹配到的组件将渲染在这里 -->
<router-view/>
</template>
5. 编程式导航
除了使用 <router-link>,你还可以在 JavaScript 中通过路由实例的方法来进行导航。
<script setup>
import { useRouter } from 'vue-router';
// 获取路由实例
const router = useRouter();
function goToAbout() {
// 跳转到指定路径
router.push('/about');
}
function goBack() {
// 后退一步
router.go(-1);
}
</script>
通过以上配置和使用,你就拥有了一个基础的单页面应用路由系统。
16. Vue-Router 的路由参数与传参方式
在实际应用中,我们经常需要在路由之间传递数据,比如用户的 ID、搜索的关键词等。Vue Router 提供了多种灵活的方式来实现这一点。
1. 动态路由参数 (Dynamic Route Segments)
- 用途:用于传递必要的、构成 URL 一部分的参数,如文章 ID、用户 ID 等。
- 配置:在路由路径中使用冒号
:来定义一个动态段。// router/index.js const routes = [ { // :id 就是一个动态参数 path: '/users/:id', name: 'UserProfile', component: () => import('../views/UserProfile.vue') } ]; - 导航:
<router-link :to="'/users/123'">Go to User 123</router-link> <!-- 或者使用命名的路由 --> <router-link :to="{ name: 'UserProfile', params: { id: 123 } }">Go to User 123</router-link> - 在组件中接收:使用
useRoute组合式函数来访问当前路由信息。<!-- views/UserProfile.vue --> <script setup> import { useRoute } from 'vue-router'; import { ref, watch } from 'vue'; const route = useRoute(); const userId = ref(route.params.id); // 注意:如果从 /users/123 跳转到 /users/456, // 组件实例会被复用,所以需要 watch route.params 的变化 watch(() => route.params.id, (newId) => { userId.value = newId; // ... 重新获取数据 }); </script> <template> <h1>User Profile for ID: {{ userId }}</h1> </template>
2. 查询参数 (Query Parameters)
- 用途:用于传递可选的、非路径核心的参数,如分页信息、搜索条件、筛选器等。
- 导航:
或者编程式导航:<!-- URL会变成 /search?q=vue&page=2 --> <router-link :to="{ path: '/search', query: { q: 'vue', page: 2 } }">Search</router-link>router.push({ path: '/search', query: { q: 'vue' } }); - 在组件中接收:同样使用
useRoute,但访问的是route.query对象。<script setup> import { useRoute } from 'vue-router'; const route = useRoute(); // route.query.q -> 'vue' // route.query.page -> '2' (注意:值通常是字符串) </script>
3. Props 传参 (最佳实践)
- 用途:将组件与
$route对象解耦,让组件更具可复用性。 - 优点:组件不再关心数据是来自 URL 还是父组件,它只关心
props。这使得测试和复用变得非常简单。 - 配置:在路由定义中设置
props: true。// router/index.js const routes = [ { path: '/users/:id', name: 'UserProfile', component: () => import('../views/UserProfile.vue'), // 将 route.params 自动作为 props 传递给组件 props: true } ]; - 在组件中接收:像接收普通 props 一样,使用
defineProps。<!-- views/UserProfile.vue --> <script setup> // 不再需要 useRoute 来获取 id 了! const props = defineProps({ id: String // 路由参数总是字符串 }); // 直接使用 props.id </script> <template> <h1>User Profile for ID: {{ props.id }}</h1> </template>
17. Vue-Router 的路由守卫
路由守卫(Navigation Guards)就像是路由跳转过程中的“保安”,它们允许你在导航发生之前、期间或之后执行一些逻辑,比如权限验证、数据预取或操作确认。
1. 全局守卫 (Global Guards)
- 定义在路由实例上,对所有路由跳转都生效。
router.beforeEach(to, from, next):最常用的守卫。在任何导航被确认之前调用。to: 即将进入的目标路由对象。from: 当前导航正要离开的路由对象。next: (在 v4 中行为有变) 函数,必须被调用来解析这个钩子。在 Vue Router 4 中,更推荐直接返回一个值:return true或不返回 (undefined):允许导航。return false:取消当前导航。return '/login'或return { name: 'Login' }:重定向到另一个位置。
示例:登录验证
// router/index.js
router.beforeEach((to, from) => {
const isAuthenticated = localStorage.getItem('token');
// 如果目标路由需要认证,并且用户未登录
if (to.meta.requiresAuth && !isAuthenticated) {
// 重定向到登录页
return { name: 'Login' };
}
// 否则,允许导航
return true;
});
2. 路由独享守卫 (Per-Route Guards)
- 直接在路由配置对象中定义,只对该条路由生效。
beforeEnter(to, from): 在进入该路由前调用。const routes = [{ path: '/admin', component: AdminPanel, beforeEnter: (to, from) => { // 只有访问 /admin 时才会触发 if (!isAdmin()) return '/'; } }];
3. 组件内守卫 (In-Component Guards)
- 在组件内部定义的守卫,可以访问组件实例 (
this在 Options API 中)。 onBeforeRouteLeave(to, from): 在导航离开当前组件的对应路由时调用。常用于提示用户保存未保存的修改。onBeforeRouteUpdate(to, from): 在当前路由改变,但该组件被复用时调用(例如,从/users/123导航到/users/456)。
示例:离开前确认
<script setup>
import { onBeforeRouteLeave } from 'vue-router';
import { ref } from 'vue';
const isFormDirty = ref(true); // 假设表单已被修改
onBeforeRouteLeave((to, from) => {
if (isFormDirty.value) {
const answer = window.confirm('你有未保存的更改,确定要离开吗?');
if (!answer) {
return false; // 取消导航
}
}
});
</script>
18. Vue-Router 的路由元信息
路由元信息 (meta) 是一个附加到路由配置上的自定义数据对象。它允许我们为路由附加一些“元数据”,然后在导航守卫或其他地方使用。
-
用途:
- 定义哪些路由需要权限验证 (
meta: { requiresAuth: true })。 - 定义页面标题 (
meta: { title: 'My Page' })。 - 定义页面使用的布局组件 (
meta: { layout: 'AdminLayout' })。 - 存储面包屑导航信息。
- 定义哪些路由需要权限验证 (
-
配置:在路由对象中添加
meta字段。const routes = [{ path: '/profile', name: 'Profile', component: Profile, meta: { requiresAuth: true, title: 'User Profile' } }]; -
使用:在全局守卫中访问
to.meta。router.beforeEach((to, from) => { // 设置页面标题 document.title = to.meta.title || 'Default Title'; // 权限验证 (接上面的例子) if (to.meta.requiresAuth && !isLoggedIn()) { return '/login'; } });
19. Vue 何时需要全局状态
虽然 props 和 emit 能解决大部分组件通信问题,但在某些场景下,它们会变得非常繁琐。这时就需要引入全局状态管理(如 Pinia)。
需要全局状态的典型场景:
-
深层组件通信 (Props Drilling):
- 问题:当一个状态需要从顶层组件传递给一个非常深层的子组件时,中间的所有组件都需要作为“中转站”来传递这个 prop,即使它们自己根本用不到。这使得代码耦合度高且难以维护。
- 解决方案:深层子组件直接从全局 Store 中获取状态,无需中间组件的参与。
-
兄弟或远亲组件通信:
- 问题:两个没有直接父子关系的组件需要共享或修改同一份数据(例如,一个 Header 组件需要显示用户的登录状态,而登录操作是在一个独立的 Login 页面组件中完成的)。
- 解决方案:将用户状态存放在全局 Store 中,Header 和 Login 组件都可以访问和修改它。
-
跨路由的状态持久化:
- 问题:你希望某些数据在用户切换页面(路由)后仍然保持不变,比如购物车内容、用户偏好设置。
- 解决方案:将这些数据存放在全局 Store 中。Store 的生命周期与整个 Vue 应用的生命周期绑定,而不是单个组件。
-
复杂的业务逻辑和状态关系:
- 问题:当多个状态之间存在复杂的依赖关系,或者修改一个状态需要执行一系列复杂的异步操作时,将这些逻辑放在单个组件中会使其变得臃肿。
- 解决方案:将这些复杂的业务逻辑封装在 Store 的
actions中,让组件只负责调用 action,保持组件的简洁。
总结:当你发现数据在组件树中“传来传去”很麻烦,或者多个不相关的组件需要共享一份数据时,就是引入 Pinia 的最佳时机。
20. Pinia 基本用法 - Store
Pinia是 Vue 官方推荐的新一代状态管理库,它非常简洁、直观且对 TypeScript 支持友好。
1. 安装与设置
npm install pinia
// src/main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App);
const pinia = createPinia(); // 创建 Pinia 实例
app.use(pinia); // 挂载到应用
app.mount('#app');
2. 定义一个 Store
Store 是 Pinia 的核心,它是一个包含了 state, getters, actions 的实体。通常在 src/stores/ 目录下创建文件,如 counter.js。
// src/stores/counter.js
import { defineStore } from 'pinia';
// 使用 defineStore() 定义一个 store
// 第一个参数是 store 的唯一 ID,Pinia 用它来连接 devtools
export const useCounterStore = defineStore('counter', {
// 1. State: 状态,必须是一个返回初始状态的函数
state: () => ({
count: 0,
name: 'Eduardo'
}),
// 2. Getters: 类似于 store 的计算属性
getters: {
doubleCount: (state) => state.count * 2,
// getters 也可以使用其他 getters
doubleCountPlusOne() {
return this.doubleCount + 1;
}
},
// 3. Actions: 类似于组件的 methods,用于修改 state
actions: {
increment() {
// 可以直接通过 this 访问 state
this.count++;
},
randomizeCounter() {
this.count = Math.round(100 * Math.random());
},
// Actions 可以是异步的
async fetchData() {
// const data = await fetchApi();
// this.someData = data;
}
}
});
3. 在组件中使用 Store
<script setup>
import { useCounterStore } from '../stores/counter';
import { storeToRefs } from 'pinia';
// 在 setup 中调用 use...Store() 函数来获取 store 实例
const counterStore = useCounterStore();
// 陷阱:直接解构会失去响应性!
// const { count, name } = counterStore; // ❌ 错误的方式
// 解决方案:使用 storeToRefs()
// 它会将 state 和 getters 转换成响应式的 ref
const { count, name, doubleCount } = storeToRefs(counterStore);
// Actions 可以直接从 store 实例中解构,因为它们是绑定到 store 上的函数
const { increment, randomizeCounter } = counterStore;
</script>
<template>
<div>
<!-- 直接访问 state 和 getters (现在是 ref) -->
<p>Name: {{ name }}</p>
<p>Count: {{ count }}</p>
<p>Double Count: {{ doubleCount }}</p>
<!-- 调用 actions -->
<button @click="increment">Increment</button>
<button @click="randomizeCounter">Randomize</button>
</div>
</template>
关键点:
defineStore: 用于创建 store。state: 存储核心数据。getters: 派生或计算状态。actions: 修改状态的方法(同步或异步)。storeToRefs: 在解构 store 时保持state和getters的响应性的关键工具。
21. Pinia 三大核心概念
Pinia 的设计极其简洁,其核心功能围绕着三个概念构建,它们共同构成了一个完整的状态管理单元 (Store)。
1. State (状态)
- 是什么:
State是 Store 的“数据源”,是你应用中需要全局共享的核心数据。它本质上是一个返回初始状态对象的函数。 - 为什么是函数:这与组件中的
data选项是函数的原因相同。在服务端渲染 (SSR) 或多实例应用中,如果state是一个普通对象,那么所有实例将共享同一个状态对象,导致交叉污染。通过返回一个新对象的函数,可以确保每个 Store 实例都有自己独立的状态。 - 如何定义:
state: () => ({ count: 0, user: null, items: [] }) - 如何访问/修改:
- 在
actions和getters中,通过this访问,如this.count。 - 在组件中,通过
store.count访问。可以直接修改,如store.count++,但不推荐。标准做法是通过actions来修改。
- 在
2. Getters (派生状态)
- 是什么:
Getters就像是 Store 的计算属性 (Computed Properties)。它们用于从state或其他getters中派生出新的状态。 - 为什么需要 Getters:
- 缓存:Getters 的结果会被缓存。只要其依赖的
state没有改变,多次访问同一个 getter 会立即返回缓存的结果,而不会重新计算,提升了性能。 - 代码复用:将复杂的计算逻辑(如过滤、排序、格式化数据)封装在 getter 中,可以让多个组件共享这段逻辑,避免代码重复。
- 缓存:Getters 的结果会被缓存。只要其依赖的
- 如何定义:一个包含多个函数的对象,每个函数接收
state作为第一个参数。getters: { // 接收 state doubleCount: (state) => state.count * 2, // 在 getter 内部,可以通过 this 访问其他 getter // 注意:这里要用普通函数,不能用箭头函数,否则 this 指向会错误 doubleCountPlusOne() { return this.doubleCount + 1; } } - 如何访问:与访问
state属性一样,store.doubleCount。
3. Actions (动作)
- 是什么:
Actions就像是 Store 的方法 (Methods)。它们是用来修改state的主要途径。 - 为什么需要 Actions:
- 封装业务逻辑:可以将复杂的、多步骤的状态变更逻辑封装在一个 action 中。例如,一个
loginaction 可能包括发起 API 请求、更新用户状态、设置 Token 等多个步骤。 - 支持异步:Actions 可以是异步的 (
async/await)。这是它们与getters最大的区别之一。你可以在 action 中执行任何异步操作,如网络请求。 - 可追踪性:通过 Vue Devtools,你可以清晰地追踪到每个 action 的调用及其对 state 产生的变更,这对于调试非常有帮助。
- 封装业务逻辑:可以将复杂的、多步骤的状态变更逻辑封装在一个 action 中。例如,一个
- 如何定义:一个包含多个函数的对象,函数内部通过
this来访问和修改state。actions: { increment() { // this 指向 store 实例 this.count++; }, async fetchUser(userId) { this.user = null; // 重置状态 try { const userData = await api.fetchUser(userId); this.user = userData; } catch (error) { console.error('Failed to fetch user:', error); } } } - 如何调用:在组件中调用
store.increment()或store.fetchUser(123)。
总结:State 是数据,Getters 是派生数据,Actions 是修改数据的方法。这三者共同构成了一个内聚、可预测的状态管理单元。
22. Pinia - Store 的响应式解构
这是一个在 Composition API 中使用 Pinia 时非常关键的技术点,不注意就会导致数据失去响应性。
问题所在
当你从 Pinia store 实例中直接使用 ES6 的解构赋值来获取 state 或 getters 时,你会得到一个非响应式的普通变量。
<script setup>
import { useCounterStore } from '../stores/counter';
const counterStore = useCounterStore();
// ❌ 错误的做法!
// 'count' 和 'doubleCount' 只是普通的 number,
// 它们的值是解构那一刻的快照。
// 当 store 中的 count 变化时,这里的 'count' 变量不会更新,视图也不会更新。
const { count, doubleCount } = counterStore;
</script>
<template>
<!-- 这个值将永远是初始值,不会变化 -->
<p>{{ count }}</p>
</template>
原因:解构赋值 const { count } = obj 的本质是 const count = obj.count。它只是取出了 obj.count 当时的值,并赋给了一个新的常量 count。这个新常量与原始的 obj 之间没有任何响应式链接。
解决方案:storeToRefs()
Pinia 提供了一个非常方便的辅助函数 storeToRefs 来解决这个问题。
- 是什么:
storeToRefs是一个函数,它接收一个 Pinia store 实例作为参数,并返回一个对象。这个对象中的每个属性都是对原始 store 中state和getters属性的响应式引用 (ref)。 - 如何使用:
<script setup>
import { useCounterStore } from '../stores/counter';
import { storeToRefs } from 'pinia'; // 1. 导入
const counterStore = useCounterStore();
// ✅ 正确的做法!
// 'count' 和 'doubleCount' 现在是 ref 对象,
// 它们的值会与 store 中的状态保持同步。
const { count, doubleCount } = storeToRefs(counterStore);
// Actions 是绑定在 store 实例上的方法,可以直接解构,因为它们本身不是响应式数据
const { increment } = counterStore;
</script>
<template>
<!-- 在模板中,ref 会被自动解包 -->
<p>{{ count }}</p>
<p>{{ doubleCount }}</p>
<button @click="increment">Increment</button>
</template>
总结:
- 要解构
state和getters,请使用storeToRefs()。 actions可以直接从 store 实例上解构。
23. Pinia 最小可变点原则
这是一个关于如何组织和使用 Store 的设计原则和最佳实践,旨在提升代码的可维护性和可预测性。
核心思想:组件应该尽可能少地直接修改 Store 的状态。状态的变更应该被封装在可复用的、语义化的 Actions 中。组件的角色是“触发动作”,而不是“执行具体的状态变更逻辑”。
为什么遵循这个原则?
-
逻辑集中与复用 (Centralized & Reusable Logic)
- 反例 (不推荐):
// 在组件 A 中 function addUser() { if (usersStore.users.length < 10) { usersStore.users.push({ name: newUser.value, id: Date.now() }); usersStore.hasChanged = true; } } // 在组件 B 中,也需要添加用户... 又写一遍类似逻辑 - 遵循原则:
// 在 usersStore.js 的 actions 中 actions: { addUser(newUser) { if (this.users.length >= 10) { console.warn('Cannot add more users.'); return; } this.users.push({ name: newUser.name, id: Date.now() }); this.hasChanged = true; } } // 在任何组件中,只需调用 usersStore.addUser({ name: 'Alice' }); - 好处:所有与“添加用户”相关的业务逻辑(如数量检查、状态更新)都封装在 Store 内部。如果未来逻辑需要改变(比如最大用户数变成 20),你只需要修改 Store 的一个地方,而不是去翻找所有可能修改了这个状态的组件。
- 反例 (不推荐):
-
提升可预测性与可调试性 (Predictability & Debuggability)
- 当所有的状态变更都通过
actions进行时,你的代码库就变得非常容易理解和追踪。 - 结合 Vue Devtools,你可以清晰地看到每一次状态变更是由哪个
action触发的,以及它造成了哪些state的变化。 - 如果组件可以随意地、零散地修改 Store 的
state(例如store.count++,store.user.name = 'x'),那么当出现 bug 时,你很难定位是哪个组件在哪种情况下错误地修改了状态。
- 当所有的状态变更都通过
-
简化组件 (Simpler Components)
- 组件的职责应该聚焦于UI 的展示和用户交互的响应。
- 将复杂的业务逻辑移入 Store 的
actions,可以让组件代码变得更加简洁、清晰,只关心“何时调用哪个 action”,而不关心“action 具体做了什么”。
实践中的平衡
虽然原则上推荐通过 actions 修改状态,但在某些非常简单的场景下,直接修改 state 也是可以接受的,比如在一个简单的原型开发或一个非常局部的状态修改中。Pinia 并没有像 Vuex 的严格模式那样强制禁止直接修改。
然而,在构建可维护的、长期的应用时,始终坚持通过 actions 来封装状态变更,是一个会让你受益匪浅的良好习惯。
总结:“最小可变点原则”鼓励我们将状态变更的逻辑内聚到 Store 的 actions 中,让组件成为动作的“触发者”而非“执行者”,从而构建出更健壮、更易于维护的应用程序。
更多推荐


所有评论(0)