PDF.js 从 demo 深入浅出 6: MessageHandler 跨线程通讯
2026年10月27日 · StreamPDF Team

前言
第 2 篇的总结列了四个「重角色」, 本篇处理第二个: MessageHandler 的消息机制.
前面五篇 demo 里, MessageHandler 出现过四次, 每次调用形态不同:
| 调用 | 所在篇 | 形态 | 载荷 |
|---|---|---|---|
messageHandler.send("Ready", null) | 第 2 篇 | 纯 action | 宣告握手尾声 |
worker.messageHandler.sendWithPromise("GetDocRequest", docParams) | 第 2 篇 | action + callbackId | 跨线程参数包 |
messageHandler.sendWithPromise("GetPage", { pageIndex: 0 }) | 第 3 篇 | action + callbackId | 页码请求 |
messageHandler.sendWithStream("GetOperatorList", { pageId: 0, ... }) | 第 5 篇 | action + streamId | 流式 operatorList |
前两篇形态都是「单次请求, 单次答复」, 第四篇是「生产端分块推, 消费端按背压拉」的流式通道. 本篇从第 2 篇的创建处入手, 顺着这四次调用, 把 MessageHandler 内部的信封协议和三种形态的完整链路走一遍.
如果你想同时看调用方视角 (api.js 文章里的用法) 与协议实现视角 (本文), 请对照 MessageHandler 源码解读. 本篇不重复源码解读里的 API 列表, 只聚焦 demo 中实际发生的执行路径.
创建: 握手拿到 workerId 之后
第 2 篇 第 261 行 worker 回了 GetDocRequest 的回执 workerId, 主线程立刻建一个 MessageHandler:
const messageHandler = new MessageHandler(docId, workerId, worker.port);
// docId = "d1"
// workerId = worker 端自分配的 id (demo 里通常也是 "worker" 或类似字符串)
// worker.port = Worker 对象本身 (Worker 也是一个 MessagePort-like)三个参数分别填进实例字段:
this.sourceName = "d1"; // 我是谁
this.targetName = workerId; // 我跟谁说话
this.comObj = worker.port; // 发消息走哪
this.callbackId = 1; // 下次请求的 callbackId
this.streamId = 1; // 下次流式通道的 streamId
this.callbackCapabilities = {}; // callbackId → Promise.withResolvers()
this.actionHandler = {}; // actionName → handler (WorkerTransport 后续注册)
this.streamControllers = {}; // streamId → ReadableStream controller (我发起的流)
this.streamSinks = {}; // streamId → sink (对端发起的流, 我是生产端)
comObj.addEventListener("message", this.#onMessage.bind(this), { signal: this.#messageAC.signal });构造函数只做两件事: 建四个空 Map, 把 #onMessage 绑进 worker.port 的 message 事件. 此时 actionHandler 还是空的——所有业务 handler 由后续 WorkerTransport.setupMessageHandler() 通过 .on(...) 注册.
设计要点: MessageHandler 本身完全不知道业务语义. 它只认"信封"的三个关键字段 (
action,callbackId,streamId), 不认GetDocRequest/GetPage这些 action 名. 业务语义全在WorkerTransport的.on(...)注册表里. 这让 MessageHandler 可以被主线程↔worker、主线程↔主线程 (LoopbackPort)、甚至未来主线程↔iframe 等任意两端复用.
信封协议: 三形态互斥分发
所有穿过 comObj.postMessage 的消息都套同一个信封. 信封之上再按是否带 stream / callback / callbackId / streamId 四个关键字段分形态:
#onMessage({ data }) {
if (data.targetName !== this.sourceName) return;
if (data.stream) { /* 流控制或流数据: PULL/ENQUEUE/CLOSE/... */ }
if (data.callback) { /* 纯回包: 对端给我答复, 带 callbackId */ }
// 剩下的才是 action
const action = this.actionHandler[data.action];
if (data.callbackId) { /* action + callbackId: 执行后必须回 callback */ }
else if (data.streamId) { /* action + streamId: 执行后建 sink, 不回 action 形态答复 */ }
else { /* 纯 action, fire-and-forget */ }
}严格顺序: stream → callback → action, 互斥, 一次消息只走一条路. 两个字段同时出现 (比如既带 stream 又带 callback) 说明信封被错误组装, #onMessage 会漏掉其中一个.
sourceName / targetName 是软路由: 匹配不到就 return, 不抛错. demo 只有一个文档 (d1 ↔ worker), 所有消息都匹配; 如果同时跑两份文档, 第二份 d2 的 MessageHandler 会自动忽略 d1 的消息, 无需额外隔离逻辑.
形态一: 纯 action —— Ready 宣告
第 2 篇 握手最后一步:
messageHandler.send("Ready", null);send 是最简单的一条通道:
send(actionName, data, transfers) {
this.comObj.postMessage({
sourceName: this.sourceName, // "d1"
targetName: this.targetName, // "worker"
action: actionName, // "Ready"
data, // null
}, transfers); // undefined
}信封里没有 callbackId, 没有 streamId, 没有 stream 字段. 对端 worker 的 MessageHandler 收到后, #onMessage 走到最后一条 else, 直接调 this.actionHandler["Ready"]. worker 端注册了这个 handler, 内部做"把自己标记为已就绪"之类的事. 做完就完了——不回包. 这就是 fire-and-forget.
demo 里这条消息很容易被忽略, 因为它不影响任何 promise 或返回值. 但 WorkerTransport 在 setupMessageHandler 里注册了大量带 callbackId 的 action handler (GetDoc / GetPage / SaveDocument 等), 所有请求-应答通道都在那里. send 只用于"通知型"消息: Ready、configure(verbosity)、以及后续的 Terminate.
形态二: action + callbackId —— GetPage 取第一页
demo 最典型的请求-应答发生在第 3 篇的 pdf.getPage(1):
const promise = messageHandler
.sendWithPromise("GetPage", { pageIndex: 0 });sendWithPromise 的发送侧
sendWithPromise(actionName, data, transfers) {
const callbackId = this.callbackId++; // 1, 每次请求递增
const capability = Promise.withResolvers();
this.callbackCapabilities[callbackId] = capability;
try {
comObj.postMessage({
sourceName: "d1", targetName: "worker",
action: "GetPage", callbackId: 1,
data: { pageIndex: 0 },
}, transfers);
} catch (ex) {
capability.reject(ex);
}
return capability.promise;
}与 send 唯一区别: 带了 callbackId, 并把 capability resolver 记在 this.callbackCapabilities[1] 表里. postMessage 返回后这条 promise 还在 pending —— 必须等 worker 端回一条 callback 形态的消息来 resolve.
信封里没有 streamId, 所以对端 worker 的 #onMessage 不会走流式分支. 它走到 action 分支, 发现信封带 callbackId, 就按"执行后回包"处理.
对端处理: action + callbackId 的语义
worker 端 MessageHandler 的 #onMessage 拿到信封:
const action = this.actionHandler["GetPage"]; // worker 端注册的 handler
if (data.callbackId) {
Promise.try(action, data.data).then(
result => comObj.postMessage({
sourceName: "worker", targetName: "d1",
callback: CallbackKind.DATA,
callbackId: 1,
data: result, // 解析好的 pageInfo
}),
reason => comObj.postMessage({
sourceName: "worker", targetName: "d1",
callback: CallbackKind.ERROR,
callbackId: 1,
reason: wrapReason(reason),
})
);
return;
}信封的 action 字段在回包里不再出现——回包是纯 callback 形态, 只带 callback (DATA=1 或 ERROR=2), callbackId, data 或 reason.
回到主线程: callback 分支 resolve 悬挂的 promise
主线程 #onMessage 收到这条回包, 走 callback 分支:
if (data.callback) {
const callbackId = data.callbackId; // 1
const capability = this.callbackCapabilities[callbackId]; // 就是之前 sendWithPromise 存的那个
delete this.callbackCapabilities[callbackId];
if (data.callback === CallbackKind.DATA) {
capability.resolve(data.data); // promise resolve 出 pageInfo
} else if (data.callback === CallbackKind.ERROR) {
capability.reject(wrapReason(data.reason));
}
}Promise 回主线程事件循环, demo 里 await promise 的后续代码拿到 pageInfo, 构造 PDFPageProxy 返回.
sendWithPromise 的同步 try-catch 兜什么
try {
comObj.postMessage({ /* ... */ }, transfers);
} catch (ex) {
capability.reject(ex);
}只兜 postMessage 自身抛错: 例如 transfer 里传了已被 transfer 所有权的 ArrayBuffer, 或 data 里有不可序列化的对象. postMessage 返回后, worker 端 handler 执行期间的任何异常都已经在另一个事件循环, 必须走 callback ERROR 通道才能回来——try-catch 管不到.
这就是为什么 this.callbackCapabilities[callbackId] = capability 必须先存再 postMessage: 如果 postMessage 抛错, catch 块里能直接找到 capability reject 掉, 不留悬挂.
形态三: action + streamId —— GetOperatorList 流式渲染
demo 里最复杂的通道出现在第 5 篇 page.render(...) 触发的 _pumpOperatorList:
const readableStream = messageHandler.sendWithStream(
"GetOperatorList",
{ pageId: 0, pageIndex: 0, intent: 2, cacheKey: "2__", ... },
undefined, // queueingStrategy
transfer
);
const reader = readableStream.getReader();
reader.read().then(({ value, done }) => { /* 追加进 operatorList */ });sendWithStream: ReadableStream start 回调作为 "建通道请求"
sendWithStream(actionName, data, queueingStrategy, transfers) {
const streamId = this.streamId++; // 1
return new ReadableStream({
start: controller => {
const startCapability = Promise.withResolvers();
this.streamControllers[streamId] = {
controller, startCall: startCapability,
pullCall: null, cancelCall: null, isClosed: false,
};
comObj.postMessage({
sourceName: "d1", targetName: "worker",
action: "GetOperatorList", streamId: 1,
data: { pageId: 0, pageIndex: 0, intent: 2, cacheKey: "2__" },
desiredSize: controller.desiredSize, // 初始值, ReadableStream 告诉你"我还能接多少"
}, transfers);
return startCapability.promise;
},
// pull / cancel 见下文
}, queueingStrategy);
}关键: start 回调返回一个 promise. ReadableStream 会等这个 promise resolve 后, 才开始向消费端推数据. 所以主线程在 start 里先把 streamId=1 + action 发给 worker, 再把 startCapability.promise 返回给 ReadableStream. Worker 回 START_COMPLETE 后这个 promise resolve, ReadableStream 才开始接受 pull 请求.
信封带 streamId 但不带 stream 字段——它是 action + streamId 形态, 不是流控制形态.
对端: action + streamId → #createStreamSink
worker 端 MessageHandler 的 #onMessage 看到 data.streamId = 1, 走 this.#createStreamSink(data):
// 为 streamId=1 创建 sink 对象:
const streamSink = {
enqueue(chunk, size = 1, transfers) { /* 发 ENQUEUE */ },
close() { /* 发 CLOSE */ },
error(reason) { /* 发 ERROR */ },
sinkCapability: Promise.withResolvers(),
onPull: null, // 主线程 pull 到来时回调我
onCancel: null, // 主线程 cancel 到来时回调我
isCancelled: false,
desiredSize: data.desiredSize, // 从 start 消息里拿的初始值
ready: null, // 当 desiredSize <= 0 时挂起, pull 到来时 resolve
};
this.streamSinks[1] = streamSink;
Promise.try(action, data.data, streamSink).then(
() => comObj.postMessage({ stream: START_COMPLETE, streamId: 1, success: true }),
reason => comObj.postMessage({ stream: START_COMPLETE, streamId: 1, reason: wrapReason(reason) })
);sink 是主动生产者: worker 端的 GetOperatorList handler 拿到 sink 后, 每解析完一块绘制指令就调 sink.enqueue(chunk) 把数据推给主线程; 解析完所有块时 sink.close(). 对应的 onPull / onCancel 是被动响应——当主线程 ReadableStream 需要更多数据或被取消时, MessageHandler 会回调这两个函数.
ready 字段是 sink 侧的背压信号: sink.desiredSize 是主线程 ReadableStream 告诉 sink "还能接多少", 每 enqueue 一块就扣减; 扣到 ≤ 0 时 ready 被挂起, worker 侧应该在每次 enqueue 前 await sink.ready 避免无脑推送撑爆对方缓冲区.
主线程: START_COMPLETE → startCapability resolve
worker 回 START_COMPLETE, 主线程 #processStreamMessage 处理:
case StreamKind.START_COMPLETE:
if (data.success) {
streamController.startCall.resolve();
} else {
streamController.startCall.reject(wrapReason(data.reason));
}
break;startCapability resolve → ReadableStream 内部认为 start 已完成 → 开始向消费端推数据. demo 里 reader.read() 第一次被调用时, ReadableStream 内部 buffer 为空 → ReadableStream 触发自己的 pull 回调 → 发送 PULL 消息.
pull 链: 主线程拉 → worker 推 → 回 ENQUEUE → ReadableStream controller.enqueue
主线程 ReadableStream controller 需要更多数据
→ trigger pull() 回调
→ sendWithStream 里 pull 回调发 PULL 消息给 worker
→ worker #processStreamMessage 处理 PULL
→ reset sink.desiredSize = data.desiredSize
→ 调 sink.onPull (或默认空函数)
→ 回 PULL_COMPLETE
→ 主线程收到 PULL_COMPLETE
→ streamController.pullCall.resolve
→ ReadableStream 开始从 worker 拉数据
→ worker GetOperatorList handler 解析完一块
→ sink.enqueue(chunk)
→ 发 ENQUEUE 消息
→ 主线程 #processStreamMessage 处理 ENQUEUE
→ streamController.controller.enqueue(data.chunk)
每轮 pull → 若干次 enqueue → close. ReadableStream 内部的 desiredSize 会在消费端读取时自动更新, 下次 pull 就带新值.
取消链: 主线程 cancel → worker CANCEL → CANCEL_COMPLETE
demo 里第 5 篇提到的「100ms 延迟取消」最终会调 readableStream.cancel(reason):
demo: streamReader.cancel(renderingCancelledException)
→ ReadableStream trigger cancel() 回调
→ sendWithStream 里 cancel 回调发 CANCEL 消息
→ worker #processStreamMessage 处理 CANCEL
→ 调 sink.onCancel(reason)
→ 回 CANCEL_COMPLETE
→ sink.isCancelled = true
→ sink.sinkCapability.reject(reason)
→ 主线程收到 CANCEL_COMPLETE
→ streamController.cancelCall.resolve
→ #deleteStreamController 等三条 promise settle 后清理
sink 侧也必须同步清理: sink.isCancelled = true 让后续的 sink.enqueue / sink.close / sink.error 变成空操作——防止 worker 继续往一个已取消的通道里灌数据.
8 种 StreamKind 的位置一览
| StreamKind | 发方 | 收方 | 含义 |
|---|---|---|---|
| START_COMPLETE | sink | controller | sink 创建完毕, action 执行完毕 |
| PULL | controller | sink | "我需要更多数据" |
| PULL_COMPLETE | sink | controller | "我响应了你的 pull" |
| ENQUEUE | sink | controller | "这是一块数据" |
| CLOSE | sink | controller | "没有更多数据了" |
| ERROR | sink | controller | "出错了" |
| CANCEL | controller | sink | "通道取消, 别再推了" |
| CANCEL_COMPLETE | sink | controller | "我响应了你的取消" |
三种形态的 demo 对照表
| demo 调用 | 信封形态 | 主通道字段 | 对端需要回什么 |
|---|---|---|---|
send("Ready", null) | 纯 action | action = "Ready" | 不回包 |
sendWithPromise("GetPage", ...) | action + callbackId | callbackId = 1 | callback (DATA 或 ERROR) |
sendWithStream("GetOperatorList", ...) | action + streamId | streamId = 1 + desiredSize | START_COMPLETE → ENQUEUE × N → CLOSE (或 CANCEL) |
三条通道互不干扰: 同一个 comObj (worker.port) 同时跑三条消息流, 靠 callbackId / streamId 字段路由到正确的表项. 任何一条通道的消息, 信封里的 sourceName / targetName 都精确到文档 (d1 ↔ worker), 多文档并存时自动隔离.
回钩: WorkerTransport 是真正的"语义层"
第 2 篇里 MessageHandler 创建之后, 紧接着 WorkerTransport 构造函数调了 messageHandler.on("GetDoc", handler)、.on("DocException", ...) 等 20 多次注册——这些 handler 才是 MessageHandler 另一端要跑的"业务". MessageHandler 自己不知道 GetDoc 和 GetPage 有什么不同, 它只知道"信封上写了 action, 就去 actionHandler 里找".
这层分工意味着: 任何自定义 action 都可以插进来, 只要两端都注册了 handler. 除了 pdf.js 自带的 20+ 种, 用户自定义的扩展 (editor 里的 Stamp / FreeText 等) 也走同一套通道——MessageHandler 不区分内置 action 与扩展 action.
总结
本篇跟随 demo 中三次具体调用, 把 MessageHandler 内部的完整链路走了一遍:
- 信封协议: 四关键字段 (action / callbackId / streamId / stream) 决定消息走哪条路, sourceName/targetName 做软路由;
- 纯 action (
send): Ready 宣告, 不回包; - action + callbackId (
sendWithPromise): GetPage 请求, worker 执行后回 callback (DATA 或 ERROR), 主线程从 callbackCapabilities 表 resolve 悬挂的 promise; - action + streamId (
sendWithStream): GetOperatorList 流式通道, ReadableStream start/pull/cancel 三回调与对端 sink 配对, 8 种 StreamKind 组成双向背压状态机.
三条通道互不干扰, 同一个 comObj 同时承载, 靠 callbackId/streamId 精确路由. MessageHandler 本身无业务语义, 所有 action 的语义全在 WorkerTransport 的 .on(...) 注册表里——它是最小原语封装.
想同时看源码层 API 列表与实现细节, 请对照 MessageHandler 源码解读. 想走下一个 demo 钩子? 回到第 2 篇的四个重角色清单, 接下来是 WorkerTransport 初始化 (demo 第 2 篇第 4 步紧接着 MessageHandler 创建之后的那块).
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制2026年10月27日
- PDF.js 从 demo 深入浅出 9: 网络流与 Range 请求2026年10月27日
- PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手2026年10月27日
- PDF.js 从 demo 深入浅出 7: WorkerTransport 初始化2026年10月27日
- PDF.js 从 demo 深入浅出 5: render 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 4: getViewport 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 3: getPage 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 2: getDocument 方法全貌2026年10月27日
- PDF.js 源码解读: TextLayer2026年10月27日
- PDF.js 源码解读: font_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_display2026年10月27日
- PDF.js 源码解读: PDFObjects2026年10月27日
- PDF.js 源码解读: 网络流2026年10月27日
- PDF.js 源码解读: internal_evt.js2026年10月27日
- PDF.js 源码解读: math_clamp.js2026年10月27日
- PDF.js 源码解读: css_utils.js2026年10月27日
- PDF.js 源码解读: scripting_utils2026年10月27日
- PDF.js 源码解读: MurmurHash3_642026年10月27日
- PDF.js 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: image_utils.js2026年10月27日
- PDF.js 源码解读: BasePDFStream2026年10月27日
- PDF.js 源码解读: 共享工具层2026年10月27日
- PDF.js 源码解读: MessageHandler2026年10月27日
- PDF.js 源码解读: api2026年10月27日
- PDF.js 源码解读: fonts2026年10月27日
- PDF.js 源码解读: glyphlist2026年10月27日
- PDF.js 源码解读: binary_cmap2026年10月27日
- PDF.js 源码解读: cmap2026年10月27日
- PDF.js 源码解读: encodings2026年10月27日
- PDF.js 源码解读: metrics2026年10月27日
- PDF.js 源码解读: standard_fonts2026年10月27日
- PDF.js 源码解读: fonts_utils2026年10月27日
- PDF.js 源码解读: default_appearance2026年10月27日
- PDF.js 源码解读: metadata_parser2026年10月27日
- PDF.js 源码解读: name_number_tree2026年10月27日
- PDF.js 源码解读: struct_tree2026年10月27日
- PDF.js 源码解读: catalog2026年10月27日
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: crypto2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_stream2026年10月27日
- PDF.js 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_stream2026年10月27日
- PDF.js 源码解读: jpeg_stream2026年10月27日
- PDF.js 源码解读: decrypt_stream2026年10月27日
- PDF.js 源码解读: brotli_stream2026年10月27日
- PDF.js 源码解读: run_length_stream2026年10月27日
- PDF.js 源码解读: ascii_hex_stream2026年10月27日
- PDF.js 源码解读: ascii_85_stream2026年10月27日
- PDF.js 源码解读: lzw_stream2026年10月27日
- PDF.js 源码解读: predictor_stream2026年10月27日
- PDF.js 源码解读: flate_stream2026年10月27日
- PDF.js 源码解读: decode_stream2026年10月27日
- PDF.js 源码解读: chunked_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- PDF.js 深入浅出 11: Parser2026年10月26日
- PDF.js 深入浅出 10: XRef2026年10月26日
- PDF.js 深入浅出 9: CanvasGraphics2026年10月26日
- PDF.js 深入浅出 8: Document 与 Page2026年10月26日
- AI 时代, 我们到底应该做什么才能活下去(StreamPDF 创立有感)2026年10月26日
- PDF.js 深入浅出 7: PartialEvaluator2026年10月25日
- PDF.js 深入浅出 6: PDFManager.initDocument2026年10月25日
- PDF.js 深入浅出 5: PDFFetchStream 和 PDFWorkerStream2026年10月25日
- PDF.js 深入浅出 4: MessageHandler2026年10月22日
- PDF.js 深入浅出 3: getViewport & render2026年10月22日
- PDF.js 从 demo 深入浅出 1: 自定义 viewer 渲染 PDF2026年10月19日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日