结论先说
跨线程、跨 iframe、跨标签页传消息,浏览器只给了一个原语:`postMessage`。它没类型、没退订、没请求响应,于是每个项目都要重写一遍同样的胶水。Evil Martians 把这层胶水抽成 `typed-channel`:核心 **271 字节**(压缩后),带 postMessage 传输层 **341 字节**,零依赖;需要「发出去等回话」时再引 `createTypedRpcChannel`,约 1 kB。
裸用 postMessage 的五个坑
以最常见的 Worker 场景为例,页面发任务、Worker 回结果,写法大概是 `switch (event.data.type)` 分流。小脚本没问题,项目一大就出事:
- **边界上没有类型**:`event.data` 是 any,消息名是字符串。把 `generated:color` 拼成 `generated:colors` 照样编译通过,运行时静默失败。
- **没有一处声明契约**:Worker 收什么、发什么,只存在于两边 switch 的并集里,加一条消息得靠人去两个文件里对齐。
- **清理要自己管**:`addEventListener` 不返回退订函数,得自己留着 handler 引用去 `removeEventListener`,组件反复挂载卸载就漏监听。
- **每种边界复制一遍**:iframe 还要加 origin 校验和目标 origin,BroadcastChannel 再来一遍,页内事件总线换成 `CustomEvent` 再来一遍。
- **请求响应得手搓**:id 自增、pending Map、超时 reject——而超时之后对面其实还在算,永远停不下来。
它怎么拆这个问题
作者把职责切成两块:**transport 只负责把消息送过某条边界,channel 只知道有哪些消息、各带什么载荷**。类型写一次,两边的 `emit` 和 `on` 都被 TypeScript 检查。
- 用两个类型映射声明双向消息,Worker 侧把顺序反过来用同一份定义。
- `on` 直接返回退订函数,不再需要保存 handler。
- 换传输层就换一种边界:Worker、iframe、BroadcastChannel、MessagePort、页内 EventTarget 共用同一套调用。
- 传输层是「两个函数」的接口(`on` / `emit`),所以 Figma 插件那种私有 API 也能十几行接上;甚至可以把多个传输层塞进数组,一次 emit 同时打到当前标签页和其它标签页。
0.11 补上的那一半:可取消的请求
单向消息之外,真正难写的是「发出去要答案」。新加的 `createTypedRpcChannel` 用 `request` / `handle` 成对出现,并且**请求在类型上被写成一个函数**:参数是发出去的载荷,返回值是拿到的答案(类型映射里写普通值,不写 Promise,处理器可以直接返回或返回 Promise)。
最值得抄的是取消的设计——它**没有**自己做 timeout 选项,而是接受 `AbortSignal`:
- 超时就是 `AbortSignal.timeout(5000)`;组件卸载就 `controller.abort()`;两者取先到,用 `AbortSignal.any([...])` 合并。
- 中止不只是让本地 Promise 失败,它会向对面发一条 abort,让**处理器拿到的 signal 一起中止**,活儿真的停下来。
- 一条硬规则随之而来:对面没有处理器的请求永远不会自己结束,只能被 signal 收掉,所以发请求时最好都带超时。
- BroadcastChannel 上请求可能打到多个标签页,第一个回答胜出——「有没有哪个标签页缓存了这个」就是一次 request。
- `unlisten()` 会把挂起的请求全部 reject,并给对面发 abort。
取舍要看清
作者明说了两点:`requests()` 运行时零成本,**只做类型推导,不做载荷校验**,结构化克隆照原样传;消息通道和 RPC 通道故意分成两个工厂,不用回话的项目就只付 271 字节。
要试的话,`npm install typed-channel`,先从一个消息映射和 `createTypedChannel` 起步,确实需要答案再加 RPC。真正有价值的地方不是省了那几百字节,而是把「谁跟谁之间有哪些消息」从散落的 switch 收敛成一份可被编译器检查的契约。

内容与图片版权归原作者所有 · 原文: https://evilmartians.com/chronicles/how-to-make-postmessage-type-safe-in-under-350-bytes