这应该是史上最全面最优雅窗口通信封装了
本文由 小茗同学 发表于 2025-05-20 浏览(84)
最后修改 2025-05-20 标签:

前言

众所周知,跨域窗口通信(例如父页面和iframe子页面、当前页和window.oepn打开页等)一般都是通过window.postMessagewindow.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'

其它的都能接受,唯独不能传递函数这个非常不方便,要知道我们经常会把一些类似onOkonCancelonUpdate等作为参数传递。

那么如何解决呢?首先想到的是强行序列化,比如直接传递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,
  };
}