StreamPDFStreamPDF
返回博客

PDF.js 源码解读: chunked_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: chunked_stream

前言

这是「字节与流」层的第一篇。

前几篇一直在往上走:PDFManager 怎么建立文档、XRef 怎么定位对象、PartialEvaluator 怎么把内容流解释成 operatorList。但这些工作都建立在一个朴素的前提上——字节已经在那里了。本篇回到最底层,回答一个问题:网络上的 PDF 文件,是怎么变成解析器可以随便 seek 的字节流的。

PDF.js 有两条字节供给路径:本地文件、内存中的 ArrayBuffer 直接构造 Stream,整份文件一次性在内存里;URL 与 HTTP Range 场景构造 ChunkedStream,文件按固定大小分块,只有真正被读到的块才会下载。

为什么不能简单地「下载完再解析」?因为 PDF 的读取模式天然是随机访问。startxref 在文件尾部,xref 段指向散布在全文件的 12 0 obj,页面内容流、字体、图片又各自在任意偏移。刚读完文件头,下一行就可能要跳到几十兆之外。

PDF.js 的做法是把「逻辑上的完整文件」和「物理上的已下载数据」分开:ChunkedStream 构造时就分配了整份文件长度的 Uint8Array,任何偏移都能寻址,但某些块还没到货时读它会抛 MissingDataException;ChunkedStreamManager 负责把「缺哪块」翻译成 Range 请求,把响应写回缓冲区,并唤醒正在等待的解析逻辑。

一句话概括:ChunkedStream 是一个带缺页机制的字节数组,ChunkedStreamManager 是它的缺页中断处理器。

版本说明:本篇按当前仓库这一版 chunked_stream.js 讲解。ChunkedStream 的状态字段是公开命名(progressiveDataLength、_loadedChunks),管理器则混用了私有字段(#aborted、#loadedStreamCapability)与下划线字段。代码里用到了 Promise.withResolvers 与 Map.prototype.getOrInsertComputed,阅读时不要套用旧版本的 _loadedChunks 实现或手写的 deferred 对象。

模块总览

chunked_stream.js 共 542 行,可以切成七块:

区块关键方法职责
基础状态constructor、progressiveDataLength、_loadedChunks预分配全文缓冲,记录已到达的块
缺数据检查ensureByte、ensureRange读之前确认块已加载,否则抛出缺数据异常
数据写入onReceiveData、onReceiveProgressiveData把响应写进缓冲,登记对应的块
读取接口getByte、getBytes、getByteRange在已加载区域内读字节,返回零拷贝视图
块状态查询getMissingChunks、numChunksLoaded、isDataLoaded、nextEmptyChunk给调度器提供「还缺什么」
子流makeSubStream用原型继承复用父对象状态,不复制内存
请求调度requestRange、requestRanges、requestAllChunks、_requestChunks、groupChunks、sendRequest、onReceiveData、abort合并 Range 请求、回填数据、自动预取、上报进度

它在整条链路里的位置:

层模块输入输出
网络PDFDataTransportStream / getRangeReaderURL、Range 头ArrayBuffer 分片
调度ChunkedStreamManager块号区间合并后的 Range 请求
缓冲ChunkedStream响应分片、块号全文长度的 Uint8Array
解析Lexer / Parser任意偏移的字节token、Dict、BaseStream
对象与文档XRef / PDFDocument解析结果对象索引、页面树

整条链路可以压缩成一句:网络提供分片,管理器把分片调度成块,ChunkedStream 把块伪装成完整文件,解析器则完全不知道自己读的是一个按需下载的文件。

两种字节供给:Stream 与 ChunkedStream

Stream 只有七十多行,它假设 bytes 已经完整。ChunkedStream 继承它,保留同样的读取 API,但在读取前插入一层「块是否已加载」的检查。

维度StreamChunkedStream
内存来源调用方传入完整的 Uint8Array构造时按文件长度 new Uint8Array(length) 预留
数据可用性构造完成即全部可读只有 _loadedChunks 里的块可读
缺数据行为不存在「缺数据」,读到末尾返回 -1抛出 MissingDataException,由上层补数据后重试
getByte直接下标,末尾返回 -1先过 ensureByte,再下标
getBytes直接 subarray先过 ensureRange,再 subarray
子流新建 Stream,共享同一个 buffer原型对象共享父流状态,不新建缓冲
典型来源本地文件、解码输出、字符串流HTTP Range、流式响应
生命周期随文档存活,内容不变块只增不减,abort 时整体取消

预分配全文缓冲看起来奢侈:一份 50 MB 的 PDF 会立刻占用 50 MB。但解析器因此可以在任意偏移读写,subarray 视图零拷贝依赖同一块连续内存,块状态查询又是 O(1);代价只是内存上限等于文件大小,对浏览器阅读器而言这是更简单也更可控的一侧。

设计要点:ChunkedStream 把「长度」和「可用性」拆成两个正交的概念。length 来自 HTTP 响应头,决定缓冲大小与块总数;_loadedChunks 决定当前能读哪里。解析器只依赖前者,调度器只维护后者。任何把两者混在一起的实现,都会在「文件很长但只读了尾部」的场景里退化成整文件下载。

ChunkedStream:预分配缓冲与缺页检查

构造与状态

class ChunkedStream extends Stream {
  progressiveDataLength = 0;
 
  _lastSuccessfulEnsureByteChunk = -1; // Single-entry cache
 
  _loadedChunks = new Set();
 
  constructor(length, chunkSize, manager) {
    super(
      /* arrayBuffer = */ new Uint8Array(length),
      /* start = */ 0,
      /* length = */ length,
      /* dict = */ null
    );
 
    this.chunkSize = chunkSize;
    this.numChunks = Math.ceil(length / chunkSize);
    this.manager = manager;
  }

构造函数只做四件事:按文件长度分配缓冲、把起点设为 0、按 rangeChunkSize 算出块数、保留管理器引用。它没有向网络发出任何请求——对象创建是同步且廉价的,真正的数据要等第一次读取触发缺页后才开始流动。

progressiveDataLength 是为「不按 Range、而是整份响应顺序流下来」的模式准备的。此时网络层会持续调用 onReceiveProgressiveData,数据从 0 开始一段段追加;只要读取位置落在这段前缀里,就一定不缺数据,连 Set 都不用查。

_lastSuccessfulEnsureByteChunk 是一个很小的优化:解析器经常在同一个块里连续读几十个字节,每次都查 Set 是浪费;记住「上一个成功的块号」,命中就直接返回。因为块一旦加载就不会被卸载,这个缓存永远不会过期。

onReceiveData:把 Range 响应填进缓冲区

  onReceiveData(begin, chunk) {
    const chunkSize = this.chunkSize;
    if (begin % chunkSize !== 0) {
      throw new Error(`Bad begin offset: ${begin}`);
    }
 
    // Using `this.length` is inaccurate here since `this.start` can be moved
    // (see the `moveStart` method).
    const end = begin + chunk.byteLength;
    if (end % chunkSize !== 0 && end !== this.bytes.length) {
      throw new Error(`Bad end offset: ${end}`);
    }
 
    if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
      assert(
        chunk instanceof ArrayBuffer,
        "onReceiveData - expected an ArrayBuffer."
      );
    }
    this.bytes.set(new Uint8Array(chunk), begin);
    const beginChunk = Math.floor(begin / chunkSize);
    const endChunk = Math.floor((end - 1) / chunkSize) + 1;
 
    for (let curChunk = beginChunk; curChunk < endChunk; ++curChunk) {
      // Since a value can only occur *once* in a `Set`, there's no need to
      // manually check `Set.prototype.has()` before adding the value here.
      this._loadedChunks.add(curChunk);
    }
  }

这是 Range 响应的落地点。四个细节值得展开:

  1. 起点必须对齐块边界。调度器按块发请求,begin 一定是 chunkSize 的整数倍;不对齐说明上层算错了。
  2. 终点要么对齐,要么正好是文件末尾。最后一块可能不满,除此之外响应都必须覆盖完整的块,服务器擅自返回半个块会被拒绝。
  3. 终点用 bytes.length 而不是 this.length。this.start 可能被 moveStart 移动,length getter 用来判断文件末尾会不准;缓冲区的真实容量才是可靠上界。
  4. 登记块用 Set 去重。服务端可能合并 Range、返回更多数据,Set.add 天然幂等,省掉一次 has 查询。

块号换算就是 Math.floor(begin / chunkSize) 与 Math.floor((end - 1) / chunkSize) + 1,半开区间不会多算一块。

onReceiveProgressiveData:顺序流追加

  onReceiveProgressiveData(data) {
    let position = this.progressiveDataLength;
    const beginChunk = Math.floor(position / this.chunkSize);
 
    if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
      assert(
        data instanceof ArrayBuffer,
        "onReceiveProgressiveData - expected an ArrayBuffer."
      );
    }
    this.bytes.set(new Uint8Array(data), position);
    position += data.byteLength;
    this.progressiveDataLength = position;
    const endChunk =
      position >= this.end
        ? this.numChunks
        : Math.floor(position / this.chunkSize);
 
    for (let curChunk = beginChunk; curChunk < endChunk; ++curChunk) {
      // Since a value can only occur *once* in a `Set`, there's no need to
      // manually check `Set.prototype.has()` before adding the value here.
      this._loadedChunks.add(curChunk);
    }
  }

顺序流模式没有 begin,数据总是接在 progressiveDataLength 后面。这里的块登记比 Range 模式保守:

  • 只有当数据写满某个块,Math.floor(position / chunkSize) 才会把它算进去。
  • 如果这次写入正好把文件写完(position 到达 this.end),则直接把 numChunks 全部标记为已加载——最后一块即使不满,也确实到齐了。

所以顺序流模式下,progressiveDataLength 通常比「已登记块的覆盖范围」更靠前。读取时先看 progressiveDataLength,就是在利用这一点:前缀连续,块标记落后一点也没关系。

设计要点:_loadedChunks 不是唯一的可用性判断依据。Range 模式看块集合,顺序流模式看连续前缀,两条路径在 ensureByte 与 ensureRange 里会合。这个双轨设计避免了「顺序流每收到半块也要登记」的复杂度,同时让进度上报仍然按块计算。

ensureByte 与 ensureRange

这两个方法是整个流式解析的「缺页中断」入口。它们不返回数据,只回答一个问题:这个位置现在能读吗。

  ensureByte(pos) {
    if (pos < this.progressiveDataLength) {
      return;
    }
 
    const chunk = Math.floor(pos / this.chunkSize);
    if (chunk > this.numChunks) {
      return;
    }
    if (chunk === this._lastSuccessfulEnsureByteChunk) {
      return;
    }
 
    if (!this._loadedChunks.has(chunk)) {
      throw new MissingDataException(pos, pos + 1);
    }
    this._lastSuccessfulEnsureByteChunk = chunk;
  }

ensureByte 有四层快速路径,顺序本身就是设计:

顺序条件结果原因
1位置在 progressiveDataLength 之前直接返回顺序流前缀一定连续
2块号已经超出总块数直接返回越界问题交给上层判断文件末尾
3块号等于上次成功块直接返回单条目缓存,省一次 Set 查询
4块不在 _loadedChunks 中抛缺数据异常触发调度器补数据
5其余记录缓存并返回下次同块读取走第 3 条

第 2 条用的是宽松上界 chunk > this.numChunks。合法块号本来就小于 numChunks,这个判断只是把明显越界的读取直接放行;真正的文件末尾由 getByte 里的 pos >= this.end 负责,所以不会让调用方读到垃圾数据。

  ensureRange(begin, end) {
    if (begin >= end) {
      return;
    }
    if (end <= this.progressiveDataLength) {
      return;
    }
 
    const beginChunk = Math.floor(begin / this.chunkSize);
    if (beginChunk > this.numChunks) {
      return;
    }
    const endChunk = Math.min(
      Math.floor((end - 1) / this.chunkSize) + 1,
      this.numChunks
    );
    for (let chunk = beginChunk; chunk < endChunk; ++chunk) {
      if (!this._loadedChunks.has(chunk)) {
        throw new MissingDataException(begin, end);
      }
    }
  }

ensureRange 是 ensureByte 的区间版本,用于 getBytes、makeSubStream 这类一次需要很多字节的场景。它有三个特点:

  1. 空区间直接放行。begin >= end 意味着没有字节要读,任何缓冲状态都满足。
  2. 连续前缀直接放行。end <= progressiveDataLength 时整个区间都在连续数据里,不必逐块查。
  3. 异常携带的是调用方请求的完整区间,而不是第一个缺失块。这是一个有意的选择:调用方需要的是一整段,调度器拿到完整区间后可以按需合并、去重;如果只报第一个缺口,补完第一个缺口后还要再抛一次,往返次数会成倍增加。

异常里的区间可能包含已经加载的块,这不会造成浪费。_requestChunks 会先过滤掉 hasChunk 为真的块,groupChunks 再把剩下的块合并成尽量少的连续区间。

设计要点:缺失检查的粒度是块,不是字节。ensureRange 只关心区间覆盖到的块是否到齐,并不保证区间内的每个字节来自同一次响应。这降低了检查成本,也让调度器可以自由地把相邻块合并成大请求。代价是可能多下载一点块边界外的数据,但块大小固定,浪费有上界。

getByte、getBytes、getByteRange

读取接口与 Stream 几乎一样,只多了缺数据检查:

  getByte() {
    const pos = this.pos;
    if (pos >= this.end) {
      return -1;
    }
    if (pos >= this.progressiveDataLength) {
      this.ensureByte(pos);
    }
    return this.bytes[this.pos++];
  }
 
  getBytes(length) {
    const pos = this.pos;
    const endPos = !length ? this.end : Math.min(pos + length, this.end);
 
    if (endPos > this.progressiveDataLength) {
      this.ensureRange(pos, endPos);
    }
    this.pos = endPos;
    return this.bytes.subarray(pos, endPos);
  }
 
  getByteRange(begin, end) {
    if (begin < 0) {
      begin = 0;
    }
    if (end > this.end) {
      end = this.end;
    }
    if (end > this.progressiveDataLength) {
      this.ensureRange(begin, end);
    }
    return this.bytes.subarray(begin, end);
  }

三点解读:

  • getByte 只在位置越过连续前缀时才调用 ensureByte。如果位置在 progressiveDataLength 之内,说明这一段一定已经收到,连块集合都不用看。这是顺序流模式下最热的路径。
  • getBytes 在检查之前就把 endPos 夹到 this.end,所以请求超出文件末尾时不会误报缺数据,只会返回较短的一段。subarray 是视图而非拷贝,调用方拿到的是缓冲区里的同一块内存。
  • getByteRange 是「绝对偏移读取」,不移动 pos。它先把区间夹到合法范围,再走同样的检查,最后返回视图。XRef 读 xref 段、Parser 读对象头都依赖它。

与 Stream 对照,ChunkedStream 的读取差异只有两处:多了「是否越过连续前缀」的判断,以及一个可能抛出的异常。解析器的其余代码完全不需要改动,这正是继承 Stream 的价值。

块状态查询

  getMissingChunks() {
    const chunks = [];
    for (let chunk = 0, n = this.numChunks; chunk < n; ++chunk) {
      if (!this._loadedChunks.has(chunk)) {
        chunks.push(chunk);
      }
    }
    return chunks;
  }
 
  get numChunksLoaded() {
    return this._loadedChunks.size;
  }
 
  get isDataLoaded() {
    return this.numChunksLoaded === this.numChunks;
  }

getMissingChunks 是线性扫描,只在「预取整份文件」或调试时调用;日常读取走的是 O(1) 的 hasChunk。numChunksLoaded 直接取 Set 大小,isDataLoaded 用它和总块数比较。

nextEmptyChunk 从给定块开始环形扫描:找到第一个未加载块返回,全部加载完返回 null,到达末尾后会绕回开头补漏;hasChunk 只是一次集合查询。两者一起构成调度器的查询接口。

块的状态只有两种:在集合里表示已加载且永不卸载,不在集合里表示尚未到货;请求中的状态由管理器的 _requestsByChunk 单独维护。

makeSubStream:不复制内存的子流

makeSubStream 是整个文件里最巧的一段。PDF 的流对象(内容流、字体文件、图片数据)几乎都是「文件中一段带 Length 的字节」,解析器需要在这段字节上再开一个流。Stream 的做法是新建一个 Stream 共享 ArrayBuffer;ChunkedStream 连对象都不想多建,直接让子流继承父对象。

  makeSubStream(start, length, dict = null) {
    if (length) {
      if (start + length > this.progressiveDataLength) {
        this.ensureRange(start, start + length);
      }
    } else if (start >= this.progressiveDataLength) {
      // When the `length` is undefined you do *not*, under any circumstances,
      // want to fallback on calling `this.ensureRange(start, this.end)` since
      // that would force the *entire* PDF file to be loaded, thus completely
      // breaking the whole purpose of using streaming and/or range requests.
      //
      // However, not doing any checking here could very easily lead to wasted
      // time/resources during e.g. parsing, since `MissingDataException`s will
      // require data to be re-parsed, which we attempt to minimize by at least
      // checking that the *beginning* of the data is available here.
      this.ensureByte(start);
    }
 
    function ChunkedStreamSubstream() {}
    ChunkedStreamSubstream.prototype = Object.create(this);
    ChunkedStreamSubstream.prototype.getMissingChunks = function () {
      const chunkSize = this.chunkSize;
      const beginChunk = Math.floor(this.start / chunkSize);
      const endChunk = Math.floor((this.end - 1) / chunkSize) + 1;
      const missingChunks = [];
      for (let chunk = beginChunk; chunk < endChunk; ++chunk) {
        if (!this._loadedChunks.has(chunk)) {
          missingChunks.push(chunk);
        }
      }
      return missingChunks;
    };
    Object.defineProperty(ChunkedStreamSubstream.prototype, "isDataLoaded", {
      get() {
        return (
          this.numChunksLoaded === this.numChunks ||
          this.getMissingChunks().length === 0
        );
      },
      configurable: true,
    });
 
    const subStream = new ChunkedStreamSubstream();
    subStream.pos = subStream.start = start;
    subStream.end = start + length || this.end;
    subStream.dict = dict;
    return subStream;
  }

先看前半段的两种分支:

  • 有 length:子流需要 [start, start + length) 的全部字节,超出连续前缀时调用 ensureRange,缺任何一块就抛异常。普通流对象的 /Length 已知,这是最常见的情况。
  • 没有 length:只 ensureByte(start),绝不 ensureRange(start, this.end)。源码注释把原因写得很重:那会强制加载整份 PDF,彻底破坏流式与 Range 请求的意义。也不能什么都不检查,否则解析会在后面的字节才失败,前面的工作全部白做。

后半段的原型技巧有三点:

  1. ChunkedStreamSubstream.prototype = Object.create(this) 把父实例本身设为子流原型,子流没有自己的 bytes、chunkSize、_loadedChunks,全部沿原型链找到父对象。
  2. 覆盖 getMissingChunks,只扫描子流自己的块窗口 [start, end);沿用父类实现会返回整份文件缺的块,管理器就会下载整个文件。
  3. 重新定义 isDataLoaded getter,判断「父流全部加载完成」或「子流窗口内没有缺失」。BaseStream 的版本用 shadow 缓存过,直接用会得到错误答案;configurable: true 保留再次覆盖的能力。实例化后只设置 pos、start、end、dict 四个自有字段,end 的 NaN 回退处理 length 为 undefined 的情况。

这个技巧的收益是内存零复制,且父流后续收到的块自动对子流可见,因为 _loadedChunks 是同一个 Set。代价是子流强引用父流,但在 PDF.js 里子流本来就是父流的临时视图。

getBaseStreams 返回自身,用于追溯图片等数据的原始流;子流沿原型链继承这个方法,返回的自然是子流自己。

设计要点:子流不是「复制一份父流的状态」,而是「让父实例成为自己的原型」。这要求基类的所有读取方法都只通过 this 访问状态,不能依赖实例自有字段。getByte、getBytes、ensureRange 全部满足这个前提,所以原型链能直接复用。反过来,任何需要「子流窗口」语义的方法(getMissingChunks、isDataLoaded)都必须显式覆盖,不能靠继承。

ChunkedStreamManager:请求调度

ChunkedStream 只负责「发现缺块」,真正把块拿回来的是 ChunkedStreamManager。它的核心工作有三件:合并请求、回填数据、自动预取。

构造与依赖

class ChunkedStreamManager {
  #aborted = false;
 
  currRequestId = 0;
 
  _chunksNeededByRequest = new Map();
 
  #loadedStreamCapability = Promise.withResolvers();
 
  _promisesByRequest = new Map();
 
  _requestsByChunk = new Map();
 
  constructor(pdfStream, args) {
    this.length = args.length;
    this.chunkSize = args.rangeChunkSize;
    this.stream = new ChunkedStream(this.length, this.chunkSize, this);
    this.pdfStream = pdfStream;
    this.disableAutoFetch = args.disableAutoFetch;
    this.msgHandler = args.msgHandler;
  }

三个映射表分别回答三个问题:

结构键值回答的问题
_requestsByChunk块号等待该块的请求号数组这个块被哪些请求等着
_chunksNeededByRequest请求号还缺的块号集合这个请求还差哪些块才能完成
_promisesByRequest请求号一个 deferred这个请求完成后该通知谁

length 通常来自 HTTP 的 Content-Length,chunkSize 来自调用方参数,默认是 2 的 16 次方(65536 字节,见 api.js 里 rangeChunkSize 的取值)。pdfStream 是实现了 getRangeReader 的数据源,disableAutoFetch 控制是否在空闲时继续预取,msgHandler 用来向主线程发送 DocProgress。

构造函数里没有发请求,也没有读取任何字节。管理器与被管理流同时创建,随后 PDFDocument 开始解析,缺页请求才会陆续产生。

sendRequest:从 range reader 到缓冲区

  async sendRequest(begin, end) {
    const rangeReader = this.pdfStream.getRangeReader(begin, end);
    let chunks = [];
 
    while (true) {
      const { value, done } = await rangeReader.read();
 
      if (this.#aborted) {
        chunks = null;
        return; // Ignoring any data after abort.
      }
      if (done) {
        break;
      }
      if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
        assert(
          value instanceof ArrayBuffer,
          "sendRequest - expected an ArrayBuffer."
        );
      }
      chunks.push(value);
    }
 
    if (chunks.length === 0 && this.disableAutoFetch) {
      // The range request wasn't dispatched, see the "GetRangeReader" handler
      // in the `src/display/api.js` file.
      return;
    }
    const data = arrayBuffersToBytes(chunks);
    chunks = null;
    this.onReceiveData({ chunk: data.buffer, begin });
  }

sendRequest 是一个异步读取循环:getRangeReader(begin, end) 返回流式读取器,每次 read() 给出一个 ArrayBuffer 分片;每读一片都检查 #aborted,终止之后到达的数据直接丢弃;所有分片用 arrayBuffersToBytes 合并成一个连续缓冲,这个工具函数会尽量少复制;最后调用 onReceiveData,把数据以 begin 为偏移写进 ChunkedStream。

chunks.length === 0 && this.disableAutoFetch 对应「请求没有被真正派发」的情况:主线程的 GetRangeReader 处理器发现数据已经通过整份流读过、只是还没送到 Worker,于是直接关闭 sink,不发出 Range 请求。此时空响应不是错误,而是「稍后会从另一条路径到达」的信号,直接返回即可。

设计要点:sendRequest 不负责「请求对应哪个逻辑请求」。它只是把一个字节区间搬回缓冲区,谁在等这块由 onReceiveData 根据 _requestsByChunk 反查。这样多个逻辑请求可以共享同一次网络请求,而不需要在发送层做任何特判。

requestRange 与 requestRanges

  requestRange(begin, end) {
    end = Math.min(end, this.length);
 
    const beginChunk = this.getBeginChunk(begin);
    const endChunk = this.getEndChunk(end);
 
    const chunks = [];
    for (let chunk = beginChunk; chunk < endChunk; ++chunk) {
      chunks.push(chunk);
    }
    return this._requestChunks(chunks);
  }
 
  requestRanges(ranges = []) {
    const chunksToRequest = [];
    for (const range of ranges) {
      const beginChunk = this.getBeginChunk(range.begin);
      const endChunk = this.getEndChunk(range.end);
      for (let chunk = beginChunk; chunk < endChunk; ++chunk) {
        if (!chunksToRequest.includes(chunk)) {
          chunksToRequest.push(chunk);
        }
      }
    }
 
    chunksToRequest.sort((a, b) => a - b);
    return this._requestChunks(chunksToRequest);
  }

字节区间到块区间的换算由两个小方法完成:getBeginChunk 直接返回 Math.floor(begin / chunkSize),getEndChunk 返回 Math.floor((end - 1) / chunkSize) + 1。后者用 end - 1 是关键:区间是半开的,结束位置本身不属于请求范围。如果写成 Math.floor(end / chunkSize),当 end 正好落在块边界时就会多请求一整块。

requestRanges 用于一次补多个不连续区间(例如同时需要 xref 段和某页内容)。它先把所有块收集起来、去重、排序,再交给 _requestChunks。排序是 groupChunks 能工作的前提。

requestAllChunks 则更直接:把 getMissingChunks 的结果交给 _requestChunks,再返回 #loadedStreamCapability.promise。它一次性请求当前所有缺失块,并返回「整份流加载完成」的承诺;noFetch 为真时只等待、不请求,用于「数据正在由别的路径送达」的场景。

_requestChunks:同一块只发一次

  _requestChunks(chunks) {
    const requestId = this.currRequestId++;
 
    const chunksNeeded = new Set();
    this._chunksNeededByRequest.set(requestId, chunksNeeded);
    for (const chunk of chunks) {
      if (!this.stream.hasChunk(chunk)) {
        chunksNeeded.add(chunk);
      }
    }
 
    if (chunksNeeded.size === 0) {
      return Promise.resolve();
    }
 
    const capability = Promise.withResolvers();
    this._promisesByRequest.set(requestId, capability);
 
    const chunksToRequest = [];
    for (const chunk of chunksNeeded) {
      const requestIds = this._requestsByChunk.getOrInsertComputed(
        chunk,
        () => {
          chunksToRequest.push(chunk);
          return [];
        }
      );
      requestIds.push(requestId);
    }
 
    if (chunksToRequest.length > 0) {
      const groupedChunksToRequest = this.groupChunks(chunksToRequest);
      for (const groupedChunk of groupedChunksToRequest) {
        const begin = groupedChunk.beginChunk * this.chunkSize;
        const end = Math.min(
          groupedChunk.endChunk * this.chunkSize,
          this.length
        );
        this.sendRequest(begin, end).catch(capability.reject);
      }
    }
 
    return capability.promise.catch(reason => {
      if (this.#aborted) {
        return; // Ignoring any pending requests after abort.
      }
      throw reason;
    });
  }

这是请求合并的核心,值得逐段拆:

  1. 请求号自增。每个逻辑请求拿到一个单调递增的 id,用于把「请求」与「块」多对多关联起来。
  2. 先过滤已加载块。调用方传来的区间可能包含已经到货的块(例如 MissingDataException 携带了整个区间),这里先剔除,避免重复下载。
  3. 全部已加载则直接完成。返回一个已解决的承诺,不占用映射表。
  4. 登记需求。_promisesByRequest 保存 deferred,_chunksNeededByRequest 保存这个请求还缺哪些块。
  5. 按块反向登记。getOrInsertComputed 的意思是「取键对应的值,没有就计算并插入」。如果这个块第一次被请求,回调会把它 push 进 chunksToRequest,并创建一个空的请求号数组;无论新旧,随后都把当前请求号追加进去。
  6. 只发新块。chunksToRequest 里只有第一次出现的块,已经在途的块不会重复发请求;后来的请求只是挂到同一个块的等待者数组上。
  7. 按连续区间发送。groupChunks 把块合并成段,段首尾换算成字节偏移,再调 sendRequest。end 夹到文件长度,避免最后一块越界。
  8. 失败传播。任一 sendRequest 失败都会 reject 这个请求的 deferred;返回的承诺在 #aborted 为真时吞掉错误,避免上层看到取消异常。

合并前后的差异可以直接列出来:

场景逐块请求(朴素实现)_requestChunks 合并后
需要相邻的 5 个块5 次 Range 请求1 次覆盖 5 块的请求
首块与尾块同时需要2 次请求仍是 2 次,两块不连续
两个解析路径先后需要同一块2 次请求,可能重复下载1 次请求,两个 deferred 共享结果
请求的区间里混有已加载块可能重复下载先被 hasChunk 过滤
服务端返回比请求更多的块多余数据被丢弃,后续再请求一次多余块一并登记,后续请求直接命中
中止每个请求各自报错统一 reject,对外承诺静默完成

设计要点:请求合并发生在「块」这一层,而不是「字节区间」这一层。块是固定大小的,合并只需判断块号是否连续,不需要处理任意区间的重叠计算。这让 groupChunks 可以用一次线性扫描完成,也让 _requestsByChunk 的键保持简单整数。

groupChunks:把零散块并成区间

  groupChunks(chunks) {
    const groupedChunks = [];
    let beginChunk = -1;
    let prevChunk = -1;
 
    for (let i = 0, ii = chunks.length; i < ii; ++i) {
      const chunk = chunks[i];
      if (beginChunk < 0) {
        beginChunk = chunk;
      }
 
      if (prevChunk >= 0 && prevChunk + 1 !== chunk) {
        groupedChunks.push({ beginChunk, endChunk: prevChunk + 1 });
        beginChunk = chunk;
      }
      if (i + 1 === chunks.length) {
        groupedChunks.push({ beginChunk, endChunk: chunk + 1 });
      }
 
      prevChunk = chunk;
    }
    return groupedChunks;
  }

算法只有一次遍历,维护当前连续段的首块 beginChunk 与上一个块 prevChunk:遇到第一个块时记下段首;当前块与上一个块不连续时,产出一个 [beginChunk, prevChunk + 1) 的区间并把当前块作为新段首;遍历到最后一个块时收尾。

源码注释明确要求输入是已排序的。requestRanges 因此先收集、去重再 sort;requestRange 按块号递增构造数组;requestAllChunks 的块号来自顺序扫描。_requestChunks 里的 chunksToRequest 取自 chunksNeeded 这个 Set,插入顺序即调用方传入 chunks 的顺序,只要上游有序,groupChunks 就能合并出尽量少的区间。传入乱序数组不会让区间越界,但会把本可合并的块切成大量碎片,请求数随之上升,所以排序必须发生在进入合并之前。

区间用 endChunk 表示开区间上界,因此 prevChunk + 1 与 chunk + 1 都不会漏掉最后一块。例如块号 [3, 4, 5, 9] 会被合并成两段:[3, 6) 与 [9, 10),原本四块需要四次请求,现在只需两次。

onReceiveData:结算请求与自动预取

  onReceiveData(args) {
    const { chunkSize, length, stream } = this;
 
    const chunk = args.chunk;
    const isProgressive = args.begin === undefined;
    const begin = isProgressive ? stream.progressiveDataLength : args.begin;
    const end = begin + chunk.byteLength;
 
    const beginChunk = Math.floor(begin / chunkSize);
    const endChunk =
      end < length ? Math.floor(end / chunkSize) : Math.ceil(end / chunkSize);
 
    if (isProgressive) {
      stream.onReceiveProgressiveData(chunk);
    } else {
      stream.onReceiveData(begin, chunk);
    }
 
    if (stream.isDataLoaded) {
      this.#loadedStreamCapability.resolve(stream);
    }
 
    const loadedRequests = [];
    for (let curChunk = beginChunk; curChunk < endChunk; ++curChunk) {
      // The server might return more chunks than requested.
      const requestIds = this._requestsByChunk.get(curChunk);
      if (!requestIds) {
        continue;
      }
      this._requestsByChunk.delete(curChunk);
 
      for (const requestId of requestIds) {
        const chunksNeeded = this._chunksNeededByRequest.get(requestId);
        if (chunksNeeded.has(curChunk)) {
          chunksNeeded.delete(curChunk);
        }
 
        if (chunksNeeded.size > 0) {
          continue;
        }
        loadedRequests.push(requestId);
      }
    }
 
    // If there are no pending requests, automatically fetch the next
    // unfetched chunk of the PDF file.
    if (!this.disableAutoFetch && this._requestsByChunk.size === 0) {
      let nextEmptyChunk;
      if (stream.numChunksLoaded === 1) {
        // This is a special optimization so that after fetching the first
        // chunk, rather than fetching the second chunk, we fetch the last
        // chunk.
        const lastChunk = stream.numChunks - 1;
        if (!stream.hasChunk(lastChunk)) {
          nextEmptyChunk = lastChunk;
        }
      } else {
        nextEmptyChunk = stream.nextEmptyChunk(endChunk);
      }
      if (Number.isInteger(nextEmptyChunk)) {
        this._requestChunks([nextEmptyChunk]);
      }
    }
 
    for (const requestId of loadedRequests) {
      const capability = this._promisesByRequest.get(requestId);
      this._promisesByRequest.delete(requestId);
      capability.resolve();
    }
 
    this.msgHandler.send("DocProgress", {
      loaded: MathClamp(
        stream.numChunksLoaded * chunkSize,
        stream.progressiveDataLength,
        length
      ),
      total: length,
    });
  }

这个方法把「收数据」与「发通知」放在一起,按执行顺序分五步:

第一步,判断模式并写入。 args.begin 为 undefined 表示顺序流,起点是当前的 progressiveDataLength;否则是 Range 响应。写入分别委托给 ChunkedStream 的两个方法。

第二步,计算块窗口。 end 未到文件末尾时用 Math.floor(end / chunkSize),到达末尾时用 Math.ceil(end / chunkSize),保证最后不满一块也被算进去。

第三步,兑现完成承诺。 所有块到齐时 #loadedStreamCapability 只会 resolve 一次。

第四步,结算逻辑请求。 遍历响应覆盖的每个块:取 _requestsByChunk 里等待它的请求号,取不到就跳过;立刻删除该块映射,防止后续再为它发请求;对每个等待者从 chunksNeeded 里删掉这块,集合空了就收进 loadedRequests。服务端可能返回比请求更多的块,所以这里遍历的是响应实际覆盖的所有块,多出来的也会被登记。

第五步,自动预取。 只有在 _requestsByChunk 为空时才会预取,避免和用户显式请求抢连接。策略见下表。

条件预取目标目的
已加载块数正好为 1,且最后一块未加载最后一块尽快拿到文件尾部的 trailer 与 startxref
其余情况从 endChunk 起的第一个空块顺序向前读,维持稳定吞吐
disableAutoFetch 为真不预取省流量,完全按需
全部块已加载nextEmptyChunk 返回 null,不预取无事可做

「首块之后直接取最后一块」是全文件最值得注意的优化。PDF 的交叉引用表在文件尾部;如果按顺序从第二块读起,要读完整份文件才能开始定位对象。先跳到尾块,startxref 与 trailer 立刻可解析,随后读 xref 指向的少数几块就能开始渲染首页。中间的大量块由后续的顺序预取慢慢补齐,不影响首屏。

第五步之后才 resolve 已完成的请求,再发送进度消息。顺序很重要:请求的兑现意味着调用方可能立刻继续解析并产生新的缺页,如果提前 resolve,预取判断就会被新请求打断;放在预取之后,可以让「无在途请求」这一个窗口尽量被利用。

进度消息里的 loaded 用 MathClamp 做了三向夹取:下界是 progressiveDataLength,顺序流模式下块标记可能落后于实际字节,不能让进度倒退;上界是 length,最后一块不满时块数乘块大小会超过文件长度,不能报出超过 100%;中间值是 numChunksLoaded * chunkSize。主线程收到 DocProgress 后更新进度条,Worker 只负责把状态说清楚。

设计要点:预取是「机会主义」的:只在没有任何在途请求时顺手多拿一块,而且优先拿信息密度最高的尾块。它不维护独立的预取队列,也不设定并发上限,因为真正需要并发的场景已经由 _requestChunks 的合并逻辑覆盖。用「空闲即预取」代替「预取调度器」,是这份实现保持简单的关键。

abort:把等待者全部叫醒

  abort(reason) {
    this.#aborted = true;
    this.pdfStream?.cancelAllRequests(reason);
 
    for (const capability of this._promisesByRequest.values()) {
      capability.reject(reason);
    }
    this.#loadedStreamCapability.reject(reason);
  }

abort 做四件事:置位终止标记、取消底层所有网络请求、拒绝所有按请求登记的 deferred、拒绝整份流加载的承诺。

置位标记有两处作用:sendRequest 的读取循环看到标记后丢弃后续分片;_requestChunks 返回的承诺在捕获到拒绝时,如果已经终止就静默返回。前者防止已取消的请求继续写缓冲,后者让调用方在终止后不会因为「补数据失败」而看到错误。

abort 本身不清理映射表,因为终止意味着整个管理器不再使用;继续保留引用只会让垃圾回收晚一点,不会造成逻辑错误。

一次页面加载的完整时序

把上面所有部件串起来,打开一份网络 PDF 的第一页大致是这样:

  1. NetworkPdfManager 构造 ChunkedStreamManager,同时创建 ChunkedStream 与 PDFDocument。此时缓冲已分配,块一个都没加载。
  2. 解析器从文件头开始读,落在第 0 块,ensureByte 抛异常,NetworkPdfManager.ensure 捕获后调用 requestRange。
  3. _requestChunks 把第 0 块登记为请求,groupChunks 产出一个单块区间,sendRequest 发出第一次 Range 请求。
  4. 响应到达,onReceiveData 写入第 0 块、结算请求,然后发现只加载了 1 块,触发「首块之后取尾块」的优化,立刻请求最后一块。
  5. 第 0 块的等待者被唤醒,解析继续读文件头与第一个对象;同时尾块的请求在途。
  6. 尾块到达,PDFDocument 从尾部找到 startxref,XRef.parse 开始读 xref 段,产生新的缺页,进入 _requestChunks。
  7. 这些缺页可能覆盖多个相邻块,被合并成一两次请求;不连续的区间则各自成段。
  8. 每次响应都会顺带预取一个空块,直到页面所需的对象全部就绪,PartialEvaluator 产出 operatorList。
  9. 如果用户滚动到后面的页面,缺页继续按需发生;如果 disableAutoFetch 为假,空闲时块会被逐步补齐,进度通过 DocProgress 上报。

这个时序解释了一个常见现象:网络面板里 PDF 的请求不是严格顺序的,往往第 0 块之后紧跟着最后一块,然后才是中间区域。那不是随机,而是尾部优先策略的直接结果。

与 MissingDataException 的协作

ChunkedStream 自己不等待、不重试,它只负责抛异常。重试逻辑在 NetworkPdfManager.ensure:

  async ensure(obj, prop, args) {
    try {
      const value = obj[prop];
      if (typeof value === "function") {
        return await value.apply(obj, args);
      }
      return value;
    } catch (ex) {
      if (!(ex instanceof MissingDataException)) {
        throw ex;
      }
      await this.requestRange(ex.begin, ex.end);
      return this.ensure(obj, prop, args);
    }
  }

这是一个「属性访问级别的重试」:先按普通方式读取属性或调用方法;只有 MissingDataException 会被接住,其它异常原样上抛;用异常携带的区间请求数据并等待完成;最后从头再执行一次同样的属性访问。

之所以可以粗暴地从头重试,是因为解析逻辑的读取都是幂等的:重新调用 getter 会重新走一遍 Lexer 与 Parser,之前没读完的部分本来就没有产生副作用。真正需要提交的结果(对象、页面、operatorList)都在数据齐全之后才写入缓存。

这也解释了 ensureRange 为什么抛完整区间而不是第一个缺失块:一次补足调用方需要的全部字节,重试才有意义;如果只补一块,下一次重试很可能在下一块再抛一次,递归深度和网络往返都会膨胀。

设计要点:缺数据不是错误,而是一种控制流。ChunkedStream 用异常把「现在读不了」向上传递,NetworkPdfManager 把它翻译成「等一会儿再读」。解析器本身完全不知道网络存在,只看到「读字节」这一个动作;流式能力因此可以透明地加在任意解析代码之下。

总结

chunked_stream.js 是 PDF.js 的字节供给层:向上把一份按需下载的文件伪装成完整的 Uint8Array,向下把解析器的随机访问翻译成尽量少的 HTTP Range 请求。

它在整条链路中的位置

阶段模块输入输出
网络分片PDFDataTransportStream / getRangeReaderURL、Range 头ArrayBuffer 分片
请求调度ChunkedStreamManager缺块区间合并后的请求、回填的数据
缓冲与缺页ChunkedStream响应分片、读取位置可寻址的字节视图或异常
词法与语法Lexer / Parser任意偏移的字节token、Dict、BaseStream
对象与文档XRef / PDFDocument / PartialEvaluator解析结果页面模型、operatorList

用一句话串起来:网络给出分片,管理器把分片变成块,ChunkedStream 把块变成可寻址的文件,解析器在缺页与重试之间完成对整份文档的按需读取。

五条设计原则

  1. 逻辑长度与物理可用性分离。构造时按文件长度一次性分配缓冲,块状态交给 _loadedChunks 与 progressiveDataLength 两个独立维度记录。解析器可以按任意偏移访问,不需要关心数据到货顺序。
  2. 缺数据用异常表达,不用阻塞等待。ensureByte 与 ensureRange 只做检查,发现缺块就抛 MissingDataException;重试与网络等待集中在 NetworkPdfManager.ensure 一层。解析代码保持完全同步的写法。
  3. 请求按块合并、按需去重。_requestChunks 用 _requestsByChunk 记录每个块的等待者,同一个块只发一次请求;groupChunks 把连续块合并成尽量少的区间。请求数量由「块分布」而不是「调用次数」决定。
  4. 预取有节制,先尾后顺。只在没有在途请求时预取一块;第一次收到数据后优先取最后一块,让 trailer 与 startxref 尽早可用。首屏不必等待整份文件,后台再顺序补齐。
  5. 子流共享父状态,不复制内存。makeSubStream 用 Object.create(this) 让子流以父实例为原型,只覆盖与窗口相关的 getMissingChunks 与 isDataLoaded。既省掉缓冲复制,也让父流后续到达的块自动对子流可见。

下一篇回到解码侧:decode_stream.js。网络层交出的只是原始字节,Flate、LZW、ASCII85、预测器、图片解码器都要在这层之上把字节变成真正可读的缓冲,而 DecodeStream 就是它们的公共基座。

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

相关文章