StreamPDFStreamPDF
返回博客

PDF.js 源码解读: MessageHandler

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: MessageHandler

前言

这是 PDF.js 源码解读系列中关于「跨线程消息枢纽」的一篇,介绍 src/shared/message_handler.js(538 行)。

worker 线程解析 PDF、主线程渲染 canvas——两者之间所有通讯(除了 Transferable 的零拷贝所有权转移)都走这一个类:主线程侧一个 MessageHandler 实例,worker 侧另一个,两个实例共享同一个 comObj(MessagePort 或 Worker 对象)。

此前 api.js 文章四次提到它是「另文展开」:

  • sendWithPromise("GetDocRequest", docParams) —— 第 2 篇 getDocument 的握手通道;
  • send("Ready", null) —— 握手尾声的宣告;
  • sendWithStream("GetOperatorList", { ... }) —— 第 5 篇 render 的流式 operatorList;
  • sendWithStream("GetTextContent", { ... }) —— 文本层的流式文本块。

这些调用方视角只展示了"API 怎么用"。本篇翻过来——站在协议实现层,看消息是怎么被包裹、分发、回调、取消、流控的。

一句话概括 MessageHandler 的职责:在 comObj.postMessage 这个最小原语之上,建立三种语义不同的通讯原语——fire-and-forget(send)、请求-应答(sendWithPromise)、流式(sendWithStream)。

文件结尾只有 2 个导出:MessageHandler 类本身、以及在跨线程 Error 身份保持上必须复用的 wrapReason 辅助函数。

模块总览

message_handler.js 可以切成七块:

区块行号职责
CallbackKind / StreamKind 常量26-40两类消息体的类型枚举
wrapReason44-74跨线程 Error 类型保持
MessageHandler 构造函数76-93四个 Map 容器 + 消息监听
#onMessage 分发主循环95-156callback / action / stream 三路分派
三个 send API158-294send / sendWithPromise / sendWithStream
流的两端:#createStreamSink + #processStreamMessage296-519sink(消费端)/ controller(生产端)的双向背压
#deleteStreamController + destroy521-535流清理与生命周期终结

消息信封:postMessage 的三层包裹

postMessage 的载荷只能是 JSON-可序列化对象。MessageHandler 规定了自己的协议——所有穿过 comObj 的消息都带这几个字段:

// 信封通用部分
{
  sourceName:   "main" | "worker" | "d1" | "d1_worker", // 消息来源
  targetName:   "main" | "worker" | "d1" | "d1_worker", // 消息目的
  // ... 具体形态的字段
}

信封之上再套三形态:

形态关键字段对端动作对应 API
Action(fire-and-forget)action, data执行后不回包send(action, data)
Action + Callback(请求-应答)action, data, callbackId执行后按 callbackId 回一个 callbacksendWithPromise(action, data)
Callback(纯回包)callback, callbackId, data/reason这是对请求的答复,不再被路由到 actionHandler—
Stream Startaction, data, streamId, desiredSize创建 sink,执行 action(同步),回 START_COMPLETEsendWithStream(action, data)
Stream Controlstream, streamIdPULL / CANCEL / CANCEL_COMPLETE 等 8 种流控制由 ReadableStream 内部自动发
Stream Datastream, streamId, chunkENQUEUE / CLOSE / ERROR,由 sink 发由 sink 侧代码主动调

#onMessage 按字段存在性分三路处理,互斥、有严格的先后顺序:

if (data.stream) { /* 流式控制或数据 */ }
if (data.callback) { /* callbackId 的回包 */ }
// 剩下的才是 action

sourceName / targetName 两个字段构成软路由:#onMessage 里只做一次精确匹配——data.targetName !== this.sourceName 就丢弃。多文档并存时(两个 getDocument 并行调用),docId 编进名字(d1 / d1_worker / d2 / d2_worker),MessageHandler 就天然隔离,互不串线。

MessageHandler 类:四个表 + 一个监听

constructor(sourceName, targetName, comObj) {
  this.sourceName = sourceName;
  this.targetName = targetName;
  this.comObj = comObj;
  this.callbackId = 1;
  this.streamId = 1;
  this.streamSinks = Object.create(null);       // streamId → sink(我这边是数据接收端)
  this.streamControllers = Object.create(null); // streamId → controller(我这边是数据生产端)
  this.callbackCapabilities = Object.create(null); // callbackId → Promise.withResolvers()
  this.actionHandler = Object.create(null);     // actionName → handler
  comObj.addEventListener("message", this.#onMessage.bind(this), {
    signal: this.#messageAC.signal,
  });
}

四张表的分工一目了然:

  • actionHandler:我能响应的远端动作。MessageHandler.on("GetPage", handler) 注册,远端 sendWithPromise("GetPage", ...) 时触发;
  • callbackCapabilities:我发请求、等远端回 callbackId。key 是回调 id,value 是 Promise.withResolvers()。id 递增分配,用完即删;
  • streamControllers:我发 sendWithStream、远端是数据生产端。我这边持有 ReadableStream 的 controller,远端往这个 sink 里 enqueue 数据;
  • streamSinks:远端发 sendWithStream、我是数据生产端。我这边持有一个 sink 对象(有 enqueue / close / error 方法),远端 ReadableStream 的 pull/cancel 控制我。

设计要点:四张表的"我/远端"关系是相对的。主线程发 sendWithStream("GetOperatorList") 时,主线程持有 streamControllers[streamId],worker 持有 streamSinks[streamId];worker 发 sendWithStream("GetReader")(把全量 PDF 数据流式推给主线程)时,关系恰好反过来。每个 MessageHandler 实例同时是某些流的生产端、某些流的消费端,所以两个 Map 都需要。

callbackId / streamId 都是自增整数,每个 MessageHandler 独立分配。worker 与主线程各自从 1 开始,不会撞——因为它们的 MessageHandler 是两个不同实例。

监听用 AbortController signal——destroy() 时 abort() 移除事件,之后 comObj.postMessage 收到的消息不会再触发任何回调。

on() / send() / sendWithPromise():前两种形态

on():注册一个 action handler

on(actionName, handler) {
  if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
    assert(typeof handler === "function",
      'MessageHandler.on: Expected "handler" to be a function.');
  }
  const ah = this.actionHandler;
  if (ah[actionName]) {
    throw new Error(`There is already an actionName called "${actionName}"`);
  }
  ah[actionName] = handler;
}

只允许每个 action 名注册一个 handler——第二条就抛错。这让远端的"我要发一个 GetPage"在接收方只有一处实现,排查时不会陷入"到底是哪个 handler 响应"的困境。

api.js 里 WorkerTransport.setupMessageHandler() 连续调 20+ 次 .on(...) 注册:GetDoc、DocException、PasswordRequest、commonobj、obj、StartRenderPage、DocProgress、FetchBinaryData、SaveDocument、Terminate 等。一个 doc 的所有消息类型都集中在这一批 .on 里,是 WorkerTransport 这个类"把 worker↔主线程通讯翻译成语义"的核心。

send():fire-and-forget

send(actionName, data, transfers) {
  this.comObj.postMessage({
    sourceName: this.sourceName,
    targetName: this.targetName,
    action: actionName,
    data,
  }, transfers);
}

最简单的一条路。没有 callbackId、没有 streamId,消息出去就完了。api.js 里两处使用:

  • messageHandler.send("Ready", null) —— 握手尾声的宣告;
  • messageHandler.send("configure", { verbosity }) —— PDFWorker 把日志级别传给 worker。

sendWithPromise():请求-应答

sendWithPromise(actionName, data, transfers) {
  const callbackId = this.callbackId++;
  const capability = Promise.withResolvers();
  this.callbackCapabilities[callbackId] = capability;
  try {
    this.comObj.postMessage({
      sourceName: this.sourceName,
      targetName: this.targetName,
      action: actionName,
      callbackId,
      data,
    }, transfers);
  } catch (ex) {
    capability.reject(ex);
  }
  return capability.promise;
}

跟 send 的唯一区别:带上 callbackId,并把 promise resolver 记在表里。远端处理 action 时,如果看到 callbackId 字段存在,就会把结果(或异常)作为一个 callback 形态的消息回过来,#onMessage 的 callback 分支再把 resolver 调起来。

注意两个细节:

  1. 同步 try-catch 只兜 postMessage 自身的抛错(transfer 所有权已释放后的二次 transfer、transfer 里的对象不可序列化等)。postMessage 返回后,远端处理 action 的任何异常都在另一边的事件循环里,必须走 callback ERROR 通道才能回来;
  2. callback 消息必须最终送达——否则 caller 拿到的 promise 永远 pending。这就是为什么 .finally 里总要 .catch(() => {}) 认领 rejection:销毁竞态时 MessageHandler 可能被提前 destroy(),callback 永远收不到。

api.js 里的四个重角色调用(GetDocRequest / GetPage / GetMetadata / SaveDocument)全走这条通道。

sendWithStream():第三种形态,最复杂

流式接口用 Web 标准 ReadableStream 封装。调用方拿到的是一个 ReadableStream,另一端拿到的是一个 sink 对象(有 enqueue / close / error),双方通过 #processStreamMessage 里的 8 种 StreamKind 消息协调背压。

发起方:ReadableStream 的 start / pull / cancel 回调

sendWithStream(actionName, data, queueingStrategy, transfers) {
  const streamId = this.streamId++,
    sourceName = this.sourceName,
    targetName = this.targetName,
    comObj = this.comObj;
 
  return new ReadableStream({
    start: controller => {
      const startCapability = Promise.withResolvers();
      this.streamControllers[streamId] = {
        controller, startCall: startCapability,
        pullCall: null, cancelCall: null, isClosed: false,
      };
      comObj.postMessage({
        sourceName, targetName,
        action: actionName, streamId, data,
        desiredSize: controller.desiredSize, // 首次推送的背压信息
      }, transfers);
      return startCapability.promise; // 告诉 ReadableStream:等我这个 promise 才开始 pull
    },
    pull: controller => {
      const pullCapability = Promise.withResolvers();
      this.streamControllers[streamId].pullCall = pullCapability;
      comObj.postMessage({ sourceName, targetName,
        stream: StreamKind.PULL, streamId,
        desiredSize: controller.desiredSize });
      return pullCapability.promise; // 告诉 ReadableStream:等我回 PULL_COMPLETE 再 pull 下一次
    },
    cancel: reason => {
      assert(reason instanceof Error, "cancel must have a valid reason");
      const cancelCapability = Promise.withResolvers();
      this.streamControllers[streamId].cancelCall = cancelCapability;
      this.streamControllers[streamId].isClosed = true;
      comObj.postMessage({ sourceName, targetName,
        stream: StreamKind.CANCEL, streamId,
        reason: wrapReason(reason) });
      return cancelCapability.promise; // 告诉 ReadableStream:等我 CANCEL_COMPLETE
    },
  }, queueingStrategy);
}

start 回调不直接 resolve——它先把 action + streamId + desiredSize 发给对端,再把 startCapability.promise 返回给 ReadableStream。对端回 START_COMPLETE 后(见下文 #processStreamMessage),startCall.resolve() 被调用,ReadableStream 才开始向消费端推数据。

这就是为什么 api.js _pumpOperatorList 里拿到 readableStream 后立刻调 reader.read() 能正常工作——start 期间 ReadableStream 内部是 await 住的,直到远端 sink 创建完毕、START_COMPLETE 回来,才开始接受 pull 请求。

pull 每次 ReadableStream 内部缓冲区满到需要更多数据时触发。只在返回的 promise resolve 后才会再触发下一次 pull,所以每一轮 pull 都必须等对端回 PULL_COMPLETE——这是天然的背压通道。

接收方(对端):#createStreamSink

对端的 MessageHandler 在 #onMessage 里发现 data.streamId 存在(不是 stream 字段,是 action 消息带 streamId),就走 #createStreamSink(data):

#createStreamSink(data) {
  const streamSink = {
    enqueue(chunk, size = 1, transfers) { /* 见下文 */ },
    close()  { /* 发 CLOSE */ },
    error(reason) { /* 发 ERROR */ },
    sinkCapability: Promise.withResolvers(),
    onPull: null,   // 被 PULL 消息触发
    onCancel: null, // 被 CANCEL 消息触发
    isCancelled: false,
    desiredSize: data.desiredSize, // 初始值来自 start 消息
    ready: null,
  };
  streamSink.sinkCapability.resolve();
  streamSink.ready = streamSink.sinkCapability.promise;
  this.streamSinks[streamId] = streamSink;
 
  Promise.try(action, data.data, streamSink).then(
    () => comObj.postMessage({ stream: StreamKind.START_COMPLETE, streamId, success: true }),
    reason => comObj.postMessage({ stream: StreamKind.START_COMPLETE, streamId, reason: wrapReason(reason) })
  );
}

streamSink 是主动生产者——sink 的 owner(如 worker 的 GetOperatorList handler)通过 enqueue(chunk) 把数据推给对端;被动响应的是 onPull / onCancel——当 ReadableStream 端需要更多数据(或取消)时,MessageHandler 会回调这两个函数。

#processStreamMessage:8 种 StreamKind 的双向状态机

所有流控制与数据消息都走这一个 switch:

case StreamKind.PULL:          // ReadableStream 端需要更多数据 → 触发 sink.onPull
case StreamKind.PULL_COMPLETE: // sink.onPull 执行完毕 → resolve ReadableStream 的 pull promise
case StreamKind.ENQUEUE:       // sink 发了数据 → controller.enqueue
case StreamKind.CLOSE:         // sink 结束 → controller.close
case StreamKind.ERROR:         // sink 报错 → controller.error
case StreamKind.CANCEL:        // ReadableStream 被取消 → 触发 sink.onCancel
case StreamKind.CANCEL_COMPLETE: // sink.onCancel 执行完毕 → resolve ReadableStream 的 cancel promise
case StreamKind.START_COMPLETE: // sink 创建 + action 执行完毕 → resolve ReadableStream 的 start promise

把它理解为两对 promise 握手:

ReadableStream 端动作往对端发对端回本端 resolve
start()start message(带 desiredSize)START_COMPLETEstartCall
pull()PULLPULL_COMPLETEpullCall
cancel()CANCELCANCEL_COMPLETEcancelCall
——ENQUEUE / CLOSE / ERRORcontroller 被直接操作,不经过 promise

enqueue 的背压:desiredSize 双向流动

sink.enqueue 里有一段不直观的逻辑:

const lastDesiredSize = this.desiredSize;
this.desiredSize -= size;
if (lastDesiredSize > 0 && this.desiredSize <= 0) {
  this.sinkCapability = Promise.withResolvers();
  this.ready = this.sinkCapability.promise;
}

desiredSize 是 ReadableStream 主动侧告诉你的「我还能接多少」。sink 每 enqueue 一块就减去对应 size;减到负数或零时,sink 侧把自己的 ready 挂起。这是"生产者等不及消费者拉"的信号——sink 的 owner(如 worker 的 operatorList 解析循环)在每次 enqueue 前应该 await sink.ready,否则就是无脑推送直到对端 ReadableStream 内部缓冲区撑爆。

对应的触发端在 PULL 消息里:

if (streamSink.desiredSize <= 0 && data.desiredSize > 0) {
  streamSink.sinkCapability.resolve();
}
streamSink.desiredSize = data.desiredSize;

对端 pull 到来时重置 desiredSize 为新值(ReadableStream 此时应该已经消耗掉一些),并在值由负转正时 resolve 掉 sink 挂起的 ready。desiredSize 双向流动——从 sink.enqueue 扣减到 PULL 到达时重置,构成完整的背压环。

#deleteStreamController:等三条 promise 全部 settle 再删

async #deleteStreamController(streamController, streamId) {
  await Promise.allSettled([
    streamController.startCall?.promise,
    streamController.pullCall?.promise,
    streamController.cancelCall?.promise,
  ]);
  delete this.streamControllers[streamId];
}

controller 被 CLOSE / ERROR / CANCEL_COMPLETE 任何一条触发删除时,不能立刻 delete——因为 ReadableStream 内部可能还在等 pullCall.promise(它还在排队执行、尚未 resolve)。立即删会让 pullCall.resolve(...) 抛 TypeError: Cannot read properties of undefined。Promise.allSettled 保证三条 promise 都 settle 后再清理,让 ReadableStream 内部所有悬挂回调安全结束。

#onMessage:分发主循环的三种形态

#onMessage({ data }) {
  if (data.targetName !== this.sourceName) return; // 软路由
 
  if (data.stream) {
    this.#processStreamMessage(data);   // StreamKind 控制或数据
    return;
  }
  if (data.callback) {
    // 纯回包:从 callbackCapabilities 取出 capability,按 DATA/ERROR resolve 或 reject
    return;
  }
 
  // 剩下的是 action
  const action = this.actionHandler[data.action];
  if (!action) throw new Error(`Unknown action from worker: ${data.action}`);
 
  if (data.callbackId) {
    // action + callbackId:执行后把结果/异常回成 callback
    Promise.try(action, data.data).then(
      result => comObj.postMessage({ callback: DATA, callbackId, data: result }),
      reason => comObj.postMessage({ callback: ERROR, callbackId, reason: wrapReason(reason) })
    );
    return;
  }
  if (data.streamId) {
    // action + streamId:创建 sink,不回 action 形态的答复
    this.#createStreamSink(data);
    return;
  }
  action(data.data); // 纯 action,fire-and-forget
}

几个值得展开的点:

Promise.try 的作用:把可能抛同步异常的 action 包进一个微任务里,让后续 .then/.catch 能捕获。没有它的话,action(data.data) 抛同步异常会直接炸在当前事件循环里,而不是走 ERROR callback。

callbackId 回包有两条:DATA 与 ERROR。两端都用 wrapReason 做 Error 类型保持。

data.callbackId 存在但 data.streamId 不存在 → 请求-应答;data.streamId 存在 → 流式;data.callback 存在 → 纯回包。 三者互斥,#onMessage 靠字段存在性区分,不靠"先到先试"的 try/catch——这是刻意的设计选择,避免消息语义歧义。

wrapReason:跨线程的 Error 身份保持

postMessage 不会把 Error 实例当作 Error 传——它会序列化成 { name, message, ... } 的普通对象。收到后要根据 name 字段重新构造成 PDF.js 自定义的异常类型:

function wrapReason(ex) {
  // 已经是正确类型 → 直接返回(避免二次构造)
  if (ex instanceof AbortException || ex instanceof InvalidPDFException ||
      ex instanceof PasswordException || ex instanceof ResponseException ||
      ex instanceof UnknownErrorException) {
    return ex;
  }
  if (!(ex instanceof Error || (typeof ex === "object" && ex !== null))) {
    unreachable('wrapReason: Expected "reason" to be a (possibly cloned) Error.');
  }
  switch (ex.name) {
    case "AbortException":     return new AbortException(ex.message);
    case "InvalidPDFException": return new InvalidPDFException(ex.message);
    case "PasswordException":   return new PasswordException(ex.message, ex.code);
    case "ResponseException":   return new ResponseException(ex.message, ex.status, ex.missing);
    case "UnknownErrorException": return new UnknownErrorException(ex.message, ex.details);
  }
  return new UnknownErrorException(ex.message, ex.toString());
}

五种自定义异常,各自有专属额外字段(PasswordException.code / ResponseException.status + missing / UnknownErrorException.details),wrapReason 都原样保留。worker 端 wrapReason 了一次(发消息时),主线程端再 wrapReason 一次(收消息时)——但因为已经是正确的 instanceof 类型,二次 wrap 会短路返回原值,不做二次构造。

destroy():生命周期终结

destroy() {
  this.#messageAC?.abort();
  this.#messageAC = null;
}

AbortController.abort() 移除 comObj 上的 message 监听器,之后无论 comObj 来多少消息,都不会再触发 #onMessage。四张表(callbackCapabilities / streamControllers / streamSinks / actionHandler)不会被显式清空,但它们持有的 capability 等对象如果被外部引用,会收到"永远不 resolve/reject"的悬挂状态——这就是 api.js 里 destroy() 时先去认领 _capability.promise.catch(() => {}) 的原因。

LoopbackPort:假 worker 回退下的同构通讯

pdf.WorkerTransport 在 _setupFakeWorker 里用 LoopbackPort 代替真实 MessagePort:

const port = new LoopbackPort();
const id = `fake${PDFWorker.#fakeWorkerId++}`;
const workerHandler = new MessageHandler(id + "_worker", id, port);
WorkerMessageHandler.setup(workerHandler, port);
this.#messageHandler = new MessageHandler(id, id + "_worker", port);

两个 MessageHandler 实例 共享同一个 LoopbackPort,谁 postMessage 谁的监听就立即被调用(同步派发)。对 MessageHandler 来说 LoopbackPort.addEventListener("message", cb) + postMessage(obj) 的接口与真实 MessagePort 完全一致——所以 MessageHandler 层对"我面对的是同线程还是跨线程"完全无感知。

这正是 api.js 文章提到的「能力回退不打扰上层」模式:LoopbackPort 只让通讯从"异步 postMessage"变成"同步事件触发",MessageHandler 的三张 API(send / sendWithPromise / sendWithStream)以及三张表的用法完全不变。

总结

MessageHandler 是 PDF.js 主线程↔worker 通讯的最小原语封装——它没有"读文件""取页面"这些业务语义,只建立三种消息形态和一套信封协议。

三种通讯形态的落地场景

形态调用方业务含义
sendWorkerTransport.on("Ready", ...)宣告"我这边好了"
sendWithPromiseWorkerTransport._setupCapability"请给我 GetDocRequest 的回执"
sendWithStreamPDFPageProxy._pumpOperatorList"把 operatorList 分块推过来"
streamSink.enqueueworker 端 GetOperatorList handler"我刚解析完一块绘制指令"

信封协议的设计原则

  1. 软路由不删消息:sourceName / targetName 匹配失败时只 return,不抛错。多文档并存场景下大量消息会自然被过滤掉。
  2. 请求必回 callback:带 callbackId 的 action 处理完必须回 callback,否则 caller 悬挂。这是契约,不是建议。
  3. 流控天然内嵌:ReadableStream 的 desiredSize 通过两次 postMessage(start 时、每轮 pull 时)在两端流动,sink 侧据此挂起 ready,构成完整背压环。
  4. 错误身份不丢失:wrapReason 让跨线程的 PDF.js 自定义异常在另一侧仍是正确的 instanceof 类型。
  5. 同构接口即回退能力:LoopbackPort 与 MessagePort 接口一致,让假 worker 回退对 MessageHandler 透明。

读懂 MessageHandler 之后,再看 api.js 文章里的四个重角色调用(GetDocRequest / Ready / GetOperatorList / GetTextContent),它们就不再是"一句笼统的 worker 通讯"——你能精确知道 callbackId 走哪张表、streamId 走哪两张表、desiredSize 怎么在两端流动。

下一篇应该接 网络流(fetch_stream.js + network_stream.js)——GetReader / GetRangeReader 消息就在 MessageHandler 的 stream 通道上跑,MessageHandler 是它们的底层。

StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.

相关文章