StreamPDFStreamPDF
返回博客

PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手

2026年10月27日 · StreamPDF Team

PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手

前言

第 7 篇已经从 WorkerTransport 走到了 GetDoc. 本篇回到更早的位置, 补齐第 2 篇第 26-33 行刻意略过的调用:

worker = PDFWorker.create({
  verbosity: undefined,
  port: undefined, // GlobalWorkerOptions.workerPort, demo 未设置
});
task._worker = worker;

此时第一篇已经配置:

pdfjsLib.GlobalWorkerOptions.workerSrc =
  "https://cdn.jsdelivr.net/npm/pdfjs-dist@6.3.289/build/pdf.worker.mjs";

本篇跟随这两个具体配置, 回答四个问题:

  1. PDFWorker.create 是同步返回, 还是等 worker 启动后才返回?
  2. CDN 上的 workerSrc 如何变成浏览器的 Worker?
  3. ready 与 test 两条消息如何构成启动握手?
  4. 真 worker 不可用时, 假 worker 如何让后续代码不变?

本篇只跟随 worker 的创建与就绪路径. GetDocRequest 是握手成功之后的下一条消息, 文档级 MessageHandler 与 WorkerTransport 分别见第 6、7 篇. 完整类结构可对照 api.js 源码解读.

PDFWorker.create 调用

第 1 步, create 先查共享 port 缓存

PDFWorker.create 本身很短:

static create(params) {
  const cachedPort = this.#workerPorts.get(params?.port);
  if (cachedPort) {
    if (cachedPort._pendingDestroy) {
      throw new Error("PDFWorker.create - the worker is being destroyed...");
    }
    return cachedPort;
  }
  return new PDFWorker(params);
}

它是一个同步工厂, 只有两种结果:

  • 用户提供了可复用的 port, 且缓存中已有 PDFWorker: 直接返回旧实例;
  • 否则: 创建新实例.

demo 的 params.port = undefined, 没有可复用端口, 因此进入 new PDFWorker(params). task._worker = worker 保存这个新实例, 供加载任务销毁时清理.

create 不是 async 函数, 也不返回 promise. 新实例会同步返回, 启动结果则挂在实例的 worker.promise 上.

第 2 步, 构造函数给 undefined 应用默认值

构造参数经过解构:

constructor({
  name = null,
  port = null,
  verbosity = getVerbosityLevel(),
} = {}) {
  this.name = name;
  this.destroyed = false;
  this.verbosity = verbosity;
 
  if (port) {
    // 用户提供的共享 worker 分支
  } else {
    this.#initialize();
  }
}

JavaScript 的默认值会在属性值为 undefined 时生效, 所以 demo 实际得到:

字段值来源
namenull未传入
portnull显式传入 undefined, 触发默认值
verbosity1getVerbosityLevel(), 默认 VerbosityLevel.WARNINGS
destroyedfalse构造时初始化

port 为假, demo 进入 #initialize(), 尝试创建真 worker.

第 3 步, 检查是否可以直接回退

#initialize() 开头先看两个条件:

if (
  PDFWorker.#isWorkerDisabled ||
  PDFWorker.#mainThreadWorkerMessageHandler
) {
  this.#setupFakeWorker();
  return;
}
  • #isWorkerDisabled: Node 环境会在类初始化时置为 true; 浏览器里此前如果回退过, 也会置为 true, 后续文档不再重复尝试真 worker;
  • #mainThreadWorkerMessageHandler: 如果主线程已经通过 globalThis.pdfjsWorker 提供 worker 实现, 直接使用它.

demo 在普通浏览器中首次创建, 两个条件都不满足, 继续真 worker 路径.

workerSrc 到 new Worker

第 4 步, 读取 GlobalWorkerOptions.workerSrc

let { workerSrc } = PDFWorker;

这里的 getter 不是简单返回空值:

static get workerSrc() {
  if (GlobalWorkerOptions.workerSrc) {
    return GlobalWorkerOptions.workerSrc;
  }
  throw new Error('No "GlobalWorkerOptions.workerSrc" specified.');
}

demo 已在第一篇设置 CDN 地址, 因此得到完整的 https://cdn.jsdelivr.net/.../pdf.worker.mjs. 如果没有设置, 异常会被 #initialize() 外层 try...catch 捕获, 随后尝试假 worker; 但假 worker 同样需要加载这份代码, 所以官方建议始终显式配置 workerSrc.

第 5 步, demo 命中 CDN Blob wrapper

demo 页面位于 streampdf.com, worker 文件位于 cdn.jsdelivr.net, 两者不同源. 浏览器不允许直接把跨域 URL 交给 new Worker(...), PDF.js 因此先判断:

if (!PDFWorker._isSameOrigin(window.location, workerSrc)) {
  workerSrc = PDFWorker._createCDNWrapper(
    new URL(workerSrc, window.location).href
  );
}

_createCDNWrapper 不下载源码, 只生成一个极小的模块包装器:

const wrapper = `await import("${url}");`;
return URL.createObjectURL(
  new Blob([wrapper], { type: "text/javascript" })
);

于是 workerSrc 从 CDN URL 变为同源 blob: URL. 包装模块里再通过 import() 加载 CDN 上的 ES module.

直接 new Worker("https://cdn.jsdelivr.net/...")  // 跨域, 会被拒绝
 
new Worker("blob:...", { type: "module" })
  → blob 模块执行 await import("https://cdn.jsdelivr.net/...")
  → CDN 返回支持跨域加载的 worker 模块

第 6 步, 创建 Worker 与全局 MessageHandler

const worker = new Worker(workerSrc, { type: "module" });
const messageHandler = new MessageHandler("main", "worker", worker);

这里的 MessageHandler 名称是 main ↔ worker, 服务的是整个 worker 的全局通道, 目前只注册 test、configure、GetDocRequest 等全局消息.

第 6 篇的 d1 ↔ d1_worker 则要在之后处理 GetDocRequest 时才创建, 服务某一份文档. 两层通道不要混淆:

通道创建时机典型消息
main ↔ worker本篇创建 Worker 后ready、test、configure、GetDocRequest
d1 ↔ d1_workerworker 处理 GetDocRequest 时Ready、GetDoc、GetPage、GetOperatorList

主线程接下来注册 error、test、ready 三个事件处理器, 为握手做准备.

ready/test 双向握手

第 7 步, 主线程立即发送第一次 test

sendTest 的定义与第一次调用如下:

const sendTest = () => {
  const testObj = new Uint8Array();
  messageHandler.send("test", testObj, [testObj.buffer]);
};
 
sendTest();

发送内容是一个空的 Uint8Array, 它的 ArrayBuffer 被列入 transfer 列表. 测试目的不是传输 PDF 数据, 而是验证两件基础能力:

  1. 主线程与 worker 的消息通道能够工作;
  2. TypedArray 经过 postMessage 后, 在 worker 侧仍然保持正确的类型.

PDF 字节、字体和图像数据随后都依赖这条二进制通道, 所以启动时先做一次最小成本的能力探测.

第 8 步, worker 启动并发送 ready

pdf.worker.mjs 加载 WorkerMessageHandler. 它的静态块检测到当前环境是 worker 后, 执行:

this.initializeFromPort(self);

初始化函数建立另一侧的全局通道:

static initializeFromPort(port) {
  const handler = new MessageHandler("worker", "main", port);
  this.setup(handler, port);
  handler.send("ready", null);
}

setup 注册三个与本文相关的处理器:

handler.on("test", ...);
handler.on("configure", ...);
handler.on("GetDocRequest", ...);

随后 worker 主动发送 ready. 这条消息只表示「worker 代码已加载, 全局消息路由已注册」, 不代表 PDF 文档已经加载——此时 worker 甚至连 docParams 都还没收到.

第 9 步, ready 触发第二次 test

主线程的 ready 处理器再次调用 sendTest():

messageHandler.on("ready", data => {
  ac.abort();
  if (this.destroyed) {
    terminateEarly();
    return;
  }
  try {
    sendTest();
  } catch {
    this.#setupFakeWorker();
  }
});

因此一次正常启动中, 主线程可能发送两次 test:

  • 创建 Worker 后立即发送一次;
  • 收到 ready 后再发送一次.

worker 用闭包变量保证只处理第一条:

let testMessageProcessed = false;
handler.on("test", data => {
  if (testMessageProcessed) {
    return;
  }
  testMessageProcessed = true;
  handler.send("test", data instanceof Uint8Array);
});

如果第一条 test 在 worker 初始化期间被排队, ready 后的第二次发送提供兜底; 如果第一条已经处理, 第二条直接忽略. 两次发送消除了脚本加载速度不同带来的时序竞争.

ready 的方向是 worker → 主线程, 表示 worker 脚本就绪; 第 7 篇的 Ready 方向是主线程 → worker, 表示文档级主线程路由就绪. 大小写不同, 语义与发生阶段也不同.

第 10 步, test 回包让 worker.promise 就绪

worker 处理第一条 test 后, 回发一个布尔值:

handler.send("test", data instanceof Uint8Array);

主线程收到 true 后:

messageHandler.on("test", data => {
  ac.abort();
  if (this.destroyed || !data) {
    terminateEarly();
    return;
  }
  this.#messageHandler = messageHandler;
  this.#port = worker;
  this.#webWorker = worker;
  this.#resolve();
});

直到这一刻, 三个字段才挂到 PDFWorker 实例上. 此前的 new PDFWorker(params) 虽然已经同步返回, 但启动仍未宣告成功.

#resolve() 做两件事:

#resolve() {
  this.#capability.resolve();
  this.#messageHandler.send("configure", {
    verbosity: this.verbosity,
  });
}
  1. resolve worker.promise, 其值是 undefined (Promise<void>), 不是另一个 worker 对象;
  2. 发送 configure, demo 中把 verbosity = 1 同步给 worker, worker 调用 setVerbosityLevel(1).

如果 test 回包为 false, 或等待期间任务已销毁, 主线程不会 resolve, 而是进入清理或回退路径.

第 11 步, 与 gpuPromise 汇合

真 worker 握手成功后, 第 2 篇的等待条件才成立:

Promise.all([worker.promise, gpuPromise]).then(([, hasGPU]) => {
  // 回填 hasGPU, 然后发送 GetDocRequest
});

demo 的 gpuPromise = Promise.resolve(false) 早已完成, 实际等待的是 worker.promise.

因此完整顺序是:

PDFWorker.create 同步返回
  → new Worker / ready / test 在后台推进
  → test 回包为 true
  → worker.promise resolve
  → Promise.all 继续
  → 发送 GetDocRequest
  → 创建文档级 d1 ↔ d1_worker 通道

PDFWorker.create 的返回值与 worker.promise 分别回答「实例是否已创建」和「实例是否可工作」, 两者不能混为一谈.

假 worker: 同一协议, 主线程运行

哪些情况会回退

真 worker 路径的任何启动失败, 都可能进入 #setupFakeWorker():

情况处理方式
Node 环境或此前已禁用 worker直接回退
主线程已有 globalThis.pdfjsWorker直接使用该实现
new Worker(...) 或早期 sendTest() 抛错捕获后回退
worker 初始化期间触发 error清理端口与 Worker 后回退
test 回包为 false清理后回退
等待期间 PDFWorker.destroy()不降级, reject worker.promise

terminateEarly 会中止事件监听、销毁全局 MessageHandler 并调用 worker.terminate(), 避免失败的真实 worker 留在后台.

LoopbackPort 替换线程边界

假 worker 先通过动态 import(this.workerSrc) 取得 WorkerMessageHandler, 然后建立本地回环端口:

const port = new LoopbackPort();
const id = `fake${PDFWorker.#fakeWorkerId++}`; // 首次为 fake0
 
const workerHandler = new MessageHandler("fake0_worker", "fake0", port);
WorkerMessageHandler.setup(workerHandler, port);
 
this.#port = port;
this.#messageHandler = new MessageHandler("fake0", "fake0_worker", port);
this.#resolve();

它不再创建 Worker, 也不执行 ready/test 握手. WorkerMessageHandler.setup 直接在主线程注册同一组 test、configure、GetDocRequest 处理器, 随后 worker.promise 就绪.

LoopbackPort 与真实 Worker 提供相同的 postMessage / 消息监听接口, 所以后续调用不变:

worker.messageHandler.sendWithPromise("GetDocRequest", docParams);

区别只在执行位置: 真 worker 把解析放到另一个线程, 假 worker 在主线程执行相同代码. 第 6、7 篇的 MessageHandler 与 WorkerTransport 不需要知道当前是哪一种.

demo 未走: 共享 workerPort 分支

如果用户设置的是:

pdfjsLib.GlobalWorkerOptions.workerPort = myWorker;

PDFWorker.create({ port: myWorker }) 会优先查 #workerPorts:

  • 已缓存且没有 _pendingDestroy: 返回已有 PDFWorker, 多份文档共享同一个 worker;
  • 已缓存但正在销毁: 立即抛错, 防止拿到半销毁实例;
  • 未缓存: new PDFWorker({ port }) 进入 #initializeFromPort(port).

#initializeFromPort 不再读取 workerSrc, 也不发送 test:

this.#port = port;
this.#messageHandler = new MessageHandler("main", "worker", port);
this.#messageHandler.on("ready", () => {
  // 外部 port 应已经完成初始化, 忽略 ready
});
this.#resolve();

这是因为调用方提供的是一个已经初始化、可由 PDF.js 直接使用的 Worker. demo 没有设置 workerPort, 所以走的是本文前面的完整创建与测试路径.

总结

本篇跟随 demo 走完了 PDFWorker.create 的完整启动链:

  1. create 同步查询共享 port 缓存, demo 未命中, 创建新 PDFWorker;
  2. 构造参数中的 undefined 触发默认值: port = null, verbosity = 1;
  3. 读取第一篇配置的 GlobalWorkerOptions.workerSrc;
  4. demo 的 CDN 地址跨域, 因此生成同源 Blob module wrapper, 再创建 Worker;
  5. 主线程建立 main ↔ worker 全局 MessageHandler, 立即发送第一次 test;
  6. worker 脚本初始化后注册全局处理器并发送 ready, 主线程再补发一次 test;
  7. worker 只处理第一条 test, 检查 Uint8Array 类型并回发布尔值;
  8. 主线程收到 true, 保存 port 与 handler, resolve worker.promise, 再发送 configure;
  9. Promise.all([worker.promise, gpuPromise]) 继续, 后续才发送 GetDocRequest;
  10. 真 worker 启动失败时, 用动态 import + LoopbackPort 在主线程运行同一套 worker 协议, 上层无需改变.

所以 PDFWorker 并不是简单的 new Worker 包装: 它同时负责启动时序、二进制能力探测、共享端口复用与透明降级. 这也是第 2 篇只凭 worker.promise 就能等待后续握手的原因.

第 9 篇回到第 7 篇留下的 GetReader 钩子, 展开网络流的 range 能力探测与 GetRangeReader 请求策略.

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

相关文章