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

前言
这是 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 行,可以切成四个角色:
| 角色 | 父类 | 职责 |
|---|---|---|
PDFWorkerStream | BasePDFStream | 把消息处理器与两个 Reader 类绑定成完整流对象 |
PDFWorkerStreamReader | BasePDFStreamReader | 请求全文流、接收头信息、按块读取、转发取消 |
PDFWorkerStreamRangeReader | BasePDFStreamRangeReader | 按 begin 与 end 请求一段字节,供 Range 读取使用 |
MessageHandler 的流式消息通道 | 无 | 在 display 与 Worker 之间传递数据块、背压与取消信号 |
它没有定义新的数据结构,也没有保存字节。它做的事情可以概括为四步:
- 用
GetReader消息向 display 层要一个全文流; - 用
ReaderHeadersReady消息拿到contentLength、isStreamingSupported、isRangeSupported; - 把收到的每个
Uint8Array解包成ArrayBuffer交给上层; - 用
GetRangeReader消息按区间请求额外字节。
它在整条链路里的位置:
| 步骤 | 所在层 | 数据形态 |
|---|---|---|
| 1 | 网络层 | URL 与 Range 请求 |
| 2 | display 层 | NetworkStream / PDFDataTransportStream 的原始分片 |
| 3 | 消息层 | sendWithStream("GetReader") 建立的跨线程流 |
| 4 | Worker 适配层 | 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 对「零分片且关闭自动预取」的情况有专门判断,避免把一次本就没有发出的请求误当成响应。
| 维度 | PDFWorkerStreamReader | PDFWorkerStreamRangeReader |
|---|---|---|
| 消息 | GetReader | GetRangeReader,带 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
);这两段代码放在一起,才能看清「通道」的完整形状:
| 层 | 对象 | 动作 |
|---|---|---|
| Worker | PDFWorkerStreamReader | 发 GetReader,拿到消息流 |
| 消息层 | MessageHandler.sendWithStream | 建立 ReadableStream,转发 pull/cancel |
| display | WorkerTransport 的 GetReader | 创建主线程全文 Reader,回填 sink |
| 网络层 | NetworkStream / PDFDataTransportStream | 读 HTTP 响应或调用 Range 传输 |
| Worker | PDFWorkerStreamReader.read | 把 Uint8Array 解包成 ArrayBuffer |
设计要点:
sendWithStream不是把整个响应体一次性postMessage过来,而是把ReadableStream的pull与cancel映射成跨线程消息。数据块只在被请求时搬运,desiredSize变化会形成背压。Worker 读得慢,主线程就不会无限入队,内存占用被限制在一个可预期的窗口内。
失败、取消与生命周期
适配器本身没有重试逻辑,错误处理全部依赖两层机制:
- Promise 与流错误:
ReaderHeadersReady失败会 rejectheadersReady;read()失败会让流进入错误状态,Worker 的read()调用收到 reject。 - 取消传播: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 / PDFDataTransportStream | URL、Range、响应头 | 主线程 Reader |
| 消息适配 | MessageHandler.sendWithStream | GetReader、pull、cancel | 跨线程 ReadableStream |
| Worker 适配 | PDFWorkerStreamReader | 消息分片 | ArrayBuffer |
| 分块管理 | ChunkedStreamManager | Range 区间、文件长度 | ChunkedStream |
| 解析 | Lexer / Parser / XRef | 任意偏移的字节 | token、对象、索引 |
用一句话串起来:display 层负责把网络响应变成字节,PDFWorkerStream 负责把字节变成 Worker 可读的流,ChunkedStream 负责把流伪装成完整文件。
四条设计原则
- 传输与解析分层。网络请求、Range 协商、响应头解析都留在 display 层;Worker 只实现
BasePDFStream契约,不感知 HTTP。 - 元数据与数据分离。
ReaderHeadersReady单独返回长度与能力,GetReader单独搬运字节,二者不互相等待。 - 按需拉取而不是推送洪流。
sendWithStream用pull与desiredSize形成背压,Worker 读到哪,主线程才送到哪,避免消息队列无限增长。 - 取消沿链路原样传递。Worker 的
cancel经消息层、sink.onCancel、主线程 Reader 一路向下,网络请求最终被中止;适配器不吞掉原因,也不伪造成功。
worker_stream.js 本身很短,但它把「浏览器网络」和「Worker 解析」这两块完全不同的世界接在了一起。理解了这层适配器,再看 ChunkedStream 的缺页重试,就不会误以为 Worker 自己在发请求;它只是在向主线程要下一块字节。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: crypto2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- PDF.js 源码解读: primitives2026年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 源码解读: chunked_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日