StreamPDFStreamPDF
返回博客

PDF.js 源码解读: api

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: api

前言

这是 PDF.js 源码解读系列中关于「展示层入口」的一篇,介绍 src/display/api.js(约 3560 行)。用户 import * as pdfjsLib from "pdf.mjs" 拿到的几乎全部名字——getDocument、PDFDocumentProxy、PDFPageProxy、PDFWorker、RenderTask——都定义在这一个文件里。

「从 demo 深入浅出」系列已经按调用链走过这些方法的实例化路径:第 2 篇跟随 getDocument 的参数展开与握手,第 3 篇跟随 getPage,第 5 篇跟随 render。本篇换一个视角:不按某一次调用走,而是把整个文件的结构完整拆开,回答「这个文件由哪几部分组成、每部分承担什么职责、相互之间怎么咬合」。

一句话概括 api.js 的职责:它是主线程的门面层——对上给用户一组干净的代理类与任务句柄,对下把每一个方法调用翻译成 MessageHandler 消息发往 worker,并把 worker 推回来的数据安置到正确的缓存与回调里。

文件末尾的导出表就是全文的目录:

export {
  build,
  getDocument,
  PDFDataRangeTransport,
  PDFDocumentLoadingTask,
  PDFDocumentProxy,
  PDFPageProxy,
  PDFWorker,
  RenderTask,
  version,
};

九个名字里,build 与 version 是构建期注入的常量字符串,剩下七个是运行时 API。

模块总览

api.js 可以切成八块:

区块大致行数是否导出职责
getDocument~240是参数解析、worker 启动、握手、网络流选择
PDFDocumentLoadingTask~110是加载过程的用户句柄:promise / onProgress / destroy
PDFDataRangeTransport~60是自定义 range 加载的抽象基类(用户继承)
PDFDocumentProxy~450是worker 端 PDFDocument 的主线程代理
PDFPageProxy~720是worker 端 PDFPage 的主线程代理,渲染状态机
PDFWorker~330是worker 的创建、握手、假 worker 回退
WorkerTransport~840否消息路由中枢,页面与对象缓存
RenderTask / InternalRenderTask~280是 / 否渲染任务的用户句柄与调度循环

八块之间的持有关系:

持有者持有谁关系
getDocument以上全部把七个类串起来,同步返回 task
PDFDocumentLoadingTask_transport、_worker加载完成后代理交给用户
PDFDocumentProxy_transport所有方法转发给 transport
WorkerTransportmessageHandler、#pageCache、三个工厂每个方法 = 一条或一组消息
PDFPageProxy_transport、_intentStates渲染状态按 cacheKey 分桶
RenderTask_internalRenderTask用户可见句柄包内部任务
InternalRenderTaskgfx(CanvasGraphics)真正驱动 canvas 绘制

getDocument:一次性展开的入口

getDocument 的全部工作可以分成五段:建任务、展参数、起 worker、组装参数包、挂握手回调。demo 系列第 2 篇已经带具体值走了一遍,这里只保留结构性的观察。

参数展开:只读一次 src

函数开头把 src 一次性展开成 35+ 个局部变量,之后再也不回读 src:

function getDocument(src = {}) {
  const task = new PDFDocumentLoadingTask();
  const { docId } = task;
 
  const url = src.url ? getUrlProp(src.url) : null;
  const data = src.data ? getDataProp(src.data) : null;
  // ... 35+ 个局部变量
  const useWorkerFetch =
    typeof src.useWorkerFetch === "boolean"
      ? src.useWorkerFetch
      : (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) ||
        !!(
          BinaryDataFactory === DOMBinaryDataFactory &&
          cMapUrl && cMapPacked && standardFontDataUrl && wasmUrl &&
          isValidFetchUrl(cMapUrl, document.baseURI) &&
          // ...
        );

每个参数的解析都落在三行以内,但模式各异:有的归一化类型(getUrlProp 把 string 转成 URL),有的语义取反(ignoreErrors = src.stopAtErrors !== true),有的级联依赖前面刚算出的变量(useSystemFonts 依赖 disableFontFace),有的是复合条件(useWorkerFetch)。这一段的规则全集见 demo 系列第 2 篇,此处不重复。

设计要点:入口函数把「用户输入」与「内部状态」的转换集中在一个代码块里完成。后面的 4000 行代码只看到干净的局部变量与参数包,永远不需要再判断「用户到底传没传」。这是 PDF.js 全库的通行做法——校验收敛在边界。

两份参数包:跨线程的与主线程自留的

参数解析完毕后被打包成两份:

const docParams = {
  docId,
  apiVersion: /* BUNDLE_VERSION */,
  data,
  password,
  disableAutoFetch,
  rangeChunkSize,
  docBaseUrl,
  enableXfa,
  evaluatorOptions: {
    maxImageSize, disableFontFace, ignoreErrors,
    isOffscreenCanvasSupported, isImageDecoderSupported,
    canvasMaxAreaInBytes, fontExtraProperties, useSystemFonts,
    useWasm, useWorkerFetch, cMapUrl, cMapPacked, iccUrl,
    standardFontDataUrl, wasmUrl,
    hasGPU: false, // 占位,worker 就绪后回填
  },
};
const transportParams = {
  ownerDocument,
  pdfBug,
  styleElement,
  enableHWA,
  loadingParams: { disableAutoFetch, enableXfa },
};

docParams 经 postMessage 发往 worker,因此里面所有值必须可序列化;transportParams 只留在主线程,所以可以装 ownerDocument 这样的 DOM 对象。evaluatorOptions.hasGPU 先占位为 false,等 gpuPromise 与 worker 握手都完成后回填真实值——GPU 探测与 worker 启动是并行的,谁也不等谁。

网络流三分支

data、range、url 三个参数互斥,优先级即代码顺序:

let networkStream;
if (data) {
  // 整份 PDF 已在内存,不需要网络流
} else if (rangeTransport) {
  networkStream = new PDFDataTransportStream({
    pdfDataRangeTransport: rangeTransport, disableRange, disableStream,
  });
} else if (url) {
  const NetworkStream = getNetworkStream(url); // 优先 fetch,回退 XHR
  networkStream = new NetworkStream({
    url, httpHeaders, withCredentials, rangeChunkSize, disableRange, disableStream,
  });
} else {
  throw new Error("getDocument - expected either `data`, `range`, or `url` parameter.");
}

getNetworkStream 按环境选择实现:http(s) 走 PDFFetchStream,不支持的旧环境回退 PDFNetworkStream(XHR)。data 分支连流都不建——字节已经齐了,worker 直接通过 GetDocRequest 的 transfer 拿到 data.buffer 的所有权,主线程侧的那份随即失效。

握手时序与竞态

Promise.all([worker.promise, gpuPromise])
  .then(function ([, hasGPU]) {
    if (worker.destroyed) throw new Error("Worker was destroyed");
    docParams.evaluatorOptions.hasGPU = hasGPU;
 
    const workerIdPromise = worker.messageHandler.sendWithPromise(
      "GetDocRequest", docParams, data ? [data.buffer] : null
    );
    // ... 建 networkStream
    return workerIdPromise.then(workerId => {
      const messageHandler = new MessageHandler(docId, workerId, worker.port);
      const transport = new WorkerTransport(
        messageHandler, task, networkStream, transportParams, transportFactory, pagesMapper
      );
      task._transport = transport;
      if (task.destroyed) throw new Error("Loading aborted");
      messageHandler.send("Ready", null);
    });
  })
  .catch(task._capability.reject)
  .finally(task._setupCapability.resolve);
 
return task;

注意三件事:

  1. task 是同步返回的,整条 .then 链在后台推进。链上任何一步出错都 reject task._capability,即用户 await loadingTask.promise 等的那个 promise。
  2. _setupCapability 无论成败都 resolve。它不向用户报告结果,只向 destroy() 报告「_transport 要么已就绪、要么永远不会就绪」——这是 destroy 与握手竞态的同步点,下文详述。
  3. 挂 task._transport 之后还要再查一次 task.destroyed:用户可能在握手期间调了 destroy(),此时抛错让链路走 .catch,有序关闭交给 transport 完成。

PDFDocumentLoadingTask:加载过程的用户句柄

class PDFDocumentLoadingTask {
  static #docId = 0;
 
  _capability = Promise.withResolvers();
  _setupCapability = Promise.withResolvers();
  _transport = null;
  _worker = null;
 
  docId = `d${PDFDocumentLoadingTask.#docId++}`;
  destroyed = false;
  onPassword = null;
  onProgress = null;
 
  get promise() {
    return this._capability.promise;
  }
}

docId 由静态计数器生成,第一个文档是 d1。这个 id 会出现在 MessageHandler 的源/目标名里(new MessageHandler(docId, workerId, ...)),多文档并存时靠它区分消息属于哪份文档。

两个 capability 分工明确:

字段谁 resolve/reject谁等待语义
_capabilitytransport 的 GetDoc / DocException 处理器用户(task.promise)文档就绪或加载失败
_setupCapabilitygetDocument 的 .finallydestroy()启动链路已结束(不论成败)

destroy 的竞态处理

destroy() 是这个小类里最讲究的方法,四步顺序不能乱:

async destroy() {
  this.destroyed = true;
  // 启动链稍后会用 "Loading aborted" reject _capability;先认领这个 rejection,
  // 避免被当成未处理的 promise 拒绝
  this._capability.promise.catch(() => {});
 
  try {
    // 必须同步设置:后续对共享 workerPort 的 PDFWorker.create() 要立即看到并抛错
    if (this._worker?.port) this._worker._pendingDestroy = true;
    // 等启动链结束,保证 _transport 已设置,"Terminate" 消息才发得出去
    await this._setupCapability.promise;
    await this._transport?.destroy();
  } catch (ex) {
    if (this._worker?.port) delete this._worker._pendingDestroy;
    throw ex;
  }
  this._transport = null;
  this._worker?.destroy();
  this._worker = null;
}

_pendingDestroy 的同步设置对应 issue 16777:如果等 await 之后再标记,事件循环空隙里另一个 getDocument 可能复用同一个 workerPort,拿到一个正在销毁的 worker。失败时回滚标记再抛错——worker 没毁掉,就不该留着「正在销毁」的封条。

PDFDataRangeTransport:推模式的 range 抽象

用户想自己控制 range 请求(比如走自定义协议、加鉴权)时,继承这个抽象类:

class PDFDataRangeTransport {
  #capability = Promise.withResolvers();
  #listener = null;
 
  constructor(length, initialData, progressiveDone = false, contentDispositionFilename = null) { ... }
 
  onDataRange(begin, chunk) {
    this.#listener({ type: "range", begin, chunk });
  }
 
  onDataProgressiveRead(chunk) {
    this.#capability.promise.then(() => {
      this.#listener({ type: "progressiveRead", chunk });
    });
  }
 
  transportReady(listener) {
    this.#listener = listener;
    this.#capability.resolve();
  }
 
  requestDataRange(begin, end) {
    unreachable("Abstract method PDFDataRangeTransport.requestDataRange");
  }
  abort() {}
}

注意两类入口的不对称:

  • onDataRange 直接转发给 listener。range 数据是 transport 通过 requestDataRange 主动拉来的,能拉到数据说明 listener 早已注册;
  • onDataProgressiveRead / onDataProgressiveDone 要先等 #capability。渐进数据是用户那边网络就绪后就推过来的,可能早于 transportReady 被调用;不排队的话这批数据会打在 #listener === null 上。

设计要点:同样是「数据到了」,拉模式天然有序、推模式必须用 promise 排队。把排队逻辑收在基类里,子类只管拿到数据就调,不用关心时序。

PDFDocumentProxy:三十个转发方法

PDFDocumentProxy 是 worker 端 PDFDocument 的代理,构造时只存两个字段:

class PDFDocumentProxy {
  constructor(pdfInfo, transport) {
    this._pdfInfo = pdfInfo;
    this._transport = transport;
    // TESTING / INTERNAL_VIEWER 构建下再动态挂 getRawData 等方法
  }
}

之后约 30 个方法,绝大多数是一行转发:getPage、getOutline、getMetadata、getAttachments、getJSActions……全部委托给 this._transport 的同名方法。值得单独说的只有四类。

同步 getter:把已就绪的数据直接摊开

numPages、fingerprints、annotationStorage、canvasFactory 等 getter 不发消息——数据在 GetDoc 握手时已经随 pdfInfo 到达主线程。isPureXfa 用了 shadow 工具:

get isPureXfa() {
  return shadow(this, "isPureXfa", !!this._transport._htmlForXfa);
}

shadow 在首次读取时把值算出来,再用 Object.defineProperty 替换成普通数据属性,之后每次访问都是纯字段读取,不再走 getter。

测试专属的动态方法

if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
  Object.defineProperty(this, "getNetworkStreamName", {
    value: () => this._transport.getNetworkStreamName(),
  });
  // getXFADatasets / getStartXRefPos / getAnnotArray 同理
}

生产构建的预处理会把整个 if 块删掉——PDFJSDev.test("TESTING") 在发布包中是常量 false,方法根本不会出现在实例上。pdf.js 全库的诊断能力都走这个开关,不污染生产 API 表面。

getOptionalContentConfig 的 intent 转换

getOptionalContentConfig({ intent = "display" } = {}) {
  const { renderingIntent } = this._transport.getRenderingIntent(intent);
  return this._transport.getOptionalContentConfig(renderingIntent);
}

字符串 intent 先经 getRenderingIntent 换算成位标志,再随请求发往 worker。这个 renderingIntent 稍后会与 render() 里的做按位与校验——两边 intent 不一致时 render 抛错,防止「按 display 取的配置拿去 print 渲染」。

cleanup 的 XFA 特例

cleanup(keepLoadedFonts = false) {
  return this._transport.startCleanup(keepLoadedFonts || this.isPureXfa);
}

纯 XFA 文档强制保留已加载的字体。XFA 表单走 HTML 渲染路径,字体靠 DOM 的 @font-face 挂在文档上;清掉字体再渲染会整页回退成系统字体,因此 isPureXfa 时无论如何都 keepLoadedFonts = true。

PDFPageProxy:intentState 分桶的渲染状态机

PDFPageProxy 是全文件状态最重的类。构造时除了页码与页信息,还建立两级对象缓存:

constructor(pageIndex, pageInfo, transport, pagesMapper, pdfBug = false) {
  this._pageIndex = pageIndex;
  this._pageInfo = pageInfo;
  this._transport = transport;
  /** @type {PDFObjects} */
  this.commonObjs = transport.commonObjs; // 文档级共享:字体、跨页图片
  this.objs = new PDFObjects();           // 页面级私有:本页图片与图案
 
  this._intentStates = new Map();
  // ...
}

commonObjs 与 objs 的分工对应 worker 推数据的两条消息:commonobj(文档级)与 obj(页面级),下文 WorkerTransport 一节会看到。

_intentStates:按 cacheKey 分桶

同一页可能被并发渲染多次:display 一份、print 一份、编辑模式一份。每份的 operatorList、renderTasks 集合、ready capability 都独立,PDF.js 用 cacheKey 把它们分桶:

const intentState = this._intentStates.getOrInsertComputed(cacheKey, makeObj);

cacheKey 由 WorkerTransport.getRenderingIntent 合成(intent 位标志 + 注解存储哈希),详见下文。render() 与 getOperatorList() 各自先算 cacheKey,同 key 的调用共享同一份 operatorList——同一页用同一 intent 渲染两次,worker 只解析一遍。

render():立即泵流,就绪后启绘

render() 的执行分两段。第一段同步完成:

render({ canvasContext, canvas = canvasContext.canvas, viewport, intent = "display", ... }) {
  const intentArgs = this._transport.getRenderingIntent(intent, annotationMode, ...);
  const { renderingIntent, cacheKey } = intentArgs;
  this.#pendingCleanup = false; // 有渲染在跑,取消挂起的清理
 
  optionalContentConfigPromise ||=
    this._transport.getOptionalContentConfig(renderingIntent);
 
  const intentState = this._intentStates.getOrInsertComputed(cacheKey, makeObj);
  if (intentState.streamReaderCancelTimeout) {
    clearTimeout(intentState.streamReaderCancelTimeout); // 取消延迟中止
    intentState.streamReaderCancelTimeout = null;
  }
 
  if (!intentState.displayReadyCapability) {
    // 首次渲染:建好 operatorList 容器,立刻开始向 worker 泵取
    intentState.displayReadyCapability = Promise.withResolvers();
    intentState.operatorList = { fnArray: [], argsArray: [], lastChunk: false, separateAnnots: null };
    this._pumpOperatorList(intentArgs);
  }
  // ... 建 InternalRenderTask,登记进 intentState.renderTasks

第二段异步发生:等「operatorList 首块就绪」与「可选内容配置」都到位后,才真正初始化画布:

  Promise.all([
    intentState.displayReadyCapability.promise,
    optionalContentConfigPromise,
  ]).then(([transparency, optionalContentConfig]) => {
    if (this.destroyed) { complete(); return; }
    if (!(optionalContentConfig.renderingIntent & renderingIntent)) {
      throw new Error("Must use the same `intent`-argument ...");
    }
    internalRenderTask.initializeGraphics({ transparency, optionalContentConfig });
    internalRenderTask.operatorListChanged();
  }).catch(complete);
 
  return renderTask; // 同步返回,不等任何 promise
}

这里有两个设计决定值得记住:

  1. 取流不等人。_pumpOperatorList 在 render() 体内同步启动,不等 canvas 就绪。worker 解析与主线程准备画布并行,第一块 operatorList 到达时 StartRenderPage 消息回执 displayReadyCapability,画布通常早已初始化完。
  2. intent 按位校验。optionalContentConfig.renderingIntent & renderingIntent 为零说明两次调用的 intent 不匹配(比如配置按 print 取、渲染按 display),直接抛错而不是渲染出缺图层的页面。

_pumpOperatorList:把流式接口泵进数组

operatorList 跨线程传输用的是 sendWithStream——worker 解析出一块就推一块:

_pumpOperatorList({ renderingIntent, cacheKey, annotationStorageSerializable, modifiedIds }) {
  const { map, transfer } = annotationStorageSerializable;
  const readableStream = this._transport.messageHandler.sendWithStream(
    "GetOperatorList",
    {
      pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1,
      pageIndex: this._pageIndex,
      intent: renderingIntent,
      cacheKey,
      annotationStorage: map,
      modifiedIds,
    },
    /* queueingStrategy = */ undefined,
    transfer
  );
  const reader = readableStream.getReader();
  const intentState = this._intentStates.get(cacheKey);
  intentState.streamReader = reader;
 
  const pump = () => {
    reader.read().then(({ value, done }) => {
      if (done) { intentState.streamReader = null; return; }
      if (this._transport.destroyed) return;
      this._renderPageChunk(value, intentState); // 追加进 fnArray/argsArray,唤醒各渲染任务
      pump();
    }, reason => { /* 出错:标记 lastChunk,唤醒并 reject 各 capability */ });
  };
  pump();
}

pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1 这一行值得展开:_pageIndex + 1 是用户眼中的页码,getPageId 把它映射成源文档页码,再 - 1 换回零基索引。页面被重排/删除/复制时(pagesMapper 的职责),用户页码与 worker 页码不再相同,所有跨线程消息都必须经过这层翻译。

_abortOperatorList:100ms 的延迟取消

渲染被取消时(比如用户快速缩放),主线程不会立刻通知 worker 停止解析:

if (reason instanceof RenderingCancelledException) {
  let delay = RENDERING_CANCELLED_TIMEOUT; // 100ms
  if (reason.extraDelay > 0 && reason.extraDelay < 1000) {
    delay += reason.extraDelay;
  }
  intentState.streamReaderCancelTimeout = setTimeout(() => {
    intentState.streamReaderCancelTimeout = null;
    this._abortOperatorList({ intentState, reason, force: true });
  }, delay);
  return;
}

注释解释了原因:viewer 里缩放/旋转后马上会以新参数重新 render() 同一页,而 cacheKey 只含 intent 不含 scale——新渲染会复用同一条流。如果一取消就掐断 worker 的解析,重新渲染得从零再解析一遍。留 100ms 窗口,新 render() 进来时 clearTimeout 掉这个延迟任务,流就保住了。超过窗口没人复用,才 force: true 真正取消。

另一层保护在此之前:

if (intentState.renderTasks.size > 0) return;

同一 intentState 上还挂着别的渲染任务时,单个任务的错误不掐断流——一次失败的 render() 不能搞坏同页的其他渲染。

cleanup:四个条件缺一不可

页面级清理不是「调了就清」:

#tryCleanup() {
  if (!this.#pendingCleanup || this.destroyed) return false;
  for (const { renderTasks, operatorList } of this._intentStates.values()) {
    if (renderTasks.size > 0 || !operatorList.lastChunk) return false;
  }
  this._intentStates.clear();
  this.objs.clear();
  this.#pendingCleanup = false;
  return true;
}

必须同时满足:有人发起过清理(#pendingCleanup)、页面未销毁、所有 intentState 都没有在跑的渲染任务、所有 operatorList 都已收完末块。render() 完成回调里打印意图会主动置 #pendingCleanup = true 并尝试清理——打印动辄几十页,打完立即释放字图数据,内存才能压住。

PDFWorker:创建、握手与回退

PDFWorker 封装「解析线程」这一资源。它的静态块在类加载时就定好两件事(GENERIC 构建):

static {
  if (isNodeJS) {
    // Node.js 不支持这里的 Worker 用法,直接禁用
    this.#isWorkerDisabled = true;
    GlobalWorkerOptions.workerSrc ||= PDFJSDev.test("LIB")
      ? "../pdf.worker.js"
      : "./pdf.worker.mjs";
  }
  this._isSameOrigin = (baseUrl, otherUrl) => { /* 非同源或非 http 返回 false */ };
  this._createCDNWrapper = url => {
    const wrapper = `await import("${url}");`;
    return URL.createObjectURL(new Blob([wrapper], { type: "text/javascript" }));
  };
}

_createCDNWrapper 解决的是真实部署里的高频问题:workerSrc 指向 CDN(跨域)时 new Worker(url) 会被浏览器拒绝。包一层同源 blob,由 blob 里的 import() 去拉 CDN 代码——import 不受 worker 脚本的同源限制。

test 握手:验证 transfer 真的可用

#initialize 建好 Worker 与 MessageHandler 后,不是直接宣布就绪,而是先发一个探测消息:

const sendTest = () => {
  const testObj = new Uint8Array();
  // 验证 postMessage 的 transfer 能力:Uint8Array 到 worker 侧仍是 Uint8Array
  messageHandler.send("test", testObj, [testObj.buffer]);
};
sendTest(); // 立即发一次
// worker 的 "ready" 消息到达后再发一次;worker 只处理先到的那条

worker 回包 data 为真,才把 #messageHandler / #port / #webWorker 挂上实例并 resolve。回包为假或出错,走 terminateEarly 清理现场并回退到假 worker。两次发送是为了覆盖时序:worker 加载快时立即的 test 就到,慢时由 ready 触发的那次兜底。

假 worker 回退链

真 worker 不可用(Node 环境、创建抛异常、test 失败)时回退到「假 worker」——把 worker 代码加载进主线程跑:

#setupFakeWorker() {
  if (!PDFWorker.#isWorkerDisabled) {
    warn("Setting up fake worker.");
    PDFWorker.#isWorkerDisabled = true; // 后续文档不再尝试真 worker
  }
  PDFWorker._setupFakeWorkerGlobal.then(WorkerMessageHandler => {
    if (this.destroyed) { /* reject */ return; }
    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);
    this.#resolve();
  });
}

LoopbackPort 是一对互通的本地端口,消息走同步派发而非 postMessage。对上层的 WorkerTransport 来说接口完全一致——它不知道对端是线程还是回环。_setupFakeWorkerGlobal 用 shadow 缓存加载 promise,全库只动态 import 一次 worker 代码。

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.\n" +
        "Please remember to await `PDFDocumentLoadingTask.destroy()`-calls."
      );
    }
    return cachedPort;
  }
  return new PDFWorker(params);
}

#workerPorts 是 WeakMap,以 workerPort 为键缓存 PDFWorker:传入同一 port 的多次 create 共享同一个 worker。撞上 _pendingDestroy 封条(上文 destroy() 同步设置的那个)直接抛错,错误信息明确告诉你该 await 销毁——这是 issue 16777 的修复。

WorkerTransport:消息路由中枢

WorkerTransport 不导出,却是主线程真正的枢纽:文档级缓存、页面缓存、消息路由表都在它身上。

三组页面缓存

#pageCache = new Map();      // pageIndex -> PDFPageProxy
#pagePromises = new Map();   // pageIndex -> Promise<PDFPageProxy>
#pageRefCache = new Map();   // "12R0" -> pageIndex

getPage 先查 #pagePromises,同一页的并发调用共享同一个 promise,worker 只收一条 GetPage 消息。注意两个索引方向:pageIndex = pageNumber - 1 是用户视角的零基序号(缓存的键),newPageIndex = pagesMapper.getPageId(pageNumber) - 1 才是发给 worker 的源文档序号。页面重排后两者分叉,缓存按用户视角建,消息按源视角发。

#pageRefCache 服务于反向查询:cachedPageNumber(ref) 把 "12R0" 形式的引用字符串(gen 为 0 时省略后缀)映射回页码,纯本地命中,不发消息。

#cacheSimpleMethod:幂等请求的本地缓存

#cacheSimpleMethod(name, data = null) {
  return this.#methodPromises.getOrInsertComputed(name, () =>
    this.messageHandler.sendWithPromise(name, data)
  );
}

GetFieldObjects、GetDocJSActions、GetOptionalContentConfig、GetMetadata 这类结果不随调用变化的请求走它:第一次调用发消息,之后直接返回缓存的 promise。反例是 getSignatureData——签名字节体量不小,缓存在 Map 里会跟着文档活一辈子,注释明确写了「不缓存,一次性取用」。

getRenderingIntent:位标志与 cacheKey

render、getOperatorList、getAnnotations、getOptionalContentConfig 都先经它把字符串参数编译成位标志:

let renderingIntent = RenderingIntentFlag.DISPLAY;
switch (intent) {
  case "any":   renderingIntent = RenderingIntentFlag.ANY; break;
  case "print": renderingIntent = RenderingIntentFlag.PRINT; break;
}
switch (annotationMode) {
  case AnnotationMode.DISABLE:         renderingIntent += RenderingIntentFlag.ANNOTATIONS_DISABLE; break;
  case AnnotationMode.ENABLE_FORMS:    renderingIntent += RenderingIntentFlag.ANNOTATIONS_FORMS; break;
  case AnnotationMode.ENABLE_STORAGE:
    renderingIntent += RenderingIntentFlag.ANNOTATIONS_STORAGE;
    annotationStorageSerializable = annotationStorage.serializable; // 序列化表单数据随消息走
    break;
}
if (isEditing) renderingIntent += RenderingIntentFlag.IS_EDITING;
if (isOpList)  renderingIntent += RenderingIntentFlag.OPLIST;
 
const { ids: modifiedIds, hash: modifiedIdsHash } = annotationStorage.modifiedIds;
const cacheKeyBuf = [renderingIntent, annotationStorageSerializable.hash, modifiedIdsHash];
return { renderingIntent, cacheKey: cacheKeyBuf.join("_"), ... };

cacheKey 把「渲染意图 + 表单快照 + 改动集合」压成一个字符串——表单内容变了,key 就变,_intentStates 自然另起一桶重新解析;表单没变,同 intent 的渲染共享流。打印且传了 PrintAnnotationStorage 时改用打印专用存储,与 display 的编辑互不影响。

setupMessageHandler:十三张消息路由

构造函数的末尾调 setupMessageHandler(),注册 worker→主线程方向的全部消息处理器。按职能分组:

数据通道三条

消息处理器要点
GetReader取 networkStream.getFullReader(),sink.onPull 里读一块 enqueue 一块,buffer 以 transfer 送出
ReaderHeadersReady回 {isStreamingSupported, isRangeSupported, contentLength};两者皆支持时摘掉 fullReader 的 onProgress
GetRangeReader按 [begin, end) 取 range reader;取不到(数据已在全量流里)直接 sink.close(),不重复发请求

GetRangeReader 里「取不到就关闭」的分支有完整注释:流式加载时数据可能已被 fullReader 拉下来但还没 postMessage 到 worker,此时再发 range 请求是浪费,worker 等全量流即可。

文档生命周期四条

消息处理器要点
GetDoc设置 pagesMapper.pagesNumber,摘出 htmlForXfa,resolve loadingTask._capability 为 PDFDocumentProxy
DocExceptionwrapReason(ex) 后 reject 同一个 capability
PasswordRequest建 #passwordCapability,调用户的 onPassword(updatePassword, code),把回调 promise 返回给 worker
DataLoaded补发一次 100% 的 onProgress,resolve downloadInfoCapability

PasswordRequest 把「worker 阻塞等密码」翻译成「主线程等用户回调」:updatePassword 收到 Error 就 reject(放弃打开),收到字符串就 resolve {password} 让 worker 重试解密。

对象下发两条

commonobj(文档级)按类型分五路:

messageHandler.on("commonobj", ([id, type, exportedData]) => {
  if (this.commonObjs.has(id)) return null; // 去重
  switch (type) {
    case "Font": {
      const font = new FontFaceObject(new FontInfo(exportedData), inspectFont, ...);
      this.fontLoader.bind(font)
        .catch(() => messageHandler.sendWithPromise("FontFallback", { id }))
        .finally(() => {
          if (!font.fontExtraProperties) font.clearData(); // 挂进 DOM 后立即释放字体字节
          this.commonObjs.resolve(id, font);
        });
      break;
    }
    case "CopyLocalImage": /* 跨页复用图片:在页面缓存里找到同 ref 的数据,structuredClone 一份 */ break;
    case "FontPath": this.commonObjs.resolve(id, new FontPathInfo(exportedData)); break;
    case "Image":    this.commonObjs.resolve(id, exportedData); break;
    case "Pattern":  this.commonObjs.resolve(id, new PatternInfo(exportedData).getIR()); break;
  }
  return null;
});

字体路径最讲究:bind 失败(系统拒绝加载)不直接判死,而是发 FontFallback 让 worker 换内置渲染兜底;成功后 clearData 立即释放可能上 MB 的字体字节,不等文档 cleanup。

obj(页面级)只接 Image / Pattern 两类,但多一道防线:

if (pageProxy._intentStates.size === 0) {
  imageData?.bitmap?.close(); // 页面已清理,直接释放 ImageBitmap
  return;
}

这是 bug 1854145 的修复:页面 cleanup 成功后 worker 才推来的数据不能入库(PDFObjects 已清空,入了也没人释放),直接 close bitmap 防泄漏。

其余:StartRenderPage 唤醒页面对象的 displayReadyCapability;DocProgress 转发加载进度;FetchBinaryData(非 MOZCENTRAL)由 binaryDataFactory 在主线程下载 CMap/字体/wasm——正是 getDocument 里 useWorkerFetch = false 时的主线程下载路径。

saveDocument 与 extractPages:序列化与重映射

保存文档要把主线程的表单改动带回 worker:

saveDocument() {
  const { map, transfer } = this.annotationStorage.serializable;
  return this.messageHandler.sendWithPromise("SaveDocument", {
    isPureXfa: !!this._htmlForXfa,
    numPages: this._numPages,
    annotationStorage: map,
    filename: this.#fullReader?.filename ?? null,
  }, transfer).finally(() => {
    this.annotationStorage.resetModified(); // 无论成败都清「已改动」标记
  });
}

extractPages 更复杂:页面经过重排后,注解里记的 pageIndex 是用户视角,worker 按源视角查找,必须经 pagesMapper.getMapping() 翻译;同一源页被抽成多份副本时,再用 copyLevels 标明每条编辑器注解归属第几份副本:

const copyLevel = copyLevels?.[v.pageIndex] ?? 0;
const sourceIdx = mapping[v.pageIndex] - 1;
if (sourceIdx !== v.pageIndex || copyLevel !== 0) {
  remapped.set(k, { ...v, pageIndex: sourceIdx, copyLevel });
  continue;
}

copyLevels 里 -1 表示该页未被抽取——对应条目仍然保留,因为抽出的印章注解可能引用着它的 bitmapId,worker 看到负值会跳过注解本身但保留位图关联。

destroy:先停页面,再停 worker

destroy() {
  if (this.destroyCapability) return this.destroyCapability.promise; // 幂等
  this.destroyed = true;
  this.destroyCapability = Promise.withResolvers();
  this.#passwordCapability?.reject(new Error("Worker was destroyed during onPassword callback"));
 
  const waitOn = [];
  for (const page of this.#pageCache.values()) waitOn.push(page._destroy()); // 等所有渲染收尾
  // ... 清缓存、resetModified
  const terminated = this.messageHandler.sendWithPromise("Terminate", null); // 等 worker 长任务结束
  waitOn.push(terminated);
 
  Promise.all(waitOn).then(() => {
    this.commonObjs.clear();
    this.fontLoader.clear();
    this.#methodPromises.clear();
    this.filterFactory.destroy();
    TextLayer.cleanup();
    this.#networkStream?.cancelAllRequests(new AbortException("Worker was terminated."));
    this.messageHandler?.destroy();
    this.messageHandler = null;
    this.destroyCapability.resolve();
  }, this.destroyCapability.reject);
  return this.destroyCapability.promise;
}

顺序是「页面 → worker → 本地资源」:先让所有 RenderTask 落定(rAF 回调可能还在排队),再发 Terminate 等 worker 收尾,最后清主线程的字体、滤镜、文本层与网络请求。挂在 onPassword 上的文档会立即收到拒绝,不会悬死。

RenderTask 与 InternalRenderTask:句柄与调度器

render() 同步返回的 RenderTask 只是一层壳,真正的调度在 InternalRenderTask:

class RenderTask {
  onContinue = null; // 每次渲染暂停时调用,参数是「继续」函数
  onError = null;    // 渲染出错/被取消时同步调用
 
  get promise() {
    return this._internalRenderTask.capability.promise;
  }
 
  cancel(extraDelay = 0) {
    this._internalRenderTask.cancel(null, extraDelay);
  }
}

注意 RenderTask 本身不是 thenable——await page.render(...) 等不到渲染完成,必须 await renderTask.promise。demo 系列第 5 篇专门踩过这个坑。

initializeGraphics:画布只能同时服务一个任务

initializeGraphics({ transparency = false, optionalContentConfig }) {
  if (this.cancelled) return;
  if (this._canvas) {
    if (InternalRenderTask.#canvasInUse.has(this._canvas)) {
      throw new Error("Cannot use the same canvas during multiple render() operations. ...");
    }
    InternalRenderTask.#canvasInUse.add(this._canvas);
  }
  const canvasContext = this._canvasContext || this._canvas.getContext("2d", {
    alpha: false,
    willReadFrequently: !this._enableHWA,
  });
  this.gfx = new CanvasGraphics(canvasContext, this.commonObjs, this.objs, ...);
  this.gfx.beginDrawing({ transform, viewport, transparency, background });
  this.operatorListIdx = 0;
  this.graphicsReady = true;
  this.graphicsReadyCallback?.();
}

#canvasInUse 是静态 WeakSet:同一 canvas 上的第二次 render() 直接抛错,报错信息里写明了解法(换 canvas 或先取消/等完成)。2D 上下文的两个开关也各有来历:alpha: false 告诉浏览器画布不透明,合成时省一次混合;willReadFrequently: !enableHWA 在开硬件加速时声明「不常读回」,让浏览器放心把画布留在 GPU 侧。

调度循环:rAF 与微任务两条路

_scheduleNext() {
  if (this._useRequestAnimationFrame) {
    this.#rAF = window.requestAnimationFrame(() => {
      this.#rAF = null;
      this._nextBound().catch(this._cancelBound);
    });
  } else {
    Promise.resolve().then(this._nextBound).catch(this._cancelBound);
  }
}
 
async _next() {
  if (this.cancelled) return;
  this.operatorListIdx = this.gfx.executeOperatorList(
    this.operatorList, this.operatorListIdx, this._continueBound, this.stepper, this._operationsFilter
  );
  if (this.operatorListIdx === this.operatorList.argsArray.length) {
    this.running = false;
    if (this.operatorList.lastChunk) {
      this.gfx.endDrawing();
      InternalRenderTask.#canvasInUse.delete(this._canvas);
      this.callback(); // 回到 PDFPageProxy.render 里的 complete
    }
  }
}

executeOperatorList 每轮最多执行约 15ms(canvas.js 的时间片常量),到点经 continueCallback 回到这里再排下一轮。显示渲染用 requestAnimationFrame 与帧对齐;打印渲染走微任务(useRequestAnimationFrame: !intentPrint)——打印没有帧的概念,微任务排空更快。若用户设了 RenderTask.onContinue,调度权交给用户:每个时间片结束时调 onContinue(continueFn),由用户决定何时调 continueFn 继续,可以借此实现「空闲时再渲染」之类的策略。

每轮结束检查「已执行到数组末尾且已是末块」,成立才 endDrawing 并回调完成;否则说明流还没推完,running = false 挂着,等下一个 chunk 到达时由 operatorListChanged() 重新启动。

版本常量与导出

文件末尾两个常量由构建期注入:

const version = typeof PDFJSDev !== "undefined" ? PDFJSDev.eval("BUNDLE_VERSION") : null;
const build = typeof PDFJSDev !== "undefined" ? PDFJSDev.eval("BUNDLE_BUILD") : null;

PDFJSDev.eval 在打包时被替换成字面量(如 "6.3.289"),源码树里直接跑则为 null。getDocument 组装 docParams 时把 apiVersion 发给 worker,worker 用它校验主从两端版本一致——主线程 API 与 worker 来自不同版本是部署事故的高发区,这一校验能在握手时就炸出来。

总结

api.js 是 PDF.js 主线程的门面:它把 worker 通讯、流式传输、字体装载、画布调度全部藏在七个导出名字背后,用户面对的只有「加载文档 → 取页面 → 渲染」三个动词。

一次完整渲染在本文各节的落点

阶段入口关键机制
加载getDocument(src)参数一次性展开、GetDocRequest 握手、网络流三分支
等待loadingTask.promise_capability / _setupCapability 双 promise
取页pdf.getPage(n)三组页面缓存、pagesMapper 页码翻译
渲染page.render(params)intentState 分桶、流式 operatorList、rAF 时间片
取消renderTask.cancel()100ms 延迟中止、同桶任务隔离
保存pdf.saveDocument()annotationStorage 序列化回传
销毁loadingTask.destroy()_pendingDestroy 封条、Terminate 收尾

六条设计原则

  1. 校验收敛在边界。src 只在 getDocument 开头读一次,展开成 35+ 个干净局部变量;内部 4000 行不再面对「用户传没传」。
  2. 跨线程的数据必须可序列化,主线程自留的随便。docParams 与 transportParams 的拆分就是按「过不过 postMessage」划的线。
  3. 同步返回句柄,异步推进链路。getDocument 返回 task、render() 返回 renderTask,都是同步的;错误经 capability 异步送达,destroy() 与启动链的竞态由 _setupCapability 与 _pendingDestroy 两个同步点消解。
  4. 流式优于整包。operatorList 按块泵、文本按 highWaterMark 分段、range 数据随要随取——首屏不等全量解析完。
  5. 取消要懒,清理要严。渲染取消留 100ms 复用窗口;清理则必须满足「无任务、流已尽」才执行,obj 消息迟到时宁可丢弃 bitmap 也不入库。
  6. 能力回退不打扰上层。真 worker 不行就假 worker,字体挂了走 FontFallback,LoopbackPort 与真端口同构——WorkerTransport 以上的代码对降级无感知。

读懂 api.js 之后,展示层的其余文件都可以按「它服务于这里的哪个环节」来定位:message_handler.js 是消息语义的实现,canvas.js 是 executeOperatorList 的算子集,fetch_stream.js / network.js 是网络流三分支的两条腿。它们各自的源码解读,另文展开。

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

相关文章