WEBKT

如何构建健壮的LWC Pub-Sub工具模块 - 含完整代码与测试最佳实践

57 0 0 0

为什么需要一个更好的 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 实现通常存在以下痛点:

  1. 内存泄漏:如果组件在销毁时(disconnectedCallback)忘记取消订阅,事件监听器会一直保留,导致组件实例无法被垃圾回收,造成内存泄漏。
  2. 性能问题:如果使用数组存储订阅者,每次发布事件都需要遍历整个数组查找匹配的订阅者,当订阅者数量庞大时,性能会下降(O(n) 复杂度)。
  3. 错误处理不足:一个订阅者的回调函数出错可能导致整个发布链中断,影响其他订阅者。
  4. 清理复杂:手动管理每个订阅的取消过程容易出错且繁琐。

我们的目标是创建一个 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 };

代码解析与设计亮点

  1. 数据结构 (Map<string, Map<object, Set<function>>>)

    • 外层 Map 的键是事件名称 (eventName),值是另一个 Map
    • 内层 Map 的键是订阅组件的实例 (subscriberContext,即 this),值是一个 Set
    • Set 存储该组件实例为该事件注册的所有回调函数 (callback)。
    • 为什么这样设计?
      • 性能:使用 Map 进行事件名称查找和订阅者上下文查找,平均时间复杂度为 O(1),远优于数组的 O(n)。
      • 精确取消unsubscribeunsubscribeAll 可以精确地定位到特定组件实例的订阅,避免误删其他组件的订阅。
      • 防止重复Set 自动处理重复的回调函数(对于同一个组件实例和同一个事件)。
      • 简化清理unsubscribeAll(this) 可以轻松移除一个组件的所有订阅,极大降低内存泄漏风险。
  2. subscribe(eventName, callback, subscriberContext)

    • 添加了必要的参数校验。
    • 惰性创建 MapSet 结构,只有在需要时才创建。
    • 关键点:必须传入 subscriberContext (this),这是实现 unsubscribeAll 的基础。
  3. unsubscribe(eventName, callback, subscriberContext)

    • 同样需要所有参数才能精确找到并删除回调。
    • 在回调被删除后,会检查 Set 和内层 Map 是否变空,如果变空则清理,避免留下空的集合结构,保持 events 的整洁。
  4. unsubscribeAll(subscriberContext)

    • 这是防止内存泄漏的核心方法
    • 它遍历所有事件,查找并删除与指定 subscriberContext 相关的所有订阅记录。
    • 必须在 LWC 的 disconnectedCallback 中调用 unsubscribeAll(this)
  5. 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>

使用说明

  1. 导入:在需要发布或订阅的组件中,从 c/pubsub 导入所需的函数 (publish, subscribe, unsubscribeAll)。
  2. 订阅 (subscribe):通常在 connectedCallback 中调用 subscribe。务必传入事件名称、一个回调函数、以及组件实例 this
    • 重要:如果你的回调函数需要访问组件的属性或方法(几乎总是这样),请确保它能正确绑定 this。最简单的方法是使用箭头函数定义回调,或者使用 this.handleEvent.bind(this) 并保存返回的绑定函数引用。
  3. 发布 (publish):在需要发布事件的地方调用 publish,传入事件名称和可选的数据 payload
  4. 清理 (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)下,是一个非常实用和可靠的选择。

LWC老司机 LWCPub-SubSalesforce

评论点评

打赏赞助
sponsor

感谢您的支持让我们更好的前行

分享

QRcode

https://www.webkt.com/article/8963