Help us learn about your current experience with the documentation. Take the survey.

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.jsstate.jsgetters.js 中所有内容的 ‘barrel’ store 索引文件。

如果你的 store 文件变得太大,就该考虑将该 store 拆分成多个 store。

使用 Option Store

Pinia 提供两种类型的 store 定义:optionsetup。 创建新 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);
  });
});

任何给定的测试只应检查以下三件事之一:

  1. store 状态的变化
  2. 对另一个 action 的调用
  3. 对副作用(例如 Axios 请求)的调用

永远不要尝试在多个测试用例中使用同一个 Pinia 实例。 始终创建一个新的 Pinia 实例,因为它才是实际保存你状态的地方。

测试使用 store 的组件

遵循官方测试文档

Pinia 需要特殊处理以支持 Vue 3 兼容模式:

  1. 必须在 Vue 实例上注册 PiniaVuePlugin
  2. 必须明确提供 Pinia 实例给 Vue Test Utils 的 shallowMount/mount
  3. 必须在渲染组件之前创建 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 符合以下标准,请遵循单步迁移:

  1. store 只包含一个模块
  2. actions、getters 和 mutations 的总和不超过 1000 行

在任何其他情况下,优先选择多步迁移。

单步迁移

遵循官方 Vuex 迁移指南

  1. 使用codemods将 store 迁移到 Pinia
  2. 根据我们的指南最佳实践修复 store 测试
  3. 更新组件以使用迁移后的 Pinia store
    1. 用 Pinia 的对应方法替换 mapActionsmapState
    2. 用 Pinia 的 mapActions 替换 mapMutations
    3. 用 Pinia 的 mapState 替换 mapGetters
  4. 根据我们的指南最佳实践修复组件测试

如果你的 diff 开始变得过大而无法审查,请选择多步迁移。

多步迁移

了解官方 Vuex 迁移指南

有一个分两部分的视频系列教程:

  1. 迁移 store(第1部分)
  2. 迁移组件(第2部分)

遵循这些步骤来迭代迁移过程,并将工作拆分为更小的合并请求:

  1. 确定你要迁移的 store。 从通过 new Vuex.Store() 定义你的 store 的文件开始,然后继续。 包括在此 store 中使用的所有模块。

  2. 创建一个迁移 issue,分配迁移 DRI(s),并列出你将要迁移的所有 store 模块。 在该 issue 中跟踪你的迁移进度。如有必要,将迁移拆分为多个 issue。

  3. 为你要迁移的 store 文件创建一个新的 CODEOWNERS (.gitlab/CODEOWNERS) 规则,包括所有 Vuex 模块依赖项和 store 规范。

    如果你只迁移单个 store 模块,那么只需要包含 state.js(或你的 index.js)、actions.jsmutations.jsgetters.js 以及它们各自的规范文件。

    分配至少两个负责审查对 Vuex store 所做更改的人员。 始终将你的更改从 Vuex store 同步到 Pinia。这一点非常重要,这样就不会在 Pinia store 中引入回归问题。

  4. 将现有 store 原样复制到新位置(例如你可以称之为 stores/legacy_store)。保持文件结构。 对你要迁移的每个 store 模块都这样做。如有必要,将此拆分为多个合并请求。

  5. 创建一个包含 store 定义 (defineStore) 的索引文件 (index.js),并在其中定义你的状态。 从 state.js 复制状态定义。暂时不要导入 actions、mutations 和 getters。

  6. 使用codemods迁移 store 文件。 在你的新 store 定义 (index.js) 中导入迁移后的模块。

  7. 如果你的 store 中存在循环依赖,请考虑使用 tryStore 插件

  8. 手动迁移 store 规范

  9. 将你的 Vuex store 与 Pinia store 同步

  10. 重构组件以使用新的 store。根据需要将此拆分为尽可能多的合并请求。 始终使用组件更新规范

  11. 移除 Vuex store。

  12. 移除 CODEOWNERS 规则。

  13. 关闭迁移 issue。

示例迁移分解

你可以使用合并请求迁移分解作为参考:

  1. Diffs store
    1. 将 store 复制到新位置并引入 CODEOWNERS 规则
    2. 自动化 store 迁移
      1. 同时创建 MrNotes store
    3. 规范迁移(actionsgettersmutations
  2. Notes store
    1. 将 store 复制到新位置
    2. 自动化 store 迁移
    3. 规范迁移(actionsgettersmutations
  3. Batch comments store
    1. 将 store 复制到新位置
    2. 自动化 store 迁移
    3. 规范迁移(actionsgettersmutations
  4. 将 Vuex store 与 Pinia store 同步
  5. Diffs store 组件迁移
    1. Diffs 应用
    2. 非 diffs 组件
    3. 文件浏览器
    4. Diffs 组件
    5. Diff 文件组件
    6. 其余 diffs 组件
  6. Batch comments 组件迁移
  7. MrNotes 组件迁移
  8. Notes store 组件迁移
    1. Diffs 组件
    2. 简单注释组件
    3. 更多注释组件
    4. 其余注释组件
    5. Notes 应用
  9. 从合并请求中移除 Vuex
    1. 同时移除 CODEOWNERS 规则

迁移后步骤

一旦你的 store 迁移完成,考虑重构它以遵循我们的最佳实践。将大型 store 拆分成小型的。 重构 tryStore 的使用

使用 codemods 自动化迁移

你可以使用 ast-grep codemods 来简化从 Vuex 到 Pinia 的迁移。

  1. 在继续之前,先在你的系统上安装 ast-grep
  2. 运行 scripts/frontend/codemods/vuex-to-pinia/migrate.sh path/to/your/store

codemods 将迁移位于你的 store 文件夹中的 actions.jsmutations.jsgetters.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

如果你还没有迁移依赖模块(如示例中的 useAnotherModuleuseRootStore),你可以创建一个临时的虚拟 store。 使用下面的指导来迁移 Vuex 模块。

迁移嵌套模块的 store

迭代迁移具有相互依赖的嵌套模块的 store 并不容易。 在这种情况下,优先迁移嵌套模块:

  1. 为嵌套的 Vuex store 模块创建一个对应的 Pinia store。
  2. 如果适用,为根模块依赖项创建一个占位符 Pinia ‘root’ store。
  3. 复制并适应已迁移模块的现有测试。
  4. 不要使用已迁移的模块。
  5. 一旦所有嵌套模块都迁移完成,你可以迁移根模块,并将占位符 store 替换为真实的 store。
  6. 在组件中用 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_helperscreateTestPiniaAction 辅助函数来迁移这些规范。

迁移前
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
  });
});