Pinia
Pinia 是用于管理 Vue 应用程序客户端状态的工具。 请参考官方文档了解如何使用 Pinia。
最佳实践
Pinia 实例
你应该始终优先使用来自 ~/pinia/instance 的共享 Pinia 实例。
这让你可以轻松地向组件添加更多 store,而无需担心多个 Pinia 实例的问题。
import { pinia } from '~/pinia/instance';
new Vue({ pinia, render(h) { return h(MyComponent); } });小型 store
倾向于创建专注于单一任务的小型 store。 这与 Vuex 的做法相反,Vuex 鼓励你创建更大的 store。
将 Pinia store 视为内聚的组件,而不是巨大的状态外观(Vuex 模块)。
Vuex 设计 ❌
flowchart TD
A[Store]
A --> B[State]
A --> C[Actions]
A --> D[Mutations]
A --> E[Getters]
B --> F[items]
B --> G[isLoadingItems]
B --> H[itemWithActiveForm]
B --> I[isSubmittingForm]
Pinia 的设计 ✅
flowchart TD
A[Items Store]
A --> B[State]
A --> C[Actions]
A --> D[Getters]
B --> E[items]
B --> F[isLoading]
H[Form Store]
H --> I[State]
H --> J[Actions]
H --> K[Getters]
I --> L[activeItem]
I --> M[isSubmitting]
单文件 store
将 state、actions 和 getters 放在单个文件中。
不要创建导入 actions.js、state.js 和 getters.js 中所有内容的 ‘barrel’ store 索引文件。
如果你的 store 文件变得太大,就该考虑将该 store 拆分成多个 store。
使用 Option Store
Pinia 提供两种类型的 store 定义:option 和 setup。 创建新 store 时优先使用 option 类型。这有助于保持一致性,并简化从 Vuex 迁移的路径。
全局 store
倾向于使用全局 Pinia store 来管理全局响应式状态。
// bad ❌
import { isNarrowScreenMediaQuery } from '~/lib/utils/css_utils';
new Vue({
data() {
return {
isNarrow: false,
};
},
mounted() {
const query = isNarrowScreenMediaQuery();
this.isNarrow = query.matches;
query.addEventListener('change', (event) => {
this.isNarrow = event.matches;
});
},
render() {
if (this.isNarrow) return null;
//
},
});// good ✅
import { pinia } from '~/pinia/instance';
import { useViewport } from '~/pinia/global_stores/viewport';
new Vue({
pinia,
...mapState(useViewport, ['isNarrowScreen']),
render() {
if (this.isNarrowScreen) return null;
//
},
});热模块替换
Pinia 提供了一个 HMR 选项,你需要手动在代码中附加它。 Pinia 通过这种方法提供的体验不佳,应该避免使用。
测试 Pinia
测试 store
官方文档建议使用 setActivePinia(createPinia()) 来测试 Pinia。
我们的建议是利用 createTestingPinia 并禁用 actions 的 stub。
它的作用与 setActivePinia(createPinia()) 相同,但默认允许我们监视任何 action。
始终在单元测试 store 时使用 createTestingPinia 并设置 stubActions: false。
一个基本的测试可能如下所示:
import { createTestingPinia } from '@pinia/testing';
import { useMyStore } from '~/my_store.js';
describe('MyStore', () => {
beforeEach(() => {
createTestingPinia({ stubActions: false });
});
it('执行某些操作', () => {
useMyStore().someAction();
expect(useMyStore().someState).toBe(true);
});
});任何给定的测试只应检查以下三件事之一:
- store 状态的变化
- 对另一个 action 的调用
- 对副作用(例如 Axios 请求)的调用
永远不要尝试在多个测试用例中使用同一个 Pinia 实例。 始终创建一个新的 Pinia 实例,因为它才是实际保存你状态的地方。
测试使用 store 的组件
Pinia 需要特殊处理以支持 Vue 3 兼容模式:
- 必须在 Vue 实例上注册
PiniaVuePlugin - 必须明确提供 Pinia 实例给 Vue Test Utils 的
shallowMount/mount - 必须在渲染组件之前创建 store,否则 Vue 会尝试使用 Vue 3 的 Pinia
完整的设置如下所示:
import Vue from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { PiniaVuePlugin } from 'pinia';
import { shallowMount } from '@vue/test-utils';
import { useMyStore } from '~/my_store.js';
import MyComponent from '~/my_component.vue';
Vue.use(PiniaVuePlugin);
describe('MyComponent', () => {
let pinia;
let wrapper;
const createComponent = () => {
wrapper = shallowMount(MyComponent, { pinia });
}
beforeEach(() => {
pinia = createTestingPinia();
// 在渲染组件之前创建 store
useMyStore();
});
it('执行某些操作', () => {
createComponent();
// 所有 actions 默认都被 stub
expect(useMyStore().someAction).toHaveBeenCalledWith({ arg: 'foo' });
expect(useMyStore().someAction).toHaveBeenCalledTimes(1);
});
});在大多数情况下,测试组件时不需要设置 stubActions: false。
相反,store 本身应该得到适当的测试,而组件测试应该检查 actions 是否以正确的参数被调用。
设置初始状态
Pinia 不允许在 actions 被 stub 后取消它们的 stub。
这意味着如果你没有设置 stubActions: false,你就不能使用它们来设置初始状态。
在这种情况下,可以直接设置状态:
describe('MyComponent', () => {
let pinia;
let wrapper;
const createComponent = () => {
wrapper = shallowMount(MyComponent, { pinia });
}
beforeEach(() => {
// 所有 actions 都被 stub,我们不能再使用它们来改变状态
pinia = createTestingPinia();
// 在渲染组件之前创建 store
useMyStore();
});
it('执行某些操作', () => {
// 直接设置状态而不是使用 action
useMyStore().someState = { value: 1 };
createComponent();
// ...
});
});从 Vuex 迁移
GitLab 正在积极从 Vuex 迁移,你可以在此处贡献并跟进这一进度。
在迁移之前,先决定你的主要状态管理器应该是什么。 如果 Pinia 是你的选择,请遵循本指南。
迁移到 Pinia 可以通过两种方式完成:单步迁移和多步迁移。
如果你的 store 符合以下标准,请遵循单步迁移:
- store 只包含一个模块
- actions、getters 和 mutations 的总和不超过 1000 行
在任何其他情况下,优先选择多步迁移。
单步迁移
- 使用codemods将 store 迁移到 Pinia
- 根据我们的指南和最佳实践修复 store 测试
- 更新组件以使用迁移后的 Pinia store
- 用 Pinia 的对应方法替换
mapActions、mapState - 用 Pinia 的
mapActions替换mapMutations - 用 Pinia 的
mapState替换mapGetters
- 用 Pinia 的对应方法替换
- 根据我们的指南和最佳实践修复组件测试
如果你的 diff 开始变得过大而无法审查,请选择多步迁移。
多步迁移
有一个分两部分的视频系列教程:
遵循这些步骤来迭代迁移过程,并将工作拆分为更小的合并请求:
-
确定你要迁移的 store。 从通过
new Vuex.Store()定义你的 store 的文件开始,然后继续。 包括在此 store 中使用的所有模块。 -
创建一个迁移 issue,分配迁移 DRI(s),并列出你将要迁移的所有 store 模块。 在该 issue 中跟踪你的迁移进度。如有必要,将迁移拆分为多个 issue。
-
为你要迁移的 store 文件创建一个新的 CODEOWNERS (
.gitlab/CODEOWNERS) 规则,包括所有 Vuex 模块依赖项和 store 规范。如果你只迁移单个 store 模块,那么只需要包含
state.js(或你的index.js)、actions.js、mutations.js和getters.js以及它们各自的规范文件。分配至少两个负责审查对 Vuex store 所做更改的人员。 始终将你的更改从 Vuex store 同步到 Pinia。这一点非常重要,这样就不会在 Pinia store 中引入回归问题。
-
将现有 store 原样复制到新位置(例如你可以称之为
stores/legacy_store)。保持文件结构。 对你要迁移的每个 store 模块都这样做。如有必要,将此拆分为多个合并请求。 -
创建一个包含 store 定义 (
defineStore) 的索引文件 (index.js),并在其中定义你的状态。 从state.js复制状态定义。暂时不要导入 actions、mutations 和 getters。 -
使用codemods迁移 store 文件。 在你的新 store 定义 (
index.js) 中导入迁移后的模块。 -
如果你的 store 中存在循环依赖,请考虑使用
tryStore插件。 -
重构组件以使用新的 store。根据需要将此拆分为尽可能多的合并请求。 始终使用组件更新规范。
-
移除 Vuex store。
-
移除 CODEOWNERS 规则。
-
关闭迁移 issue。
示例迁移分解
你可以使用合并请求迁移分解作为参考:
- Diffs store
- 将 store 复制到新位置并引入 CODEOWNERS 规则
- 自动化 store 迁移
- 同时创建 MrNotes store
- 规范迁移(actions、getters、mutations)
- Notes store
- Batch comments store
- 将 Vuex store 与 Pinia store 同步
- Diffs store 组件迁移
- Batch comments 组件迁移
- MrNotes 组件迁移
- Notes store 组件迁移
- 从合并请求中移除 Vuex
- 同时移除 CODEOWNERS 规则
迁移后步骤
一旦你的 store 迁移完成,考虑重构它以遵循我们的最佳实践。将大型 store 拆分成小型的。
重构 tryStore 的使用。
使用 codemods 自动化迁移
你可以使用 ast-grep codemods 来简化从 Vuex 到 Pinia 的迁移。
- 在继续之前,先在你的系统上安装 ast-grep。
- 运行
scripts/frontend/codemods/vuex-to-pinia/migrate.sh path/to/your/store
codemods 将迁移位于你的 store 文件夹中的 actions.js、mutations.js 和 getters.js。
运行 codemods 后手动扫描这些文件,确保它们被正确迁移。
Vuex 规范无法自动迁移,请手动迁移。
Vuex 模块调用使用 Pinia 约定替换:
| Vuex | Pinia |
|---|---|
dispatch('anotherModule/action', ...args, { root: true }) |
useAnotherModule().action(...args) |
dispatch('action', ...args, { root: true }) |
useRootStore().action(...args) |
rootGetters['anotherModule/getter'] |
useAnotherModule().getter |
rootGetters.getter |
useRootStore().getter |
rootState.anotherModule.state |
useAnotherModule().state |
如果你还没有迁移依赖模块(如示例中的 useAnotherModule 和 useRootStore),你可以创建一个临时的虚拟 store。
使用下面的指导来迁移 Vuex 模块。
迁移嵌套模块的 store
迭代迁移具有相互依赖的嵌套模块的 store 并不容易。 在这种情况下,优先迁移嵌套模块:
- 为嵌套的 Vuex store 模块创建一个对应的 Pinia store。
- 如果适用,为根模块依赖项创建一个占位符 Pinia ‘root’ store。
- 复制并适应已迁移模块的现有测试。
- 不要使用已迁移的模块。
- 一旦所有嵌套模块都迁移完成,你可以迁移根模块,并将占位符 store 替换为真实的 store。
- 在组件中用 Pinia store 替换 Vuex store。
避免循环依赖
绝对不要在你的 Pinia store 中创建循环依赖。 不幸的是,Vuex 设计允许创建相互依赖的模块,我们必须稍后重构。
store 设计中的循环依赖示例:
graph TD
A[Store Alpha] --> Foo(Action Foo)
B[Store Beta] --> Bar(Action Bar)
A -- calls --> Bar
B -- calls --> Foo
为了缓解这个问题,考虑在从 Vuex 迁移期间为 Pinia 使用 tryStore 插件:
迁移前
// store_alpha/actions.js
function callOtherStore() {
// bad ❌, 创建了循环依赖
useBetaStore().bar();
}// store_beta/actions.js
function callOtherStore() {
// bad ❌, 创建了循环依赖
useAlphaStore().bar();
}迁移后
// store_alpha/actions.js
function callOtherStore() {
// OK ✅, 避免了循环依赖
this.tryStore('betaStore').bar();
}// store_beta/actions.js
function callOtherStore() {
// OK ✅, 避免了循环依赖
this.tryStore('alphaStore').bar();
}这将使用 Pinia 实例按名称查找 store,并防止循环依赖问题。
store 名称在调用 defineStore('storeName', ...) 时定义。
使用 tryStore 时,必须在组件挂载之前初始化两个 store:
// 提前创建 store
useAlphaStore();
useBetaStore();
new Vue({ pinia, render(h) { return h(MyComponent); } });tryStore 辅助函数只能在迁移期间使用。永远不要在适当的 Pinia store 中使用它。
重构 tryStore
迁移完成后,重新设计 store 以消除所有循环依赖非常重要。
解决这个问题的最简单方法是创建一个顶层 store 来协调其他 store。
重构前
graph TD
A[Store Alpha] --> Foo(Action Foo)
A -- calls --> Bar
B[Store Beta] --> Bar(Action Bar)
B -- calls --> Foo
重构后
graph TD
C[Store Gamma]
A[Store Alpha] --- Bar(Action Bar)
B[Store Beta] --- Foo(Action Foo)
C -- calls --> Bar
C -- calls --> Foo
与 Vuex 同步
这个 syncWithVuex 插件将你的状态从 Vuex 同步到 Pinia,反之亦然。
这允许你在迁移期间通过在应用中同时拥有两个 store 来迭代迁移组件。
使用示例:
// Vuex store @ ./store.js
import Vuex from 'vuex';
import createOldStore from './stores/old_store';
export default new Vuex.Store({
modules: {
oldStore: createOldStore(),
},
});// Pinia store
import { defineStore } from 'pinia';
import oldVuexStore from './store'
export const useMigratedStore = defineStore('migratedStore', {
syncWith: {
store: oldVuexStore,
name: 'oldStore', // 如果 Vuex `modules` 中定义了旧 store 名称,请使用它
namespaced: true, // 如果 Vuex 模块是命名空间的,设置为 'true'
},
// 这里的状态与 Vuex 同步,对 migratedStore 的任何更改也会传播到 Vuex store
state() {
// ...
},
// ...
});覆盖
一个 Vuex store 定义可以在多个 Vuex store 实例中共享。
在这种情况下,我们不能仅依赖 store 配置来同步我们的 Pinia store 与 Vuex store。
我们需要使用 syncWith 辅助函数将我们的 Pinia store 指向实际的 Vuex store 实例。
// 这会覆盖现有的 `syncWith` 配置
useMigratedStore().syncWith({ store: anotherOldStore });
// `useMigratedStore` 现在只与 `anotherOldStore` 同步
new Vue({ pinia, render(h) { return h(MyComponent) } });迁移 store 测试
testAction
一些 Vuex 测试可能使用 testAction 辅助函数来测试某些 actions 或 mutations 是否被调用。
我们可以使用 Jest 中 helpers/pinia_helpers 的 createTestPiniaAction 辅助函数来迁移这些规范。
迁移前
describe('SomeStore', () => {
it('运行 actions', () => {
return testAction(
store.actionToBeCalled, // 立即调用的 action
{ someArg: 1 }, // action 调用参数
{ someState: 1 }, // 初始 store 状态
[{ type: 'MUTATION_NAME', payload: '123' }], // 期望的 mutation 调用
[{ type: 'actionName' }], // 期望的 action 调用
);
});
});迁移后
import { createTestPiniaAction } from 'helpers/pinia_helpers';
describe('SomeStore', () => {
let store;
let testAction;
beforeEach(() => {
store = useMyStore();
testAction = createTestPiniaAction(store);
});
it('运行 actions', () => {
return testAction(
store.actionToBeCalled,
{ someArg: 1 },
{ someState: 1 },
[{ type: store.MUTATION_NAME, payload: '123' }], // 对迁移后的 mutation 的显式引用
[{ type: store.actionName }], // 对迁移后的 action 的显式引用
);
});
});避免在你的适当 Pinia 测试中使用 testAction:这应该只在迁移期间使用。
始终优先显式测试每个 action 调用。
自定义 getters
Pinia 允许在 Vue 3 中定义自定义 getters。由于我们使用的是 Vue 2,这是不可能的。
为了解决这个问题,你可以使用 helpers/pinia_helpers 中的 createCustomGetters 辅助函数。
迁移前
describe('SomeStore', () => {
it('运行 actions', () => {
const dispatch = jest.fn();
const getters = { someGetter: 1 };
someAction({ dispatch, getters });
expect(dispatch).toHaveBeenCalledWith('anotherAction', 1);
});
});迁移后
import { createCustomGetters } from 'helpers/pinia_helpers';
describe('SomeStore', () => {
let store;
let getters;
beforeEach(() => {
getters = {};
createTestingPinia({
stubActions: false,
plugins: [
createCustomGetters(() => ({
myStore: getters, // 测试中使用的每个 store 也应该在这里声明
})),
],
});
store = useMyStore();
});
it('运行 actions', () => {
getters.someGetter = 1;
store.someAction();
expect(store.anotherAction).toHaveBeenCalledWith(1);
});
});避免在适当的 Pinia 测试中模拟 getters:这应该只用于迁移。 相反,提供有效的状态,以便 getter 可以返回正确的值。
迁移组件测试
Pinia 默认不在 actions 中返回 promises。
因此,使用 createTestingPinia 时要特别注意。
由于它 stubs 所有 actions,它不保证 action 会返回 promise。
如果你的组件代码期望 action 返回 promise,请相应地 stub 它。
describe('MyComponent', () => {
let pinia;
beforeEach(() => {
pinia = createTestingPinia();
useMyStore().someAsyncAction.mockResolvedValue(); // 这现在返回一个 promise
});
});