如何构建健壮的LWC Pub-Sub工具模块 - 含完整代码与测试最佳实践
为什么需要一个更好的 Pub-Sub?
生产级 pubsub.js 模块实现
代码解析与设计亮点
如何在 LWC 项目中集成和使用
1. 发布者组件 (publisherComponent)
2. 订阅者组件 (subscriberComponent)
使用说明
单元测试最佳实践 (Jest)
1. 模拟 pubsub 模块
2. 测试发布者组件 (publisherComponent.test.js)
3. 测试订阅者组件 (subscriberComponent.test.js)
测试关键点
结论
在 LWC (Lightning Web Components) 开发中,组件间通信是一个常见需求。对于非父子关系的组件,发布-订阅(Pub-Sub)模式是一种有效的解耦方案。然而,简单的 Pub-Sub 实现往往容易引入内存泄漏和性能问题。这篇文章将带你一步步构建一个生产级别的 LWC Pub-Sub 工具模块,包含健壮的错误处理、高效的订阅者管理以及清晰的清理机制,并探讨集成与测试的最佳实践。
为什么需要一个更好的 Pub-Sub?
标准的 DOM 事件在 LWC 中对于跨组件通信(尤其是不相关的组件)存在限制。虽然 Lightning Message Service (LMS) 是 Salesforce 官方推荐的解决方案,但在某些场景下,你可能需要一个更轻量级、完全在客户端运行的 Pub-Sub 机制,或者在不支持 LMS 的环境(如 LWC 开源项目)中使用。
简单的 Pub-Sub 实现通常存在以下痛点:
- 内存泄漏:如果组件在销毁时(
disconnectedCallback
)忘记取消订阅,事件监听器会一直保留,导致组件实例无法被垃圾回收,造成内存泄漏。 - 性能问题:如果使用数组存储订阅者,每次发布事件都需要遍历整个数组查找匹配的订阅者,当订阅者数量庞大时,性能会下降(O(n) 复杂度)。
- 错误处理不足:一个订阅者的回调函数出错可能导致整个发布链中断,影响其他订阅者。
- 清理复杂:手动管理每个订阅的取消过程容易出错且繁琐。
我们的目标是创建一个 pubsub.js
模块来解决这些问题。
生产级 pubsub.js
模块实现
下面是一个健壮的 Pub-Sub 模块实现。我们使用 Map
来存储事件和订阅者,以优化查找性能,并提供了一个方便的 unsubscribeAll
方法来简化组件销毁时的清理工作。
// pubsub.js /** * 事件名称到订阅者映射 * 结构: Map<eventName: string, Map<subscriberContext: object, Set<callback: function>>> * eventName: 事件的唯一名称 * subscriberContext: 订阅组件的实例 (this) * callback: 事件触发时执行的回调函数 */ const events = new Map(); /** * 订阅一个事件。 * @param {string} eventName - 要订阅的事件名称。 * @param {function} callback - 事件触发时执行的回调函数。 * @param {object} subscriberContext - 订阅组件的实例 (通常是 this)。用于后续精确取消订阅和 unsubscribeAll。 */ const subscribe = (eventName, callback, subscriberContext) => { if (!eventName || typeof eventName !== 'string') { console.error('PubSub Error: eventName 必须是一个非空字符串。'); return; } if (typeof callback !== 'function') { console.error(`PubSub Error: 订阅事件 "${eventName}" 时提供的 callback 不是一个函数。`); return; } if (!subscriberContext || typeof subscriberContext !== 'object') { console.error(`PubSub Error: 订阅事件 "${eventName}" 时必须提供 subscriberContext (组件实例 this)。`); return; } // 获取或创建事件名称对应的 Map let subscribers = events.get(eventName); if (!subscribers) { subscribers = new Map(); events.set(eventName, subscribers); } // 获取或创建该订阅者上下文对应的 Set let callbacks = subscribers.get(subscriberContext); if (!callbacks) { callbacks = new Set(); subscribers.set(subscriberContext, callbacks); } // 添加回调函数 callbacks.add(callback); // console.log(`PubSub: ${subscriberContext.constructor.name} 订阅了事件 "${eventName}"`); }; /** * 取消订阅一个事件。 * @param {string} eventName - 要取消订阅的事件名称。 * @param {function} callback - 要取消的回调函数。 * @param {object} subscriberContext - 订阅组件的实例 (通常是 this)。 */ const unsubscribe = (eventName, callback, subscriberContext) => { if (!eventName || typeof eventName !== 'string') { console.error('PubSub Error: eventName 必须是一个非空字符串。'); return; } if (typeof callback !== 'function') { console.error(`PubSub Error: 取消订阅事件 "${eventName}" 时提供的 callback 不是一个函数。`); return; } if (!subscriberContext || typeof subscriberContext !== 'object') { console.error(`PubSub Error: 取消订阅事件 "${eventName}" 时必须提供 subscriberContext (组件实例 this)。`); return; } const subscribers = events.get(eventName); if (!subscribers) { // console.warn(`PubSub Warn: 尝试取消订阅不存在的事件 "${eventName}"`); return; // 事件本身就不存在 } const callbacks = subscribers.get(subscriberContext); if (!callbacks) { // console.warn(`PubSub Warn: 尝试为未订阅事件 "${eventName}" 的上下文取消订阅`); return; // 该上下文没有订阅这个事件 } // 从 Set 中移除回调 const success = callbacks.delete(callback); // 如果这个 context 的回调 Set 为空了,可以从 subscribers Map 中移除这个 context if (callbacks.size === 0) { subscribers.delete(subscriberContext); } // 如果这个 eventName 的 subscribers Map 也为空了,可以从全局 events Map 中移除这个 eventName if (subscribers.size === 0) { events.delete(eventName); } // if (success) { // console.log(`PubSub: ${subscriberContext.constructor.name} 取消订阅了事件 "${eventName}" 的一个回调`); // } }; /** * 取消指定上下文的所有订阅。 * 通常在 LWC 的 disconnectedCallback 中调用,传入 this。 * @param {object} subscriberContext - 要取消其所有订阅的组件实例 (this)。 */ const unsubscribeAll = (subscriberContext) => { if (!subscriberContext || typeof subscriberContext !== 'object') { console.error('PubSub Error: 调用 unsubscribeAll 时必须提供 subscriberContext (组件实例 this)。'); return; } let unsubscribeCount = 0; // 遍历所有事件 events.forEach((subscribers, eventName) => { // 检查当前事件是否有该上下文的订阅 if (subscribers.has(subscriberContext)) { unsubscribeCount += subscribers.get(subscriberContext).size; // 记录取消了多少个回调 subscribers.delete(subscriberContext); // 直接移除该上下文的所有回调 // 如果移除后该事件没有其他订阅者了,从全局 Map 中删除该事件 if (subscribers.size === 0) { events.delete(eventName); } } }); // if (unsubscribeCount > 0) { // console.log(`PubSub: ${subscriberContext.constructor.name} 通过 unsubscribeAll 取消了 ${unsubscribeCount} 个订阅。`); // } }; /** * 发布一个事件。 * @param {string} eventName - 要发布的事件名称。 * @param {*} payload - 传递给订阅者回调函数的数据。 */ const publish = (eventName, payload) => { if (!eventName || typeof eventName !== 'string') { console.error('PubSub Error: eventName 必须是一个非空字符串。'); return; } const subscribers = events.get(eventName); if (!subscribers || subscribers.size === 0) { // console.log(`PubSub Info: 发布事件 "${eventName}",但没有订阅者。`); return; // 没有订阅者,直接返回 } // console.log(`PubSub: 发布事件 "${eventName}" 给 ${subscribers.size} 个上下文。Payload:`, payload); // 遍历所有订阅了该事件的上下文 subscribers.forEach((callbacks, context) => { // 遍历该上下文的所有回调函数 callbacks.forEach(callback => { try { // 使用 call 或 apply 可以指定回调函数执行时的 this 上下文,但这里回调函数是订阅时提供的, // 通常已经绑定了正确的上下文 (或者就是箭头函数),直接调用即可。 // 如果需要强制上下文,可以用 callback.call(context, payload); callback(payload); } catch (error) { console.error(`PubSub Error: 执行事件 "${eventName}" 的一个订阅者回调时出错。Context: ${context.constructor.name}, Error:`, error); // 一个回调出错不应影响其他回调的执行 } }); }); }; export { subscribe, unsubscribe, unsubscribeAll, publish };
代码解析与设计亮点
数据结构 (
Map<string, Map<object, Set<function>>>
)- 外层
Map
的键是事件名称 (eventName
),值是另一个Map
。 - 内层
Map
的键是订阅组件的实例 (subscriberContext
,即this
),值是一个Set
。 Set
存储该组件实例为该事件注册的所有回调函数 (callback
)。- 为什么这样设计?
- 性能:使用
Map
进行事件名称查找和订阅者上下文查找,平均时间复杂度为 O(1),远优于数组的 O(n)。 - 精确取消:
unsubscribe
和unsubscribeAll
可以精确地定位到特定组件实例的订阅,避免误删其他组件的订阅。 - 防止重复:
Set
自动处理重复的回调函数(对于同一个组件实例和同一个事件)。 - 简化清理:
unsubscribeAll(this)
可以轻松移除一个组件的所有订阅,极大降低内存泄漏风险。
- 性能:使用
- 外层
subscribe(eventName, callback, subscriberContext)
- 添加了必要的参数校验。
- 惰性创建
Map
和Set
结构,只有在需要时才创建。 - 关键点:必须传入
subscriberContext
(this
),这是实现unsubscribeAll
的基础。
unsubscribe(eventName, callback, subscriberContext)
- 同样需要所有参数才能精确找到并删除回调。
- 在回调被删除后,会检查
Set
和内层Map
是否变空,如果变空则清理,避免留下空的集合结构,保持events
的整洁。
unsubscribeAll(subscriberContext)
- 这是防止内存泄漏的核心方法。
- 它遍历所有事件,查找并删除与指定
subscriberContext
相关的所有订阅记录。 - 必须在 LWC 的
disconnectedCallback
中调用unsubscribeAll(this)
。
publish(eventName, payload)
- 查找事件的所有订阅者上下文。
- 遍历每个上下文的所有回调函数。
- 健壮性:使用
try...catch
包裹每个回调函数的执行。这样,即使一个回调函数抛出异常,也不会阻止其他回调函数的执行。 - 错误会被打印到控制台,方便调试。
如何在 LWC 项目中集成和使用
假设你已经将上面的代码保存为 force-app/main/default/lwc/pubsub/pubsub.js
。
1. 发布者组件 (publisherComponent
)
// publisherComponent.js import { LightningElement } from 'lwc'; import { publish } from 'c/pubsub'; // 导入 publish 函数 export default class PublisherComponent extends LightningElement { messageToSend = ''; handleInputChange(event) { this.messageToSend = event.target.value; } publishMessage() { const payload = { message: this.messageToSend, timestamp: new Date() }; console.log('Publisher: 发布消息', payload); publish('myCustomEvent', payload); // 发布事件和数据 } }
<!-- publisherComponent.html --> <template> <lightning-card title="发布者组件" icon-name="utility:outbound_call"> <div class="slds-m-around_medium"> <lightning-input label="输入要发送的消息" value={messageToSend} onchange={handleInputChange} ></lightning-input> <lightning-button label="发布消息" onclick={publishMessage} variant="brand" class="slds-m-top_small" ></lightning-button> </div> </lightning-card> </template>
2. 订阅者组件 (subscriberComponent
)
// subscriberComponent.js import { LightningElement, track } from 'lwc'; import { subscribe, unsubscribeAll } from 'c/pubsub'; // 导入 subscribe 和 unsubscribeAll export default class SubscriberComponent extends LightningElement { @track receivedMessage = '尚未收到消息'; @track receivedTimestamp = ''; // 存储回调函数的引用,以便取消订阅 // 使用箭头函数确保 this 指向组件实例 handleEvent = (payload) => { console.log('Subscriber: 收到消息', payload); this.receivedMessage = payload.message; this.receivedTimestamp = payload.timestamp.toLocaleTimeString(); }; connectedCallback() { // 组件连接到 DOM 时订阅事件 console.log('Subscriber: 连接并订阅 myCustomEvent'); subscribe('myCustomEvent', this.handleEvent, this); // 传入回调函数和 this } disconnectedCallback() { // 组件从 DOM 中移除时取消所有订阅 console.log('Subscriber: 断开连接并取消所有订阅'); unsubscribeAll(this); // !!! 关键:传入 this 清理该组件的所有订阅 } }
<!-- subscriberComponent.html --> <template> <lightning-card title="订阅者组件" icon-name="utility:inbound_call"> <div class="slds-m-around_medium"> <p>收到的消息: {receivedMessage}</p> <p>接收时间: {receivedTimestamp}</p> </div> </lightning-card> </template>
使用说明
- 导入:在需要发布或订阅的组件中,从
c/pubsub
导入所需的函数 (publish
,subscribe
,unsubscribeAll
)。 - 订阅 (
subscribe
):通常在connectedCallback
中调用subscribe
。务必传入事件名称、一个回调函数、以及组件实例this
。- 重要:如果你的回调函数需要访问组件的属性或方法(几乎总是这样),请确保它能正确绑定
this
。最简单的方法是使用箭头函数定义回调,或者使用this.handleEvent.bind(this)
并保存返回的绑定函数引用。
- 重要:如果你的回调函数需要访问组件的属性或方法(几乎总是这样),请确保它能正确绑定
- 发布 (
publish
):在需要发布事件的地方调用publish
,传入事件名称和可选的数据payload
。 - 清理 (
unsubscribeAll
):必须在disconnectedCallback
中调用unsubscribeAll(this)
。这是防止内存泄漏的关键步骤。它会移除该组件实例注册的所有事件监听器。
单元测试最佳实践 (Jest)
测试使用 Pub-Sub 的 LWC 组件时,通常需要模拟 (mock) pubsub
模块。
1. 模拟 pubsub
模块
在你的 Jest 测试文件所在的 __tests__
目录下,创建一个 __mocks__
子目录,并在其中创建 c/pubsub.js
文件来提供模拟实现。
// force-app/main/default/lwc/myComponent/__tests__/__mocks__/c/pubsub.js // 使用 jest.fn() 创建模拟函数,以便我们可以跟踪它们的调用 export const subscribe = jest.fn(); export const unsubscribe = jest.fn(); export const unsubscribeAll = jest.fn(); export const publish = jest.fn(); // (可选) 提供一个重置所有模拟函数状态的方法,方便在每个测试用例前清理 export const mockReset = () => { subscribe.mockClear(); unsubscribe.mockClear(); unsubscribeAll.mockClear(); publish.mockClear(); }; // (可选) 模拟发布事件,以便测试订阅者逻辑 // 存储模拟的订阅 const mockSubscriptions = new Map(); // 重新实现模拟 subscribe subscribe.mockImplementation((eventName, callback, context) => { if (!mockSubscriptions.has(eventName)) { mockSubscriptions.set(eventName, []); } mockSubscriptions.get(eventName).push({ callback, context }); }); // 模拟 publish publish.mockImplementation((eventName, payload) => { if (mockSubscriptions.has(eventName)) { mockSubscriptions.get(eventName).forEach(sub => { try { sub.callback(payload); } catch (e) { console.error('Mock PubSub Error:', e); } }); } }); // 模拟 unsubscribeAll unsubscribeAll.mockImplementation((context) => { mockSubscriptions.forEach((subs, eventName) => { const remainingSubs = subs.filter(sub => sub.context !== context); if (remainingSubs.length === 0) { mockSubscriptions.delete(eventName); } else { mockSubscriptions.set(eventName, remainingSubs); } }); });
2. 测试发布者组件 (publisherComponent.test.js
)
// publisherComponent.test.js import { createElement } from 'lwc'; import PublisherComponent from 'c/publisherComponent'; import { publish, mockReset } from 'c/pubsub'; // 导入模拟的 publish 和 reset // 告诉 Jest 使用我们定义的模拟版本 jest.mock('c/pubsub'); describe('c-publisher-component', () => { beforeEach(() => { // 在每个测试前重置模拟函数状态 mockReset(); }); afterEach(() => { while (document.body.firstChild) { document.body.removeChild(document.body.firstChild); } }); it('点击按钮时应调用 publish 函数', () => { const element = createElement('c-publisher-component', { is: PublisherComponent }); document.body.appendChild(element); // 模拟用户输入 const inputEl = element.shadowRoot.querySelector('lightning-input'); inputEl.value = '测试消息'; inputEl.dispatchEvent(new CustomEvent('change')); // 模拟用户点击按钮 const buttonEl = element.shadowRoot.querySelector('lightning-button'); buttonEl.click(); // 断言 publish 被调用了一次 expect(publish).toHaveBeenCalledTimes(1); // 断言 publish 被调用时传入了正确的参数 expect(publish).toHaveBeenCalledWith('myCustomEvent', { message: '测试消息', timestamp: expect.any(Date) // 时间戳是动态的,检查类型即可 }); }); });
3. 测试订阅者组件 (subscriberComponent.test.js
)
// subscriberComponent.test.js import { createElement } from 'lwc'; import SubscriberComponent from 'c/subscriberComponent'; import { subscribe, unsubscribeAll, publish as mockPublish } from 'c/pubsub'; // 导入模拟函数 jest.mock('c/pubsub'); // 使用模拟模块 describe('c-subscriber-component', () => { afterEach(() => { while (document.body.firstChild) { document.body.removeChild(document.body.firstChild); } // 清理模拟订阅状态 (如果你的模拟实现了清理) // mockClearSubscriptions(); // 假设有一个这样的函数 }); it('连接时应调用 subscribe', () => { const element = createElement('c-subscriber-component', { is: SubscriberComponent }); document.body.appendChild(element); // 断言 subscribe 被调用 expect(subscribe).toHaveBeenCalledTimes(1); // 断言 subscribe 使用了正确的事件名、一个函数和组件实例 expect(subscribe).toHaveBeenCalledWith('myCustomEvent', expect.any(Function), element); }); it('断开连接时应调用 unsubscribeAll', () => { const element = createElement('c-subscriber-component', { is: SubscriberComponent }); document.body.appendChild(element); // 将组件从 DOM 中移除,触发 disconnectedCallback document.body.removeChild(element); // 断言 unsubscribeAll 被调用 expect(unsubscribeAll).toHaveBeenCalledTimes(1); expect(unsubscribeAll).toHaveBeenCalledWith(element); // 传入了正确的上下文 }); it('收到发布事件时应更新界面', async () => { const element = createElement('c-subscriber-component', { is: SubscriberComponent }); document.body.appendChild(element); // 初始状态检查 const messageP = element.shadowRoot.querySelector('p:nth-of-type(1)'); expect(messageP.textContent).toBe('收到的消息: 尚未收到消息'); // 模拟发布事件 (使用我们模拟实现中的 mockPublish) const testPayload = { message: '来自测试的消息', timestamp: new Date() }; mockPublish('myCustomEvent', testPayload); // 等待 DOM 更新 await Promise.resolve(); // 检查 UI 是否已更新 expect(messageP.textContent).toBe(`收到的消息: ${testPayload.message}`); const timeP = element.shadowRoot.querySelector('p:nth-of-type(2)'); expect(timeP.textContent).toContain(testPayload.timestamp.toLocaleTimeString()); }); });
测试关键点
- 模拟模块:核心是使用
jest.mock('c/pubsub')
和提供一个模拟实现文件。 - 跟踪调用:使用
jest.fn()
创建模拟函数,允许你用expect(...).toHaveBeenCalled...
来断言函数是否被调用以及如何调用。 - 测试发布者:断言
publish
是否在正确的时机被调用,并且带有正确的参数。 - 测试订阅者:
- 断言
subscribe
是否在connectedCallback
中被正确调用。 - 断言
unsubscribeAll
是否在disconnectedCallback
中被正确调用。 - 模拟事件触发:在测试文件中直接调用模拟的
publish
函数(可能需要重命名导入以避免冲突,如publish as mockPublish
),然后断言组件的状态或 DOM 是否按预期更新。
- 断言
结论
通过使用 Map
优化性能、强制传入 subscriberContext
并提供 unsubscribeAll
方法,我们构建了一个更健壮、更易于维护、不易产生内存泄漏的 LWC Pub-Sub 模块。记住,关键在于 connectedCallback
中订阅,并在 disconnectedCallback
中调用 unsubscribeAll(this)
进行清理。结合 Jest 进行单元测试,可以确保你的组件通信逻辑按预期工作,并且在组件生命周期变化时能够正确地管理资源。
虽然 Lightning Message Service (LMS) 是 Salesforce 平台上的首选方案,但这个自定义 Pub-Sub 模块在你需要更细粒度控制、轻量级实现或在 LMS 不可用的环境(如 LWC OSS)下,是一个非常实用和可靠的选择。