Skip to main content

Pinia 状态管理及其原理

一、什么是状态管理

组件的单向数据流如下:

其中,包括以下几个部分:

  • State 状态:驱动整个应用的数据源;
  • View 视图:对 State 状态的一种声明式映射;
  • Actions 交互:State 状态根据用户输入而作出相应的变更。

当有多个组件共享一个共同的状态时,单向数据流会变得复杂:

  • 多个视图依赖于同一份状态(可通过透传的方式解决,但会导致 Prop 逐级透传,代码变得繁琐冗长)
  • 来自不同视图的交互可能要更改同一份状态(可通过事件逐层同步,但也会导致代码难以维护)

因此,就需要抽取出组件间的共享状态,放在一个全局单例中来管理。这样组件树就变成了一个大的“视图”,不管在树的哪个位置,组件都可以访问其中的状态或触发动作。

二、Pinia 安装及使用

Pinia 是 Vue 官方提供的状态管理库,用于跨组件共享状态。Pinia 的优点如下:

  • 与 Vue DevTools 集成;
  • 更完善的 TypeScript 支持;
  • 更简洁的 API;
  • 轻量化,体积约 1KB;
  • 模块热更新 (HMR)
  • 支持 SSR 服务端渲染。

1、安装

npm install pinia
# yarn
yarn add pinia

2、基本用法

src/main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createPinia } from 'pinia'

createApp(App).mount('#app')
createApp(App).use(createPinia()).mount('#app')

实现效果:

3、storeToRefs 解构 store

const { countPinia } = store 这种解构是不合法的,会破坏响应式,如果需要解构 store,需要用 storeToRefs 来实现:

App.vue
<template>
<div>
<span>count is {{ store.count }}</span>
<span>count is {{ count }}</span>
<button @click="handleAdd">+1</button>
</div>
</template>

<script setup lang="ts">
import { useMainStore } from './stores'
import { storeToRefs } from 'pinia'

const store = useMainStore()
const { count } = storeToRefs(store)

const handleAdd = () => {
store.increment()
}
</script>

4、监听 store 状态变化

可以通过 store 的 $subscribe() 方法监听状态变化:

src/App.Vue
<template>
<div>
<span>count is {{ store.count }}</span>
<button @click="handleAdd">+1</button>
</div>
</template>

<script setup lang="ts">
import { useMainStore } from './stores'

const store = useMainStore()

store.$subscribe((mutation, state) => {
console.log(mutation, state)
})

const handleAdd = () => {
store.increment()
}
</script>

当状态变化时,打印以下结果:

默认情况下,当使用 $subscribe 组件被卸载时,state subscriptions 将被自动删除,如果想在组件卸载后保留它们,可以给 $subscribe 的第二个参数加上 { detached: true }

5、getters 用法

getters 相当于 store 的 computed,用于封装计算属性,第一个参数是 state。使用如下:

src/stores/index.ts
import { defineStore } from 'pinia'

export const useMainStore = defineStore('main', {
state: () => ({
count: 0
}),
getters: {
// 传 state - 普通函数
doubleCount1(state) {
return state.count * 2
},
// 传 state - 箭头函数
doubleCount2: (state) => state.count * 2,
// 不传 state 时,可以使用 this 的方式拿到 state 的值
// 但这种方式需要手动声明返回值的类型
tripleCount(): number {
return this.count * 3
}
},
actions: {
increment() {
this.count++
}
}
})

实现效果:

三、Pinia 原理解析

1、createPinia 返回 pinia 实例

  1. createPinia 通过 effect 作用域创建响应式对象 state;
  2. 然后使用 markRaw 声明一个 pinia 对象,使其不会被响应式代理;
  3. 在其中用 install 注册执行函数保存当前的 pinia 对象,注入到 vue 中;
  4. 同时使用 use 来注册 pinia 插件;
  5. 最后返回 pinia 实例。
createPinia.ts
// https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/createPinia.ts

export function createPinia(): Pinia {
const scope = effectScope(true)
// 通过 effect 作用域创建响应式对象 state,effect 作用域会捕获其中所创建的响应式副作用
const state = scope.run<Ref<Record<string, StateTree>>>(() =>
ref<Record<string, StateTree>>({})
)!

// 存储扩展 store 的 plugin
let _p: Pinia['_p'] = []
// install 之前使用 pinia.use() 添加的 plugin
let toBeInstalled: PiniaPlugin[] = []

// 使用 markRaw 声明一个 pinia 对象,使其不会被响应式代理
const pinia: Pinia = markRaw({
// 使用 app.use(pinia) 时,会触发这里的注册执行函数
install(app: App) {
// 将当前使用的 pinia 赋给 activePinia 全局变量
setActivePinia(pinia)
// 判断是否为 Vue2 环境
// 如果是 Vue2,则全局注册在 PiniaVuePlugin 中完成
if (!isVue2) {
// 如果不是 Vue2,保存 app 实例
pinia._a = app
// 然后将 pinia 注入到 app 实例,供后续使用
app.provide(piniaSymbol, pinia)
// 再将 pinia 暴露为全局属性 $pinia
app.config.globalProperties.$pinia = pinia
if (USE_DEVTOOLS) {
registerPiniaDevtools(app, pinia)
}
// 处理未执行的 pinia 插件,然后置空
toBeInstalled.forEach((plugin) => _p.push(plugin))
toBeInstalled = []
}
},

// use 可添加一个 plugin 以扩展每个 store
// 接收 plugin 参数,返回当前 pinia
use(plugin) {
// 如果 this._a 为空且非 Vue2 环境
if (!this._a && !isVue2) {
// 将 plugin 暂存到 toBeInstalled 中,等待 install 时进行安装
toBeInstalled.push(plugin)
} else {
// 否则直接添加到 this._p 中
_p.push(plugin)
}
return this
},

// 扩展 store 的所有 pinia 插件
_p,
// app 实例,在 install 时会被设置
_a: null,
// pinia 的 effect 作用域对象,每个 store 都是独立的 scope
_e: scope,
// 在这个 pinia 中注册的 stores
_s: new Map<string, StoreGeneric>(),
// pinia 所有 state 的合集
state,
})

// 集成 vue devtools
if (USE_DEVTOOLS && typeof Proxy !== 'undefined') {
pinia.use(devtoolsPlugin)
}

return pinia
}

2、defineStore 定义 store

defineStore 会对三种创建形式进行兼容,然后在 useStore 中根据参数类型调用 createSetupStorecreateOptionsStore 方法创建 store,同时将相关的变量和方法并入 store 中,最后返回 store。

src/stores/index.ts
import { defineStore } from 'pinia'

// 创建形式一
export const useMainStore = defineStore('main', {
state: () => ({
count: 0
}),
actions: {
increment() {
this.count++
}
}
})

// 创建形式二
export const useMainStore = defineStore({
id: 'main',
state: () => ({
count: 0
}),
actions: {
increment() {
this.count++
}
}
})

// 创建形式三
export const useMainStore = defineStore('main', () => {
const count = ref(0)
function increment() {
count.value++
}
return { count, increment }
})

3、storeToRefs 解构原理

storeToRefs 会判断是否为 vue2,如果是 vue2 则用 toRefs 将 store 转为普通对象;如果不是 vue2 则遍历 store 原始对象的键值,将 ref 与 reactive 类型的值转为 ref 然后复制到一个新的对象 refs 中,最后返回这个新的对象。

storeToRefs.ts
// https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/storeToRefs.ts

export function storeToRefs<SS extends StoreGeneric>(
store: SS
): ToRefs<
StoreState<SS> & StoreGetters<SS> & PiniaCustomStateProperties<StoreState<SS>>
> {
// It's easier to just use toRefs() even if it includes more stuff
if (isVue2) {
// 如果是 vue2 直接返回 toRefs(store)
return toRefs(store)
} else {
// 如果非 vue2,则过滤 store 中的非 ref 或 reactive 对象

// 通过 toRaw 获取 store 的原始对象并赋值
store = toRaw(store)

const refs = {} as ToRefs<
StoreState<SS> &
StoreGetters<SS> &
PiniaCustomStateProperties<StoreState<SS>>
>
for (const key in store) {
const value = store[key]
if (isRef(value) || isReactive(value)) {
// 使用 toRef 获取一个新的 ref
refs[key] = toRef(store, key)
}
}

return refs
}
}