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 的痛点:在复杂的组件中,同一个逻辑关注点(比如一个用户认证功能)的代码,会被分散到 datamethodscomputedwatch 等不同的选项中,导致代码碎片化,难以阅读和维护。

  • Composition API 的解决方案:它允许我们将与某个逻辑关注点相关的代码组织在一起。你不再按“选项类型”组织代码,而是按“功能”来组织。这使得逻辑的复用提取变得极其简单。

    • 比喻:Options API 像是把所有书按“颜色”分类(红色的放一堆,蓝色的放一堆),而 Composition API 则是按“主题”分类(所有关于历史的书放一起,所有关于科学的书放一起),后者显然更有条理。
响应式系统 (Reactivity System)

这是 Vue 的“魔法”所在。它是一个能让数据变化自动驱动视图更新的系统。

  • 核心流程
    1. 你将一个普通的 JavaScript 对象传递给 Vue 的响应式 API (如 refreactive)。
    2. Vue 会将这个对象转换成一个响应式对象
    3. 当你在模板中访问这个对象的属性时,Vue 会追踪 (Track) 这个依赖关系,记录下“这段 UI 依赖于这个数据”。
    4. 当你修改这个对象的属性时,Vue 会触发 (Trigger) 更新,找到所有依赖于该数据的 UI 部分,并高效地重新渲染它们。

Vue 3 的响应式系统是基于 ES6 的 Proxy 实现的,相比 Vue 2 的 Object.defineProperty 更加强大和高效。


3. Composition API 基础语法讲解

所有 Composition API 的逻辑都写在 setup 函数(或 <script setup> 语法糖)中。

  • setup 函数:是组件的入口点,在组件实例创建之前执行。
核心 API
  1. 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>
      
  2. 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>
      
  3. 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>
      
  4. 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 的响应式陷阱

在使用响应式系统时,有一些常见的错误会导致数据失去响应性。

  1. 解构 reactive 对象

    • 陷阱:当你使用解构赋值从一个 reactive 对象中提取属性时,提取出的变量会失去与原始对象的响应式链接。
      import { reactive } from 'vue';
      
      const state = reactive({ count: 0, name: 'Vue' });
      
      // 错误!count 和 name 只是普通的数字和字符串,不再是响应式的
      let { count, name } = state; 
      
      count++; // state.count 不会改变,视图也不会更新
      
    • 解决方案:使用 toRefstoRefreactive 对象的属性转换成 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 会更新,视图也会更新
      
  2. 直接替换整个 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);
      
  3. 忘记 .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() 函数内部就做着类似的事情:

  1. 创建代理:当你调用 reactive(originalObject) 时,Vue 会返回 new Proxy(originalObject, handler)

  2. handler 对象:这个对象定义了代理的行为,其中最重要的两个是 getset

    • 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):当“体育版”有新内容时,报社就会根据“体育版读者列表”,给所有订阅者(包括你)送去最新的报纸。
具体工作流程
  1. Effect (副作用)

    • 在 Vue 中,任何需要被响应式数据驱动的操作,都可以被包装成一个副作用函数 (Effect)。最典型的 Effect 就是组件的渲染函数computed 的 getter 函数和 watch 的回调函数也是 Effect。
    • 当一个 Effect 首次执行时,Vue 会将其设置为全局唯一的 activeEffect,表示“当前正在运行的副作用是这个”。
  2. Track (追踪 / 依赖收集)

    • activeEffect 正在执行时,如果它访问了任何一个响应式对象的属性(例如,在模板中用到了 {{ state.count }}),就会触发该属性的 Proxyget 陷阱。
    • get 陷阱内部,Vue 执行追踪操作:
      • 它会查看当前是否存在 activeEffect
      • 如果存在,它就会建立一个双向的映射关系:“state.count 这个属性,被 activeEffect(即组件渲染函数)依赖了”。
    • 这个映射关系通常是用一个复杂的全局数据结构(如 WeakMap<target, Map<key, Set<effect>>>)来存储的,可以高效地记录 哪个对象 -> 哪个属性 -> 被哪些 Effect 依赖
  3. Trigger (触发 / 派发更新)

    • 当你修改一个响应式对象的属性时(例如,state.count++),就会触发该属性的 Proxyset 陷阱。
    • 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)依赖追踪
  • 工作流程
    1. 通过 Proxy 实现细粒度的依赖追踪,精确到组件使用了哪个对象的哪个属性。
    2. 当数据变化时(例如 state.user.name = 'new name'),Vue 知道只有依赖于 state.user.name 的组件需要更新。
    3. Vue 自动地精确地触发这些组件的重新渲染。
  • 开发者体验:开发者只需要直接修改数据即可,心智负担小。你只需要相信 Vue 会帮你搞定一切。
  • 优点
    • 性能优化更自动化:由于更新是精确的,通常不需要开发者手动进行性能优化(如 React 中的 memouseCallback)。
    • 心智模型简单:直接修改状态,符合直觉。
  • 比喻:Vue 就像一个智能的管家,你只需要告诉他哪个家具换了,他就会自动找到需要调整的房间,并只调整那个房间。
React: 不可变状态,手动优化 (Immutable State & Manual Optimization)
  • 核心机制:基于不可变数据 (Immutable Data)手动控制
  • 工作流程
    1. 状态是不可变的,你不能直接修改它,必须通过 setState (或 useState 的 setter) 来创建一个新的状态对象
    2. 当调用 setState 时,React 会将该组件标记为“脏” (dirty),并从该组件开始,自上而下地重新渲染其整个子组件树(除非被优化)。
    3. React 使用虚拟 DOM 的 Diff 算法来找出实际需要更新的 DOM 部分。
  • 开发者体验:开发者需要明确地调用状态更新函数,并且为了避免不必要的子组件渲染,通常需要手动使用 React.memo, useMemo, useCallback 等工具进行性能优化。
  • 优点
    • 数据流清晰:单向数据流和不可变性使得状态的变更更容易追溯和调试。
    • 函数式编程思想:与函数式编程范式结合得很好。
  • 比喻:React 就像一个建筑承包商,你告诉他“设计图纸有变动”(创建了新状态),他会重新检查从变动点开始的整个楼层和下面的所有楼层(重新渲染子树),然后决定哪些地方需要真正地重建(更新 DOM)。如果你想让他跳过某些没变化的房间,你需要提前给他一份“免检清单”(memo)。

核心差异总结

特性VueReact
范式响应式编程函数式编程
更新粒度组件属性级别组件级别
触发方式自动 (修改数据即可)手动 (setState)
性能优化默认高效,通常无需手动优化需要开发者手动优化 (memo 等)
心智模型“数据变了,UI 自动变”“创建新状态,触发自上而下的重新渲染”

8. Vue 常用的生命周期函数

生命周期钩子(函数)是在组件从创建到销毁的整个过程中,在特定阶段自动执行的函数。在 Composition API 中,它们需要从 vue 中导入,并以 onXxx 的形式使用。

核心生命周期钩子(按执行顺序):

  1. onBeforeMount:

    • 时机:在组件被挂载到 DOM 之前调用。
    • 状态:模板已经编译完成,但尚未创建真实的 DOM 节点。
    • 用途:可以在这里执行一些不会操作 DOM 的初始化操作。
  2. onMounted:

    • 时机:在组件被挂载到 DOM 之后调用。
    • 状态:组件已经拥有了真实的 DOM 元素,可以通过 ref 访问到它们。
    • 用途最常用的钩子之一。非常适合执行需要访问 DOM 的操作,如初始化第三方库(图表、地图等)、发送网络请求获取数据。
  3. onBeforeUpdate:

    • 时机:当响应式数据发生变化,导致组件即将重新渲染之前调用。
    • 状态:数据已经更新,但 DOM 尚未更新。
    • 用途:可以在 DOM 更新前获取一些状态,比如更新前的滚动位置。
  4. onUpdated:

    • 时机:在组件因数据变化而重新渲染之后调用。
    • 状态:数据和 DOM 都已是最新状态。
    • 用途:执行依赖于更新后 DOM 的操作。但要注意避免在此钩子中修改状态,否则可能导致无限更新循环。
  5. onBeforeUnmount:

    • 时机:在组件实例被卸载之前调用。
    • 状态:组件实例、指令、事件监听器等仍然完全可用。
    • 用途非常重要的钩子,用于执行副作用清理工作。比如清除定时器、取消订阅、解绑手动添加的 DOM 事件监听器等。
  6. 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 的清理函数watchwatchEffect 可以返回一个函数,这个函数会在副作用即将重新执行之前或者侦听器停止时被调用。这对于处理可能频繁变化的异步操作非常有用。

示例:使用 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。

  1. v-if / v-else-if / v-else - 条件渲染

    • 用途:根据表达式的真假值,来条件性地创建或销毁元素。
    • 特点:是“真正”的条件渲染,如果条件为假,元素及其子组件会被完全从 DOM 中移除。开销比 v-show 大。
    • v-show vs v-ifv-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>
      
  2. v-for - 列表渲染

    • 用途:基于一个数组或对象,渲染一个列表。
    • 语法item in items(item, index) in items
    • key 的重要性必须为每个 v-for 的项提供一个唯一且稳定key attribute。key 帮助 Vue 识别每个节点,从而在列表数据变化时,能够高效地复用和重新排序现有元素,而不是暴力地重新渲染整个列表。
      <ul>
        <li v-for="user in users" :key="user.id">
          {{ user.name }}
        </li>
      </ul>
      
  3. 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>
      
  4. v-model - 双向数据绑定

    • 用途:在表单控件(如 <input>, <textarea>, <select>)或组件上创建双向数据绑定。
    • 原理:它本质上是一个语法糖,背后做了两件事:
      1. 使用 v-bind 绑定了一个 value (或 checked, selected) attribute。
      2. 使用 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 (父 -> 子)
  • 用途:父组件向子组件传递数据。这是最常用、最基础的单向数据流方式。
  • 工作方式
    1. 父组件:通过 v-bind (或简写 :) 将数据绑定到子组件标签的 attribute 上。
    2. 子组件:通过 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 (子 -> 父)
  • 用途:子组件向父组件发送消息或触发事件。
  • 工作方式
    1. 子组件:通过 defineEmits 声明它可能触发的事件,然后使用返回的 emit 函数来触发事件,可以携带数据。
    2. 父组件:通过 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 会:
    1. 向子组件传递一个名为 modelValue 的 prop。
    2. 监听子组件触发的 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 的用法

provideinject 是一对用于解决深层组件通信问题的 API。

  • 用途:当一个父组件需要为它的所有后代组件(无论层级多深)提供数据或方法时,使用 provide/inject 可以避免逐层传递 props(即“props drilling”)。
  • 工作方式
    1. 祖先组件:使用 provide 函数来“提供”数据。provide 接收两个参数:一个键 (key)(通常是 StringSymbol)和一个值 (value)
    2. 后代组件:使用 inject 函数来“注入”由祖先提供的数据。inject 使用相同的键来查找数据。
  • 特点
    • 非响应式:默认情况下,provide 提供的数据不是响应式的。如果希望后代组件能够响应数据的变化,你需要提供一个响应式对象(如 refcomputed)。
    • 推荐使用 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)
  • 当一个组件需要接收多个位置的内容时,可以使用具名插槽。
  • 子组件通过 name attribute 为 <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 组件。
  • 工作方式
    1. 子组件:在 <slot> 标签上像绑定 props 一样绑定数据,如 <slot :item="data"></slot>
    2. 父组件:在使用 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> 标签。to prop 指定了目标路径。
  • <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 何时需要全局状态

虽然 propsemit 能解决大部分组件通信问题,但在某些场景下,它们会变得非常繁琐。这时就需要引入全局状态管理(如 Pinia)。

需要全局状态的典型场景:

  1. 深层组件通信 (Props Drilling)

    • 问题:当一个状态需要从顶层组件传递给一个非常深层的子组件时,中间的所有组件都需要作为“中转站”来传递这个 prop,即使它们自己根本用不到。这使得代码耦合度高且难以维护。
    • 解决方案:深层子组件直接从全局 Store 中获取状态,无需中间组件的参与。
  2. 兄弟或远亲组件通信

    • 问题:两个没有直接父子关系的组件需要共享或修改同一份数据(例如,一个 Header 组件需要显示用户的登录状态,而登录操作是在一个独立的 Login 页面组件中完成的)。
    • 解决方案:将用户状态存放在全局 Store 中,Header 和 Login 组件都可以访问和修改它。
  3. 跨路由的状态持久化

    • 问题:你希望某些数据在用户切换页面(路由)后仍然保持不变,比如购物车内容、用户偏好设置。
    • 解决方案:将这些数据存放在全局 Store 中。Store 的生命周期与整个 Vue 应用的生命周期绑定,而不是单个组件。
  4. 复杂的业务逻辑和状态关系

    • 问题:当多个状态之间存在复杂的依赖关系,或者修改一个状态需要执行一系列复杂的异步操作时,将这些逻辑放在单个组件中会使其变得臃肿。
    • 解决方案:将这些复杂的业务逻辑封装在 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 时保持 stategetters 的响应性的关键工具。

21. Pinia 三大核心概念

Pinia 的设计极其简洁,其核心功能围绕着三个概念构建,它们共同构成了一个完整的状态管理单元 (Store)。

1. State (状态)
  • 是什么State 是 Store 的“数据源”,是你应用中需要全局共享的核心数据。它本质上是一个返回初始状态对象的函数
  • 为什么是函数:这与组件中的 data 选项是函数的原因相同。在服务端渲染 (SSR) 或多实例应用中,如果 state 是一个普通对象,那么所有实例将共享同一个状态对象,导致交叉污染。通过返回一个新对象的函数,可以确保每个 Store 实例都有自己独立的状态
  • 如何定义
    state: () => ({
      count: 0,
      user: null,
      items: []
    })
    
  • 如何访问/修改
    • actionsgetters 中,通过 this 访问,如 this.count
    • 在组件中,通过 store.count 访问。可以直接修改,如 store.count++,但不推荐。标准做法是通过 actions 来修改。
2. Getters (派生状态)
  • 是什么Getters 就像是 Store 的计算属性 (Computed Properties)。它们用于从 state 或其他 getters 中派生出新的状态。
  • 为什么需要 Getters
    • 缓存:Getters 的结果会被缓存。只要其依赖的 state 没有改变,多次访问同一个 getter 会立即返回缓存的结果,而不会重新计算,提升了性能。
    • 代码复用:将复杂的计算逻辑(如过滤、排序、格式化数据)封装在 getter 中,可以让多个组件共享这段逻辑,避免代码重复。
  • 如何定义:一个包含多个函数的对象,每个函数接收 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 中。例如,一个 login action 可能包括发起 API 请求、更新用户状态、设置 Token 等多个步骤。
    • 支持异步:Actions 可以是异步的 (async/await)。这是它们与 getters 最大的区别之一。你可以在 action 中执行任何异步操作,如网络请求。
    • 可追踪性:通过 Vue Devtools,你可以清晰地追踪到每个 action 的调用及其对 state 产生的变更,这对于调试非常有帮助。
  • 如何定义:一个包含多个函数的对象,函数内部通过 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 的解构赋值来获取 stategetters 时,你会得到一个非响应式的普通变量。

<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 中 stategetters 属性的响应式引用 (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>

总结

  • 要解构 stategetters,请使用 storeToRefs()
  • actions 可以直接从 store 实例上解构。

23. Pinia 最小可变点原则

这是一个关于如何组织和使用 Store 的设计原则最佳实践,旨在提升代码的可维护性和可预测性。

核心思想:组件应该尽可能少地直接修改 Store 的状态。状态的变更应该被封装在可复用的、语义化的 Actions 中。组件的角色是“触发动作”,而不是“执行具体的状态变更逻辑”。

为什么遵循这个原则?
  1. 逻辑集中与复用 (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 的一个地方,而不是去翻找所有可能修改了这个状态的组件。
  2. 提升可预测性与可调试性 (Predictability & Debuggability)

    • 当所有的状态变更都通过 actions 进行时,你的代码库就变得非常容易理解和追踪。
    • 结合 Vue Devtools,你可以清晰地看到每一次状态变更是由哪个 action 触发的,以及它造成了哪些 state 的变化。
    • 如果组件可以随意地、零散地修改 Store 的 state(例如 store.count++, store.user.name = 'x'),那么当出现 bug 时,你很难定位是哪个组件在哪种情况下错误地修改了状态。
  3. 简化组件 (Simpler Components)

    • 组件的职责应该聚焦于UI 的展示用户交互的响应
    • 将复杂的业务逻辑移入 Store 的 actions,可以让组件代码变得更加简洁、清晰,只关心“何时调用哪个 action”,而不关心“action 具体做了什么”。
实践中的平衡

虽然原则上推荐通过 actions 修改状态,但在某些非常简单的场景下,直接修改 state 也是可以接受的,比如在一个简单的原型开发或一个非常局部的状态修改中。Pinia 并没有像 Vuex 的严格模式那样强制禁止直接修改。

然而,在构建可维护的、长期的应用时,始终坚持通过 actions 来封装状态变更,是一个会让你受益匪浅的良好习惯。

总结:“最小可变点原则”鼓励我们将状态变更的逻辑内聚到 Store 的 actions 中,让组件成为动作的“触发者”而非“执行者”,从而构建出更健壮、更易于维护的应用程序。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐