扯皮
之前的重构文章中有提到过我接手的项目是类似掘金 AI 助手: 这屎山代码终于让我给重构了😠,一起来看看这次重构的几个 case - 掘金
但关于 postMessage 相关实践的重构并没有提,是因为针对于这部分内容也没有摸索出比较好的实践,而且那时的通信业务还不算特别复杂🤔
直到最近对 postMessage 通信的需求越来越频繁,所以在此简单记录一篇项目中的实践
正文
项目背景
首先为什么会用到 postMessage 是因为我们的助手并不仅仅在一个平台应用使用,任何平台应用经过配置后都可以在视图层接入,目前项目中就接入了大概有四个平台应用,至于接入的方案不用多想就是 iframe,助手作为一个单独的项目进行维护
那么像类似这样主平台应用控制助手回答的需求就需要进行通信实现:
简单描述一下实现逻辑:
助手与平台应用约定事件 => 助手监听事件 => 平台应用交互发送事件消息 => 接手助手接收到该消息并处理业务逻辑
很简单对吧,无非就是发布订阅的逻辑,但在早期助手代码中的画风是这样的:
再补充一个助手主动发送事件的代码:
以上两小段内容均摘抄自前几个月前的助手代码,原汁原味😇
可能仅从代码片段来看大伙觉得没什么,但是混杂在各种业务代码中就知道有多少槽点了:
- 只知道监听事件不知道卸载事件 ✋
- React 中的闭包问题精准踩坑 ✋
- 事件名直接硬编码在业务层代码中 ✋
- 参数传递直接 any 零帧起手 ✋
但好在一开始针对于事件名的约定还是有模有样的,要是这也是乱起的那真的...😑
重构(第一版)
所以前几个月的时候我就先针对于这几个问题进行重构,我们一个一个来看:
卸载事件无脑在 useEffect 中 return,但你的事件处理函数就需要提出来了:
当然解决这个问题也可以用到前段比较热门的 Abort Controller 方式: Abort Controller 被严重低估了, 任何中止逻辑都应该使用它 - 掘金
但是闭包问题我们还没解,而且针对于事件处理函数在业务中是十分复杂的,所以到最后还是需要将 handler 函数提出去
不过这里的闭包问题还不是普通使用 ref 就能够解决的,因为在整个组件生命周期内我们需要保证这里 handler 函数都是唯一的,不然组件 render 后 useEffect 中监听的 handler 就成了闭包🤔
而我们知道 useCallback 能够缓存对应函数,但是无可避免需要添加依赖项,依赖项更改就又无法保证函数的唯一性了,所以我们需要手动封装一个满足既能解决闭包又能保证函数唯一性的工具函数:
如果你对 ahooks 比较熟悉,一定知道这个工具函数其实就是 useMemoizedFn,而在这个监听事件的场景中还是推荐使用 useEventListener,闭包问题和卸载都不需要你操心,关注业务即可:
解决前两个问题来看后面两个:类型问题其实太好说了,一般业务侧添加类型的话难度并不大,纯粹看你有没有这个想法。而针对于 postMessage 和 onmessage 的场景无外乎就是规范通信事件和传参类型
针对于发送事件来说我们可以统一使用联合类型这样约定:
再封装一个通用的 sendMessage 方法,这样针对于业务就完全屏蔽掉了 postMessage,而针对于 postMessage 也方便统一进行扩展:
而我们对业务代码暴露的事件会再封装一层,以大写命名区分业务侧的其他函数,并补充 jsdoc 注释:
这样的好处一是在我们新增事件编写方法时就有了完整的类型提示:
而在业务侧使用时能够看到 jsdoc 的注释说明该事件的具体业务,传参也一样有了提示:
针对于监听事件我们只需要一样补充一个联合类型,然后在业务中直接导入使用:
这就是最开始的第一版重构,很简单的一层封装就把一开始的几个问题解决了
重构(第二版)
至于为什么要进行第二版重构是因为通信的事件越来越多,在开发和调试上都暴露出了一些问题
首先来说说开发上的体验,不知道是否有注意到我们上面重构的那一版每次添加一个发送消息的方法需要两个步骤:
- 在 SendMessageTypes 联合类型里添加新的事件和参数类型
- 封装一个该事件对应的函数供业务侧调用
问题出在第二步中,即便我们已经统一封装了 sendMessage 方法并有类型提示,但封装具体事件的函数参数类型还需要重复写一遍,有点鸡肋:
思考一下为什么要封装这个玩意儿:是因为方便业务侧使用,并且含有 jsdoc 注释来表明该事件含义
那纯靠类型能实现吗?只靠联合类型还真不行,因为注释没地方写啊😶
或许联合类型并不是一个好的解法,我们可以将其拆开,使用枚举类型定义事件,再搭配一个参数类型:
之后我们再重构原来的 sendMessage 函数传参类型:
业务侧统一使用函数,不再需要针对于单个类型编写一个函数了,只不过每次使用需要单独导入事件枚举,这样才能推断出参数类型
但是比之前要好很多:业务侧通过枚举类型来避免硬编码事件名以防出错,事件的 jsdoc 注释也比较清晰
除此之外 postMessage 的通信还缺少日志,特别是在跟其他应用联调过程中:到底是我没发消息还是你没给我发啊?🤬
这时候我们使用统一的 sendMessage 就起到作用了,只需要在这里打上日志即可,当然为了与其他日志区分我通常会打上显眼的前缀 tag 来便于调试:
需要注意的是监听事件的日志一定要注意位置,需要放到项目初始化时就开启监听,避免遗漏
这里的样式其实就是用到我之前封装的一个 log 方法:
趁着手头业务不忙,简单记一次封装 console.log 的奇葩经历😶 - 掘金
当然为了省事也可以直接自定义代码片段:
虽然用起来方便,但是觉得每次都是这一坨样式代码看着很不舒服,所以才有了封装 log 的那篇文章,后来还是习惯了丑就丑吧🤣
{
"Print to console": {
"prefix": "log",
"body": ["console.log(\n '%ccheck:', \n 'color: #c41d7f;background: #fff0f6;padding: 2px 4px;border-radius: 4px', \n $1\n);",],
"description": "Log output to console"
}
}
业务场景
谈完重构再来聊聊在项目中遇到的两个业务场景,比较有代表性且解决方式都很相似,所以放到一块来说🤪
事件监听拦截渲染
前面我有提到我们的助手是需要通过 iframe 接到不同的平台应用中的,不同应用都会有定制化需求。那作为助手侧来讲我肯定是需要知道当前接入的是什么应用,这个信息的传递是很有必要的
最简单的方法其实平台应用把信息拼接到 URL 参数上,比如这样:
<iframe src="http://localhost:3000?applicationType=xxx"></iframe>
之后在我们的助手中拿到 applicationType 做全局存储即可。但由于历史原因我们并没有采取这样的做法,而且在助手中有很多 router.push 路由跳转的逻辑,每次 push 都会刷掉 URL 上的参数
所以我们依然是通过 postMessage 通信来拿到这个信息,但需要注意的是这个消息是必须在项目初始化时拿到的,所以它的逻辑应该是这样:
助手初始化 => 助手向应用发送消息请求获取信息 => 应用收到该消息并向助手发送信息 => 助手收到信息后全局存储,渲染视图
也就是说如果没有拿到应用信息我们就默认无法使用助手
我们可以单独封装一个守卫组件来包裹整个项目:
我们的项目使用的是 umi,实际上 umi 提供了初始化的地方:运行时配置 - getinitialstate,通过文档可以看到它返回的是一个 Promise 而且会阻塞视图渲染,正好符合我们的需求:
但是从我们上面写的代码来看拿到结果是在事件回调中,位置有些尴尬
这时候其实可以自己创建一个 Promise 来解,借助 Promise.withResolvers 即可:
当然这个 API 还算比较新,存在兼容性问题:
可以自己手动兼容一下,其实本质上就是帮我们创建一个 promise 把它的 resolve、reject 提取出来罢了,算是一种语法糖:
promise 化 hook:useMessageRequest
上面其实是一个很好的例子,因为它涵盖了一个较为完整的通信:一来一回
在早期我们的项目中其实并没有复杂的通信,就像一开始的这个业务,实际上只需平台应用要发送事件将划词内容传给助手,助手监听该事件进行问答即可:
但随着后面业务增多,大部分情况都需要助手先主动向平台应用发送消息,平台再发送消息给助手所需要的内容
这种场景和发送请求获取结果十分相似,唯独业务代码写起来很恶心,因为发送事件和监听事件是分开的,这会导致连贯的业务逻辑被强制分离,不易维护🤐
所以我们希望进行一层封装:屏蔽掉发送事件和监听事件逻辑,让业务侧使用起来就跟调用接口一样
那么怎么封装呢🤔?一开始我是想封装成一个工具类,但是我们把这个通信逻辑想象成一个接口,我们怎么对一个接口进行封装才能让用户使用起来更方便?
这时我想到了 ahooks 中的 useRequest,实际上可以仿照 useRequest 的使用风格来封装一个 useMessageRequest,当然功能上肯定没有 useRequest 齐全🤪
首先是参数,这里我设置了五个配置项:
前三个不用说了,后两个简单解释一下,用过 useRequest 都知道 manual 会决定通信是否在初始(挂载)时自动发出,而 defaultParams 就是初始时发送的传参,所以在自动发送的场景下这两个参数需要搭配使用,不过通信逻辑大部分情况下都是手动调用😇
紧接着给配置项增加默认值,并添加该 hook 中的一些状态,老三套没什么好解释的:
接下来就要实现核心的 runAsync 逻辑了,也是我们发送消息的地方:
可以看到这里多了很多 ref,比较关键的 traceId,像网络请求天然的 请求 => 响应 都是一一对应的,但是我们这里通信可没有这个特性,为了能够保证发出去的消息和接收到的消息能够对应上,必然是需要一个能够保证唯一性的字段
至于这个字段名随意约定,逻辑都是一样的:发消息者初始化一个 id,回应者收到消息将该 id 跟内容一并返回
除此之外的思路依旧是使用 Promise.withResolvers 来创建一个 promise 并将其返回,这样业务侧使用起来就和封装的接口方法一样,最后将其返回供业务侧使用
下面补充监听和重置逻辑:
思路就是根据配置的事件名以及 traceId 来保证接收到的消息准确,然后调用之前创建 promise 时保存的 resolve 方法即可
当然这只是一个丐版实现,像这里监听的事件也可以进行优化,比如调用时监听,结束后就卸载掉等,具体根据实际业务进行调整
现在我们在业务中就可以像接口一样使用了:
再补充一个主应用调试一下效果,看着还可以,最主要的是使用过程中有完整的类型提示🤩:
RPC 初探
到目前为止我们只是站在助手的角度解决问题,而且也仅仅是针对于【助手主动向发送消息平台应用获取数据】这一个业务场景做了封装。那针对于平台应用主动发送的消息呢?还是得写一大堆监听逻辑,因为是被动接收就无法再进行封装了😑
但是我们思考一下:无论是助手还是平台,我们发送消息的目的要么就是获取数据,要么就是控制接收方的一些行为
那有没有一种办法直接屏蔽掉消息通信,助手能够直接远程调用平台上的方法,相反平台也能远程调用助手上的方法呢?这其实就已经演化成了 RPC,比如 antfu 大佬的这个项目就是做这个的:antfu/birpc: Message-based two-way remote procedure call.
从给的示例来看本质是通过 websocket 进行双向通信,websocket 因为也是类似发布订阅的逻辑,所以这里看样子是直接接管了发布和监听逻辑,只需要配置提供远程调用的方法即可
但因为还没有在我们项目中进行实践,所以这块先挖个坑等以后回填🤪
我们可以简单来看看源码逻辑,createBirpc 执行后可以看到返回的是一个代理对象:
很显然做了数据劫持,当我们 rpc.getXXX 调用时会触发消息发送的逻辑,这里只展示部分核心源码:
可以看到跟我们之前的实现思路一样,发送消息必然是需要创建一个新的 Promise 并返回,但注意这里我们是在调用对方的远程方法,源码则是将我们调用的方法信息通过 post 发送给对方
再来看监听的逻辑,这里还是比较有意思的:
我们举个例子捋一下哈:现有 A、B 两个应用接入 RPC,A 现在调用 B 上的 say 方法,此时会发送 type(msg.t)为 TYPE_REQUEST 的消息,在 B 上通过监听发现消息类型为 TYPE_REQUEST,这时会通过消息里的 method 名称调用一开始 createBirpc 我们传入 function 集合上的方法,也就形成了远程调用
而该方法的返回结果会再进行一次 post 给到 A 应用,此时发送 type(msg.t)为 TYPE_RESPONSE 的消息,而在 A 应用会监听到该消息,因为类型为 TYPE_RESPONSE 所以走 else 分支,而最开始 A 调用 B 上 say 方法返回的 Promise,在这里才做真正的 resolve
那么反过来 B 调用 A 也是同样的逻辑,小画一手流程:
看完之后总感觉能够通过 postMessage 封装这一套 RPC 逻辑🤔
但是这套实现方案比较鸡肋的是远程调用的方法都是在 createBirpc 调用时传入的,实际代码业务比较散乱的话不太容易组织,具体还是等以后有机会实践的结果吧
End
最后补充一下 useMessageRequest 的源码,不过这个方法依赖我们一开始重构的类型定义,所以看个逻辑就行😄,具体还是根据实际业务进行调整
import { useEffect, useRef, useState } from "react";
import { useMemoizedFn } from "ahooks";
import {
sendMessage,
SendMessageEnum,
SendMessageTypes,
ReceiveMessageTypes,
ReceiveMessageEnum,
} from "../utils/postMessage";
export function useMessageRequest<T extends SendMessageEnum, K extends ReceiveMessageEnum>(options: {
sendEvent: T;
reveiveEvent: K;
defaultParams?: SendMessageTypes[T];
timeout?: number;
manual?: boolean;
}) {
const { sendEvent, reveiveEvent, defaultParams, timeout = 3_000, manual = false } = options;
const [data, setData] = useState<ReceiveMessageTypes[K]["data"]>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const traceIdRef = useRef("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resolveRef = useRef<((value: ReceiveMessageTypes[K]["data"]) => void) | null>(null);
const rejectRef = useRef<((reason?: any) => void) | null>(null);
const runAsync = useMemoizedFn((data: SendMessageTypes[T], origin?: string) => {
if (typeof data?.traceId !== "string") {
throw new Error("traceId is required and must be a string");
}
traceIdRef.current = data.traceId;
setError(undefined);
setLoading(true);
const { promise, resolve, reject } = Promise.withResolvers<ReceiveMessageTypes[K]["data"]>();
resolveRef.current = resolve;
rejectRef.current = reject;
sendMessage({
type: sendEvent,
data,
origin,
});
timerRef.current = setTimeout(() => {
const error = new Error("TIME_OUT");
rejectRef.current?.(error);
setLoading(false);
setError(error);
}, timeout);
return promise;
});
const reset = useMemoizedFn(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
traceIdRef.current = "";
resolveRef.current = null;
rejectRef.current = null;
setData(undefined);
setLoading(false);
setError(undefined);
});
useEffect(() => {
if (!manual && defaultParams) {
runAsync(defaultParams);
}
const handleMessage = (event: MessageEvent) => {
const { type, data } = event.data;
if (type === reveiveEvent && data.traceId === traceIdRef.current) {
clearTimeout(timerRef.current!);
setLoading(false);
setData(data as ReceiveMessageTypes[K]["data"]);
resolveRef.current?.(data as ReceiveMessageTypes[K]["data"]);
}
};
window.addEventListener("message", handleMessage);
return () => {
reset();
window.removeEventListener("message", handleMessage);
};
}, []);
return {
data,
loading,
error,
runAsync,
};
}