前言
众所周知,跨域窗口通信(例如父页面和iframe子页面、当前页和window.oepn打开页等)一般都是通过window.postMessage
和window.onmessage
实现,一个用来发送消息,一个用来接收消息,既不支持回调,又不支持异步,更有一堆的使用限制(例如只能传递普通数据,无法序列化的数据不能被发送),直接使用非常不方便。
Chrome插件开发中的contentScripts和普通scripts也是基于
postMessage
通信。
窗口通信封装
基于以上痛点,笔者对消息通信进行了整体封装,封装使用非常简单,并且具有以下特点:
- 支持回调;
- 支持异步;
- 支持抛出异常;
- 支持将函数作为参数传递;
- 无需手动设置targetWindow,90%场景自动捕获;
onMessage
同时支持精确匹配和模糊匹配;
行了,不吹牛逼了,直接上菜,只有一个APIinitWindowMessage(scene)
:
// scene用来标识场景,只有scene相同的2个窗口发送消息才能接收到
const { postMessage, onMessage } = initWindowMessage('your_scene');
异步回调
以最典型的iframe父子页面通信为例(其它场景类似):
父页面:
const { postMessage, onMessage } = initWindowMessage('test_iframe');
// 假设父页面有一个名为test的异步方法
const test = async (a, b) => {
await sleep(1000);
return a + b;
};
onMessage('test', test)
子iframe页面:
const { postMessage, onMessage } = initWindowMessage('test_iframe');
const result = await postMessage('test', 2, 3);
console.log(result); // 延迟1秒输出5
异常回调
继续完善上面的例子,父页面:
const { postMessage, onMessage } = initWindowMessage('test_iframe');
// 假设父页面有一个名为test的异步方法
const test = async (a, b) => {
await sleep(1000);
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('参数不是数字')
}
return a + b;
};
onMessage('test', test)
子iframe页面:
const { postMessage, onMessage } = initWindowMessage('test_iframe');
try {
const result = await postMessage('test', 'aaa', 3);
console.log(result);
} catch (e) {
console.error(e); // 延迟1秒抛出异常:参数不是数字
}
函数作为参数传递
默认情况下,postMessage
传递的参数必须是可以序列化的,任何不能序列化的数据(比如某个DOM
对象、函数、循环引用对象、甚至是moment()
返回的对象)都会报如下异常:
Uncaught (in promise) DataCloneError: Failed to execute 'postMessage' on 'Window'
其它的都能接受,唯独不能传递函数这个非常不方便,要知道我们经常会把一些类似onOk
、onCancel
、onUpdate
等作为参数传递。
那么如何解决呢?首先想到的是强行序列化,比如直接传递function test(a, b) { return a + b; }
这样的字符串,接收到后再new Function
实例化,这种方式使用限制非常多,一个函数可能依赖其它方法、依赖外部npm包,甚至可能依赖本地某个无法序列化的数据,所以此路不通。
考虑到函数的主要作用还是执行一段逻辑再返回结果,既然我们无法直接传递函数,那么我们可以变相的传递函数执行结果,函数执行依然在原来的窗口,postMessage
前先把函数在本地缓存起来、并用一个随机的functionId
标识,执行的时候再通过这个functionId
把缓存的函数取出来!
假设有一个onUpdate
方法需要传递:
postMessage('someMethod', onUpdate);
底层传递过去的onUpdate
实际上一个字符串,类似这样_local_function_xxxx
,但用户使用我们的onMessage接收到的仍然是一个正常的函数,只不过被我们包装了一层:
const onUpdate = (...params) => {
// 再次调用封装好的postMessage发送消息到原窗口获取并执行本地缓存的函数
return postMessage('getLocalFunction', '_local_function_xxxx', ...params)
}
getLocalFunction
大致逻辑如下:
onMessage('getLocalFunction', (functionId, ...params) => {
if (!localFunctions[functionId]) throw new Error('未找到本地缓存方法:' + functionId);
return localFunctions[functionId](...params);
});
至此,我们完美地实现了任意函数参数传递,并且同样支持异步回调,上述转换过程用户不需要做任何处理也没有任何感知!!!给用户的感觉就是函数被直接传递过去了!为了进一步使用方便,可以给对象类型加上递归判断处理。
使用示例,父页面:
const { postMessage, onMessage } = initWindowMessage('test_iframe');
const showDialog = async ({ title, onOk }) => {
Modal.confirm({
title,
onOk,
});
};
onMessage('showDialog', showDialog)
子iframe页面:
const { postMessage, onMessage } = initWindowMessage('test_iframe');
postMessage('showDialog', {
title: '我是标题',
onOk: async () => {
await sleep(1000);
console.log('点击了确定按钮');
}
});
iframe页面跳转兼容
一个典型场景是父页面保持不变,子iframe会经常跳来跳去。没关系,每次初始化时底层会自动发送一条auto-connect
消息,以此自动捕获targetWindow,不管iframe页面怎么刷新,父页面无需重新初始化,会自动捕获targetWindow,具体实现逻辑可直接查看代码。
支持模糊匹配
除此之外还支持*
模糊匹配,一般建议最多只监听一次:
onMessage('*', (eventName, ...params) => {
console.log(eventName, ...params);
});
和普通onMessage不同的地方:
- 第一个参数变成了
eventName
; - 必须要有返回值才会触发回调;
代码
时间关系来不及完整解读,其它部分可直接阅读源码,完整代码一共只有100多行:
/**
* 初始化窗口通信
* @param {string} scene 场景,必传,互相通信的2个窗口必须保证 scene 相同
* @param {Window} targetWindow 目标窗口对象(父窗口或子窗口),互相通信时允许有一方不传,自动从 event.source 获取
* @returns {{postMessage: function, onMessage: function}} 返回postMessage和onMessage方法
*/
export function initWindowMessage(scene: string, targetWindow?: Window) {
// iframe模式下自动设置目标窗口
if (!targetWindow && window !== window.parent) {
targetWindow = window.parent;
}
// window.open模式下自动设置目标窗口
if (!targetWindow && window.opener) {
targetWindow = window.opener;
}
// 回调函数集合
const callbacks = new Map();
// 监听器集合
const eventListeners = new Map();
// 生成唯一ID
const getRandomId = () => Math.random().toString(36).substring(2);
// postMessage不支持传递function,所以将其存储在本地,通过functionId做一层映射
const localFunctions = {};
const fnPrefix = '_local_function_';
// 存储函数
function storageFunction(fn) {
const functionId = `${fnPrefix}${getRandomId()}`;
localFunctions[functionId] = fn;
return functionId;
}
// 简单循环处理function特殊参数,暂不考虑递归
const handleFn = (payload, handle) => {
return (
payload?.map((item) => {
if (typeof item === 'object') {
for (const key in item) {
item[key] = handle(item[key]);
}
}
return handle(item);
}) || []
);
};
/**
* 发送消息并等待响应
* @param {string} eventName 事件名称
* @param {*} [payload] 负载数据
* @returns {Promise} 返回一个Promise,resolve接收回调数据
*/
function postMessage(eventName, ...payload) {
if (!targetWindow) {
console.warn('TargetWindow is null.');
return;
}
return new Promise((resolve, reject) => {
const callbackId = getRandomId();
callbacks.set(callbackId, { resolve, reject });
targetWindow.postMessage(
{
scene,
type: 'event',
eventName,
payload,
// payload: handleFn(payload, (item) => (typeof item === 'function' ? storageFunction(item) : item)),
callbackId,
},
'*'
);
});
}
// 监听消息
window.addEventListener('message', async (e) => {
// 过滤无效消息
if (!e.data || e.data.scene !== scene) return;
// console.log('收到来自这里的消息:', location.href, e.target.location.href, e.data);
// 始终根据 source 自动更新 targetWindow
// 父子iframe场景下,如果iframe经常动态销毁和重建,自动更新可以减少一些逻辑处理
targetWindow = e.source as Window;
const { type, eventName, payload, callbackId } = e.data;
if (type === 'event') {
const payloads = handleFn(payload, (item) => {
if (typeof item === 'string' && item.startsWith(fnPrefix)) {
// 由于我们是通过prefix来判断是否为本地函数,这里必须修改前缀避免再次被转换
const key = `getFn_${item}`;
return (...params) => postMessage('getLocalFunction', key, ...params);
}
return item;
});
// 处理事件
const listeners = eventListeners.get(eventName) || [];
// 支持模糊匹配
const wildcardListeners = eventListeners.get('*') || [];
try {
// 执行所有监听器并收集结果
const results = await Promise.all(listeners.map((listener) => Promise.resolve(listener(...payloads))));
// 注意模糊匹配时参数顺序不一样,第一个参数是 eventName
const wildcardResults = await Promise.all(wildcardListeners.map((listener) => Promise.resolve(listener(eventName, ...payloads))));
// 如果有回调ID,将最后一个监听器的结果作为回调返回
if (callbackId) {
// 优先返回精确监听的值,其次再返回模糊匹配的值
const result = results[results.length - 1] || wildcardResults[wildcardResults.length - 1];
// 如果没有精确监听,且模糊匹配也没有返回值,不触发回调
// 精确监听和普通监听唯一的不同是,必须要有返回值才会触发回调
if (!listeners.length && !result) {
return;
}
targetWindow.postMessage(
{
scene,
type: 'callback',
callbackId,
payload: result,
},
'*'
);
}
} catch (error) {
console.error(error);
// 捕获错误并返回给调用方
if (callbackId) {
targetWindow.postMessage(
{
scene,
type: 'callback',
callbackId,
error,
},
'*'
);
}
}
} else if (type === 'callback' && callbackId) {
// 处理回调
const callback = callbacks.get(callbackId);
if (callback) {
if (e.data.error) {
callback.reject(e.data.error);
} else {
callback.resolve(e.data.payload);
}
// 回调使用完删除
callbacks.delete(callbackId);
}
}
});
/**
* 监听消息
* @param {string} eventName 事件名称
* @param {function} listener 监听函数(支持异步):(...params) => callbackValue
*/
function onMessage(eventName, listener) {
if (!eventListeners.has(eventName)) {
eventListeners.set(eventName, []);
}
eventListeners.get(eventName).push(listener);
}
onMessage('getLocalFunction', (functionId, ...params) => {
functionId = functionId?.replace('getFn_', '') || '';
if (!localFunctions[functionId]) throw new Error(`未找到本地缓存方法:${functionId}`);
return localFunctions[functionId](...params);
});
// 如果2个窗口都没设置 targetWindow,自动连接探活
postMessage('auto-connect');
return {
postMessage,
onMessage,
};
}