状态管理
Vue 组件是构建 Vue 应用的基本区块,它允许我们在内部编写标记语言(HTML)、逻辑(JS)和样式(CSS)。
下面是一个单文件组件的示例,用于从状态中展示一系列的数字。
<template>
<div>
<h2>The numbers are {{ numbers }}!</h2>
</div>
</template>
<script setup>
import { ref } from "vue";
const numbers = ref([1, 2, 3]);
</script>
ref()
函数让组件对响应式做好准备。如果模板中使用的响应式属性的值发生改变,组件的视图将重新渲染。
在上面的例子中,numbers
是一个组件中使用的响应式的值。如果 numbers
是另一个组件中的值,在这里要被使用呢?例如,我们需要一个专门负责显示 numbers
的组件(就像上面这个),另一个组件负责操作 numbers
的值。
如果我们想要在多个组件中共享 numbers
,此时 numbers
就不应该仅仅作为组件级状态了,应该是应用级的状态。这就是我们要讨论的话题,状态管理 —— 应用级的状态管理。
在讨论如何管理应用的状态之前,我们先了解一下父组件和子组件之间是如何通过 props 来共享数据的。
Props
假设我们有一个应用,一开始它只有一个父组件和一个子组件。Vue 给予了我们从父组件向子组件传递数据的能力。
Props 用起来相当简单。我们要做的就是在子组件的 prop 上绑定对应的值即可。这有一个例子,通过使用 v-bind
指令,向 props 传递一个数组。
<template>
<div>
<ChildComponent :numbers="numbers" />
</div>
</template>
<script setup>
import { ref } from "vue";
import ChildComponent from "./ChildComponent";
const numbers = ref([1, 2, 3]);
</script>
<template>
<div>
<h2>{{ numbers }}</h2>
</div>
</template>
<script setup>
const { buttonText } = defineProps(["numbers"]);
</script>
ParentComponent
把数组 numbers
当做 props 传递给了 ChildComponent
。ChildComponent
把 numbers
绑定到它的模板上。
组件事件
如果我们需要反向传值该怎么办呢?例如我们需要在上面例子中的子组件中加入一个新的数字。
我们不能直接修改 props
,因为 props
只能单向传递(从祖先组件向后代组件传递)。要让子组件告诉父组件什么事情,我们可以使用自定义事件。
在 Vue 中,自定义事件实际上是通过原生的 CustomEvent 对象来实现的,这些事件主要用于组件之间的数据和行为的传递与协调。
下面是一个子组件 ChildComponent
使用自定义事件来改变父组件 ParentComponent’s
的 numbers
属性的例子:
<template>
<div>
<h2>{{ numbers }}</h2>
<input v-model="number" type="number" />
<button @click="$emit('number-added', Number(number))">
Add new number
</button>
</div>
</template>
<script setup>
const { numbers } = defineProps(["numbers"]);
</script>
<template>
<div>
<ChildComponent :numbers="numbers" @number-added="(n) => numbers.push(n)" />
</div>
</template>
<script setup>
import { ref } from "vue";
import ChildComponent from "./ChildComponent";
const numbers = ref([1, 2, 3]);
</script>
ChildComponent
有一个输入框,用于获取输入的数字。还有一个按钮。按钮用于触发自定义事件 number-added
,并将捕获的 number
值传递出去。
在 ParentComponent
中,在渲染子组件的时候指定一个自定义事件的监听器 @number-added
。当这个事件在子组件中被触发,监听器对应的方法会将事件传递过来的 numbers
值放到 ParentComponent
的 numbers
数组中。
简单的状态管理
我们可以使用 props 向下传递数据,使用自定义事件向上发送消息。我们如何在兄弟组件间传值或通信呢?
我们不能像上面那样使用自定义事件,因为这些事件是在特定组件的接口内部发出的,因此自定义事件的监听器需要在组件被渲染的位置声明。在两个独立的同级组件之间,不会有哪个组件在领一个组件中渲染。
译注
要不它们为什么叫兄弟组件呢。
有一种简单的管理应用级状态的方法,就是创建一个负责在组件间共享数据的 store。这个 store 可以管理应用的状态,并提供修改状态的方法。
例如,我们可以像下面这样创建一个简单的 store:
import { reactive } from "vue";
export const store = reactive({
numbers: [1, 2, 3],
addNumber(newNumber) {
this.numbers.push(newNumber);
},
});
这个 store 包含一个 numbers
数组和一个 addNumber
方法,addNumber
接收一个参数,直接将参数更新到 numbers
上。
你是否已经注意到,这里使用 reactive()
来创建状态对象。在 Vue 3.x 中,我们可以导入并使用 reactive()
函数,使用 JavaScript 对象来创建响应式状态。当 addNumber()
变更了这个响应式状态,任何使用了这个响应式状态的组件都将会自动更新!
我们创建一个负责显示 store 中 numbers
数组的组件,叫作 NumberDisplay
:
<template>
<div>
<h2>{{ store.numbers }}</h2>
</div>
</template>
<script setup>
import { store } from "../store.js";
</script>
我们现在创建另一个组件,叫作 NumberSubmit
,它允许用户为我们的数组添加数字:
<template>
<div>
<input v-model="numberInput" type="number" />
<button @click="store.addNumber(numberInput)">Add new number</button>
</div>
</template>
<script setup>
import { ref } from "vue";
import { store } from "../store.js";
const numberInput = ref(0);
</script>
组件 NumberSubmit
包含一个 addNumber()
方法,该方法调用了 store.addNumber()
并传递相应的参数。
Store 中的方法接收有效参数,并直接修改 store.numbers
数组。得益于 Vue 的响应式,每当 store 中的 numbers
数组发生改变,依赖该值的 DOM 元素(NumberDisplay
组件中的 <template>
)都会自动更新。
每当我们讨论组件之间互相影响时,我们都是使用较为宽泛的「interact」一词。实际上这些组件不会彼此影响,而是通过改变 store 中的状态来间接影响。
如果我们仔细观察所有与 store 交互的代码,我们可以得到一个模式:
NumberSubmit
方法具有直接操作 store 中方法的职责,所以我们认为它是一个store action
。store 的方法也有一些要负责的事情 —— 直接改变 store 中的状态。我们会将这些方法称作
store mutation
。NumberDisplay
并不关心 store 中或者是NumberSubmit
中有存在什么类型的方法,它只关心如何从 store 中获取信息。所以我们会说NumberDisplay
是一种store getter
。
一个 action
会触发一个 mutation
。Mutation
会改变 store 中的状态,从而影响视图或组件。视图/组件使用 getters
检索并获取 store 中的数据。我们现在距离以更结构化的方式处理应用级的数据越来越近了。
译注
这段涉及了状态管理中的五个概念:store
、state
、getter
、action
和 mutation
。
Pinia 的作者在 Pinia 文档 中保留了这五个概念原本的英文单词,没有翻译。可能是在中文中没有找到能精准对应这五个概念的词语吧。
因此,本文档中后面再出现的这五个概念一律保留原词,和官方文档保持一致。
译注
state
就是状态。
getter
是获取状态的方法,类似于计算方法 computed
。
action
是方法,可以类比为 method
。
mutation
是异步方法,由 action 触发,进行异步处理。在 Vuex 中使用,在 Pinia 中已经废弃了。
store
是上述四部分的集合。
Pinia
Pinia 是一个状态管理模式,同时也是一个 Vue.js 的状态管理库,它提供了一种结构化且可扩展的方式来管理应用级状态。
Pinia 是 Vuex 等其它状态管理解决方案的替代品,目前是 Vue 的官方状态管理库。它提供了一种简单有效的方式来创建和管理 store,其中封装了一些 state、action、和 getter。
在 Pinia 中,我们可以使用 defineStore()
来定义一个 store。Pinia 允许我们使用类似选项式 API 或是组合式 API 的语法来定义 store。下面是我们使用组合式 API 的语法来定义一个 useNumberStore()
函数,从而创建一个 numbers
store。
import { ref } from "vue";
import { defineStore } from "pinia";
export const useNumbersStore = defineStore("numbers", () => {
const numbers = ref([1, 2, 3]);
function addNumber(newNumber) {
this.numbers.push(newNumber);
}
return { numbers, addNumber };
});
在这个例子中,我们定义了一个叫作 numbers
的 store,它的初始状态包含一个 numbers
属性。我们同样定义了一个 action addNumber()
,它可以修改 numbers
状态。
我们接下俩创建 Pinia 实例,将其安装到我们的 Vue 应用中。
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import "./styles.css";
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.mount("#app");
这时,我们就可以在组件中使用我们新创建的 store 了。在 NumberDisplay
组件中,我们从 store 文件中导入 useNumbersStore()
函数,并执行它,得到 store 实例。我们可以在组件的模板中直接引用 store 中 numbers
的值。
<template>
<div>
<h2>{{ store.numbers }}</h2>
</div>
</template>
<script setup>
import { useNumbersStore } from "../store";
const store = useNumbersStore();
</script>
在 NumberSubmit
组件中,我们可以使用像之前一样的方法来创建实例,然后执行 store 中的 addNumber()
方法,用于更新 store 中的 numbers
属性。
<template>
<div>
<input v-model="numberInput" type="number" />
<button @click="store.addNumber(numberInput)">Add new number</button>
</div>
</template>
<script setup>
import { ref } from "vue";
import { useNumbersStore } from "../store";
const store = useNumbersStore();
const numberInput = ref(0);
</script>
这样修改后,我们的程序可以像之前一样运行。
在演练场中查看代码对于这样一个简单的 Pinia 实现来说,使用 Pinia 来管理状态可能显得不是那么必要,并且看上去和使用 reactive()
创建的 store 非常相似。话虽如此,Pinia 还是可以在更复杂的情况中提供更多的功能,例如我们可以使用插件扩展 Pinia 的功能、获得 Vue 开发者工具提供的支持、更多的 TypeScript 支持 和 服务端渲染支持。
那么,正确的方法是?
每一种管理应用级状态的方案都有各自的优缺点。
简单的 Store
优点:容易搭建。
缺点:状态和状态变化没有明确的定义。
Pinia
优点: 开发者工具支持、插件 + TypeScript + 服务端渲染支持
缺点: 需要写很多额外的样板代码。
归根结底,我们需要了解我们的应用程序需哪种方法,并且需要知道哪个是最适合的。