剖析 Vuex 或 Pinia 中插件机制的源码实现,并设计一个自定义插件,实现跨标签页的状态同步。

大家好,我是老码农,今天咱们来聊聊 Vuex 和 Pinia 里的插件机制,顺便手撸一个跨标签页状态同步的插件。保证你听完之后,不仅能明白插件是怎么回事儿,还能自己动手做出点东西来。

开场白:插件,你的状态管理小助手

咱们先来想想,为什么要用插件?就像给你的 VS Code 装插件一样,Vuex 和 Pinia 里的插件也是用来增强功能的。比如,你想记录每次状态变化,或者想在状态改变时触发一些外部操作,这时候插件就派上用场了。它们就像状态管理的"小助手",帮你处理一些额外的、和核心逻辑不太相关的任务。

Vuex 插件:在状态变化间穿梭

Vuex 的插件机制相对简单,但足够强大。咱们先来看看 Vuex 插件的核心原理。

  • 定义: Vuex 插件就是一个函数,它接收 store 作为参数。

    const myPlugin = (store) => {
      // 在 store 初始化后被调用
      store.subscribe((mutation, state) => {
        // 每次 mutation 之后调用
        // mutation 的格式是 { type, payload }
        console.log(mutation.type, mutation.payload, state);
      })
    }
  • 使用: 在创建 Vuex.Store 实例时,通过 plugins 选项传入插件。

    const store = new Vuex.Store({
      state: {
        count: 0
      },
      mutations: {
        increment (state) {
          state.count++
        }
      },
      plugins: [myPlugin]
    })
  • 原理剖析: Vuex 在创建 Store 实例时,会遍历 plugins 数组,依次调用每个插件函数,并将 store 实例作为参数传递给插件。插件函数内部,就可以利用 store 提供的 API,比如 subscribe (监听 mutation) 和 subscribeAction (监听 action),来监听状态变化,执行自定义逻辑。

    subscribe 方法会监听每一个mutation的调用,接收两个参数:

    • mutation: 一个对象,描述了这次mutation的信息,包含 type (mutation 类型) 和 payload (mutation 携带的参数)。
    • state: 当前应用的state对象。

    subscribeAction 方法类似,但监听的是Action,接收两个参数:

    • action: 一个对象,描述了这次action的信息,包含 type (action 类型) 和 payload (action 携带的参数)。
    • state: 当前应用的state对象。

源码追踪:Vuex 插件的幕后英雄

为了更深入地理解 Vuex 插件的原理,咱们可以简单地追踪一下 Vuex 的源码(简化版):

class Store {
  constructor (options) {
    this._committing = false; // 控制是否允许直接修改状态
    this._actions = Object.create(null); // 存储 actions
    this._mutations = Object.create(null); // 存储 mutations
    this._subscribers = []; // 存储 mutation 的订阅者
    this._actionSubscribers = []; // 存储 action 的订阅者
    this.state = options.state || {};  // 初始化state

    // 注册 mutations 和 actions (省略)
    // ...

    // 应用插件
    options.plugins.forEach(plugin => plugin(this));
  }

  commit = (type, payload) => {
    // ...执行 mutation 的逻辑
    this._withCommit(() => {
      this._mutations[type].forEach(handler => handler(this.state, payload));
    })
    this._subscribers.forEach(sub => sub({ type, payload }, this.state)); // 通知订阅者
  }

  dispatch = (type, payload) => {
    // ...执行 action 的逻辑
    this._actionSubscribers.forEach(sub => sub({ type, payload }, this.state)); // 通知 action 订阅者
    return this._actions[type].reduce((promise, handler) => {
      return promise.then(() => handler(this, payload))
    }, Promise.resolve())
  }

  subscribe = (fn) => {
    this._subscribers.push(fn);
    return () => {
      this._subscribers = this._subscribers.filter(sub => sub !== fn);
    }
  }

  subscribeAction = (fn) => {
    this._actionSubscribers.push(fn);
    return () => {
      this._actionSubscribers = this._actionSubscribers.filter(sub => sub !== fn);
    }
  }

  _withCommit (fn) {
    const committing = this._committing
    this._committing = true
    fn()
    this._committing = committing
  }
}

这段代码简化了 Vuex 的实现,但展示了插件是如何被调用的,以及 subscribe 函数如何工作的。关键点在于:

  1. options.plugins.forEach(plugin => plugin(this));: 在 Store 构造函数中,会遍历 plugins 数组,调用每个插件,并将 this (即 Store 实例) 作为参数传递给插件。
  2. this._subscribers.forEach(sub => sub({ type, payload }, this.state));:在 commit 方法中,每次 mutation 执行后,会遍历 _subscribers 数组,调用每个订阅者函数,并将 mutation 信息和当前 state 作为参数传递给订阅者。

Pinia 插件:更灵活的扩展方式

Pinia 的插件机制比 Vuex 更灵活一些,它提供了更多的钩子函数,让你可以在更多的地方插入自定义逻辑。

  • 定义: Pinia 插件也是一个函数,它接收一个 context 对象作为参数。这个 context 对象包含以下属性:

    • pinia: Pinia 实例。
    • app: Vue 应用实例。
    • store: 当前的 store 实例。
    • options: 定义 store 时的选项对象。
    import { defineStore } from 'pinia'
    
    const myPiniaPlugin = (context) => {
      const { pinia, app, store, options } = context
    
      // 在 store 初始化后被调用
      console.log('Store created:', store.$id)
    
      store.$subscribe((mutation, state) => {
        // 每次状态改变后调用
        // mutation 的格式是 { storeId, type, payload }
        console.log(mutation.type, mutation.payload, state);
      }, { detached: false }) // detached 选项控制是否在组件卸载时自动取消订阅
    
      return {
        // 可以返回一些属性,这些属性会被添加到 store 实例上
        customProperty: 'Hello from the plugin!'
      }
    }
  • 使用: 在创建 Pinia 实例时,通过 use 方法注册插件。

    import { createPinia } from 'pinia'
    
    const pinia = createPinia()
    pinia.use(myPiniaPlugin)
    
    // 创建 Vue 应用
    import { createApp } from 'vue'
    import App from './App.vue'
    
    const app = createApp(App)
    app.use(pinia)
    app.mount('#app')
  • 原理剖析: Pinia 在创建 store 实例时,会遍历通过 pinia.use 注册的插件,依次调用每个插件函数,并将包含 piniaappstoreoptionscontext 对象作为参数传递给插件。插件函数内部,可以利用 context 对象提供的属性,比如 store$subscribe 方法,来监听状态变化,执行自定义逻辑。

    $subscribe 方法和Vuex的 subscribe 类似,监听每一个状态的变化,接收两个参数:

    • callback: 状态变化时执行的回调函数,接收两个参数:mutation(描述变化的对象,包含 storeId, type, payload)和 state(当前状态)。
    • options: 可选的配置项,例如 detached(控制是否在组件卸载时自动取消订阅)。

源码追踪:Pinia 插件的实现细节

同样,咱们也可以简单地追踪一下 Pinia 的源码(简化版):

import { effectScope } from 'vue'

class Pinia {
  constructor() {
    this._e = effectScope(true)
    this.state = this._e.run(() => ref({}))
    this._f = [] // plugins
  }

  use(plugin) {
    this._f.push(plugin)
    return this
  }

  install(app) {
    app.provide(piniaSymbol, this)
    app.config.globalProperties.$pinia = this
  }
}

function defineStore(id, options) {
  const pinia = this.pinia
  const scope = effectScope()
  let store

  function $patch(stateOrFn) {
    // ... patching logic
  }

  function $reset() {
    // ... reset logic
  }

  const partialStore = {
    $id: id,
    $patch,
    $reset,
    $subscribe(callback, options) {
      // ... subscribe logic
    }
  }

  store = scope.run(() =>
    reactive(
      extend(
        {
          $pinia: pinia,
          $dispose: scope.stop,
        },
        partialStore,
        options.state ? options.state() : {},
      )
    )
  )

  pinia._f.forEach(plugin => {
    Object.assign(store, plugin({ pinia, app: this._app, store, options }))
  })

  return store
}

这段代码同样进行了简化,但核心逻辑如下:

  1. pinia._f.push(plugin):在 Pinia.use 方法中,会将插件添加到 _f 数组中。
  2. pinia._f.forEach(plugin => { Object.assign(store, plugin({ pinia, app: this._app, store, options })) }):在 defineStore 函数中,创建 store 实例后,会遍历 _f 数组,调用每个插件,并将包含 piniaappstoreoptions 的对象作为参数传递给插件。插件的返回值会被合并到 store 实例上。

跨标签页状态同步插件:让状态飞起来

现在,咱们来设计一个跨标签页状态同步的插件。这个插件的核心思想是利用 localStorageBroadcast Channel API 在多个标签页之间共享状态。这里咱们使用 Broadcast Channel API,因为它更现代,性能更好。

Broadcast Channel API 简介

Broadcast Channel API 允许同源的浏览器上下文(比如不同的标签页、iframe)之间进行简单的单向通信。

  • 创建频道: const channel = new BroadcastChannel('my-channel');
  • 发送消息: channel.postMessage({ type: 'UPDATE_STATE', payload: { count: 1 } });
  • 接收消息: channel.onmessage = (event) => { console.log(event.data); };

插件设计思路

  1. 监听状态变化: 使用 Vuex 的 subscribe 或 Pinia 的 $subscribe 监听状态变化。
  2. 广播状态: 在状态变化时,通过 Broadcast Channel API 将新的状态广播到其他标签页。
  3. 接收状态: 在插件初始化时,创建一个 Broadcast Channel 实例,监听来自其他标签页的状态更新消息,并更新当前 store 的状态。

Vuex 插件实现

const createSyncStatePlugin = (channelName = 'my-state-channel') => {
  return (store) => {
    const channel = new BroadcastChannel(channelName);

    // 监听来自其他标签页的状态更新
    channel.onmessage = (event) => {
      if (event.data.type === 'SYNC_STATE') {
        store.replaceState(event.data.payload); // 使用 replaceState 替换整个 state
      }
    };

    // 监听状态变化,并广播到其他标签页
    store.subscribe((mutation, state) => {
      if (mutation.type !== 'SET_STATE_FROM_BROADCAST') { // 避免循环广播
        channel.postMessage({ type: 'SYNC_STATE', payload: state });
      }
    });

    // 在初始时,发送当前状态给其他标签页,确保新打开的标签页能同步到最新状态
    setTimeout(() => {
      channel.postMessage({ type: 'SYNC_STATE', payload: store.state });
    }, 1000); // 延迟1秒,确保所有标签页都已加载
  };
};

// 使用插件
const store = new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment (state) {
      state.count++
    },
    SET_STATE_FROM_BROADCAST (state, newState) {
      // 用于从广播消息中设置 state,避免再次触发广播
      Object.assign(state, newState);
    }
  },
  plugins: [createSyncStatePlugin()]
})

Pinia 插件实现

import { defineStore } from 'pinia'

const createSyncStatePiniaPlugin = (channelName = 'my-state-channel') => {
  return ({ store }) => {
    const channel = new BroadcastChannel(channelName);

    channel.onmessage = (event) => {
      if (event.data.type === 'SYNC_STATE') {
        store.$patch(event.data.payload);
      }
    };

    store.$subscribe((mutation, state) => {
      if (mutation.type !== 'setStateFromBroadcast') {
        channel.postMessage({ type: 'SYNC_STATE', payload: state });
      }
    }, { detached: false });

    // 在初始时,发送当前状态给其他标签页
    setTimeout(() => {
      channel.postMessage({ type: 'SYNC_STATE', payload: store.$state });
    }, 1000);
  };
};

// 使用插件
const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++;
    },
    setStateFromBroadcast(newState) {
      Object.assign(this, newState);
    }
  }
})

import { createPinia } from 'pinia'
const pinia = createPinia()
pinia.use(createSyncStatePiniaPlugin())

import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)
app.use(pinia)
app.mount('#app')

代码解释

  • createSyncStatePlugin(channelName) / createSyncStatePiniaPlugin(channelName) 这是一个工厂函数,用于创建插件实例。可以传入 channelName 来指定广播频道的名称。
  • BroadcastChannel(channelName) 创建一个 BroadcastChannel 实例,用于跨标签页通信。
  • channel.onmessage = (event) => { ... } 监听来自其他标签页的消息。当接收到 SYNC_STATE 类型的消息时,使用 store.replaceState(event.data.payload) (Vuex) 或 store.$patch(event.data.payload) (Pinia) 更新当前 store 的状态。
  • store.subscribe((mutation, state) => { ... }) / store.$subscribe((mutation, state) => { ... }, { detached: false }) 监听状态变化。当状态发生变化时,通过 channel.postMessage({ type: 'SYNC_STATE', payload: state }) 将新的状态广播到其他标签页。
  • mutation.type !== 'SET_STATE_FROM_BROADCAST' / mutation.type !== 'setStateFromBroadcast' 这是一个防止循环广播的关键点。当通过广播消息更新 state 时,会触发 mutation/action,如果不加判断,就会再次广播,导致无限循环。因此,我们定义了一个特殊的 mutation/action 类型 SET_STATE_FROM_BROADCAST / setStateFromBroadcast,用于从广播消息中设置 state,并在广播时排除这种类型的 mutation/action。
  • setTimeout(() => { ... }, 1000) 在插件初始化时,延迟 1 秒发送当前状态给其他标签页。这是为了确保所有标签页都已加载,并且 BroadcastChannel 实例已经创建。

注意事项

  • 同源策略: Broadcast Channel API 只能在同源的标签页之间通信。
  • 数据大小限制: Broadcast Channel API 传输的数据大小有限制,不适合传输大型数据。
  • 性能: 频繁的状态同步可能会影响性能,需要根据实际情况进行优化。
  • replaceState vs $patch: Vuex 使用 replaceState 替换整个 state,而 Pinia 使用 $patch 进行部分更新。$patch 通常更高效,因为它只更新需要更新的部分。
  • 初始化状态: 插件在初始化时会发送当前状态给其他标签页,这确保了新打开的标签页能够同步到最新的状态。

总结

今天,咱们一起学习了 Vuex 和 Pinia 的插件机制,并手撸了一个跨标签页状态同步的插件。希望通过这次分享,你不仅能理解插件的原理,还能灵活运用插件来扩展你的状态管理方案。记住,插件是状态管理的“小助手”,用好了能让你事半功倍!

好了,今天的分享就到这里,下次再见!

发表回复

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