Pinia 基础 - Pinia Essentials

  • 接着学 Pinia
  • Ref: https://pinia.vuejs.org/

创建 pinia 实例

1
2
3
4
5
6
7
8
9
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 (如 Pinia) 是一个保存状态和业务逻辑的实体,并不与组件树绑定(它承载着全局状态),每个组件都可以读取和写入它。它有三个概念,state、getter 和 action,我们可以假设这些概念相当于组件中的 data、 computed 和 methods。

1
2
3
4
5
6
7
8
9
import { defineStore } from "pinia";

// `defineStore()` 的返回值的命名是自由的
// 但最好含有 store 的名字,且以 `use` 开头,以 `Store` 结尾。
// (比如 `useUserStore`,`useCartStore`,`useProductStore`)
// 第一个参数是你的应用中 Store 的唯一 ID。
export const useAlertsStore = defineStore("alerts", {
// 其他配置...
});

其中 defineStore 的第一个参数是 store 的唯一 ID,是必须传入的,Pinia 将用它来连接 store 和 devtools。为了养成习惯性的用法,将返回的函数命名为 use... 是一个符合组合式函数风格的约定。

defineStore() 的第二个参数可接受两类值:Setup 函数或 Option 对象

Option Store

与 Vue 的选项式 API 类似,我们也可以传入一个带有 state、actions 与 getters 属性的 Option 对象

1
2
3
4
5
6
7
8
9
10
11
export const useCounterStore = defineStore("counter", {
state: () => ({ count: 0, name: "Eduardo" }),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++;
},
},
});

你可以认为 state 是 store 的数据 (data),getters 是 store 的计算属性 (computed),而 actions 则是方法 (methods)。

Setup Store

我们可以传入一个函数,该函数定义了一些响应式属性和方法,并且返回一个带有我们想暴露出去的属性和方法的对象。

1
2
3
4
5
6
7
8
9
10
export const useCounterStore = defineStore("counter", () => {
const count = ref(0);
const name = ref("Eduardo");
const doubleCount = computed(() => count.value * 2);
function increment() {
count.value++;
}

return { count, name, doubleCount, increment };
});
  • ref() 就是 state 属性
  • computed() 就是 getters
  • function() 就是 actions

要让 pinia 正确识别 state,你必须在 setup store 中返回 state 的所有属性。这意味着,你不能在 store 中使用私有属性。

Setup store 比 Option Store 带来了更多的灵活性,因为你可以在一个 store 内创建侦听器,并自由地使用任何组合式函数。Setup store 也可以依赖于全局提供的属性,比如路由。任何应用层面提供的属性都可以在 store 中使用 inject() 访问,就像在组件中一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { inject } from "vue";
import { useRoute } from "vue-router";
import { defineStore } from "pinia";

export const useSearchFilters = defineStore("search-filters", () => {
const route = useRoute();
// 这里假定 `app.provide('appProvided', 'value')` 已经调用过
const appProvided = inject("appProvided");

// ...

return {
// ...
};
});

不要返回像 route 或 appProvided (上例中)之类的属性,因为它们不属于 store,而且你可以在组件中直接用 useRoute() 和 inject('appProvided') 访问。

使用 Store

1
2
3
4
5
<script setup>
import { useCounterStore } from "@/stores/counter";
// 在组件内部的任何地方均可以访问变量 `store` ✨
const store = useCounterStore();
</script>
  • store 是一个用 reactive 包装的对象,这意味着不需要在 getters 后面写 .value。就像 setup 中的 props 一样,我们不能对它进行解构
  • 为了从 store 中提取属性时保持其响应性,你需要使用 storeToRefs()。它将为每一个响应式属性创建引用。同时,你可以直接从 store 中解构 action,因为它们也被绑定到 store 上:
1
2
3
4
5
6
7
8
9
10
<script setup>
import { storeToRefs } from "pinia";
const store = useCounterStore();
// `name` 和 `doubleCount` 都是响应式引用
// 下面的代码同样会提取那些来自插件的属性的响应式引用
// 但是会跳过所有的 action 或者非响应式(非 ref 或者 非 reactive)的属性
const { name, doubleCount } = storeToRefs(store);
// 名为 increment 的 action 可以被解构
const { increment } = store;
</script>

State

重置 state

在 Setup Stores 中,您需要创建自己的 $reset() 方法:

1
2
3
4
5
6
7
8
9
export const useCounterStore = defineStore("counter", () => {
const count = ref(0);

function $reset() {
count.value = 0;
}

return { count, $reset };
});

变更 state

除了用 store.count++ 直接改变 store,你还可以调用 $patch 方法。它允许你用一个 state 的补丁对象在同一时间更改多个属性:

1
2
3
4
5
store.$patch({
count: store.count + 1,
age: 120,
name: "DIO",
});

$patch 方法也接受一个函数来组合难以用补丁对象实现的变更:

1
2
3
4
store.$patch((state) => {
state.items.push({ name: "shoes", quantity: 1 });
state.hasChanged = true;
});

替换 state

不能完全替换掉 store 的 state,因为那样会破坏其响应性。但是可以 patch 它。

1
2
3
4
// 这实际上并没有替换`$state`
store.$state = { count: 24 };
// 在它内部调用 `$patch()`:
store.$patch({ count: 24 });

订阅 state

可以通过 store 的 $subscribe() 方法侦听 state 及其变化。比起普通的 watch(),使用 $subscribe() 的好处是 subscriptions 在 patch 后只触发一次

1
2
3
4
5
6
7
8
9
10
11
cartStore.$subscribe((mutation, state) => {
// import { MutationType } from 'pinia'
mutation.type; // 'direct' | 'patch object' | 'patch function'
// 和 cartStore.$id 一样
mutation.storeId; // 'cart'
// 只有 mutation.type === 'patch object'的情况下才可用
mutation.payload; // 传递给 cartStore.$patch() 的补丁对象。

// 每当状态发生变化时,将整个 state 持久化到本地存储。
localStorage.setItem("cart", JSON.stringify(state));
});
刷新时机

在底层实现上,$subscribe() 使用了 Vue 的 watch() 函数。你可以传入与 watch() 相同的选项:

1
2
3
4
5
6
7
cartStore.$subscribe(
(mutation, state) => {
// 每当状态发生变化时,将整个 state 持久化到本地存储
localStorage.setItem("cart", JSON.stringify(state));
},
{ flush: "sync" },
);
取消订阅

默认情况下,state subscription 会被绑定到添加它们的组件上 (如果 store 在组件的 setup() 里面)。这意味着,当该组件被卸载时,它们将被自动删除。如果你想在组件卸载后依旧保留它们,请将 { detached: true } 作为第二个参数,以将 state subscription 从当前组件中分离:

1
2
3
4
5
<script setup>
const someStore = useSomeStore();
// 此订阅器即便在组件卸载之后仍会被保留
someStore.$subscribe(callback, { detached: true });
</script>

可以在 pinia 实例上使用 watch() 函数侦听整个 state。

1
2
3
4
5
6
7
8
watch(
pinia.state,
(state) => {
// 每当状态发生变化时,将整个 state 持久化到本地存储。
localStorage.setItem("piniaState", JSON.stringify(state));
},
{ deep: true },
);

Getter

对于 Option Store 需要写 getter,但对于 Setup Store,你可以直接使用 computed() 来创建 getter。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
// 自动推断出返回类型是一个 number
doubleCount(state) {
return state.count * 2
},
// 返回类型**必须**明确设置
doublePlusOne(): number {
// 整个 store 的 自动补全和类型标注 ✨
return this.doubleCount + 1
},
},
})

通过 this 可以访问 store 内的其他 getter 和 state。

使用 setup() 时的用法

1
2
3
4
5
<script setup>
const store = useCounterStore();
store.count = 3;
store.doubleCount; // 6
</script>

使用选项式 API 的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { mapState } from "pinia";
import { useCounterStore } from "../stores/counter";

export default {
computed: {
// 允许在组件中访问 this.doubleCount
// 与从 store.doubleCount 中读取的相同
...mapState(useCounterStore, ["doubleCount"]),
// 与上述相同,但将其注册为 this.myOwnName
...mapState(useCounterStore, {
myOwnName: "doubleCount",
// 你也可以写一个函数来获得对 store 的访问权
double: (store) => store.doubleCount,
}),
},
};

Action

Action 相当于组件中的 method。它们可以通过 defineStore() 中的 actions 属性来定义,并且它们也是定义业务逻辑的完美选择。

1
2
3
4
5
6
7
8
9
10
11
12
13
export const useCounterStore = defineStore("main", {
state: () => ({
count: 0,
}),
actions: {
increment() {
this.count++;
},
randomizeCounter() {
this.count = Math.round(100 * Math.random());
},
},
});

类似 getter,action 也可通过 this 访问整个 store 实例,并支持完整的类型标注(以及自动补全✨)。不同的是,action 可以是异步的,你可以在它们里面 await 调用任何 API,以及其他 action!下面是一个使用 Mande 的例子。请注意,你使用什么库并不重要,只要你得到的是一个Promise。你甚至可以 (在浏览器中) 使用原生 fetch 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { mande } from "mande";

const api = mande("/api/users");

export const useUsers = defineStore("users", {
state: () => ({
userData: null,
// ...
}),

actions: {
async registerUser(login, password) {
try {
this.userData = await api.post({ login, password });
showTooltip(`Welcome back ${this.userData.name}!`);
} catch (error) {
showTooltip(error);
// 让表单组件显示错误
return error;
}
},
},
});

使用 action

你可以使用 mapActions() 辅助函数将 action 属性映射为你组件中的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
import { useCounterStore } from "../stores/counter";
export default defineComponent({
setup() {
const counterStore = useCounterStore();
return { counterStore };
},
methods: {
incrementAndPrint() {
this.counterStore.increment();
console.log("New Count:", this.counterStore.count);
},
},
});
</script>

订阅 action

你可以通过 store.$onAction() 来监听 action 和它们的结果。传递给它的回调函数会在 action 本身之前执行。after 表示在 promise 解决之后,允许你在 action 解决后执行一个回调函数。同样地,onError 允许你在 action 抛出错误或 reject 时执行一个回调函数。

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
30
31
32
33
34
const unsubscribe = someStore.$onAction(
({
name, // action 名称
store, // store 实例,类似 `someStore`
args, // 传递给 action 的参数数组
after, // 在 action 返回或解决后的钩子
onError, // action 抛出或拒绝的钩子
}) => {
// 为这个特定的 action 调用提供一个共享变量
const startTime = Date.now()
// 这将在执行 "store "的 action 之前触发。
console.log(`Start "${name}" with params [${args.join(', ')}].`)

// 这将在 action 成功并完全运行后触发。
// 它等待着任何返回的 promise
after((result) => {
console.log(
`Finished "${name}" after ${
Date.now() - startTime
}ms.\nResult: ${result}.`
)
})

// 如果 action 抛出或返回一个拒绝的 promise,这将触发
onError((error) => {
console.warn(
`Failed "${name}" after ${Date.now() - startTime}ms.\nError: ${error}.`
)
})
}
)

// 手动删除监听器
unsubscribe()

默认情况下,action 订阅器会被绑定到添加它们的组件上(如果 store 在组件的 setup() 内)。这意味着,当该组件被卸载时,它们将被自动删除。如果你想在组件卸载后依旧保留它们,请将 true 作为第二个参数传递给 action 订阅器,以便将其从当前组件中分离:

1
2
3
4
5
<script setup>
const someStore = useSomeStore()
// 此订阅器即便在组件卸载之后仍会被保留
someStore.$onAction(callback, true)
</script>

在组件外使用 store

Pinia store 依靠 pinia 实例在所有调用中共享同一个 store 实例。大多数时候,只需调用你定义的 useStore() 函数,完全开箱即用。例如,在 setup() 中,你不需要再做任何事情。但在组件之外,情况就有点不同了。 实际上,useStore() 给你的 app 自动注入了 pinia 实例。这意味着,如果 pinia 实例不能自动注入,你必须手动提供给 useStore() 函数。 你可以根据不同的应用,以不同的方式解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { createRouter } from 'vue-router'
const router = createRouter({
// ...
})

// ❌ 由于引入顺序的问题,这将失败
const store = useStore()

router.beforeEach((to, from, next) => {
// 我们想要在这里使用 store
if (store.isLoggedIn) next()
else next('/login')
})

router.beforeEach((to) => {
// ✅ 这样做是可行的,因为路由器是在其被安装之后开始导航的,
// 而此时 Pinia 也已经被安装。
const store = useStore()

if (to.meta.requiresAuth && !store.isLoggedIn) return '/login'
})