解释 Pinia 源码中插件机制的实现,以及如何通过插件访问和修改 `Store` 实例。

各位观众老爷,晚上好!我是今晚的讲师,咱们今天聊聊 Pinia 源码里那些有点意思的插件机制。放心,保证不让你听得想睡觉。

Pinia 插件机制:让你的 Store 飞起来

想象一下,你的 Pinia store 就像一辆汽车。它能跑,能载人,基本功能没问题。但如果你想让它更牛逼,比如加个涡轮增压,或者装个自动驾驶系统,那就得靠插件了。Pinia 的插件机制,就是让你给 Store 加各种“外挂”的魔法。

插件的定义:一个简单的函数

Pinia 插件本质上就是一个函数。这个函数接收一个 PiniaPluginContext 对象作为参数,你可以在这个函数里对 Store 进行各种操作。

import { PiniaPluginContext } from 'pinia';

function myPlugin(context: PiniaPluginContext) {
  // 在这里对 Store 进行操作
}

这个 PiniaPluginContext 对象里都有些啥呢?咱们来细瞅瞅:

属性 类型 描述
pinia Pinia Pinia 实例。你可以用它来访问和操作所有 Store。
app VueApp (Vue 3) / any (Vue 2) Vue 应用实例。如果你在 Vue 应用中使用 Pinia,就可以访问 Vue 的各种 API。
store PiniaStore 当前正在使用的 Store 实例。这是你操作 Store 的主要入口。
options DefineStoreOptions 定义 Store 时的选项对象。你可以根据这些选项来定制插件的行为。

插件的注册:让 Pinia 知道你的存在

要让你的插件生效,你需要把它注册到 Pinia 实例上。有两种方式:

  1. 全局注册: 对所有 Store 生效。

    import { createPinia } from 'pinia';
    import myPlugin from './myPlugin';
    
    const pinia = createPinia();
    pinia.use(myPlugin); // 注册插件
  2. Store 特定的注册: 只对特定的 Store 生效。这需要在定义 Store 的时候,通过 defineStoreoptions 对象来指定。

    import { defineStore } from 'pinia';
    import myPlugin from './myPlugin';
    
    export const useMyStore = defineStore('myStore', {
      state: () => ({
        count: 0,
      }),
      actions: {
        increment() {
          this.count++;
        },
      },
      pinia: {
        plugins: [myPlugin], // Store 特定的插件
      },
    });

    注意:options 对象中指定插件,需要在 pinia 属性下创建一个 plugins 数组,并将你的插件函数添加到这个数组中。

插件的功能:无限可能

有了插件,你就可以对 Store 做各种各样的事情:

  1. 添加全局属性: 比如,给所有 Store 都加上一个 $reset 方法,方便重置状态。

    import { PiniaPluginContext } from 'pinia';
    
    function resetPlugin({ store }: PiniaPluginContext) {
      store.$reset = () => {
        const initialState = store.$state; // 保存初始状态
        store.$patch(initialState); // 使用 $patch 来更新状态
      };
    }
    
    export default resetPlugin;

    使用:

    import { defineStore } from 'pinia';
    
    export const useMyStore = defineStore('myStore', {
      state: () => ({
        count: 0,
      }),
      actions: {
        increment() {
          this.count++;
        },
      },
    });
    
    const myStore = useMyStore();
    myStore.$reset(); // 重置状态
  2. 修改 Store 的行为: 比如,在每次状态更新的时候,自动把状态保存到 localStorage。

    import { PiniaPluginContext } from 'pinia';
    
    function persistPlugin({ store }: PiniaPluginContext) {
      const STORAGE_KEY = `pinia-store-${store.$id}`;
    
      // 初始化时从 localStorage 加载状态
      const storedState = localStorage.getItem(STORAGE_KEY);
      if (storedState) {
        store.$patch(JSON.parse(storedState));
      }
    
      // 监听状态变化,保存到 localStorage
      store.$subscribe((mutation, state) => {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
      });
    }
    
    export default persistPlugin;

    使用:

    import { defineStore } from 'pinia';
    
    export const useMyStore = defineStore('myStore', {
      state: () => ({
        count: 0,
      }),
      actions: {
        increment() {
          this.count++;
        },
      },
    });
    
    // 不需要手动调用,状态会自动保存和加载
  3. 扩展 Store 的功能: 比如,添加一些常用的工具函数,方便操作数据。

    import { PiniaPluginContext } from 'pinia';
    
    function utilsPlugin({ store }: PiniaPluginContext) {
      store.$double = () => {
        // 假设 state 中有 count 属性
        if (typeof store.$state.count === 'number') {
          return store.$state.count * 2;
        } else {
          return 0; // 或者抛出错误,取决于你的需求
        }
      };
    }
    
    export default utilsPlugin;

    使用:

    import { defineStore } from 'pinia';
    
    export const useMyStore = defineStore('myStore', {
      state: () => ({
        count: 0,
      }),
      actions: {
        increment() {
          this.count++;
        },
      },
    });
    
    const myStore = useMyStore();
    const doubledCount = myStore.$double(); // 使用 $double 函数
    console.log(doubledCount);

访问和修改 Store 实例:核心技巧

在插件里,最重要的就是访问和修改 Store 实例。PiniaPluginContext 提供的 store 属性,就是你操作 Store 的入口。

  • 访问 Store 的状态: 直接通过 store.$state 访问。

    const count = store.$state.count;
  • 修改 Store 的状态: 有三种方式:

    1. 直接修改: 最简单粗暴的方式,但不推荐。因为这样修改的状态不会被 Pinia 追踪,导致一些插件(比如持久化插件)失效。

      store.$state.count = 10; // 不推荐
    2. $patch 方法: 推荐使用的方式。$patch 可以批量更新状态,而且会被 Pinia 追踪。

      store.$patch({
        count: 10,
        name: 'John',
      });
      
      // 也可以传入一个函数,进行更复杂的状态更新
      store.$patch((state) => {
        state.count++;
        state.name = state.name + ' Doe';
      });
    3. 调用 actions: 如果你想通过 actions 来修改状态,也是可以的。不过要注意,actions 必须是同步的。

      store.increment(); // 调用 actions
  • 访问 Store 的 actions: 直接通过 store.actionName() 访问。

    store.increment();
  • 访问 Store 的 getters: 直接通过 store.getterName 访问。

    const doubleCount = store.doubleCount;

源码解析:看看 Pinia 是怎么实现插件机制的

Pinia 的插件机制其实并不复杂,核心代码主要在 pinia.tsstore.ts 文件里。

  1. createPinia 函数: 这个函数负责创建 Pinia 实例,并且提供了 use 方法来注册插件。

    export function createPinia(): Pinia {
      const scope = effectScope(true)
      const state = scope.run(() => reactive({}))!
    
      let _p: Pinia['_p'] = []
    
      const pinia: Pinia = markRaw({
        install(app: App) {
          setActivePinia(pinia)
          app.provide(piniaSymbol, pinia)
          app.config.globalProperties.$pinia = pinia
          // 避免在 ssr 中出现警告,如果未找到 defineStore,则使用一个假函数
          if (__DEV__ && !app.config.globalProperties.$defineStore) {
            Object.defineProperty(app.config.globalProperties, '$defineStore', {
              get() {
                return () => {
                  throw new Error(
                    '`defineStore` is not defined. Did you forget to install pinia as a plugin?n' +
                      'ex: `app.use(pinia)`.'
                  )
                }
              },
            })
          }
        },
    
        use(plugin: PiniaPlugin) {
          _p.push(plugin)
          return this
        },
    
        _p,
        _a: null,
        _e: scope,
        _s: new Set<SubscriptionCallback<Pinia>>(),
        state,
      })
    
      return pinia
    }

    pinia.use(plugin) 实际上就是把插件函数添加到一个数组 _p 里。

  2. defineStore 函数: 这个函数负责定义 Store,并且在 Store 创建之后,会遍历 _p 数组,执行所有的插件函数。

    export function defineStore<
      Id extends string,
      S extends StateTree,
      G extends _GettersTree<S>,
      A extends _ActionsTree
    >(
      id: Id,
      options:
        | DefineStoreOptions<Id, S, G, A>
        | ((this: Pinia, pinia: Pinia) => DefineStoreOptions<Id, S, G, A>),
      pinia?: Pinia | null
    ): StoreDefinition<Id, S, G, A> {
    
      const isSetupStore = typeof options === 'function'
    
      function useStore(pinia?: Pinia | null, hot?: StoreGeneric): Store<Id, S, G, A> {
        const currentPinia =
          pinia ?? currentActivePinia ?? activePinia
        if (!currentPinia && typeof window !== 'undefined') {
          throw new Error(
            `[🍍]: getActivePinia was called with no active Pinia. Did you forget to install pinia as a plugin?n` +
              `See https://pinia.vuejs.org/core-concepts/plugins.html#using-a-plugin`
          )
        }
    
        pinia = currentPinia!
    
        if (__DEV__ && hot) {
          hot._hmrType = 'defineStore'
          hot._hmrId = id
        }
    
        const idToUse = isSetupStore ? id : options.id
    
        if (pinia._s.has(idToUse)) {
          return pinia._s.get(idToUse)! as Store<Id, S, G, A>
        }
    
        let optionsForStore: DefineStoreOptions<Id, S, G, A>
        if (isSetupStore) {
          optionsForStore = options(pinia)
        } else {
          optionsForStore = options
        }
    
        const { state, actions, getters } = optionsForStore
    
        let initialState: S | undefined
        if (!isSetupStore) {
          initialState = pinia.scope.run(() =>
            state ? state() : {}
          )!
        }
    
        // 将 store 作为一个可观察的对象,以便触发依赖项
        const store: StoreGeneric = pinia.scope.run(() =>
          reactive(
            extend(
              {
                $id: idToUse,
                $onAction: addSubscription.bind(null, actionSubscriptions),
                $onAction: addSubscription.bind(null, actionSubscriptions),
                $patch,
                $reset,
                $subscribe,
                $dispose,
                $state: initialState,
                $options: optionsForStore,
              },
              state ? defineSetupStore(pinia, idToUse, { state, actions, getters }, initialState, isOptionsAPI) : defineSetupStore(pinia, idToUse, optionsForStore as any, initialState, isOptionsAPI)
            )
          )
        )!
    
        // devtools custom properties
        if (__DEV__) {
          devtoolsPlugin &&
            devtoolsPlugin({
              app: pinia._a,
              store,
              id: idToUse,
              acceptHMR: hot ? acceptHMR : null,
              pinia,
            })
        }
    
        // apply all plugins before anything
        pinia._p.forEach((plugin) => {
          plugin({
            pinia,
            app: pinia._a,
            store,
            options: optionsForStore,
          })
        })
    
        // 将 store 添加到 pinia 的集合中
        pinia._s.add(idToUse)
    
        return store as Store<Id, S, G, A>
      }
    
      useStore.$id = id
      useStore.$reset = function $reset() {
        const store = this()
        store.$reset()
      }
      useStore.$patch = function $patch(state: ((storeState: S) => void) | Partial<S>) {
        const store = this()
        store.$patch(state)
      }
      useStore.$subscribe = function $subscribe(
        callback: SubscriptionCallback<S>,
        options?: SubscriptionOptions
      ) {
        const store = this()
        return store.$subscribe(callback, options)
      }
    
      return useStore
    }

    defineStore 函数内部,可以看到这段代码:

    pinia._p.forEach((plugin) => {
      plugin({
        pinia,
        app: pinia._a,
        store,
        options: optionsForStore,
      });
    });

    这行代码就是遍历 _p 数组,执行所有的插件函数。插件函数接收一个 PiniaPluginContext 对象,包含了 pinia 实例,Vue 应用实例,当前 Store 实例,以及定义 Store 时的选项对象。

实际案例:一个完整的插件示例

咱们来写一个完整的插件示例,实现一个简单的状态同步功能。这个插件会将 Store 的状态同步到另一个 Store。

import { PiniaPluginContext, Store } from 'pinia';

interface SyncPluginOptions {
  targetStore: Store;
  keys: string[]; // 需要同步的 state keys
}

function syncPlugin(options: SyncPluginOptions) {
  return function ({ store }: PiniaPluginContext) {
    const { targetStore, keys } = options;

    store.$subscribe((mutation, state) => {
      keys.forEach((key) => {
        if (state.hasOwnProperty(key)) {
          targetStore.$patch({ [key]: state[key] });
        }
      });
    });

    targetStore.$subscribe((mutation, state) => {
      keys.forEach((key) => {
        if (state.hasOwnProperty(key)) {
          store.$patch({ [key]: state[key] });
        }
      });
    });
  };
}

export default syncPlugin;

使用:

import { defineStore } from 'pinia';
import syncPlugin from './syncPlugin';

export const useStoreA = defineStore('storeA', {
  state: () => ({
    name: 'Alice',
    age: 20,
  }),
  actions: {
    setName(name: string) {
      this.name = name;
    },
  },
});

export const useStoreB = defineStore('storeB', {
  state: () => ({
    name: 'Bob',
    age: 30,
  }),
  actions: {
    setAge(age: number) {
      this.age = age;
    },
  },
  pinia: {
    plugins: [
      syncPlugin({
        targetStore: useStoreA(),
        keys: ['name', 'age'],
      }),
    ],
  },
});

const storeA = useStoreA();
const storeB = useStoreB();

// 现在,修改 storeA 的 name 或 age,storeB 也会同步更新
storeA.setName('Charlie');
console.log(storeA.name); // Charlie
console.log(storeB.name); // Charlie

// 修改 storeB 的 age,storeA 也会同步更新
storeB.setAge(40);
console.log(storeA.age); // 40
console.log(storeB.age); // 40

注意事项:

  • 插件的执行顺序: 插件的执行顺序和注册顺序有关。先注册的插件先执行。
  • 插件的副作用: 插件可能会产生副作用,比如修改全局变量,或者操作 DOM。要注意控制副作用,避免影响其他模块。
  • 插件的性能: 插件可能会影响性能,比如在每次状态更新的时候执行一些复杂的计算。要注意优化插件的性能,避免影响用户体验。
  • 避免循环依赖: 如果两个 Store 互相依赖,并且都使用了插件,可能会导致循环依赖的问题。要注意避免循环依赖,或者使用一些技巧来解决循环依赖的问题。
  • 类型安全: 在使用 TypeScript 的时候,要注意插件的类型安全。要确保插件函数接收的参数类型正确,并且返回值的类型也正确。

总结:

Pinia 的插件机制是一个强大的工具,可以让你扩展 Store 的功能,定制 Store 的行为,提高开发效率。只要掌握了插件的定义,注册,访问和修改 Store 实例的技巧,就可以玩转 Pinia 的插件机制,让你的 Store 飞起来。

希望今天的讲座对大家有所帮助。如果有什么问题,欢迎随时提问。下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注