前端开发整理(五): Vue框架的组件通信和全局状态管理
组件间通信
父子组件通信
父子组件通信是指,B 组件引入到 A 组件里渲染,此时 A 是 B 的父级;B 组件的一些数据需要从 A 组件拿,B 组件有时也要告知 A 组件一些数据变化情况;Child.vue 是直接挂载在 Father.vue 下面的。
1
2
3
4
# 父组件
Father.vue
│ # 子组件
└─Child.vue
常用的方法有:
| 方案 | 父组件向子组件 | 子组件向父组件 |
|---|---|---|
| props / emits | props | emits |
| v-model / emits | v-model | emits |
| ref / emits | ref | emits |
| provide / inject | provide | inject |
| EventBus | emit / on | emit / on |
| Reactive State | - | - |
| Vuex | - | - |
| Pinia | - | - |
为了方便阅读,下面的父组件统一叫 Father.vue ,子组件统一叫 Child.vue 。
props / emits
这是 Vue 跨组件通信最常用,也是基础的一个方案,它的通信过程是:
- 父组件 Father.vue 通过 props 向子组件 Child.vue 传值
- 子组件 Child.vue 则可以通过 emits 向父组件 Father.vue 发起事件通知
最常见的场景就是统一在父组件发起 AJAX 请求,拿到数据后,再根据子组件的渲染需要传递不同的 props 给不同的子组件使用。
下发 props
下发的过程是在 Father.vue 里完成的,父组件在向子组件下发 props 之前,需要导入子组件并启用它作为自身的模板,然后在 setup 里处理好数据并 return 给 <template /> 用。
在 Father.vue 的 <script /> 里:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Father.vue
import { defineComponent } from 'vue'
import Child from '@cp/Child.vue'
interface Member {
id: number
name: string
}
export default defineComponent({
// 需要启用子组件作为模板
components: {
Child,
},
// 定义一些数据并 `return` 给 `<template />` 用
setup() {
const userInfo: Member = {
id: 1,
name: 'Petter',
}
// 不要忘记 `return` ,否则 `<template />` 拿不到数据
return {
userInfo,
}
},
})
然后在 Father.vue 的 <template /> 这边拿到 return 出来的数据,把要传递的数据通过属性的方式绑定在组件标签上:
1
2
3
4
5
6
7
8
9
<!-- Father.vue -->
<template>
<Child
title="用户信息"
:index="1"
:uid="userInfo.id"
:user-name="userInfo.name"
/>
</template>
在 <template /> 绑定属性这里,如果是普通的字符串,比如上面的 title,则直接给属性名赋值就可以。如果是变量名,或者其他类型如 number 、 boolean 等,比如上面的 index,则需要通过属性动态绑定的方式来添加,使用 v-bind: 或者 : 符号进行绑定。
这样就完成了 props 数据的下发。
接收 props
接收的过程是在 Child.vue 里完成的,在 <script /> 部分,子组件通过与 setup 同级的 props 来接收数据。
它可以是一个 string[] 数组,把要接受的变量名放到这个数组里,直接放进来作为数组的 item :
1
2
3
4
// Child.vue
export default defineComponent({
props: ['title', 'index', 'userName', 'uid'],
})
但这种情况下,使用者不知道这些属性的使用限制,例如是什么类型的值、是否必传等等。
带有类型限制的 props
和 TypeScript 一样,类型限制可以为程序带来更好的健壮性, Vue 的 props 也支持增加类型限制。
相对于传递一个 string[] 类型的数组,更推荐的方式是把 props 定义为一个对象,以对象形式列出,每个 Property 的名称和值分别是各自的名称和类型,只有合法的类型才允许传入。
支持的类型有:
| 类型 | 含义 |
|---|---|
| String | 字符串 |
| Number | 数值 |
| Boolean | 布尔值 |
| Array | 数组 |
| Object | 对象 |
| Date | 日期数据,e.g. new Date() |
| Function | 函数,e.g. 普通函数、箭头函数、构造函数 |
| Promise | Promise 类型的函数 |
| Symbol | Symbol 类型的值 |
给 props 加上类型限制的示例代码为:
1
2
3
4
5
6
7
8
9
// Child.vue
export default defineComponent({
props: {
title: String,
index: Number,
userName: String,
uid: Number,
},
})
如果需要对某个 Prop 允许多类型,比如这个 uid 字段,可能是数值,也可能是字符串,那么可以在类型这里,使用一个数组,把允许的类型都加进去:
1
2
3
4
5
6
7
8
9
10
11
12
// Child.vue
export default defineComponent({
props: {
// 单类型
title: String,
index: Number,
userName: String,
// 这里使用了多种类型
uid: [Number, String],
},
})
可选以及带有默认值的 props
所有 props 默认都是可选的,如果不传递具体的值,则默认值都是 undefined ,可能引起程序运行崩溃, Vue 支持对可选的 props 设置默认值,也是通过对象的形式配置 props 的选项。
其中支持配置的选项有:
| 选项 | 类型 | 含义 |
|---|---|---|
| type | string | 类型 |
| required | boolean | 是否必传, true 代表必传, false 代表可选 |
| default | any | 与 type 选项的类型相对应的默认值,如果 required 选项是 false ,但这里不设置默认值,则会默认为 undefined |
| validator | function | 自定义验证函数,需要 return 一个布尔值, true 代表校验通过, false 代表校验不通过,当校验不通过时,控制台会抛出警告信息 |
了解了配置选项后,接下来再对 props 进行改造,将其中部分选项设置为可选,并提供默认值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Child.vue
export default defineComponent({
props: {
// 可选,并提供默认值
title: {
type: String,
required: false,
default: '默认标题',
},
// 默认可选,单类型
index: Number,
// 添加一些自定义校验
userName: {
type: String,
// 在这里校验用户名必须至少 3 个字
validator: (v) => v.length >= 3,
},
// 默认可选,但允许多种类型
uid: [Number, String],
},
})
使用 props
该部分也是在Child.vue中操作。
在 <template /> 部分, Vue 3 的使用方法和 Vue 2 是一样的,比如要渲染父组件传入的 props :
1
2
3
4
5
6
7
<!-- Child.vue -->
<template>
<p>标题:</p>
<p>索引:</p>
<p>用户id:</p>
<p>用户名:</p>
</template>
在 Vue 2 里,只需要通过 this.uid 、 this.userName 就可以使用父组件传下来的 Prop ,但是 Vue 3 没有了 this ,所以是通过 setup 的入参进行操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Child.vue
export default defineComponent({
props: {
title: String,
index: Number,
userName: String,
uid: Number,
},
// 在这里需要添加一个入参
setup(props) {
// 该入参包含了当前组件定义的所有 props
console.log(props)
},
})
关于 Setup 函数的第一个入参 props :
- 该入参包含了当前组件定义的所有 props (如果父组件 Father.vue 传进来的数据在 Child.vue 里未定义,不仅不会拿到,并且在控制台会有警告信息)。
- 该入参可以随意命名,比如可以写成一个下划线
_,通过_.uid也可以拿到数据,但是语义化命名是一个良好的编程习惯。 - 该入参具备响应性,父组件修改了传递下来的值,子组件也会同步得到更新,因此请不要直接解构,可以通过 toRef 或 toRefs API 转换为响应式变量
传递和获取非props属性
如果父组件 Father.vue 传进来的数据在 Child.vue 里未定义,不仅不会拿到,并且在控制台会有警告信息。这种情况虽然无法从 props 里拿到对应的数据,但也不意味着不能传递任何未定义的属性数据,在父组件,除了可以给子组件绑定 props ,还可以根据实际需要去绑定一些特殊的属性。
比如给子组件设置 class、id,或者 data-xxx 之类的一些自定义属性,如果子组件 Child.vue 的 <template /> 里只有一个根节点,那么这些属性默认会自动继承并渲染在 Node 节点上。
这一类非 props 属性通常称之为 attrs 。
在 Child.vue 里,可以通过 setup 的第二个参数 context 里的 attrs 来获取到这些属性,并且父组件传递了什么类型的值,获取到的也是一样的类型,这一点和使用 Element.getAttribute() 完全不同。Vue 3 允许多个根节点,多个根节点的情况下,无法直接继承这些 attrs 属性(在 inheritAttrs: true 的情况也下无法默认继承),需要在子组件 Child.vue 里通过 v-bind 绑定到要继承在节点上。
绑定、接收、调用与校验emits
父组件 Father.vue 需要获取子组件 Child.vue 的数据更新情况,可以由子组件通过 emits 进行通知。事件的逻辑是由父组件决定的,因此需要在父组件 Father.vue 的 <script /> 里先声明数据变量和一个更新函数,并且这个更新函数通常会有一个入参作为数据的新值接收。
和 props 一样,可以指定是一个数组,把要接收的 emit 事件名称写进去;如果子组件需要更新数据并通知父组件,可以使用 setup 第二个参数 context 里的 emit 方法触发。emit 方法最少要传递一个参数:事件名称。
事件名称是指父组件 Father.vue 绑定事件时 @update-age="updateAge" 里的 update-age ,如果改成 @hello="updateAge" ,那么事件名称就需要使用 hello ,一般情况下事件名称和更新函数的名称会保持一致,方便维护。
对于需要更新数据的情况, emit 还支持传递更多的参数,对应更新函数里的入参,所以可以看到上面例子里的 emit('update-age', 2) 有第二个参数,传递了一个 2 的数值,就是作为父组件 updateAge 的入参 newAge 传递。
如果需要通信的数据很多,建议第二个入参使用一个对象来管理数据;子组件在传递新数据时,就应该使用对象的形式传递;这对于更新表单等数据量较多的场景非常好用。
和 props 一样,子组件在接收 emits 时也可以对这些事件做一些验证,这个时候就需要将 emits 配置为对象,然后把事件名称作为 key , value 则对应为一个用来校验的方法。
v-model / emits
使用 v-model 的方式更为简单:
- 在 Father.vue ,通过 v-model 向 Child.vue 传值
- Child.vue 通过自身设定的 emits 向 Father.vue 通知数据更新
v-model 的用法和 props 非常相似,但是很多操作上更为简化,但操作简单带来的 “副作用” ,就是功能上也没有 props 那么多。
绑定 v-model
在 Father.vue 里操作,和下发 props 的方式类似,都是在子组件上绑定 Father.vue 定义好的数据,这是绑定一个数据的例子:
1
2
3
4
<!-- Father.vue -->
<template>
<Child v-model:username="userInfo.name" />
</template>
Vue 3 可以直接绑定 v-model ,而无需在子组件指定 model 选项,并且 Vue 3 的 v-model 需要使用英文冒号 : 指定要绑定的属性名,同时也支持绑定多个 v-model 。如果要绑定多个数据,写多个 v-model 即可:
1
2
3
4
5
6
7
8
<!-- Father.vue -->
<template>
<Child
v-model:uid="userInfo.id"
v-model:username="userInfo.name"
v-model:age="userInfo.age"
/>
</template>
一个 v-model 其实就是一个 prop ,它支持的数据类型和 prop 是一样的,所以子组件在接收数据的时候,完全按照 props 去定义就可以了。
配置emits
使用 props / emits ,如果要更新父组件的数据,还需要在父组件声明一个更新函数并绑定事件给子组件,才能够更新。
而使用 v-model / emits ,无需在父组件声明更新函数,只需要在子组件 Child.vue 里通过 update: 前缀加上 v-model 的属性名这样的格式,即可直接定义一个更新事件:
1
2
3
4
5
6
7
8
9
10
// Child.vue
export default defineComponent({
props: {
uid: Number,
username: String,
age: Number,
},
// 注意这里的 `update:` 前缀
emits: ['update:uid', 'update:username', 'update:age'],
})
ref / emits
父组件可以给子组件绑定 ref 属性,然后通过 Ref 变量操作子组件的数据或者调用子组件里面的方法。先在 <template /> 处给子组件标签绑定 ref 属性:
1
2
3
4
<!-- Father.vue -->
<template>
<Child ref="child" />
</template>
然后在 <script /> 部分定义好对应的变量名称 child (记得要 return 出来哦),即可通过该变量操作子组件上的变量或方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Father.vue
import { defineComponent, onMounted, ref } from 'vue'
import Child from '@cp/Child.vue'
export default defineComponent({
components: {
Child,
},
setup() {
// 给子组件定义一个 `ref` 变量
const child = ref<InstanceType<typeof Child>>()
// 请保证视图渲染完毕后再执行操作
onMounted(async () => {
// 执行子组件里面的 AJAX 请求函数
await child.value!.queryList()
// 显示子组件里面的弹窗
child.value!.isShowDialog = true
})
// 必须 `return` 出去才可以给到 `<template />` 使用
return {
child,
}
},
})
需要注意的是,在子组件 Child.vue 里,变量和方法也需要在 setup 里 return 出来才可以被父组件调用到。
爷孙组件通信
爷孙组件是比父子组件通信要更深层次的引用关系(也有称之为 “隔代组件” )。
C 组件被引入到 B 组件里, B 组件又被引入到 A 组件里渲染,此时 A 是 C 的爷爷级别(可能还有更多层级关系),它们之间的关系可以假设如下:
1
2
3
Grandfather.vue
└─Son.vue
└─Grandson.vue
Grandson.vue 并非直接挂载在 Grandfather.vue 下面,他们之间还隔着至少一个 Son.vue (在实际业务中可能存在更多层级),如果使用 props ,只能一级组件一级组件传递下去,太繁琐。
因此需要更直接的通信方式来解决这种问题,这一 Part 就是讲一讲 C 和 A 之间的数据传递,常用的方法有:
| 方案 | 爷组件向孙组件 | 孙组件向爷组件 |
|---|---|---|
| provide / inject | provide | inject |
| EventBus | emit / on | emit / on |
| Reactive State | - | - |
| Vuex | - | - |
| Pinia | - | - |
因为上下级的关系的一致性,爷孙组件通信的方案也适用于父子组件通信,只需要把爷孙关系换成父子关系即可,为了方便阅读,下面的爷组件统一叫 Grandfather.vue,子组件统一叫 Grandson.vue 。
provide / inject
这个通信方式也是有两部分:
- Grandfather.vue 通过 provide 向孙组件 Grandson.vue 提供数据和方法
- Grandson.vue 通过 inject 注入爷爷组件 Grandfather.vue 的数据和方法
无论组件层次结构有多深,发起 provide 的组件都可以作为其所有下级组件的依赖提供者。provide 和 inject 绑定并不是可响应的,这是刻意为之的,除非传入了一个可侦听的对象。
发起 provide
在 Vue 3 , provide 需要导入并在 setup 里启用,并且现在是一个全新的方法,每次要 provide 一个数据的时候,就要单独调用一次。provide 的 TS 类型如下:
1
2
3
4
5
// `provide` API 本身的类型
function provide<T>(key: InjectionKey<T> | string, value: T): void
// 入参 `key` 的其中一种类型
interface InjectionKey<T> extends Symbol {}
每次调用 provide 的时候都需要传入两个参数:
| 参数 | 说明 |
|---|---|
| key | 数据的名称 |
| value | 数据的值 |
其中 key 一般使用 string 类型就可以满足大部分业务场景,如果有特殊的需要(例如开发插件时可以避免和用户的业务冲突),可以使用 InjectionKey<T> 类型,这是一个继承自 Symbol 的泛型:
1
2
import type { InjectionKey } from 'vue'
const key = Symbol() as InjectionKey<string>
一个具体案例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Grandfather.vue
import { defineComponent, provide, ref } from 'vue'
export default defineComponent({
setup() {
// 声明一个响应性变量并 provide 其自身
// 孙组件获取后可以保持响应性
const msg = ref('Hello World!')
provide('msg', msg)
// 只 provide 响应式变量的值
// 孙组件获取后只会得到当前的值
provide('msgValue', msg.value)
// 声明一个方法并 provide
function printMsg() {
console.log(msg.value)
}
provide('printMsg', printMsg)
},
})
接收 inject
在 Vue 3 , inject 和 provide 一样,也是需要先导入然后在 setup 里启用,也是一个全新的方法,每次要 inject 一个数据的时候,也是要单独调用一次。
另外还有一个特殊情况需要注意,当 Grandson.vue 的父级、爷级组件都 provide 了相同名字的数据下来,那么在 inject 的时候,会优先选择离它更近的组件的数据。
根据不同的场景, inject 可以接受不同数量的入参,入参类型也各不相同。
默认情况下, inject API 的 TS 类型如下:
1
function inject<T>(key: InjectionKey<T> | string): T | undefined
每次调用时只需要传入一个参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| key | string | 与 provide 相对应的数据名称 |
在孙组件里 inject 爷组件 provide 下来的数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Grandson.vue
import { defineComponent, inject } from 'vue'
import type { Ref } from 'vue'
export default defineComponent({
setup() {
// 获取响应式变量
const msg = inject<Ref<string>>('msg')
console.log(msg!.value)
// 获取普通的字符串
const msgValue = inject<string>('msgValue')
console.log(msgValue)
// 获取函数
const printMsg = inject<() => void>('printMsg')
if (typeof printMsg === 'function') {
printMsg()
}
},
})
在每个 inject 都使用尖括号 <> 添加了相应的 TS 类型,并且在调用变量的时候都进行了判断,这是因为默认的情况下, inject 除了返回指定类型的数据之外,还默认带上 undefined 作为可能的值。
如果明确数据不会是 undefined ,也可以在后面添加 as 关键字指定其 TS 类型,这样 TypeScript 就不再因为可能出现 undefined 而提示代码有问题:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Grandson.vue
import { defineComponent, inject } from 'vue'
import type { Ref } from 'vue'
export default defineComponent({
setup() {
// 获取响应式变量
const msg = inject('msg') as Ref<string>
console.log(msg.value)
// 获取普通的字符串
const msgValue = inject('msgValue') as string
console.log(msgValue)
// 获取函数
const printMsg = inject('printMsg') as () => void
printMsg()
},
})
inject API 还支持设置默认值,可以接受更多的参数。默认情况下,只需要传入第二个参数指定默认值即可,此时它的 TS 类型如下:
1
function inject<T>(key: InjectionKey<T> | string, defaultValue: T): T
对于不可控的情况,建议在 inject 时添加一个兜底的默认值,防止程序报错:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Grandson.vue
import { defineComponent, inject, ref } from 'vue'
import type { Ref } from 'vue'
export default defineComponent({
setup() {
// 获取响应式变量
const msg = inject<Ref<string>>('msg', ref('Hello'))
console.log(msg.value)
// 获取普通的字符串
const msgValue = inject<string>('msgValue', 'Hello')
console.log(msgValue)
// 获取函数
const printMsg = inject<() => void>('printMsg', () => {
console.log('Hello')
})
printMsg()
},
})
需要注意的是, inject 的什么类型的数据,其默认值也需要保持相同的类型。
inject API 在第二个 TS 类型的基础上,还有第三个 TS 类型,可以传入第三个参数:
1
2
3
4
5
function inject<T>(
key: InjectionKey<T> | string,
defaultValue: () => T,
treatDefaultAsFactory?: false,
): T
当第二个参数是一个工厂函数,那么可以添加第三个值,将其设置为 true ,此时默认值一定会是其 return 的值。在 Grandson.vue 里新增一个 inject ,接收一个不存在的函数名,并提供一个工厂函数作为默认值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Grandson.vue
import { defineComponent, inject } from 'vue'
interface Food {
name: string
count: number
}
export default defineComponent({
setup() {
// 获取工厂函数
const getFood = inject<() => Food>('nonexistentFunction', () => {
return {
name: 'Pizza',
count: 1,
}
})
console.log(typeof getFood) // function
const food = getFood()
console.log(food) // {name: 'Pizza', count: 1}
},
})
此时因为第三个参数默认为 Falsy 值 ,所以可以得到一个函数作为默认值,并可以调用该函数获得一个 Food 对象。如果将第三个参数传入为 true ,再运行程序则会在 const food = getFood() 这一行报错。因为此时第三个入参告知 inject ,默认值是一个工厂函数,因此默认值不再是函数本身,而是函数的返回值,所以 typeof getFood 得到的不再是一个 function 而是一个 object :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Grandson.vue
import { defineComponent, inject } from 'vue'
interface Food {
name: string
count: number
}
export default defineComponent({
setup() {
// 获取工厂函数
const getFood = inject<() => Food>(
'nonexistentFunction',
() => {
return {
name: 'Pizza',
count: 1,
}
},
true
)
console.log(typeof getFood) // object
// 此时下面的代码无法运行
// 报错 Uncaught (in promise) TypeError: getMsg is not a function
const food = getFood()
console.log(food)
},
})
兄弟组件通信
兄弟组件是指两个组件都挂载在同一个 Father.vue 下,但两个组件之间并没有什么直接的关联,先看看它们的关系:
1
2
3
Father.vue
├─Brother.vue
└─LittleBrother.vue
这种层级关系下,如果组件之间要进行通信,目前通常有这两类选择:
下面的内容将进入全局组件通信的讲解。
全局组件通信
全局组件通信是指项目下两个任意组件,不管是否有直接关联(例如父子关系、爷孙关系)都可以直接进行交流的通信方案。
举个例子,像下面这种项目结构, B2.vue 可以采用全局通信方案直接向 D2.vue 发起交流,而无需经过它们各自的父组件。
1
2
3
4
5
6
7
8
A.vue
├─B1.vue
├───C1.vue
├─────D1.vue
├─────D2.vue
├───C2.vue
├─────D3.vue
└─B2.vue
常用的方法有:
| 方案 | 发起方 | 接收方 |
|---|---|---|
| EventBus | emit | on |
| Reactive State | - | - |
| Vuex | - | - |
| Pinia | - | - |
EventBus
EventBus 通常被称之为 “全局事件总线” ,是用在全局范围内通信的一个常用方案;Vue 3 应用实例不再实现事件触发接口,因此移除了 $on 、 $off 和 $once 这几个事件 API ,无法像 Vue 2 一样利用 Vue 实例创建 EventBus 。
以 mitt 为例,示范如何创建一个 Vue 3 的 EventBus ,首先需要安装它。
1
npm i mitt
然后在 src/libs 文件夹下,创建一个名为 eventBus.ts 的文件,文件内容和 Vue 2 的写法其实是一样的,只不过是把 Vue 实例换成了 mitt 实例。
1
2
3
// src/libs/eventBus.ts
import mitt from 'mitt'
export default mitt()
接下来就可以定义通信的相关事件了,常用的 API 和参数如下:
| 方法名称 | 作用 |
|---|---|
| on | 注册一个侦听事件,用于接收数据 |
| emit | 调用方法发起数据传递 |
| off | 用来移除侦听事件 |
on 的参数:
| 参数 | 类型 | 作用 | |
|---|---|---|---|
| type | string | symbol | 方法名 |
| handler | function | 接收到数据之后要做什么处理的回调函数 |
这里的 handler 建议使用具名函数,因为匿名函数无法销毁。
emit 的参数:
| 参数 | 类型 | 作用 | |
|---|---|---|---|
| type | string | symbol | 与 on 对应的方法名 |
| data | any | 与 on 对应的,允许接收的数据 |
off 的参数:
| 参数 | 类型 | 作用 | |
|---|---|---|---|
| type | string | symbol | 与 on 对应的方法名 |
| handler | function | 要被删除的,与 on 对应的 handler 函数名 |
创建和移除侦听事件
在需要暴露交流事件的组件里,通过 on 配置好接收方法,同时为了避免路由切换过程中造成事件多次被绑定,从而引起多次触发,需要在适当的时机 off 掉:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { defineComponent, onBeforeUnmount } from 'vue'
import eventBus from '@libs/eventBus'
export default defineComponent({
setup() {
// 声明一个打招呼的方法
function sayHi(msg = 'Hello World!') {
console.log(msg)
}
// 启用侦听
eventBus.on('sayHi', sayHi)
// 在组件卸载之前移除侦听
onBeforeUnmount(() => {
eventBus.off('sayHi', sayHi)
})
},
})
调用侦听事件
在需要调用交流事件的组件里,通过 emit 进行调用:
1
2
3
4
5
6
7
8
9
import { defineComponent } from 'vue'
import eventBus from '@libs/eventBus'
export default defineComponent({
setup() {
// 调用打招呼事件,传入消息内容
eventBus.emit('sayHi', 'Hello')
},
})
Reactive State
在 Vue 3 里,使用响应式的 reactive API 也可以实现一个小型的状态共享库,如果运用在一个简单的 H5 活动页面这样小需求里,完全可以满足使用。
创建状态中心
首先在 src 目录下创建一个 state 文件夹,并添加一个 index.ts 文件,写入以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/state/index.ts
import { reactive } from 'vue'
// 如果有多个不同业务的内部状态共享
// 使用具名导出更容易维护
export const state = reactive({
// 设置一个属性并赋予初始值
message: 'Hello World',
// 添加一个更新数据的方法
setMessage(msg: string) {
this.message = msg
},
})
设定状态更新逻辑
接下来在一个组件 Child.vue 的 <script /> 里添加以下代码,分别进行了以下操作:
- 打印初始值
- 对 state 里的数据启用侦听器
- 使用 state 里的方法更新数据
- 直接更新 state 的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Child.vue
import { defineComponent, watch } from 'vue'
import { state } from '@/state'
export default defineComponent({
setup() {
console.log(state.message)
// Hello World
// 因为是响应式数据,所以可以侦听数据变化
watch(
() => state.message,
(val) => {
console.log('Message 发生变化:', val)
},
)
setTimeout(() => {
state.setMessage('Hello Hello')
// Message 发生变化: Hello Hello
}, 1000)
setTimeout(() => {
state.message = 'Hi Hi'
// Message 发生变化: Hi Hi
}, 2000)
},
})
观察全局状态变化
继续在另外一个组件 Father.vue 里写入以下代码,导入 state 并在 <template /> 渲染其中的数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- Father.vue -->
<template>
<div></div>
<Child />
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import Child from '@cp/Child.vue'
import { state } from '@/state'
export default defineComponent({
components: {
Child,
},
setup() {
return {
state,
}
},
})
</script>
可以观察到当 Child.vue 里的定时器执行时, Father.vue 的视图也会同步得到更新。
Vuex
Vuex 是 Vue 生态里面非常重要的一个成员,运用于状态管理模式。它也是一个全局的通信方案,对比 EventBus,Vuex 的功能更多,更灵活,但对应的学习成本和体积也相对较大,通常大型项目才会用上 Vuex 。
Pinia 已经成为 Vue 生态最新的官方状态管理库,不仅适用于 Vue 3 ,也支持 Vue 2 ,而 Vuex 将进入维护状态,不再增加新功能, Vue 官方强烈建议在新项目中使用 Pinia 。
Pinia
Pinia 和 Vuex 一样,也是 Vue 生态里面非常重要的一个成员,也都是运用于全局的状态管理。但面向 Componsition API 而生的 Pinia ,更受 Vue 3 喜爱,已被钦定为官方推荐的新状态管理工具。下一部分会对其进行详细介绍。
全局状态管理
Pinia的安装和启用
Pinia 目前还没有被广泛的默认集成在各种脚手架里,所以如果原来创建的项目没有 Pinia ,则需要手动安装它:
1
2
# 需要 cd 到的项目目录下
npm install pinia
查看的 package.json ,看看里面的 dependencies 是否成功加入了 Pinia 和它的版本号(下方是示例代码,以实际安装的最新版本号为准),从而验证Pinia是否集成到的项目里:
1
2
3
4
5
{
"dependencies": {
"pinia": "^2.0.11"
}
}
然后打开 src/main.ts 文件,添加下面那两行有注释的新代码:
1
2
3
4
5
6
7
import { createApp } from 'vue'
import { createPinia } from 'pinia' // 导入 Pinia
import App from '@/App.vue'
createApp(App)
.use(createPinia()) // 启用 Pinia
.mount('#app')
也可以通过 Create Preset 创建新项目(选择 vue 技术栈进入,选择 vue3-ts-vite 模板),可以得到一个集成常用配置的项目启动模板,该模板现在使用 Pinia 作为全局状态管理工具。
状态树结构
开始写代码之前,先来看一个对比,直观的了解 Pinia 的状态树构成,才能在后面的环节更好的理解每个功能的用途。
| 作用 | Vue Component | Vuex | Pinia |
|---|---|---|---|
| 数据管理 | data | state | state |
| 数据计算 | computed | getters | getters |
| 行为方法 | methods | mutations / actions | actions |
可以看到 Pinia 的结构和用途都和 Vuex 与 Component 非常相似,并且 Pinia 相对于 Vuex ,在行为方法部分去掉了 mutations (同步操作)和 actions (异步操作)的区分,更接近组件的结构,入门成本会更低一些。
下面来创建一个简单的 Store ,开始用 Pinia 来进行状态管理。
创建 Store
Pinia 的核心也是称之为 Store 。参照 Pinia 官网推荐的项目管理方案,也是先在 src 文件夹下创建一个 stores 文件夹,并在里面添加一个 index.ts 文件,然后就可以来添加一个最基础的 Store 。Store 是通过 defineStore 方法来创建的,它有两种入参形式:
形式1:接收两个参数
接收两个参数,第一个参数是 Store 的唯一 ID ,第二个参数是 Store 的选项:
1
2
3
4
5
6
// src/stores/index.ts
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
// Store 选项...
})
形式2:接收一个参数
接收一个参数,直接传入 Store 的选项,但是需要把唯一 ID 作为选项的一部分一起传入:
1
2
3
4
5
6
7
// src/stores/index.ts
import { defineStore } from 'pinia'
export const useStore = defineStore({
id: 'main',
// Store 选项...
})
不论是哪种创建形式,都必须为 Store 指定一个唯一 ID 。另外可以看到这里把导出的函数名命名为 useStore ,以 use 开头是 Vue 3 对可组合函数的一个命名约定。并且使用的是 export const 而不是 export default,这样在使用的时候可以和其他的 Vue 组合函数保持一致,都是通过 import { xxx } from 'xxx' 来导入。
如果有多个 Store ,可以分模块管理,并根据实际的功能用途进行命名( e.g. useMessageStore 、 useUserStore 、 useGameStore … )。
管理 state
给 Store 添加 state
通过一个箭头函数的形式来返回数据,并且能够正确的帮推导 TypeScript 类型:
1
2
3
4
5
6
7
8
9
10
// src/stores/index.ts
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
// 先定义一个最基本的 message 数据
state: () => ({
message: 'Hello World',
}),
// ...
})
需要注意一点的是,如果不显式 return ,箭头函数的返回值需要用圆括号 () 套起来,这个是箭头函数的要求。所以相当于这样写:
1
2
3
4
5
6
7
8
9
// ...
export const useStore = defineStore('main', {
state: () => {
return {
message: 'Hello World',
}
},
// ...
})
手动指定数据类型
虽然 Pinia 会帮推导 TypeScript 的数据类型,但有时候可能不太够用,比如下面这段代码,请留意代码注释的说明:
1
2
3
4
5
6
7
8
9
10
11
// ...
export const useStore = defineStore('main', {
state: () => {
return {
message: 'Hello World',
// 添加了一个随机消息数组
randomMessages: [],
}
},
// ...
})
randomMessages 的预期应该是一个字符串数组 string[] ,但是这个时候 Pinia 会帮推导成 never[] ,那么类型就对不上了。这种情况下就需要手动指定 randomMessages 的类型,可以通过 as 来指定:
1
2
3
4
5
6
7
8
9
10
11
// ...
export const useStore = defineStore('main', {
state: () => {
return {
message: 'Hello World',
// 通过 as 关键字指定 TS 类型
randomMessages: [] as string[],
}
},
// ...
})
或者使用尖括号 <> 来指定,这两种方式是等价的:
1
2
3
4
5
6
7
8
9
10
11
// ...
export const useStore = defineStore('main', {
state: () => {
return {
message: 'Hello World',
// 通过尖括号指定 TS 类型
randomMessages: <string[]>[],
}
},
// ...
})
获取和更新 state
获取 state 有多种方法,略微有区别,但相同的是,他们都是响应性的。
使用 store 实例
对于Pinia,数据直接是挂在 store 上的,而不是 store.state 上的。所以,可以直接通过 store.message 直接调用 state 里的数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { defineComponent } from 'vue'
import { useStore } from '@/stores'
export default defineComponent({
setup() {
// 像 useRouter 那样定义一个变量拿到实例
const store = useStore()
// 直接通过实例来获取数据
console.log(store.message)
// 这种方式需要把整个 store 给到 template 去渲染数据
return {
store,
}
},
})
但一些比较复杂的数据这样写会很长,所以有时候更推荐用下面介绍的 computed API 和 storeToRefs API 等方式来获取。
使用 computed API
现在 state 里已经有定义好的数据了,下面这段代码是在 Vue 组件里导入的 Store ,并通过计算数据 computed 拿到里面的 message 数据传给 template 使用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script lang="ts">
import { computed, defineComponent } from 'vue'
import { useStore } from '@/stores'
export default defineComponent({
setup() {
// 像 useRouter 那样定义一个变量拿到实例
const store = useStore()
// 通过计算拿到里面的数据
const message = computed(() => store.message)
console.log('message', message.value)
// 传给 template 使用
return {
message,
}
},
})
</script>
如果要更新数据:
- 可以通过提前定义好的 Store Actions 方法进行更新。
- 在定义 computed 变量的时候,配置好setter的行为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// 其他代码和上一个例子一样,这里省略... // 修改:定义 computed 变量的时候配置 getter 和 setter const message = computed({ // getter 还是返回数据的值 get: () => store.message, // 配置 setter 来定义赋值后的行为 set(newVal) { store.message = newVal }, }) // 此时不再抛出 Write operation failed: computed value is readonly 的警告 message.value = 'New Message.' // store 上的数据已成功变成了 New Message. console.log(store.message)
使用 storeToRefs API
这是一个专门为 Pinia Stores 设计的 API ,类似于 toRefs ,区别在于,它会忽略掉 Store 上面的方法和非响应性的数据,只返回 state 上的响应性数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { defineComponent } from 'vue'
import { useStore } from '@/stores'
// 记得导入这个 API
import { storeToRefs } from 'pinia'
export default defineComponent({
setup() {
const store = useStore()
// 通过 storeToRefs 来拿到响应性的 message
const { message } = storeToRefs(store)
console.log('message', message.value)
return {
message,
}
},
})
通过这个方式拿到的 message 变量是一个 Ref 类型的数据,所以可以像普通的 ref 变量一样进行读取和赋值。
1
2
3
4
5
// 直接赋值即可
message.value = 'New Message.'
// store 上的数据已成功变成了 New Message.
console.log(store.message)
使用 toRefs API
如 使用 storeToRefs API 部分所说,该 API 本身的设计就是类似于 toRefs ,所以也可以直接用 toRefs 把 state 上的数据转成 ref 变量:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 注意 toRefs 是 vue 的 API ,不是 Pinia
import { defineComponent, toRefs } from 'vue'
import { useStore } from '@/stores'
export default defineComponent({
setup() {
const store = useStore()
// 跟 storeToRefs 操作都一样,只不过用 Vue 的这个 API 来处理
const { message } = toRefs(store)
console.log('message', message.value)
return {
message,
}
},
})
像上面这样,对 store 执行 toRefs 会把 store 上面的 getters 、 actions 也一起提取,如果只需要提取 state 上的数据,可以这样做:
1
2
// 只传入 store.$state
const { message } = toRefs(store.$state)
使用 toRef API
toRef 是 toRefs 的兄弟 API ,一个是只转换一个字段,一个是转换所有字段,所以它也可以用来转换 state 数据变成 ref 变量:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 注意 toRef 是 vue 的 API ,不是 Pinia
import { defineComponent, toRef } from 'vue'
import { useStore } from '@/stores'
export default defineComponent({
setup() {
const store = useStore()
// 遵循 toRef 的用法即可
const message = toRef(store, 'message')
console.log('message', message.value)
return {
message,
}
},
})
使用 actions 方法
Pinia 所有操作都集合为 action ,无需区分同步和异步,按照平时的函数定义即可更新 state ,具体操作详见管理 actions一节。
批量更新 state
在 获取和更新state 部分说的都是如何修改单个 state 数据,那么有时候要同时修改很多个,会显得比较繁琐。
Pinia 也提供了一个 $patch API 用于同时修改多个数据,它接收一个参数:
| 参数 | 类型 | 语法 |
|---|---|---|
| partialState | 对象 / 函数 | store.$patch(partialState) |
传入一个对象
当参数类型为对象时,key 是要修改的 state 数据名称, value 是新的值(支持嵌套传值),用法如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 继续用前面的数据,这里会打印出修改前的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"Hello World","randomMessages":[]}
/**
* 注意这里,传入了一个对象
*/
store.$patch({
message: 'New Message',
randomMessages: ['msg1', 'msg2', 'msg3'],
})
// 这里会打印出修改后的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"New Message","randomMessages":["msg1","msg2","msg3"]}
对于简单的数据,直接修改成新值是非常好用的。
但有时候并不单单只是修改,而是要对数据进行拼接、补充、合并等操作,相对而言开销就会很大,这种情况下,更适合 传入一个函数 来处理。
传入一个函数
当参数类型为函数时,该函数会有一个入参 state ,是当前实例的 state ,等价于 store.$state ,用法如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 这里会打印出修改前的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"Hello World","randomMessages":[]}
/**
* 注意这里,这次是传入了一个函数
*/
store.$patch((state) => {
state.message = 'New Message'
// 数组改成用追加的方式,而不是重新赋值
for (let i = 0; i < 3; i++) {
state.randomMessages.push(`msg${i + 1}`)
}
})
// 这里会打印出修改后的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"New Message","randomMessages":["msg1","msg2","msg3"]}
全量更新 state
虽然可以对所有数据都执行一次 “补丁更新” 来达到 “全量更新” 的目的,但 Pinia 也提供了一个更好的办法。从前面多次提到 state 数据可以通过 store.$state 来拿到,而这个属性本身是可以直接赋值的。
state 上现在有 message 和 randomMessages 这两个数据,那么要全量更新为新的值,就这么操作:
1
2
3
4
store.$state = {
message: 'New Message',
randomMessages: ['msg1', 'msg2', 'msg3'],
}
注意必须遵循 state 原有的数据和对应的类型。
重置 state
Pinia 提供了一个 $reset API 挂在每个实例上面,用于重置整棵 state 树为初始数据:
1
2
// 这个 store 是上面定义好的实例
store.$reset()
具体例子:
1
2
3
4
5
6
7
8
9
// 修改数据
store.message = 'New Message'
console.log(store.message) // 输出 New Message
// 3s 后重置状态
setTimeout(() => {
store.$reset()
console.log(store.message) // 输出最开始的 Hello World
}, 3000)
订阅 state
Pinia 提供了一个用于订阅 state 的 $subscribe API 。
#### 订阅 API 的 TS 类型
在了解这个 API 的使用之前,先看一下它的 TS 类型定义:
1
2
3
4
5
6
7
// $subscribe 部分的 TS 类型
// ...
$subscribe(
callback: SubscriptionCallback<S>,
options?: { detached?: boolean } & WatchOptions
): () => void
// ...
添加订阅
$subscribe API 的功能类似于 watch ,但它只会在 state 被更新的时候才触发一次,并且在组件被卸载时删除(参考:组件的生命周期)。
从 订阅API的TS类型 可以看到,它可以接受两个参数,第一个参数是必传的 callback 函数,一般情况下默认用这个方式即可,使用例子:
1
2
3
4
// 可以在 state 出现变化时,更新本地持久化存储的数据
store.$subscribe((mutation, state) => {
localStorage.setItem('store', JSON.stringify(state))
})
这个 callback 里面有 2 个入参:
| 入参 | 作用 |
|---|---|
| mutation | 本次事件的一些信息 |
| state | 当前实例的 state |
其中 mutation 包含了以下数据:
| 字段 | 值 |
|---|---|
| storeId | 发布本次订阅通知的 Pinia 实例的唯一 ID(由 创建 Store 时指定) |
| type | 有 3 个值:返回 direct 代表直接更改数据;返回 patch object 代表是通过 传入一个对象 更改;返回 patch function 则代表是通过 传入一个函数 更改 |
| events | 触发本次订阅通知的事件列表 |
| payload | 通过 传入一个函数 更改时,传递进来的荷载信息,只有 type 为 patch object 时才有 |
如果不希望组件被卸载时删除订阅,可以传递第二个参数 options 用以保留订阅状态,传入一个对象。
可以简单指定为 { detached: true },也可以搭配 watch API 的选项一起用:
1
2
3
4
5
6
store.$subscribe(
(mutation, state) => {
// ...
},
{ detached: true },
)
移除订阅
在 添加订阅 部分已了解过,默认情况下,组件被卸载时订阅也会被一并移除,但如果之前启用了 detached 选项,就需要手动取消了。前面在 订阅API的TS类型 里提到,在启用 $subscribe API 之后,会有一个函数作为返回值,这个函数可以用来取消该订阅。
1
2
3
4
5
6
7
8
9
10
// 定义一个退订变量,它是一个函数
const unsubscribe = store.$subscribe(
(mutation, state) => {
// ...
},
{ detached: true },
)
// 在合适的时期调用它,可以取消这个订阅
unsubscribe()
管理 getters
Pinia 的 getters 是用来计算数据的。
给 Store 添加 getter
添加普通的 getter
继续用刚才的 message ,来定义一个 Getter ,用于返回一句拼接好的句子:
1
2
3
4
5
6
7
8
9
10
11
12
13
// src/stores/index.ts
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
state: () => ({
message: 'Hello World',
}),
// 定义一个 fullMessage 的计算数据
getters: {
fullMessage: (state) => `The message is "${state.message}".`,
},
// ...
})
添加引用 getter 的 getter
有时候可能要引用另外一个 getter 的值来返回数据,这个时候不能用箭头函数了,需要定义成普通函数而不是箭头函数,并在函数内部通过 this 来调用当前 Store 上的数据和方法。继续在上面的例子里,添加多一个 emojiMessage 的 getter ,在返回 fullMessage 的结果的同时,拼接多一串 emoji :
1
2
3
4
5
6
7
8
9
10
11
12
export const useStore = defineStore('main', {
state: () => ({
message: 'Hello World',
}),
getters: {
fullMessage: (state) => `The message is "${state.message}".`,
// 这个 getter 返回了另外一个 getter 的结果
emojiMessage(): string {
return `🎉🎉🎉 ${this.fullMessage}`
},
},
})
如果只写 JavaScript ,可能对这一条所说的限制觉得很奇怪,事实上用 JS 写箭头函数来引用确实不会报错,但如果用的是 TypeScript ,不按照这个写法,在 VSCode 提示和执行 TSC 检查的时候都会给抛出一条错误。
另外关于普通函数的 TS 返回类型,官方建议显式的进行标注,就像这个例子里的 emojiMessage(): string 里的 : string 。
给 getter 传递参数
getter 本身是不支持参数的,但和 Vuex 一样,支持返回一个具备入参的函数,用来满足需求。
1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
state: () => ({
message: 'Hello World',
}),
getters: {
// 定义一个接收入参的函数作为返回值
signedMessage: (state) => {
return (name: string) => `${name} say: "The message is ${state.message}".`
},
},
})
调用的时候是这样:
1
2
3
const signedMessage = store.signedMessage('Petter')
console.log('signedMessage', signedMessage)
// Petter say: "The message is Hello World".
这种情况下,这个 getter 只是调用的函数的作用,不再有缓存,如果通过变量定义了这个数据,那么这个变量也只是普通变量,不具备响应性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 通过变量定义一个值
const signedMessage = store.signedMessage('Petter')
console.log('signedMessage', signedMessage)
// Petter say: "The message is Hello World".
// 2s 后改变 message
setTimeout(() => {
store.message = 'New Message'
// signedMessage 不会变
console.log('signedMessage', signedMessage)
// Petter say: "The message is Hello World".
// 必须这样再次执行才能拿到更新后的值
console.log('signedMessage', store.signedMessage('Petter'))
// Petter say: "The message is New Message".
}, 2000)
获取和更新 getter
getter 和 state 都属于数据管理,读取和赋值的方法是一样的,请参考上方 获取和更新 state 一节的内容。
管理 actions
Pinia 只需要用 actions 就可以解决各种数据操作,无需像 Vuex 一样区分为 mutations / actions 两大类。
给 Store 添加 action
可以为当前 Store 封装一些可以开箱即用的方法,支持同步和异步。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/stores/index.ts
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
state: () => ({
message: 'Hello World',
}),
actions: {
// 异步更新 message
async updateMessage(newMessage: string): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
// 这里的 this 是当前的 Store 实例
this.message = newMessage
resolve('Async done.')
}, 3000)
})
},
// 同步更新 message
updateMessageSync(newMessage: string): string {
// 这里的 this 是当前的 Store 实例
this.message = newMessage
return 'Sync done.'
},
},
})
在 action 里,如果要访问当前实例的 state 或者 getter ,只需要通过 this 即可操作,方法的入参完全不再受 Vuex 那样有固定形式的困扰。
调用 action
在 Pinia 中只要像普通的函数一样使用即可,不需要和 Vuex 一样调用 commit 或者 dispatch。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default defineComponent({
setup() {
const store = useStore()
const { message } = storeToRefs(store)
// 立即执行
console.log(store.updateMessageSync('New message by sync.'))
// 3s 后执行
store.updateMessage('New message by async.').then((res) => console.log(res))
return {
message,
}
},
})
添加多个 Store
到这里,对单个 Store 的配置和调用相信都已经清楚了,实际项目中会涉及到很多数据操作,还可以用多个 Store 来维护不同需求模块的数据状态。
这一点和 Vuex 的 Module 比较相似,目的都是为了避免状态树过于臃肿,但用起来会更为简单。
目录结构建议
建议统一存放在 src/stores 下面管理,根据业务需要进行命名,比如 user 就用来管理登录用户相关的状态数据。
1
2
3
4
5
6
7
8
src
└─stores
│ # 入口文件
├─index.ts
│ # 多个 store
├─user.ts
├─game.ts
└─news.ts
里面暴露的方法就统一以 use 开头加上文件名,并以 Store 结尾,作为小驼峰写法,比如 user 这个 Store 文件里面导出的函数名就是:
1
2
3
4
// src/stores/user.ts
export const useUserStore = defineStore('user', {
// ...
})
然后以 index.ts 里作为统一的入口文件, index.ts 里的代码写为:
1
2
3
export * from './user'
export * from './game'
export * from './news'
这样在使用的时候,只需要从 @/stores 里导入即可,无需写完整的路径,例如只需要这样:import { useUserStore } from '@/stores',而无需这样:import { useUserStore } from '@/stores/user'。
在 Vue 组件 / TS 文件里使用
这里以一个比较简单的业务场景举例,希望能够方便的理解如何同时使用多个 Store 。
假设目前有一个 userStore 是管理当前登录用户信息, gameStore 是管理游戏的信息,而 “个人中心” 这个页面需要展示 “用户信息” ,以及 “该用户绑定的游戏信息”,那么就可以这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { defineComponent, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
// 这里导入要用到的 Store
import { useUserStore, useGameStore } from '@/stores'
import type { GameItem } from '@/types'
export default defineComponent({
setup() {
// 先从 userStore 获取用户信息(已经登录过,所以可以直接拿到)
const userStore = useUserStore()
const { userId, userName } = storeToRefs(userStore)
// 使用 gameStore 里的方法,传入用户 ID 去查询用户的游戏列表
const gameStore = useGameStore()
const gameList = ref<GameItem[]>([])
onMounted(async () => {
gameList.value = await gameStore.queryGameList(userId.value)
})
return {
userId,
userName,
gameList,
}
},
})
切记每个 Store 的 ID 必须不同,如果 ID 重复,在同一个 Vue 组件 / TS 文件里定义 Store 实例变量的时候,会以先定义的为有效值,后续定义的会和前面一样。
Store 之间互相引用
如果在定义一个 Store 的时候,要引用另外一个 Store 的数据,也是很简单,回到那个 message 的例子,添加一个 getter ,它会返回一句问候语欢迎用户:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/stores/message.ts
import { defineStore } from 'pinia'
// 导入用户信息的 Store 并启用它
import { useUserStore } from './user'
const userStore = useUserStore()
export const useMessageStore = defineStore('message', {
state: () => ({
message: 'Hello World',
}),
getters: {
// 这里就可以直接引用 userStore 上面的数据了
greeting: () => `Welcome, ${userStore.userName}!`,
},
})
假设现在 userName 是 Petter ,那么会得到一句对 Petter 的问候:
1
2
const messageStore = useMessageStore()
console.log(messageStore.greeting) // Welcome, Petter!
专属插件的使用
Pinia 拥有非常灵活的可扩展性,有专属插件可以开箱即用满足更多的需求场景。
如何查找插件
插件有统一的命名格式 pinia-plugin-* ,所以可以在 npmjs 上搜索这个关键词来查询目前有哪些插件已发布。
点击查询:pinia-plugin - npmjs
如何使用插件
这里以 pinia-plugin-persistedstate 为例,这是一个让数据持久化存储的 Pinia 插件。插件也是独立的 npm 包,需要先安装,再激活,然后才能使用。
激活方法会涉及到 Pinia 的初始化过程调整,这里不局限于某一个插件,通用的插件用法如下(请留意代码注释):
1
2
3
4
5
6
7
8
9
10
11
12
// src/main.ts
import { createApp } from 'vue'
import App from '@/App.vue'
import { createPinia } from 'pinia' // 导入 Pinia
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 导入 Pinia 插件
const pinia = createPinia() // 初始化 Pinia
pinia.use(piniaPluginPersistedstate) // 激活 Pinia 插件
createApp(App)
.use(pinia) // 启用 Pinia ,这一次是包含了插件的 Pinia 实例
.mount('#app')
使用前
Pinia 默认在页面刷新时会丢失当前变更的数据,没有在本地做持久化记录:
1
2
3
4
5
6
7
8
9
10
// 其他代码省略
const store = useMessageStore()
// 假设初始值是 Hello World
setTimeout(() => {
// 2s 后变成 Hello World!
store.message = store.message + '!'
}, 2000)
// 页面刷新后又变回了 Hello World
使用后
按照 persistedstate 插件的文档说明,在其中一个 Store 启用它,只需要添加一个 persist: true 的选项即可开启:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/stores/message.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'
const userStore = useUserStore()
export const useMessageStore = defineStore('message', {
state: () => ({
message: 'Hello World',
}),
getters: {
greeting: () => `Welcome, ${userStore.userName}`,
},
// 这是按照插件的文档,在实例上启用了该插件,这个选项是插件特有的
persist: true,
})
回到的页面,现在这个 Store 具备了持久化记忆的功能了,它会从 localStorage 读取原来的数据作为初始值,每一次变化后也会将其写入 localStorage 进行记忆存储。
1
2
3
4
5
6
7
8
9
10
11
12
// 其他代码省略
const store = useMessageStore()
// 假设初始值是 Hello World
setTimeout(() => {
// 2s 后变成 Hello World!
store.message = store.message + '!'
}, 2000)
// 页面刷新后变成了 Hello World!!
// 再次刷新后变成了 Hello World!!!
// 再次刷新后变成了 Hello World!!!!
可以在浏览器查看到 localStorage 的存储变化,以 Chrome 浏览器为例,按 F12 ,打开 Application 面板,选择 Local Storage ,可以看到以当前 Store ID 为 Key 的存储数据。这是其中一个插件使用的例子,更多的用法请根据自己选择的插件的 README 说明操作。

