StreamPDFStreamPDF
返回博客

PDF.js 从 demo 深入浅出 13: createDocumentHandler 与 Ready 之后的 setupDoc

2026年10月29日 · StreamPDF Team

PDF.js 从 demo 深入浅出 13: createDocumentHandler 与 Ready 之后的 setupDoc

前言

第 2 篇的握手末尾留了两个「点击查看事件」:

  1. worker 收到 GetDocRequest, 回执 workerId;
  2. 主线程发送 Ready, worker 收到后开始加载文档.

这两个事件的处理者都不在主线程, 而在 worker 线程的 src/core/worker.js 里. 前面的篇章中, worker 侧一直是黑盒: 第 7 篇只在主线程视角扫过 setupDoc 的轮廓, 第 9 篇展开的是网络流. 本篇正式进入 worker 线程, 跟随 demo 回答三个问题:

  1. worker 收到 GetDocRequest 后做了什么, 为什么回执恰好是 'd0_worker'?
  2. createDocumentHandler 注册的 20+ 个消息处理器都是什么?
  3. Ready 之后, setupDoc 如何选出 PdfManager, 把文档初始化出来并发回 GetDoc?

本篇只跟随 demo 的文档加载主干. 网络流与 range 请求策略见第 9 篇, initDocument 内部的 xref 解析可对照深入浅出 6: PDFManager.initDocument 与 pdf_manager 源码解读.

GetDocRequest 事件: createDocumentHandler

第 102 行, 全局通道上的文档入口

第 8 篇提到, worker 启动时在全局通道 worker ↔ main 上只注册了三个处理器: test, configure, GetDocRequest. 前两个服务于启动握手, 最后一个就是文档入口:

handler.on("GetDocRequest", data => this.createDocumentHandler(data, port));

主线程用 sendWithPromise("GetDocRequest", docParams) 发出消息 (第 6 篇的形态二), 因此 createDocumentHandler 的返回值会被包成 callback 回包, 送回主线程. demo 的 docParams 载荷在第 2 篇已经组装好: docId: 'd0', apiVersion: '6.3.289', data: null, rangeChunkSize: 65536 等.

第 111-130 行, 版本校验

createDocumentHandler 做的第一件事不是建通道, 而是校验:

const { docId, apiVersion } = docParams;
const workerVersion = PDFJSDev.eval("BUNDLE_VERSION"); // 构建时注入
if (apiVersion !== workerVersion) {
  throw new Error(
    `The API version "${apiVersion}" does not match ` +
      `the Worker version "${workerVersion}".`
  );
}

demo 的 apiVersion = '6.3.289', 与 CDN 上 pdf.worker.mjs 的构建版本一致, 校验通过.

Q: 为什么要在 worker 里再校验一次版本? A: pdf.mjs 与 pdf.worker.mjs 是两个独立文件, 部署时完全可能一个被更新, 另一个命中旧缓存. 版本不一致时消息协议和参数结构都可能对不上. 在这里抛错, 主线程会收到形态二的 ERROR callback, loadingTask.promise 直接 reject, 失败得早而且明确.

第 132-149 行, 原型污染检查

GENERIC 构建保留了一段看似奇怪的检查:

for (const prop in {}) {
  throw new Error(buildMsg("Object", prop));
}
for (const prop in []) {
  throw new Error(buildMsg("Array", prop));
}

对空对象和空数组做 for...in, 正常环境一次都不会进循环体. 只有宿主页面里的其他脚本错误地扩展了 Object.prototype / Array.prototype 时, 这里才会枚举出多余的属性并立即抛错.

源码注释解释了动机: PDF.js 内部的字体解析大量依赖 for...in 的枚举行为, 被污染的原型链会让字体加载以难以排查的方式失败. 与其在字体解析里到处加 Object.hasOwn 拖慢热路径, 不如在文档入口一次性 fail fast. demo 的页面没有污染原型, 顺利通过.

第 150-166 行, 创建 d0_worker ↔ d0 文档级通道

const workerHandlerName = docId + "_worker"; // 'd0_worker'
let handler = new MessageHandler(workerHandlerName, docId, port);

这条通道与第 2 篇主线程侧的 new MessageHandler('d0', 'd0_worker', worker.port) 互为镜像: 同一个 port, sourceName 与 targetName 恰好对调. 从这一刻起, 这份文档的所有消息都走 d0 ↔ d0_worker, 与全局通道 main ↔ worker 彻底分开 — 第 8 篇的两层通道对照表在这里合拢.

同时建立的还有一组闭包状态, 它们就是这份文档在 worker 侧的全部生命周期:

闭包变量初始值职责
pdfManagerundefined文档管理器, setupDoc 时才赋值
terminatedfalse收到 Terminate 后置 true, 所有入口先检查它
cancelXHRsnull取消进行中的数据请求, getPdfManager 里赋值
WorkerTasksnew Set()登记可终止的任务单位 (GetOperatorList 等)
verbosity1读取当前日志级别, 供耗时统计判断

startWorkerTask / finishWorkerTask 只是把任务加进 / 移出这个 Set; 收到 Terminate 时统一遍历终止, 防止已销毁的文档继续在后台解析.

第 396-1044 行, 注册 20+ 业务处理器

接下来是长达 600 余行的注册表. 按 demo 中的执行时机关心程度, 可以这样分组:

分组消息worker 侧职责
页面元数据GetPage, GetPageIndex, GetAnnotations, GetStructTree解析页面对象, 返回元数据或注释
目录查询GetOutline, GetDestinations, GetMetadata, GetPageLayout, GetPermissions 等大多是 ensureCatalog(prop) 一行转发
文档数据GetData, SaveDocument, ExtractPages全量字节, 增量保存, 抽页导出
流式求值GetOperatorList, GetTextContent拿到 sink 后边解析边推流 (第 6 篇形态三)
字体兜底FontFallback主线程字形加载失败时的回退路径 (第 12 篇)
生命周期Cleanup, Terminate, Ready清缓存, 销毁文档, 启动加载

demo 的加载主干只会触发其中的 Ready; GetPage 与 GetOperatorList 分别在第 3 篇和第 11 篇展开. 其余消息 demo 全程不会发送, 但它们共享同一个注册表, 无需额外初始化.

Q: 注册时 pdfManager 还是 undefined, handler 里引用它不会出事吗? A: 不会. handler 都是闭包, 引用的是变量本身, 执行时才取值. 而所有业务消息都只能由主线程发出, 主线程发它们的前提是 loadingTask.promise 已 resolve, 那时 setupDoc 早已给 pdfManager 赋好值. 注册顺序与赋值时序天然错开.

第 1062 行, 返回 workerHandlerName

注册表建立完毕, 函数最后一行:

return workerHandlerName; // 'd0_worker'

这个返回值经 callback 回包送达主线程, 正是第 2 篇第 1 步收到的回执 workerId = 'd0_worker'. 注意时序: 此刻 worker 还没有读到 PDF 的任何一个字节, 它只是把「接待这份文档的窗口」开好了. 主线程拿到 workerId 后建镜像通道, 创建 WorkerTransport 注册主线程侧路由, 最后才发 Ready.

Ready 事件: setupDoc 启动文档加载

第 1041-1044 行, Ready 触发 setupDoc

handler.on("Ready", function () {
  setupDoc(docParams);
  docParams = null; // we don't need docParams anymore -- saving memory.
});

Q: 为什么 worker 不在收到 GetDocRequest 时直接开始加载, 要等 Ready? A: 因为加载一开始, worker 就要向主线程发 GetReader / DocProgress 索取字节和汇报进度. 如果主线程侧的 WorkerTransport 还没注册好这些消息的 handler, 消息就会丢失. Ready 的含义是「主线程路由就绪」— 第 7 篇刻意把 send("Ready") 放在 setupMessageHandler() 之后, 两边约定好这个顺序, 消除竞态.

docParams = null 释放参数包: 其中的 evaluatorOptions 等已被拆解进 pdfManagerArgs, 原对象不再保留.

第 325-394 行, setupDoc 的骨架

setupDoc 本体是一段 promise 链, 围绕三个闭包回调组织:

function setupDoc(data) {
  function onSuccess(doc) {
    ensureNotTerminated();
    handler.send("GetDoc", { pdfInfo: doc });
  }
  function onFailure(ex) { /* 密码与错误分支, 见下文 */ }
  function pdfManagerReady() { /* loadDocument, 见下文 */ }
 
  ensureNotTerminated();
  getPdfManager(data)
    .then(function (newPdfManager) {
      // terminated 检查略
      pdfManager = newPdfManager;
      pdfManager.requestLoadedStream(/* noFetch = */ true).then(stream => {
        handler.send("DataLoaded", { length: stream.bytes.byteLength });
      }, () => {});
    })
    .then(pdfManagerReady, onFailure);
}

三步分工: getPdfManager 选出文档管理器; 旁路挂一个 requestLoadedStream(noFetch = true), 等全量字节齐了之后发 DataLoaded; 主链进入 pdfManagerReady 做文档初始化. noFetch = true 表示不主动催 range 请求, 只靠渐进全量流自然灌满 — 与第 7 篇「GetDoc 与 DataLoaded 是两条独立消息」的结论呼应.

第 193-302 行, getPdfManager: 两种 PdfManager 的竞速

demo 的 data = null, 走进网络分支:

const pdfStream = new PDFWorkerStream({ msgHandler: handler });
const fullReader = pdfStream.getFullReader();

PDFWorkerStream 把 reader 操作翻译成 GetReader / GetRangeReader 消息, 字节通道本身第 7、9 篇已经走完, 这里不再展开. 关键是接下来的两条并行的路径:

路径 A: fullReader.headersReady.then(...)
  → isRangeSupported 为 true
  → new NetworkPdfManager({ source: pdfStream, length: 22491828, ... })
  → 把 cachedChunks 里抢先到达的块灌进去
  → resolve(newPdfManager)
 
路径 B: readData() 循环
  → await fullReader.read() 逐块收全量流
  → newPdfManager 未建立: cachedChunks.push(value)
  → newPdfManager 已建立: newPdfManager.sendProgressiveData(value)
  → 全量读完仍未建立 (服务器不支持 range): new LocalPdfManager(合并后的字节)
维度路径 A (NetworkPdfManager)路径 B 兜底 (LocalPdfManager)
触发条件响应头支持 range全量流读完都没等到 range 能力
数据源pdfStream (惰性, 缺块再要)内存中的完整字节
ensure 行为捕获 MissingDataException → requestRange → 重试直接读, 不存在缺块
demo 是否命中是否

第 9 篇已经验证 demo 服务器支持 range (isRangeSupported = true, contentLength = 22491828), 因此路径 A 胜出: 响应头一回来, NetworkPdfManager 就建立, getPdfManager 的 promise 立刻 resolve — 不等全量下载. 而路径 B 的 readData 循环不会停, 它继续把全量流的每一块经 sendProgressiveData 灌进 ChunkedStreamManager, 让后续解析大概率命中已有数据.

cachedChunks 处理的是一个真实的竞态: 全量流的第一块可能比响应头校验更早到达, 先缓存, 等 manager 建立后一次性补灌, 一个字节都不丢.

另一个细节:

pdfManagerArgs.disableAutoFetch ||= fullReader.isStreamingSupported;
// demo: false ||= true → true

流式全量流已经在持续送数据, 解析器缺块时就没必要再为「自动拉取相邻块」发额外请求, 于是把 disableAutoFetch 升为 true. 第 2 篇解析出的 disableAutoFetch: false 在这里被运行时条件改写.

第 115-126 行 (pdf_manager.js), initDocument 五步

pdfManagerReady 调用 loadDocument(false), 第一步是:

async initDocument(recoveryMode) {
  await this.ensureDoc("checkHeader");      // %PDF- 魔数与版本
  await this.ensureDoc("parseStartXRef");   // 文件尾 startxref 定位
  await this.ensureDoc("parse", [recoveryMode]); // 解析 xref 表与 trailer
  await this.ensureDoc("checkFirstPage", [recoveryMode]);
  await this.ensureDoc("checkLastPage", [recoveryMode]);
}

这五步把「一段字节」变成「一份可寻址的文档」: 校验文件头, 从文件尾找到 xref 入口, 解析交叉引用表建立对象索引, 再抽查首页与末页确认页数可信. demo 的文档结构规范, 五步顺序通过.

对 NetworkPdfManager 来说, 每一步的 ensure 都可能撞上 MissingDataException, 触发 requestRange 向主线程要 [begin, end) 区间, 拿到后自动重试 — xref 在文件尾, 首批 range 请求因而直奔文件末尾, 不必等全量流流到那里. xref 解析本身的细节见深入浅出 6: initDocument.

第 351-368 行, XRefParseException 与恢复模式

loadDocument(false) 失败时按错误类型分流:

loadDocument(false).then(onSuccess, function (reason) {
  ensureNotTerminated();
  if (!(reason instanceof XRefParseException)) {
    onFailure(reason);
    return;
  }
  pdfManager.requestLoadedStream().then(function () {
    ensureNotTerminated();
    loadDocument(true).then(onSuccess, onFailure);
  }, onFailure);
});

只有 XRefParseException 才配进入恢复模式: 先 requestLoadedStream() 等全量字节就位, 再以 recoveryMode = true 重试 — 此时解析器不再信任 xref 表, 改为全文扫描重建对象索引. 其他错误 (密码, 网络中断) 直接进 onFailure, 不做无谓重试. demo 的文档 xref 完好, 一次通过, 这条路径未走.

第 168-191 行, loadDocument 组装 GetDoc 载荷

初始化完成后, 收集文档级元数据:

const [numPages, fingerprints] = await Promise.all([
  pdfManager.ensureDoc("numPages"),      // 756
  pdfManager.ensureDoc("fingerprints"),  // 文件标识数组
]);
const htmlForXfa = isPureXfa ? ... : null; // demo 非 XFA, 为 null
return { numPages, fingerprints, htmlForXfa };

回到 onSuccess: handler.send("GetDoc", { pdfInfo: doc }). 这条纯 action 消息跨越线程后, 由第 7 篇主线程侧的 GetDoc 处理器接收: 写入 pagesMapper.pagesNumber = 756, 创建 PDFDocumentProxy, resolve loadingTask._capability. demo 的 await loadingTask.promise 到此恢复执行 — 与第 2 篇的返回值一节首尾相接.

demo 未走: 密码循环

如果文档加密, loadDocument 会抛 PasswordException, 进入 onFailure 的密码分支:

const res = await handler.sendWithPromise("PasswordRequest", ex);
// 主线程 loadingTask.onPassword 提供密码后:
pdfManager.updatePassword(password);
pdfManagerReady(); // 重试整个加载

worker 不保存重试次数, 密码错就再发一次 PasswordRequest, 形成循环, 直到用户给对或主线程 reject. demo 的文档未加密, password = null, 此分支未走.

生命周期: Cleanup 与 Terminate

注册表末尾的两个生命周期消息, 决定了这份文档在 worker 侧如何收场:

handler.on("Cleanup", function () {
  return pdfManager.cleanup(/* manuallyTriggered = */ true);
});
 
handler.on("Terminate", async function () {
  terminated = true;
  // pdfManager.terminate → cancelXHRs → 终止全部 WorkerTasks → handler.destroy()
});

Cleanup 只做缓存清理 (字体, 图片等), 文档仍可继续使用; Terminate 则是终局: 先置 terminated 封死所有入口, 再中止网络请求, 遍历 WorkerTasks 逐个终止, 最后 handler.destroy() 拆掉 d0_worker ↔ d0 通道本身. demo 的页面不销毁文档, 两者都不会触发; 它们对应主线程侧 loadingTask.destroy() 的另一半.

总结

本篇跟随 demo 进入 worker 线程, 走完了第 2 篇留下的两个事件:

  1. GetDocRequest 到达, createDocumentHandler 先校验 apiVersion 与构建版本一致, 再检查原型链未被污染;
  2. 创建文档级通道 d0_worker ↔ d0, 与主线程镜像配对, 并建立 pdfManager / terminated / WorkerTasks 等闭包状态;
  3. 同步注册 20+ 业务处理器后返回 'd0_worker', 主线程由此拿到 workerId — 此刻 PDF 还一个字节未读;
  4. Ready 到达才执行 setupDoc: getPdfManager 在「响应头支持 range → NetworkPdfManager」与「全量读完 → LocalPdfManager」之间竞速, demo 命中前者, cachedChunks 兜住抢先到达的数据;
  5. initDocument 五步建立对象索引, loadDocument 收集 numPages = 756 与 fingerprints, onSuccess 发回 GetDoc;
  6. 旁路的 requestLoadedStream(noFetch = true) 等全量字节齐后发 DataLoaded; 密码错误走 PasswordRequest 循环, xref 损坏走恢复模式;
  7. Cleanup / Terminate 掌管文档在 worker 侧的缓存清理与最终销毁.

至此, getDocument 的两个线程都已完成各自的就绪宣告, 文档加载闭环合拢. demo 的下一步 pdf.getPage(1) 将第一次真正用到本篇注册的 GetPage 处理器 — 回到第 3 篇.

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

PDF.js 从 demo 深入浅出系列