StreamPDFStreamPDF
返回博客

PDF.js 源码解读: worker_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: worker_stream

前言

这是 PDF.js 源码解读系列中关于「字节传输」的一篇,介绍 src/core/worker_stream.js(89 行)里的 PDFWorkerStream、PDFWorkerStreamReader 与 PDFWorkerStreamRangeReader。

前面的文章一直在沿着解析链路向下走:Parser 把字节切成 token,XRef 把对象号映射到文件偏移,ChunkedStream 把网络分块伪装成一份完整的文件。但这些模块都有一个共同前提——Worker 手里已经有一个可以读的 BasePDFStream。本篇补上的正是这个前提:当 PDF 来自 URL 时,Worker 里的字节究竟是谁送进来的,又是怎么送进来的。

PDF.js 把网络访问留在主线程,也就是 display 层;把解析放在 Worker。Worker 不直接 fetch,也不直接持有主线程的网络流,两边只通过 MessageHandler 的流式消息协议搬字节。worker_stream.js 是 Worker 这一侧的适配器:它实现 BasePDFStream 的契约,把 read() 翻译成跨线程消息,再把消息里收到的 Uint8Array 还原成 ArrayBuffer。

一句话概括:PDFWorkerStream 是 Worker 的虚拟字节源,上游是 display 层的网络流,下游是 ChunkedStreamManager 与解析器。

版本说明:本篇按当前仓库这一版 worker_stream.js 讲解。它没有自己的网络请求、缓存或重试逻辑,只依赖 src/shared/base_pdf_stream.js 与 MessageHandler 的 sendWithStream、sendWithPromise。旧版本里常见的 PDFNetworkStream 命名属于 display 层,不要和这里的 PDFWorkerStream 混为一谈。

模块总览

文件只有 89 行,可以切成四个角色:

角色父类职责
PDFWorkerStreamBasePDFStream把消息处理器与两个 Reader 类绑定成完整流对象
PDFWorkerStreamReaderBasePDFStreamReader请求全文流、接收头信息、按块读取、转发取消
PDFWorkerStreamRangeReaderBasePDFStreamRangeReader按 begin 与 end 请求一段字节,供 Range 读取使用
MessageHandler 的流式消息通道无在 display 与 Worker 之间传递数据块、背压与取消信号

它没有定义新的数据结构,也没有保存字节。它做的事情可以概括为四步:

  1. 用 GetReader 消息向 display 层要一个全文流;
  2. 用 ReaderHeadersReady 消息拿到 contentLength、isStreamingSupported、isRangeSupported;
  3. 把收到的每个 Uint8Array 解包成 ArrayBuffer 交给上层;
  4. 用 GetRangeReader 消息按区间请求额外字节。

它在整条链路里的位置:

步骤所在层数据形态
1网络层URL 与 Range 请求
2display 层NetworkStream / PDFDataTransportStream 的原始分片
3消息层sendWithStream("GetReader") 建立的跨线程流
4Worker 适配层PDFWorkerStreamReader.read() 返回 ArrayBuffer
5分块与解析层PDFManager / ChunkedStreamManager / ChunkedStream
6解析器Lexer / Parser / XRef

注意这张表的方向:PDFWorkerStream 在 Worker 里,但它并不是数据的生产者。它只是消费者,用消息向主线程索取字节。真正发起 HTTP 请求的是 display 层的 #networkStream。

为什么需要这一层适配器

Worker 的 getPdfManager 有两条路径。调用方直接把整份 ArrayBuffer 传进来时,走 LocalPdfManager,字节已经在内存里;调用方只给 URL 时,Worker 没有网络能力,只能构造一个 PDFWorkerStream:

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

这里的 source 不是 URL,也不是字节数组,而是一个只有 msgHandler 的对象。适配器所需的一切都来自这个消息处理器。

随后 Worker 等待 headersReady,再决定使用哪种管理器:

fullReader.headersReady
  .then(() => {
    if (!fullReader.isRangeSupported) {
      return;
    }
    pdfManagerArgs.source = pdfStream;
    pdfManagerArgs.length = fullReader.contentLength;
    // We don't need auto-fetch when streaming is enabled.
    pdfManagerArgs.disableAutoFetch ||= fullReader.isStreamingSupported;
 
    newPdfManager = new NetworkPdfManager(pdfManagerArgs);
    // There may be a chance that `newPdfManager` is not initialized for
    // the first few iterations of the `readData` code. Be sure to send
    // all cached chunks, if any, to chunked_stream via pdf_manager.
    for (const chunk of cachedChunks) {
      newPdfManager.sendProgressiveData(chunk);
    }
    cachedChunks = null;
 
    resolve(newPdfManager);
    cancelXHRs = null;
  })
  .catch(reason => {
    reject(reason);
    cancelXHRs = null;
  });

这段代码解释了三个字段的用途:

字段决定什么
contentLength全文长度,也就是 ChunkedStream 的缓冲大小
isRangeSupported能否走 NetworkPdfManager 的按需 Range 路径
isStreamingSupported是否已经在流式接收,从而关闭自动预取

如果服务端不支持 Range,Worker 会继续顺序读取 fullReader,把分片缓存到 cachedChunks,读完后拼成完整字节并退回 LocalPdfManager。因此全文 Reader 不只服务于「全文下载」,它也是判断传输能力的探针。

设计要点:适配器把「传输能力协商」和「读取字节」拆成两条消息。ReaderHeadersReady 只返回元数据,不携带数据块;GetReader 只负责数据流,不负责能力判断。这样 Worker 可以在收到任何字节之前就决定采用流式还是整文件路径,而不用先猜后补。

BasePDFStream 的契约

PDFWorkerStream 本身只有三行有效代码:

class PDFWorkerStream extends BasePDFStream {
  constructor(source) {
    super(source, PDFWorkerStreamReader, PDFWorkerStreamRangeReader);
  }
}

父类在构造时保存 source,并把两个 Reader 类记在私有字段里:

  constructor(source, PDFStreamReader, PDFStreamRangeReader) {
    if (
      (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
      this.constructor === BasePDFStream
    ) {
      unreachable("Cannot initialize BasePDFStream.");
    }
    this._source = source;
 
    this.#PDFStreamReader = PDFStreamReader;
    this.#PDFStreamRangeReader = PDFStreamRangeReader;
  }

这段继承关系决定了 PDFWorkerStream 不需要自己实现 getFullReader() 或 getRangeReader()。父类已经写好:

  getFullReader() {
    assert(
      !this._fullReader,
      "BasePDFStream.getFullReader can only be called once."
    );
    return (this._fullReader = new this.#PDFStreamReader(this));
  }
 
  /**
   * Gets a reader for the range of the PDF data.
   *
   * NOTE: Currently this method is only expected to be invoked *after*
   * the `BasePDFStreamReader.prototype.headersReady` promise has resolved.
   *
   * @param {number} begin - the start offset of the data.
   * @param {number} end - the end offset of the data.
   * @returns {BasePDFStreamRangeReader}
   */
  getRangeReader(begin, end) {
    if (end <= this._progressiveDataLength) {
      return null;
    }
    const reader = new this.#PDFStreamRangeReader(this, begin, end);
    this._rangeReaders.add(reader);
    return reader;
  }

这里有两条容易忽略的契约:

  • 全文 Reader 只能创建一次,_fullReader 会被缓存;重复调用会触发断言。
  • Range Reader 只有在请求区间超过已经顺序收到的数据长度时才会创建;否则直接返回 null,因为那一段字节已经通过全文流拿到了。

_progressiveDataLength 来自父类:它读取 _fullReader._loaded,没有全文 Reader 时返回 0。对 Worker 来说,这个值表示「顺序流已经送到第几字节」,Range 请求可以据此去重。

  cancelAllRequests(reason) {
    this._fullReader?.cancel(reason);
 
    // Always create a copy of the rangeReaders.
    for (const reader of new Set(this._rangeReaders)) {
      reader.cancel(reason);
    }
  }

取消时会同时通知全文 Reader 与所有 Range Reader。遍历前先复制 Set,是因为 cancel 可能反过来修改集合,直接迭代会有漏项风险。

设计要点:BasePDFStream 只规定「怎么创建 Reader」和「怎么统一取消」,不规定字节从哪里来。URL、Range、Node 文件、消息通道都可以套进同一组 Reader 接口,PDFManager 因此不需要为每一种传输写分支。

PDFWorkerStreamReader

constructor 方法

全文 Reader 的构造函数做了两件事:建立数据流,再单独等头信息。

class PDFWorkerStreamReader extends BasePDFStreamReader {
  _reader = null;
 
  constructor(stream) {
    super(stream);
    const { msgHandler } = stream._source;
 
    const readableStream = msgHandler.sendWithStream("GetReader");
    this._reader = readableStream.getReader();
 
    msgHandler.sendWithPromise("ReaderHeadersReady").then(data => {
      this._contentLength = data.contentLength;
      this._isStreamingSupported = data.isStreamingSupported;
      this._isRangeSupported = data.isRangeSupported;
 
      this._headersCapability.resolve();
    }, this._headersCapability.reject);
  }

sendWithStream("GetReader") 返回一个本地 ReadableStream。这个对象不是网络响应,而是 MessageHandler 用消息协议模拟出来的流:它的 start 会向 display 层发出请求,pull 会请求下一块,cancel 会发送取消信号。getReader() 只是把这个流变成可逐块读取的句柄。

紧接着的 sendWithPromise("ReaderHeadersReady") 是另一条独立请求。display 层会等待主线程网络流的 headersReady,然后把三个字段回传:

字段来源用途
contentLength主线程 Reader 的 contentLength决定 ChunkedStream 长度
isStreamingSupported主线程 Reader 的流式能力决定是否关闭自动预取
isRangeSupported主线程 Reader 的 Range 能力决定是否创建网络分块管理器

字段写完后,构造函数调用 this._headersCapability.resolve()。这个 capability 定义在父类 BasePDFStreamReader 上,对外暴露为 headersReady。因此 Worker 看到的 headersReady 并不是 Worker 自己产生的,而是主线程网络响应头经过一次消息往返后的投影。

失败分支直接把 this._headersCapability.reject 作为 then 的第二个参数。这里没有额外的 try 或 catch:消息层的错误会原样进入 capability,等待方拿到的就是同一个原因。

设计要点:数据流与头信息分成两条消息,意味着字节可以比头信息先到,也可以后到。read() 只关心数据流,headersReady 只关心元数据,二者互不阻塞。对于流式响应,这能避免「先等完整响应头,再开始读已经到达的字节」这种不必要的串行化。

read 方法

  async read() {
    const { value, done } = await this._reader.read();
    if (done) {
      return { value: undefined, done: true };
    }
    // `value` is wrapped into Uint8Array, we need to
    // unwrap it to ArrayBuffer for further processing.
    return { value: value.buffer, done: false };
  }

read() 的返回值严格遵循 BasePDFStreamReader 的契约:结束是 done: true 且 value 为 undefined,正常分片是 done: false 且 value 为 ArrayBuffer。

为什么要多做一次 value.buffer?display 层在入队时写的是:

sink.enqueue(new Uint8Array(value), 1, [value]);

主线程的 Reader 产出的是 ArrayBuffer,但 ReadableStream 的负载需要是可序列化的视图,所以这里包了一层 Uint8Array,同时把原始 ArrayBuffer 放进 transfer list。到了 Worker 侧,消息层交回来的是 Uint8Array,适配器再取出它背后的 ArrayBuffer。上层 ChunkedStream 只认识 ArrayBuffer,这一步是必要的类型还原。

需要留意,value.buffer 取的是整个底层缓冲区。display 层每次都用完整的 ArrayBuffer 新建视图,因此不存在「视图只覆盖缓冲区一部分」的问题。如果将来改成复用大缓冲区并切片,这里就必须同时检查 byteOffset 与 byteLength,不能盲目取 .buffer。

cancel 方法

  cancel(reason) {
    this._reader.cancel(reason);
  }

取消只是把原因交给本地 ReadableStream 的 cancel。消息层会把它转成取消消息发给 display 层,display 层再调用主线程 Reader 的 cancel,最终中止网络请求。整条取消链没有在 Worker 侧保存额外状态,也没有重试;适配器只负责转发。

reason 必须是 Error,这是 MessageHandler 流式协议的断言要求。PDFWorkerStream.cancelAllRequests 传入的通常是 AbortException,所以能顺利通过。

PDFWorkerStreamRangeReader

constructor 方法

Range Reader 的结构与全文 Reader 类似,只是消息名和参数不同,并且没有头信息阶段。

class PDFWorkerStreamRangeReader extends BasePDFStreamRangeReader {
  _reader = null;
 
  constructor(stream, begin, end) {
    super(stream, begin, end);
    const { msgHandler } = stream._source;
 
    const readableStream = msgHandler.sendWithStream("GetRangeReader", {
      begin,
      end,
    });
    this._reader = readableStream.getReader();
  }

begin 与 end 会作为消息数据发到 display 层。那边的处理器会调用主线程 #networkStream.getRangeReader(begin, end),再把这个区间对应的 ReadableStream 接回来。Worker 侧完全不知道 HTTP Range 头、分块大小或缓存状态,它只表达「我要这一段」。

Range Reader 不等待 ReaderHeadersReady。它只应该在 headersReady 已经 resolve 之后创建,这是 BasePDFStream.getRangeReader 注释里明确写下的前提。ChunkedStreamManager 正是在拿到文件长度和 Range 能力之后才发起分块请求,所以这个时序由上层保证。

read 与 cancel 方法

  async read() {
    const { value, done } = await this._reader.read();
    if (done) {
      return { value: undefined, done: true };
    }
    return { value: value.buffer, done: false };
  }
 
  cancel(reason) {
    this._reader.cancel(reason);
  }

这两个方法与全文 Reader 完全一致,连注释都省掉了。差异只在构造阶段:全文 Reader 用 GetReader 且带能力协商,Range Reader 用 GetRangeReader 且只带区间。

display 层有一个特殊分支:如果请求的区间已经被全文流覆盖,getRangeReader 会返回 null,处理器会直接 sink.close()。Worker 侧的 Range Reader 于是读到 done: true,得到零个分片。ChunkedStreamManager.sendRequest 对「零分片且关闭自动预取」的情况有专门判断,避免把一次本就没有发出的请求误当成响应。

维度PDFWorkerStreamReaderPDFWorkerStreamRangeReader
消息GetReaderGetRangeReader,带 begin 与 end
头信息额外请求 ReaderHeadersReady无
读取范围从当前位置顺序读到结尾只读指定区间
创建时机任何时候,通常只创建一次headersReady 之后,可按需创建多个
空结果文件读完时 done: true区间已被全文流覆盖时直接关闭
生命周期存放在 _fullReader登记在 _rangeReaders,统一取消

设计要点:全文 Reader 与 Range Reader 共享同一套消息流机制,但职责严格分开。全文 Reader 负责「整份文件的顺序视图」和「传输能力探测」,Range Reader 负责「补齐随机访问缺口」。把两者合并成一个带偏移参数的接口,会让 headersReady 与区间请求的时序纠缠在一起,反而更难保证正确性。

消息通道的另一端

Worker 侧的 GetReader 只是请求,真正响应它的是 display 层的 WorkerTransport.setupMessageHandler:

    messageHandler.on("GetReader", (data, sink) => {
      assert(
        this.#networkStream,
        "GetReader - no `BasePDFStream` instance available."
      );
      this.#fullReader = this.#networkStream.getFullReader();
      // If stream or range turn out to be disabled, once `headersReady` is
      // resolved, this is our only way to report loading progress.
      this.#fullReader.onProgress = evt => this.#onProgress(evt);
 
      sink.onPull = () => {
        this.#fullReader
          .read()
          .then(function ({ value, done }) {
            if (done) {
              sink.close();
              return;
            }
            assert(
              value instanceof ArrayBuffer,
              "GetReader - expected an ArrayBuffer."
            );
            // Enqueue data chunk into sink, and transfer it
            // to other side as `Transferable` object.
            sink.enqueue(new Uint8Array(value), 1, [value]);
          })
          .catch(reason => {
            sink.error(reason);
          });
      };

#networkStream 是 PDFDataTransportStream 或 NetworkStream,也就是主线程真正访问 URL、处理响应头、发 Range 请求的对象。Worker 侧的每次 read() 最终都会触发这里的 sink.onPull,再由主线程 Reader 读一块网络数据。

头信息处理器则在主线程等待网络流的 headersReady:

messageHandler.on('ReaderHeadersReady', async data => {
  await this.#fullReader.headersReady;
 
  const { isStreamingSupported, isRangeSupported, contentLength } = this.#fullReader;
 
  if (isStreamingSupported && isRangeSupported) {
    this.#fullReader.onProgress = null; // See comment in "GetReader" above.
  }
  return { isStreamingSupported, isRangeSupported, contentLength };
});

它返回的对象正好对应 Worker 侧写入的三个字段。contentLength 在流式响应里可能一开始未知,但 headersReady 保证它已经确定;如果主线程拿不到,Promise 会 reject,Worker 的 headersReady 也随之 reject。

Range 处理器则更短:

    messageHandler.on("GetRangeReader", (data, sink) => {
      assert(
        this.#networkStream,
        "GetRangeReader - no `BasePDFStream` instance available."
      );
      const rangeReader = this.#networkStream.getRangeReader(
        data.begin,
        data.end
      );

这两段代码放在一起,才能看清「通道」的完整形状:

层对象动作
WorkerPDFWorkerStreamReader发 GetReader,拿到消息流
消息层MessageHandler.sendWithStream建立 ReadableStream,转发 pull/cancel
displayWorkerTransport 的 GetReader创建主线程全文 Reader,回填 sink
网络层NetworkStream / PDFDataTransportStream读 HTTP 响应或调用 Range 传输
WorkerPDFWorkerStreamReader.read把 Uint8Array 解包成 ArrayBuffer

设计要点:sendWithStream 不是把整个响应体一次性 postMessage 过来,而是把 ReadableStream 的 pull 与 cancel 映射成跨线程消息。数据块只在被请求时搬运,desiredSize 变化会形成背压。Worker 读得慢,主线程就不会无限入队,内存占用被限制在一个可预期的窗口内。

失败、取消与生命周期

适配器本身没有重试逻辑,错误处理全部依赖两层机制:

  1. Promise 与流错误:ReaderHeadersReady 失败会 reject headersReady;read() 失败会让流进入错误状态,Worker 的 read() 调用收到 reject。
  2. 取消传播:Worker 的 cancel 进入消息层,display 的 sink.onCancel 调用主线程 Reader 的 cancel,网络请求随之中止。

PDFWorkerStream 的生命周期跟随文档。WorkerTransport.destroy 时会调用 #networkStream.cancelAllRequests,主线程先取消所有网络 Reader;Worker 被终止后,消息端口关闭,尚未完成的流会以错误结束。适配器不需要额外的 destroy 方法,因为它没有持有独立资源。

还有一个容易忽略的边界:当 disableAutoFetch 为真且 Range 请求没有真正发出时,ChunkedStreamManager.sendRequest 会收到零个分片。这段逻辑不在 worker_stream.js 里,但它和 Range Reader 的「空结果」直接相关。读这个文件时若只看 read(),会觉得空流很奇怪;把 getRangeReader 返回 null、display 关闭 sink、sendRequest 提前返回这三处连起来,才能解释清楚。

总结

PDFWorkerStream 是 PDF.js 的跨线程字节适配层:它把 Worker 对 BasePDFStream 的读取请求,翻译成 display 层的网络读取;再把网络层产出的 ArrayBuffer,包装成 Worker 解析器熟悉的 Reader 接口。

它在整条链路中的位置

阶段模块输入输出
网络接入NetworkStream / PDFDataTransportStreamURL、Range、响应头主线程 Reader
消息适配MessageHandler.sendWithStreamGetReader、pull、cancel跨线程 ReadableStream
Worker 适配PDFWorkerStreamReader消息分片ArrayBuffer
分块管理ChunkedStreamManagerRange 区间、文件长度ChunkedStream
解析Lexer / Parser / XRef任意偏移的字节token、对象、索引

用一句话串起来:display 层负责把网络响应变成字节,PDFWorkerStream 负责把字节变成 Worker 可读的流,ChunkedStream 负责把流伪装成完整文件。

四条设计原则

  1. 传输与解析分层。网络请求、Range 协商、响应头解析都留在 display 层;Worker 只实现 BasePDFStream 契约,不感知 HTTP。
  2. 元数据与数据分离。ReaderHeadersReady 单独返回长度与能力,GetReader 单独搬运字节,二者不互相等待。
  3. 按需拉取而不是推送洪流。sendWithStream 用 pull 与 desiredSize 形成背压,Worker 读到哪,主线程才送到哪,避免消息队列无限增长。
  4. 取消沿链路原样传递。Worker 的 cancel 经消息层、sink.onCancel、主线程 Reader 一路向下,网络请求最终被中止;适配器不吞掉原因,也不伪造成功。

worker_stream.js 本身很短,但它把「浏览器网络」和「Worker 解析」这两块完全不同的世界接在了一起。理解了这层适配器,再看 ChunkedStream 的缺页重试,就不会误以为 Worker 自己在发请求;它只是在向主线程要下一块字节。

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

相关文章