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

前言
本篇完整解读一个源文件:src/shared/base_pdf_stream.js,共 240 行。
此前的网络流文章把这个文件作为 fetch、XHR、Node.js 与 push 传输中的一个子系统介绍。本篇把视角收窄:基类文件本身确立了什么契约、持有什么状态,又有哪些决定被明确留给子类?
文件导出三个类:
BasePDFStream持有字节来源并负责创建 reader;BasePDFStreamReader描述整份文档的流式读取与响应元数据;BasePDFStreamRangeReader描述独立请求的字节区间。
这三个类自身都不下载数据。它们的价值在于提供统一接口,让 worker 按需拉取字节,而不必关心主线程的数据究竟来自 fetch、XMLHttpRequest、本地文件流,还是应用自己提供的 buffer。
模块总览
| 区块 | 行号 | 职责 |
|---|---|---|
BasePDFStream | 22-93 | 保存 source 与 reader 类;创建、取消 reader |
BasePDFStreamReader | 98-201 | full reader 的元数据、进度与抽象 read() / cancel() |
BasePDFStreamRangeReader | 206-238 | 最小 range reader 接口 |
| 导出 | 240 | 只导出上述三个基类 |
依赖被刻意压到最小:只从 util.js 引入 assert 和 unreachable。模块内没有网络、解析器或 worker 消息代码。
核心对象关系是:
BasePDFStream
├── 至多一个 BasePDFStreamReader
└── Set 中零到多个 BasePDFStreamRangeReader所以 full 与 range 不是同一个游标上的两种方法,而是各自拥有独立 I/O 状态的 reader 对象,只由 stream 的小型注册表进行协调。
BasePDFStream.constructor():注入具体实现
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;
}基类不负责选择传输方式。具体 stream 通过 super 注入两个 reader 构造函数:
super(source, PDFFetchStreamReader, PDFFetchStreamRangeReader);这是不依赖注册表或工厂框架的构造函数注入。BasePDFStream 决定何时可以创建 reader;被注入的类决定自己的 read() 与 cancel() 如何工作。私有字段防止之后替换这些类。
source 被保存为 _source,子类从中读取传输相关选项:URL、请求头、range 大小、disableRange、disableStream 或 PDFDataRangeTransport。基类刻意不为这个对象规定 schema。
直接实例化保护在测试或未构建源码中生效:它会拦住 new BasePDFStream(...),但允许子类调用。后文的抽象方法保护,才是防止 reader 实现缺失的更重要一道约束。
BasePDFStream._progressiveDataLength:衡量 full stream 的覆盖范围
get _progressiveDataLength() {
return this._fullReader?._loaded ?? 0;
}这个 getter 只回答一个问题:full reader 当前报告已经加载了多少字节?
在 getFullReader() 之前,或任何字节到达之前,值都是 0。它不是文件长度、解析器已消费的字节数,也不是 range 流量之和;只有 full reader 的 _loaded 字段参与计算。
这个区别解释了它唯一的用途:当请求区间已经完全处于渐进加载的覆盖范围内时,避免再发 range 请求。
BasePDFStream.getFullReader():唯一的整文档 reader
getFullReader() {
assert(
!this._fullReader,
"BasePDFStream.getFullReader can only be called once."
);
return (this._fullReader = new this.#PDFStreamReader(this));
}每个 stream 的 full reader 都是单例。构造与注册同时完成,因此之后 _progressiveDataLength 和 cancelAllRequests() 都能访问同一个对象。
断言约束的是生命周期,而不是 I/O:
- 第一次调用构造具体 full reader;
- 实例保存到
_fullReader; - 第二次调用直接失败,不会替换或复制 reader。
取消不会重置 _fullReader,所以 stream 被取消后也不能再取得第二个 full reader。这符合调用方一次文档加载对应一个生命周期的使用方式。
是否启动请求由子类在自己的 reader 构造函数中决定;基类工厂只负责实例化。
BasePDFStream.getRangeReader():带条件的工厂
getRangeReader(begin, end) {
if (end <= this._progressiveDataLength) {
return null;
}
const reader = new this.#PDFStreamRangeReader(this, begin, end);
this._rangeReaders.add(reader);
return reader;
}这一层的 range 区间采用半开形式 [begin, end)。具体传输会把它转换成各自 API 所需的形式,例如 HTTP 使用闭区间,所以终点要写成 end - 1。
方法有两个互斥结果:
| 条件 | 结果 | 含义 |
|---|---|---|
end <= _progressiveDataLength | null | full reader 已加载整个请求区间 |
| 其他情况 | 新的 range reader | 区间仍需独立的 range 路径 |
判断只看 end。如果 begin 小于 _loaded、但 end 大于 _loaded,仍会创建 range reader;基类不会裁掉已经加载的前缀。
只有构造成功后才会注册 reader。与 full reader 不同,range reader 明确允许多实例,因为解析器可能同时需要互不相关的区间。
源码注释还规定了一个时序契约:调用方应当在 full reader 的 headersReady promise resolve 之后调用此方法。基类方法本身不会 await 或断言这个顺序,而是依赖调用方先确立 range 与 streaming 能力。
为什么 null 是契约的一部分
null 不表示 PDF 字节不可用,而表示这次 range 请求没有必要,因为 full stream 已经覆盖这些字节。
display/api.js 中真实的 GetRangeReader handler 显式处理了这个分支:
const rangeReader = this.#networkStream.getRangeReader(data.begin, data.end);
if (!rangeReader) {
sink.close();
return;
}关闭这个 range sink 并不会丢弃文档数据。已经在运行的 full reader 会继续向 worker 传递数据,再发一次请求只会重复工作。
BasePDFStream.cancelAllRequests():取消分发,而不是状态清零
cancelAllRequests(reason) {
this._fullReader?.cancel(reason);
// Always create a copy of the rangeReaders.
for (const reader of new Set(this._rangeReaders)) {
reader.cancel(reason);
}
}取消操作有三项精确语义:
- full reader 存在时取消其读取;
- 每个已注册 range reader 都收到同一个
reason; - range 遍历使用
Set的副本。
副本很重要,因为具体 cancel() 实现可能通过 onClosed 或 onDone 回调,同步把自己从原 Set 删除。遍历快照可以避免这种修改影响本轮取消分发。
这个方法不会等待异步关闭完成,也不会清空 _rangeReaders,更不会重置 _fullReader。它只负责取消分发,不是把 stream 恢复到初始状态的析构器。
基类也不会在 EOF 时自动移除 reader。XHR 与 push stream 自己安排移除回调;fetch 与 Node.js 则把这一生命周期细节留给具体实现和所属 stream 的生命周期。
BasePDFStreamReader.constructor():full reader 的共享状态
full reader 基类持有元数据与进度状态,但不持有任何具体字节队列:
onProgress = null;
_contentLength = 0;
_filename = null;
_headersCapability = Promise.withResolvers();
_isRangeSupported = false;
_isStreamingSupported = false;
_loaded = 0;
_stream = null;
constructor(stream) {
// Direct construction is rejected in testing/unbuilt code.
this._stream = stream;
}每个 reader 都有自己独立的 Promise.withResolvers() capability。默认值刻意保持保守:未知长度是 0,缺失文件名是 null,可选的 range/streaming 能力在子类确认前都是 false。
_stream 是指回所属 BasePDFStream 的引用。具体实现通过它访问 _source、共享请求头、响应来源和 reader 注册表回调,而不必把这些传输细节塞进基类。
_loaded 有双重作用:具体实现用它统计进度,BasePDFStream 用它判断是否可以省略 range 请求。
headersReady 与元数据 getter
get headersReady() {
return this._headersCapability.promise;
}
get filename() {
return this._filename;
}
get contentLength() {
return this._contentLength;
}
get isRangeSupported() {
return this._isRangeSupported;
}
get isStreamingSupported() {
return this._isStreamingSupported;
}这些 getter 共同构成一道元数据屏障。按照文档,它们的确定值要在 headersReady resolve 后读取:
| Getter | ready 后的含义 |
|---|---|
filename | 从 content disposition 解析出的文件名,或 null |
contentLength | PDF 字节长度,未知时为 0 |
isRangeSupported | 是否可以使用独立 range 读取 |
isStreamingSupported | 是否可以渐进消费完整响应 |
响应建立失败或 reader 被取消时,headersReady 可能 reject。子类只有在写完希望消费者看到的元数据后,才应该 resolve 它。
这个 promise 不表示文档正文已经下载完成。对 fetch 而言,它通常只表示响应头已经到达;对 push 传输而言,它可以依据 PDFDataRangeTransport 已经提供的属性立即 resolve。
display/api.js 中面向 worker 的 ReaderHeadersReady handler 正是等待这个 promise,然后返回 contentLength、isStreamingSupported 和 isRangeSupported。正是这个真实调用方,把源码注释中的 range 时序约定变成了实际的顺序保证。
BasePDFStreamReader._callOnProgress():统一通知形态
_callOnProgress() {
this.onProgress?.({ loaded: this._loaded, total: this._contentLength });
}这个辅助方法是同步且可选的。没有安装回调时什么也不做;否则每次创建一个新对象,报告当前的 _loaded 与 _contentLength,不保存进度历史。
fetch、Node.js 与 push reader 在数据到达时调用它。XHR 则直接转发原生 progress 事件,并在事件总长度可计算时使用事件中的 total。因此,基类统一的是回调形态,而不是测量进度的机制。
文档特别强调:当 streaming 不受支持时,进度回调尤其有用。此时消费者可能在等待一个完整响应,而不是持续收到正文 chunk。
BasePDFStreamReader.read() 与 cancel():full reader 的抽象操作
async read() {
unreachable("Abstract method `read` called");
}
cancel(reason) {
unreachable("Abstract method `cancel` called");
}每个 full reader 子类都必须实现这两个操作。
按照文档,read() 的返回形态与标准 stream reader 一致:
{ value, done: false } // value contains the next binary chunk
{ value, done: true } // end of input没有 chunk 可用时,pending read 会等待。文档中的取消契约要求被取消的读取以 done: true 完成,而 I/O 失败会让 pending 操作 reject。
尽管返回形态相似,BasePDFStreamReader 本身并不是 WHATWG ReadableStream 或其 reader。它没有标准的锁定、排队策略或 tee()。这种相似是刻意的:display/api.js 可以直接把它放在 MessageHandler sink 的 onPull 回调下面。
在基类 JSDoc 之外还有一层更具体的边界。主线程的 GetReader 与 GetRangeReader handler 会断言每个非结束态 value 都是 ArrayBuffer,再包成 Uint8Array 并把 buffer transfer 给 worker。因此,主线程具体 reader 实际兑现的是比“二进制数据”更严格的载荷契约。
cancel(reason) 负责启动当前 reader 的拆除。基类接口不返回 promise,也不规定实现该用 AbortController、xhr.abort()、web-stream reader,还是本地状态迁移。
BasePDFStreamRangeReader.constructor():只共享所属 stream
constructor(stream, begin, end) {
if (
(typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
this.constructor === BasePDFStreamRangeReader
) {
unreachable("Cannot initialize BasePDFStreamRangeReader.");
}
this._stream = stream;
}构造函数虽然收到 begin 和 end,却只保存 _stream。这并不意味着具体请求拿不到区间:参数仍可被每个子类构造函数立即使用,或由子类自行保存。
不同实现的选择正好说明了这一点:
- fetch 立即用两个值构造
Range请求头; - Node.js 把它们映射为
fs.createReadStream选项; - XHR 把它们交给自己的请求辅助方法;
- push 模式保存
_begin,用于把后续到达的 chunk 匹配到正确 reader,而end在发起 range 请求时使用。
因此基类没有必要强迫所有实现都携带 _begin 和 _end 字段。
range reader 的 read() 与 cancel(reason) 遵循和 full reader 相同的抽象契约。它刻意没有 headersReady、onProgress、content length 或能力 getter:响应级元数据属于唯一的 full reader,而不是每个片段。
fetch、XHR、Node.js 与 push 如何兑现契约
基类之所以很小,是因为每种传输都在相同方法背后提供自己的状态机。
| 实现 | full reader ready 与元数据 | read() 机制 | range 实现 |
|---|---|---|---|
PDFFetchStream | fetch 响应校验后 resolve;记录长度、range 能力、文件名与响应来源 | 等待 ready,再拉取 response.body.getReader();更新 _loaded | 发送以 end - 1 为包含式终点的 Range 请求头,校验响应来源,再拉取另一个 web reader |
PDFNetworkStream | 解析 XHR 响应头;支持 range 时可能中止完整 XHR | resolve 已排队的 read capability,或返回完整 arraybuffer chunk | 发起另一个 XHR,缓存响应并校验 partial-content 元数据 |
PDFNodeStream | 用 fs.lstat 取得大小并决定本地 range 策略 | 用 Readable.toWeb() 转换 Node 文件流,再拉取 reader | 使用 createReadStream 的 start: begin 与 end: end - 1 |
PDFDataTransportStream | 根据 PDFDataRangeTransport 属性立即 ready | 消费 push 进来的 chunk;多余数据排队,无数据时挂起 read capability | 调用 requestDataRange(begin, end),再按 _begin 把 push 数据路由回来 |
fetch:两个不同的 ready promise
fetch full reader 等待继承来的 _headersCapability;每个 range reader 则有自己的 _readCapability。两者都用自己的 promise 拦住 read(),直到底层 response.body reader 已经存在。
full reader 还独占渐进计数与响应元数据。当 streaming 被禁用且 range 可用时,它会利用响应头完成能力判断后取消初始正文,再 resolve headersReady。所以取消初始请求可能是能力协商的结果,并不代表文档加载失败。
XHR:reader 形态下的缓冲响应
PDFNetworkStreamReader 永远不会启用 _isStreamingSupported。它的 read() 仍返回标准 { value, done } 形态,但内部会在已缓存 chunk、完成状态和新建 pending promise 之间选择。
确认支持 range 后,XHR 会中止完整请求,改为用独立请求读取片段。full 与 range reader 也说明了为什么取消必须结束 pending read:它们共享的 endRequests helper 会以 done: true resolve 所有停放的 capability。
XHR 与 push reader 会主动把已完成或已取消的 range reader 从 _rangeReaders 删除。这种清理由具体传输负责,不是基类注册表自带的行为。
Node.js:用文件系统元数据替代 HTTP 头
PDFNodeStreamReader 把 lstat(url) 当作元数据阶段。文件大小成为 contentLength;range 能力由 disableRange 和文件是否大于两个 range chunk 共同决定。这里没有可提取的 content-disposition 文件名。
full 与 range reader 都通过 web 兼容 reader 暴露文件数据。与 fetch 一样,range 终点会从基类的排他 end 转为文件系统 API 的包含式 end - 1。
push:应用自己成为字节来源
PDFDataTransportStream 把 I/O 方向反转。transport 构造函数用 initialData 与 progressiveDone 初始化队列和完成状态;full reader 从 PDFDataRangeTransport 读取 length 与 contentDispositionFilename,通过 stream 继承那份队列状态,随后立即 resolve headersReady。
因此一次 read() 有三种完成方式:取出已排队 chunk、观察到 _done,或进入 _requests 等待应用继续 push 数据。即使当前没人读取,_enqueue() 也会更新 _loaded,从而让 _progressiveDataLength 持续有效。
对 range 而言,stream 既创建 reader,也调用 requestDataRange(begin, end)。后续 range chunk 带有 begin 偏移,stream 据此找到 _begin 匹配的已注册 reader。在这里,基类的 Set 不只是取消列表,也是数据分发表。
最后,push 模式覆写 cancelAllRequests(reason):完成基类取消分发后,还会调用 pdfDataRangeTransport.abort(),把取消继续传播给应用持有的数据源。
worker 侧适配器复用同一个基类
core/worker_stream.js 还提供了另一个实现 PDFWorkerStream。它的 reader 用 MessageHandler.sendWithStream() 包装 GetReader 与 GetRangeReader,再把 transfer 过来的 Uint8Array 拆回 ArrayBuffer。
至此设计形成闭环:主线程传输在真实 I/O 上兑现基类契约,display/api.js 把它们的 read 结果泵入消息流,worker 再消费同一套 full/range 抽象,而不必知道字节究竟来自哪种传输。
总结
base_pdf_stream.js 不是网络实现,而是所有 PDF 字节来源共享的 reader 生命周期与能力契约。
最重要的约束有六条:
- 每个 stream 只有一个 full reader。
getFullReader()只注册一次;取消不会重新打开工厂。 - range 创建是有条件的。 full reader 的
_loaded已覆盖end时,getRangeReader()返回null,调用方必须处理这个“成功省略请求”的分支。 - 元数据有时序屏障。
contentLength、filename、isRangeSupported与isStreamingSupported要在headersReadyresolve 后读取。 - reader API 形似 stream,但不是 ReadableStream。 子类实现异步
read()与cancel();主线程消费者还要求 chunk 是ArrayBuffer。 - 基类负责协调,不负责传输策略。 请求头校验、请求发起、缓冲、range 匹配和物理取消,都属于 fetch、XHR、Node.js、push 或 worker 适配器。
- 取消是能感知注册表修改的分发。 遍历 range Set 副本,让子类回调可以安全注销 reader,但基类方法本身不会清空全部生命周期状态。
划清这些边界后,再读具体 stream 文件,看到的就不再是四套互不相关的网络写法,而是同一份精确契约的不同实现。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制2026年10月27日
- PDF.js 从 demo 深入浅出 9: 网络流与 Range 请求2026年10月27日
- PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手2026年10月27日
- PDF.js 从 demo 深入浅出 7: WorkerTransport 初始化2026年10月27日
- PDF.js 从 demo 深入浅出 6: MessageHandler 跨线程通讯2026年10月27日
- PDF.js 从 demo 深入浅出 5: render 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 4: getViewport 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 3: getPage 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 2: getDocument 方法全貌2026年10月27日
- PDF.js 源码解读: TextLayer2026年10月27日
- PDF.js 源码解读: font_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_display2026年10月27日
- PDF.js 源码解读: PDFObjects2026年10月27日
- PDF.js 源码解读: 网络流2026年10月27日
- PDF.js 源码解读: internal_evt.js2026年10月27日
- PDF.js 源码解读: math_clamp.js2026年10月27日
- PDF.js 源码解读: css_utils.js2026年10月27日
- PDF.js 源码解读: scripting_utils2026年10月27日
- PDF.js 源码解读: MurmurHash3_642026年10月27日
- PDF.js 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: image_utils.js2026年10月27日
- PDF.js 源码解读: 共享工具层2026年10月27日
- PDF.js 源码解读: MessageHandler2026年10月27日
- PDF.js 源码解读: api2026年10月27日
- PDF.js 源码解读: fonts2026年10月27日
- PDF.js 源码解读: glyphlist2026年10月27日
- PDF.js 源码解读: binary_cmap2026年10月27日
- PDF.js 源码解读: cmap2026年10月27日
- PDF.js 源码解读: encodings2026年10月27日
- PDF.js 源码解读: metrics2026年10月27日
- PDF.js 源码解读: standard_fonts2026年10月27日
- PDF.js 源码解读: fonts_utils2026年10月27日
- PDF.js 源码解读: default_appearance2026年10月27日
- PDF.js 源码解读: metadata_parser2026年10月27日
- PDF.js 源码解读: name_number_tree2026年10月27日
- PDF.js 源码解读: struct_tree2026年10月27日
- PDF.js 源码解读: catalog2026年10月27日
- 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 源码解读: 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 源码解读: 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 从 demo 深入浅出 1: 自定义 viewer 渲染 PDF2026年10月19日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日