PDF.js 源码解读: chunked_stream
2026年10月27日 · StreamPDF Team

前言
这是「字节与流」层的第一篇。
前几篇一直在往上走: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 / getRangeReader | URL、Range 头 | ArrayBuffer 分片 |
| 调度 | ChunkedStreamManager | 块号区间 | 合并后的 Range 请求 |
| 缓冲 | ChunkedStream | 响应分片、块号 | 全文长度的 Uint8Array |
| 解析 | Lexer / Parser | 任意偏移的字节 | token、Dict、BaseStream |
| 对象与文档 | XRef / PDFDocument | 解析结果 | 对象索引、页面树 |
整条链路可以压缩成一句:网络提供分片,管理器把分片调度成块,ChunkedStream 把块伪装成完整文件,解析器则完全不知道自己读的是一个按需下载的文件。
两种字节供给:Stream 与 ChunkedStream
Stream 只有七十多行,它假设 bytes 已经完整。ChunkedStream 继承它,保留同样的读取 API,但在读取前插入一层「块是否已加载」的检查。
| 维度 | Stream | ChunkedStream |
|---|---|---|
| 内存来源 | 调用方传入完整的 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 响应的落地点。四个细节值得展开:
- 起点必须对齐块边界。调度器按块发请求,
begin一定是chunkSize的整数倍;不对齐说明上层算错了。 - 终点要么对齐,要么正好是文件末尾。最后一块可能不满,除此之外响应都必须覆盖完整的块,服务器擅自返回半个块会被拒绝。
- 终点用
bytes.length而不是this.length。this.start可能被moveStart移动,lengthgetter 用来判断文件末尾会不准;缓冲区的真实容量才是可靠上界。 - 登记块用
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 这类一次需要很多字节的场景。它有三个特点:
- 空区间直接放行。
begin >= end意味着没有字节要读,任何缓冲状态都满足。 - 连续前缀直接放行。
end <= progressiveDataLength时整个区间都在连续数据里,不必逐块查。 - 异常携带的是调用方请求的完整区间,而不是第一个缺失块。这是一个有意的选择:调用方需要的是一整段,调度器拿到完整区间后可以按需合并、去重;如果只报第一个缺口,补完第一个缺口后还要再抛一次,往返次数会成倍增加。
异常里的区间可能包含已经加载的块,这不会造成浪费。_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 请求的意义。也不能什么都不检查,否则解析会在后面的字节才失败,前面的工作全部白做。
后半段的原型技巧有三点:
ChunkedStreamSubstream.prototype = Object.create(this)把父实例本身设为子流原型,子流没有自己的bytes、chunkSize、_loadedChunks,全部沿原型链找到父对象。- 覆盖
getMissingChunks,只扫描子流自己的块窗口[start, end);沿用父类实现会返回整份文件缺的块,管理器就会下载整个文件。 - 重新定义
isDataLoadedgetter,判断「父流全部加载完成」或「子流窗口内没有缺失」。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;
});
}这是请求合并的核心,值得逐段拆:
- 请求号自增。每个逻辑请求拿到一个单调递增的 id,用于把「请求」与「块」多对多关联起来。
- 先过滤已加载块。调用方传来的区间可能包含已经到货的块(例如
MissingDataException携带了整个区间),这里先剔除,避免重复下载。 - 全部已加载则直接完成。返回一个已解决的承诺,不占用映射表。
- 登记需求。
_promisesByRequest保存 deferred,_chunksNeededByRequest保存这个请求还缺哪些块。 - 按块反向登记。
getOrInsertComputed的意思是「取键对应的值,没有就计算并插入」。如果这个块第一次被请求,回调会把它 push 进chunksToRequest,并创建一个空的请求号数组;无论新旧,随后都把当前请求号追加进去。 - 只发新块。
chunksToRequest里只有第一次出现的块,已经在途的块不会重复发请求;后来的请求只是挂到同一个块的等待者数组上。 - 按连续区间发送。
groupChunks把块合并成段,段首尾换算成字节偏移,再调sendRequest。end夹到文件长度,避免最后一块越界。 - 失败传播。任一
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 的第一页大致是这样:
NetworkPdfManager构造ChunkedStreamManager,同时创建ChunkedStream与PDFDocument。此时缓冲已分配,块一个都没加载。- 解析器从文件头开始读,落在第 0 块,
ensureByte抛异常,NetworkPdfManager.ensure捕获后调用requestRange。 _requestChunks把第 0 块登记为请求,groupChunks产出一个单块区间,sendRequest发出第一次 Range 请求。- 响应到达,
onReceiveData写入第 0 块、结算请求,然后发现只加载了 1 块,触发「首块之后取尾块」的优化,立刻请求最后一块。 - 第 0 块的等待者被唤醒,解析继续读文件头与第一个对象;同时尾块的请求在途。
- 尾块到达,
PDFDocument从尾部找到startxref,XRef.parse开始读 xref 段,产生新的缺页,进入_requestChunks。 - 这些缺页可能覆盖多个相邻块,被合并成一两次请求;不连续的区间则各自成段。
- 每次响应都会顺带预取一个空块,直到页面所需的对象全部就绪,
PartialEvaluator产出 operatorList。 - 如果用户滚动到后面的页面,缺页继续按需发生;如果
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 / getRangeReader | URL、Range 头 | ArrayBuffer 分片 |
| 请求调度 | ChunkedStreamManager | 缺块区间 | 合并后的请求、回填的数据 |
| 缓冲与缺页 | ChunkedStream | 响应分片、读取位置 | 可寻址的字节视图或异常 |
| 词法与语法 | Lexer / Parser | 任意偏移的字节 | token、Dict、BaseStream |
| 对象与文档 | XRef / PDFDocument / PartialEvaluator | 解析结果 | 页面模型、operatorList |
用一句话串起来:网络给出分片,管理器把分片变成块,ChunkedStream 把块变成可寻址的文件,解析器在缺页与重试之间完成对整份文档的按需读取。
五条设计原则
- 逻辑长度与物理可用性分离。构造时按文件长度一次性分配缓冲,块状态交给
_loadedChunks与progressiveDataLength两个独立维度记录。解析器可以按任意偏移访问,不需要关心数据到货顺序。 - 缺数据用异常表达,不用阻塞等待。
ensureByte与ensureRange只做检查,发现缺块就抛MissingDataException;重试与网络等待集中在NetworkPdfManager.ensure一层。解析代码保持完全同步的写法。 - 请求按块合并、按需去重。
_requestChunks用_requestsByChunk记录每个块的等待者,同一个块只发一次请求;groupChunks把连续块合并成尽量少的区间。请求数量由「块分布」而不是「调用次数」决定。 - 预取有节制,先尾后顺。只在没有在途请求时预取一块;第一次收到数据后优先取最后一块,让 trailer 与
startxref尽早可用。首屏不必等待整份文件,后台再顺序补齐。 - 子流共享父状态,不复制内存。
makeSubStream用Object.create(this)让子流以父实例为原型,只覆盖与窗口相关的getMissingChunks与isDataLoaded。既省掉缓冲复制,也让父流后续到达的块自动对子流可见。
下一篇回到解码侧:decode_stream.js。网络层交出的只是原始字节,Flate、LZW、ASCII85、预测器、图片解码器都要在这层之上把字节变成真正可读的缓冲,而 DecodeStream 就是它们的公共基座。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_stream2026年10月27日
- PDF.js 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_stream2026年10月27日
- PDF.js 源码解读: jpeg_stream2026年10月27日
- PDF.js 源码解读: decrypt_stream2026年10月27日
- PDF.js 源码解读: brotli_stream2026年10月27日
- PDF.js 源码解读: run_length_stream2026年10月27日
- PDF.js 源码解读: ascii_hex_stream2026年10月27日
- PDF.js 源码解读: ascii_85_stream2026年10月27日
- PDF.js 源码解读: lzw_stream2026年10月27日
- PDF.js 源码解读: predictor_stream2026年10月27日
- PDF.js 源码解读: flate_stream2026年10月27日
- PDF.js 源码解读: decode_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- PDF.js 深入浅出 11: Parser2026年10月26日
- PDF.js 深入浅出 10: XRef2026年10月26日
- PDF.js 深入浅出 9: CanvasGraphics2026年10月26日
- PDF.js 深入浅出 8: Document 与 Page2026年10月26日
- AI 时代, 我们到底应该做什么才能活下去(StreamPDF 创立有感)2026年10月26日
- PDF.js 深入浅出 7: PartialEvaluator2026年10月25日
- PDF.js 深入浅出 6: PDFManager.initDocument2026年10月25日
- PDF.js 深入浅出 5: PDFFetchStream 和 PDFWorkerStream2026年10月25日
- PDF.js 深入浅出 4: MessageHandler2026年10月22日
- PDF.js 深入浅出 3: getViewport & render2026年10月22日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日