Vue 基础:状态管理入门

164 阅读12分钟

原文: Vue Basics: State Management in Vue

学习如何随着应用规模的扩大,利用 ref/reactive、props/emits、provide/inject 和 Pinia 来扩展 Vue 的状态管理。

在使用 Vue 构建任何交互式应用时,“状态”是你最先接触到的核心概念之一。无论是表单中的文本、购物车里的商品,还是已登录用户的个人资料,正确地管理这些状态对于保持应用的稳定性、响应性以及易扩展性都至关重要。

Vue 3 引入了组合式 API 以及全新的响应式系统,为开发者提供了前所未有的强大且灵活的状态管理方式。在本指南中,我们将探讨 Vue 3 如何处理状态——从使用 refreactive 管理局部状态开始,到通过 propsprovide/inject 共享数据,最后进阶到 Vue 的官方状态管理库 Pinia。读完本文,你将获得一份清晰的路线图,能够根据应用的具体需求选择最合适的工具。

理解 Vue 中的状态管理概念

状态是每一个交互式 Vue 应用的核心。正是它让你的 UI 变得动态——只要更新状态,Vue 就会自动将这些变化反映在 DOM 中。

广义上讲,状态分为两种类型:

  • 局部状态: 存在于单个组件内部(例如:计数器组件中的 count 变量)。
  • 全局状态: 在多个组件或应用的不同部分之间共享(例如:用户登录认证状态)。

默认情况下,每个 Vue 组件都维护着自己的响应式状态,我们通常称之为局部状态。在探索如何在组件间共享状态以及最终使用 Pinia 进行全局管理之前,让我们先从这里入手。

使用 ref 和 reactive 管理局部状态

为了管理局部响应式状态,Vue 3 的组合式 API 提供了两个主要工具:refreactive。如果你刚接触 Vue,可能会对选择哪一个感到困惑——因此,让我们基于对局部状态的理解,来详细拆解这两个工具。

使用 ref

当处理原始值(数字、字符串、布尔值)时,请使用 ref。

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

//state
const count = ref(0)

//actions
function increment() {
  count.value++
}
</script>

<template>
  <button @click="increment">Count is: {{ count }}</button>
</template>

在这里,count 被封装在一个 ref 中,并通过 .value 来访问其值。

使用 reactive

当处理对象或数组时,请使用 reactive

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

const user = reactive({
  name: ' ',
  age: 25
})
</script>

<template>
  <input v-model="user.name" placeholder="Enter your name" />
  <p>{{ user.name }} is {{ user.age }} years old.</p>
</template>

在底层,Vue 将对象封装在一个 Proxy 中,因此它可以自动追踪变化并更新 DOM。

局部状态是 Vue 单向数据流最简单的例子:状态驱动 UI。但一旦多个组件需要共享同一份状态,仅靠局部管理就会变得困难,这时我们就需要超越局部状态了。

在组件间共享状态

局部状态对于单个组件来说运作良好,但如果多个组件需要相同的数据该怎么办?Vue 提供了几种共享状态的方式:props/emits 和 provide/inject。

Props 和 Emits

Props 允许父组件向下传递状态,而 emits 允许子组件向上发送事件。

让我们看看下面这个简单的演示:

父组件

你可以通过 props 将数据从父组件传递给子组件。

<!-- Parent.vue -->
<template>
  <Child :count="count" @increment="count++" />
</template>

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const count = ref(0)
</script>

子组件

当子组件需要更新父组件的状态时,它可以触发(emit)一个事件。

<!-- Child.vue -->
<template>
  <button @click="$emit('increment')">Clicked</button>
</template>

<script setup>
defineProps(['count'])
defineEmits(['increment'])
</script>

这种方法对于小型应用来说效果很好,但如果你有深层嵌套的组件,到处传递 props 和 emits 很快就会变得混乱,从而导致所谓的“Prop Drilling”(属性逐级透传)问题。为了避免这种情况,Vue 提供了另一种选择:provide/inject。

Provide/inject:避免 Prop Drilling

Vue 中的 provide/inject API 使得父组件能够轻松地与子组件共享数据,无论嵌套多深,都无需通过每一个中间层级向下传递 props。

让我们看看下面这个简单的代码演示:

父组件

<!-- ParentComponent.vue -->
<script setup>
import { ref, provide } from 'vue'
import ChildComponent from './ChildComponent.vue'

   const count = ref(0)
   provide('count', count)

   const increment = () => {
      count.value++
  }
</script>

<template>
  <div>
    <h2>Parent Count: {{ count }}</h2>
    <button @click="increment">Increment</button>
    <!-- 不通过props传递 -->
    <ChildComponent />
  </div>
</template>

provide() 函数用于父组件中,使其数据对后代组件可用。它接收两个参数:一个注入键和一个值。这个键可以是字符串或 Symbol,后代组件将使用该键通过 inject() 来访问对应的值。单个组件不限于调用一次;你可以使用不同的键多次调用 provide() 来共享不同的值。

provide() 的第二个参数是你想要共享的数据,它可以是任何类型——原始值、对象、函数,甚至是像 ref 或 reactive 这样的响应式状态。当你提供一个响应式值时,Vue 不会传递副本;它会建立一个实时连接,允许使用 inject() 的后代组件自动与提供者保持同步。

子组件

<!-- ChildComponent.vue -->
<script setup>
import GrandChildComponent from './GrandChildComponent.vue'
</script>
<template>
  <div>
    <h3>I am the Child</h3>
    <!-- 注意: 不通过props传递 -->
    <GrandChildComponent />
  </div>
</template>

要注入祖先组件提供的数据,请在 GrandChildComponent.vue 中使用 inject() 函数:

<!-- GrandChildComponent.vue -->
<script setup>
import { inject } from 'vue'
const count = inject('count')
</script>
<template>
  <div>
    <p>Grandchild sees count: {{ count }}</p>
  </div>
</template>

在上面的代码演示中,ParentComponent 提供了响应式的 count,因此 ChildComponent 不需要做任何操作——它只需向下传递插槽/子组件。然后 GrandChildComponent 可以直接注入 count,并对父组件的更新保持响应。

这是如何使用 provide/inject 模式的一个基本演示;不过,如果你想了解更多,这篇文章进行了非常详细的介绍:Vue 基础:探索 Vue 的 Provide/Inject 模式

provide/inject 模式非常适合在中型应用中避免 Prop Drilling。然而,随着应用规模的增长,以这种方式管理依赖关系可能会变得复杂。对于大型且复杂的应用,我们需要一个更具结构化和可扩展性的专门状态管理解决方案,这正是像 Pinia 这样的库大显身手的地方。

利用 Pinia 应对大规模状态管理

随着你的应用不断增长,在多个组件之间手动管理状态会很快变得令人头疼。我们之前介绍的模式对于小型项目来说运作良好,但一旦你开始构建大规模的生产级应用,就有许多事情需要考虑:

  • 热模块替换 (HMR)
  • 更强的团队协作约定
  • 与 Vue DevTools 的集成,包括时间轴、组件内检查和时光旅行调试 (Time-travel debugging)
  • 服务端渲染 (SSR) 支持

你需要一个中央仓库,一个单一的数据源,供多个组件读取和写入,而这正是 Pinia 登场的地方。

Pinia 是 Vue 3 的官方状态管理库,也是 Vuex 的继任者。它专为处理上述所有场景而设计。它更简单、更直观,并且旨在与组合式 API 无缝配合。

在深入代码演示之前,让我们先明确基础知识。从核心上讲,状态管理关乎你的数据存放在哪里、如何读取以及如何更新。Pinia 用四个概念将这些规范化:

  • State (状态): 这是你实际的响应式数据。把它看作你应用的单一数据源。例如用户的个人资料详情、购物车中的商品或模态框是否打开。
  • Store (仓库): 容纳状态的集中式容器。Store 将数据集中管理,而不是分散在多个组件中,因此任何组件都可以访问和更新它,而无需混乱的 Prop 逐层传递 (Prop Drilling)。
  • Getters (获取器): 它们就像 Store 的计算属性。它们允许你派生或转换状态值(例如,计算购物车中商品的成本),而无需在多处重复逻辑。
  • Actions (动作): 更新状态的函数。它们就像 Vue 组件中的 methods(方法),是你存放修改逻辑的地方,无论是增加计数器、向列表添加项目还是从 API 获取数据。

可以这样理解:

  • State 是你持有的数据
  • Getters 是你查看数据的方式
  • Actions 定义数据如何变化
  • Store 是这一切的栖身之所

接下来,让我们演示如何将 Pinia 集成到我们的计数器演示应用中。

设置 Pinia

为 Vue 3 单页应用设置 Pinia 非常简单。如果你是使用 Vue CLI 或 create-vue 从头开始创建一个新项目,设置向导甚至会询问你是否要使用 Pinia 作为你的状态管理首选。

要在新项目中手动设置 Pinia——或将其添加到现有应用中——请遵循以下步骤:

首先,使用 npmyarn 安装 Pinia 包:

npm install pinia

# or

yarn install pinia

要将 Pinia 注册到你的应用中,请打开挂载应用的入口文件(通常是 main.jsmain.ts),并在你的 Vue 应用实例上调用 app.use(pinia)

main.js

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

const pinia = createPinia()
const app = createApp(App)

app.use(pinia)
app.mount('#app')

现在我们可以开始为计数器演示应用创建 Store 了。

创建 Store

这是一个简单的计数器 Store,演示了所有四个概念。要创建一个 Store,首先新建一个文件来存放代码。一个好的做法是将这些文件放在专用的 stores 文件夹中,以保持项目井井有条。

CounterStore.js

export const useCounterStore = defineStore('counter',  {

----

})

我们要使用 Pinia 的 defineStore 方法来创建 Store。它接受两个主要参数:第一个是 Store 的 id,它在你的应用中必须是唯一的。你可以随意命名,但对于这个例子,counter 是最合适的,因为这正是我们的 Store 所管理的。

第二个参数是一个定义 Store 选项的对象。让我们分解一下可以在其中包含的内容:

State

在 Store 中定义的第一个选项是 state。如果你使用过 Vue 的选项式 API (Options API),这会让你倍感亲切。它只是一个返回对象的函数,该对象包含你的 Store 应管理的所有响应式数据。对于我们的计数器应用演示,我们将向 state 添加 count 属性:

export const useCounterStore = defineStore('counter',  {
  // State — 响应式共享数据
  state: () => ({
    count: 0,
  })
})

然后我们可以轻松地在 CounterButton.vue 组件中导入这个 Store 并使用 count 状态。

CounterButton.vue

<script setup>
import { useCounterStore } from '../stores/CounterStore.js’
const counter = useCounterStore()
</script>
<template>
  <div>
  <button >
    Clicked {{ counter.count }} times
 </button>
 
  </div>
</template>

在上面的代码示例中,我们导入了 useCounterStore 然后调用了该方法。这将返回我们在前面创建的计数器 Store 的副本。这个 Store 中的状态是全局的,意味着对它所做的任何更新都会自动反映在所有使用该 Store 的组件中。

Getters

就像 Vue 的计算属性一样,Pinia Store 允许我们定义 getters。Getter 本质上是一个从 Store 状态派生出来的计算值。当你想要基于现有状态转换、过滤或计算某些内容,而又不想在组件之间重复逻辑时,它们非常有用。例如,我们可以使用 getter 方法计算当前状态的乘积:

更新你的 CounterStore.js,添加以下代码:

export const useCounterStore = defineStore('counter',  {

  // State — 响应式共享数据
  state: () => ({
     count: 0,
  })

  // Getters — 派生状态
  getters: {
    doubleCount: (state) => state.count * 2
  },

})

就是这样。现在我们拥有了一个 doubleCount 属性,可以在任何组件中使用。

创建一个 CounterDisplay.vue 组件,使用 doubleCount 属性向用户显示消息。

<script setup>
import { useCounterStore } from '../stores/CounterStore.js'
const counter = useCounterStore()
</script>

<template>
  <div>
    <p>Current Count: {{ counter.count }}</p>
    <p>Double Count: {{ counter.doubleCount }}</p>
  </div>
</template>

Getters 设计为同步的。如果你需要执行异步工作(如获取数据),请改用 Action。

Actions

我们可以在 Store 中定义的最后一个选项是 actions。把 actions 想象成 Store 版本的组件方法——它们封装了更改状态或执行任务的逻辑。与仅用于派生和返回数据的 getters 不同,actions 旨在更新状态和处理副作用。

Actions 的一个主要优势是它们可以是异步的,这与 getters 不同。这使得它们非常适合诸如从 API 获取数据、处理表单提交或执行任何在将结果提交回 Store 之前需要时间的操作。例如,这是一个创建逻辑来增加状态 count 或从 API 获取初始 count 数据的好位置。

打开我们的 CounterStore.js 并更新以下代码:

export const useCounterStore = defineStore('counter',  {

  // State — 响应式共享数据
  state: () => ({
     count: 0,
  })

  // Getters — 派生状态
  getters: {
    doubleCount: (state) => state.count * 2
  },

  // Actions — 更新状态的逻辑
  actions: {
    increment() {
      this.count++
    },

    async fetchInitialCount() {
      const res = await fetch('/api/count')
      const data = await res.json()
      this.count = data.value
    }
  }
})

现在在 CounterButton.vue 组件内部,你可以调用 action 而不是直接修改状态:

<script setup>
import { useCounterStore } from '../stores/CounterStore.js’
const counter = useCounterStore()
</script>

<template>
  <button @click="counter.increment">Increment</button>
  <button @click="counter.fetchInitialCount">Load Initial Count</button>
  <p>Count is: {{ counter.count }}</p>
</template>

从上面的代码修改可以看出,increment() 是一个直接修改 Store 状态的简单 action,而 fetchInitialCount() 则演示了 action 也可以处理异步任务,如定时器或 API。由于 Pinia Store 是响应式的,一旦 action 更新了状态,所有使用该 Store 的组件将立即反映新值。

总结

Vue 中的状态管理不必让人感到不知所措。从小处着手,使用 refreactive 处理本地状态。当组件需要通信时,propsemits 是自然的选择。随着应用增长,provide/inject 有助于减少 Prop 逐层传递并保持条理清晰。

但当你的应用需要一个在许多组件之间共享且一致的状态时,Pinia 便脱颖而出。它提供了一个集中、可扩展的 Store,作为你应用的单一数据源。

知道何时使用每种方法是真正的关键。你不需要从第一天起就使用 Pinia,但随着项目变得越来越复杂,你会感激它的结构和可靠性。掌握了这些选项,你就可以自信地管理状态——无论你是构建一个小挂件还是一个大规模的生产级应用。

如果你想超越基础知识,官方的 Pinia 文档是最好的下一步。它深入介绍了插件、高级模式、DevTools 集成等内容——所有内容都解释得清晰实用。