大家好,我是老码农,今天咱们来聊聊 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
函数如何工作的。关键点在于:
options.plugins.forEach(plugin => plugin(this));
: 在Store
构造函数中,会遍历plugins
数组,调用每个插件,并将this
(即Store
实例) 作为参数传递给插件。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
注册的插件,依次调用每个插件函数,并将包含pinia
、app
、store
和options
的context
对象作为参数传递给插件。插件函数内部,可以利用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
}
这段代码同样进行了简化,但核心逻辑如下:
pinia._f.push(plugin)
:在Pinia.use
方法中,会将插件添加到_f
数组中。pinia._f.forEach(plugin => { Object.assign(store, plugin({ pinia, app: this._app, store, options })) })
:在defineStore
函数中,创建 store 实例后,会遍历_f
数组,调用每个插件,并将包含pinia
、app
、store
和options
的对象作为参数传递给插件。插件的返回值会被合并到 store 实例上。
跨标签页状态同步插件:让状态飞起来
现在,咱们来设计一个跨标签页状态同步的插件。这个插件的核心思想是利用 localStorage
或 Broadcast 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); };
插件设计思路
- 监听状态变化: 使用 Vuex 的
subscribe
或 Pinia 的$subscribe
监听状态变化。 - 广播状态: 在状态变化时,通过
Broadcast Channel API
将新的状态广播到其他标签页。 - 接收状态: 在插件初始化时,创建一个
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 的插件机制,并手撸了一个跨标签页状态同步的插件。希望通过这次分享,你不仅能理解插件的原理,还能灵活运用插件来扩展你的状态管理方案。记住,插件是状态管理的“小助手”,用好了能让你事半功倍!
好了,今天的分享就到这里,下次再见!