PDF.js 深入浅出 5: PDFFetchStream
2026年10月25日 · StreamPDF Team

前言
PDF.js 系列文章的第五篇,解读 PDF.js 的 PDFFetchStream 类:它如何从网络上取回 PDF 的二进制数据,以及全量请求与 Range 请求之间的关系。
本系列分析基于 PDF.js v6.3.289 源码。
PDFFetchStream 类用来从网络上请求 PDF 文件的二进制数据,包括全量请求和 Range 请求,分别通过 PDFFetchStreamReader 和 PDFFetchStreamRangeReader 来实现。
本文会依次回答三个问题:
- 为什么 PDF.js 发出一个
200请求后,有时会立刻把它切断? - Range 请求是谁触发的,为什么它要跨过 worker 与主线程两侧?
- 为什么
Content-Encoding不是identity就不能用 Range?
PDFFetchStream 类
class PDFFetchStream extends BasePDFStream {
_responseOrigin = null;
constructor(source) {
super(source, PDFFetchStreamReader, PDFFetchStreamRangeReader);
const { httpHeaders, url } = source;
assert(/https?:/.test(url.protocol), 'PDFFetchStream only supports http(s):// URLs.');
this.headers = createHeaders(/* isHttp = */ true, httpHeaders);
}
}该类非常简单,调用父类的构造函数,并传入 PDFFetchStreamReader 和 PDFFetchStreamRangeReader 作为参数。
在父类 BasePDFStream 中:
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;
}存下 source、PDFStreamReader 和 PDFStreamRangeReader。
从本系列第二篇文章的 getPdfManager 部分可以得知,getFullReader 方法被调用:
getFullReader() {
assert(
!this._fullReader,
"BasePDFStream.getFullReader can only be called once."
);
return (this._fullReader = new this.#PDFStreamReader(this));
}实例化 this.#PDFStreamReader, 指向 PDFFetchStreamReader 类。
PDFFetchStreamReader 类
进入 PDFFetchStreamReader.constructor
constructor(stream) {
super(stream);
const {
disableRange,
disableStream,
rangeChunkSize,
url,
withCredentials,
} = stream._source;
this._isStreamingSupported = !disableStream;
// Always create a copy of the headers.
const headers = new Headers(stream.headers);
fetchUrl(url, headers, withCredentials, this._abortController)
.then(response => {
stream._responseOrigin = getResponseOrigin(response.url);
ensureResponseStatus(response.status, url);
this._reader = response.body.getReader();
const responseHeaders = response.headers;
const { contentLength, isRangeSupported } =
validateRangeRequestCapabilities({
responseHeaders,
isHttp: true,
rangeChunkSize,
disableRange,
});
this._contentLength = contentLength;
this._isRangeSupported = isRangeSupported;
this._filename = extractFilenameFromHeader(responseHeaders);
// We need to stop reading when range is supported and streaming is
// disabled.
if (!this._isStreamingSupported && this._isRangeSupported) {
this.cancel(new AbortException("Streaming is disabled."));
}
this._headersCapability.resolve();
})
.catch(this._headersCapability.reject);
}这里调用了 fetchUrl 方法:
进入 PDFFetchStreamReader.constructor -> fetchUrl(...)
function fetchUrl(url, headers, withCredentials, abortController) {
return fetch(url, {
method: 'GET',
headers,
signal: abortController.signal,
mode: 'cors',
credentials: withCredentials ? 'include' : 'same-origin',
redirect: 'follow',
});
}调用浏览器的 fetch 方法获取数据。
请注意,fetch 方法不是等待全部数据回来之后才 .then 的。
这是理解 PDF.js 网络加载机制的关键。fetch() 返回的 Promise 在收到响应头(headers)时就立即 resolve,而不是等整个 body 下载完毕。body 以 ReadableStream 的形式挂在 response.body 上,可以逐块读取。
这意味着:
- PDF.js 在文件刚开始下载时就能拿到
Content-Length、Accept-Ranges等关键元数据,无需等待整个文件; - 通过
response.body.getReader()可以边下边解析,实现 progressive rendering(渐进式渲染);之所以能在第一页数据到达后就开始解析渲染,是因为解析器会用 Range 请求提前取文件尾的 xref,而不是顺序等下载; - 如果服务器支持 Range 请求,PDF.js 可以主动切断全量流,转而用
Range头按需请求特定字节区间,进一步减少首屏时间。
这就是为什么 DevTools 里有时会看到一条只有响应头、没有响应体的
200 (canceled):PDF.js 拿到响应头后就切断了全量请求,改用 Range 请求取需要的字节区间。
不过这里有一个容易误解的地方:全量流并不是总会被切断,是否切断取决于 disableStream(默认值为 false):
| 模式 | disableStream | 全量流 | Range 请求的作用 |
|---|---|---|---|
| 默认(流式) | false | 一直下载到结束,边下边喂给 ChunkedStream | 只补解析抢跑时还没到的区间(例如文件尾的 xref) |
| 关闭流式 | true | 拿到响应头后立即 cancel() | 承载全部数据,这就是 200 (canceled) 的来源 |
如果没有这个特性,PDF.js 的「秒开」体验就无从谈起。
一个最小 demo
下面这段代码复现了 PDF.js 的行为:先发一个完整的 GET 请求,拿到响应头之后立刻中断它,再改用 Range 请求。
const PDF_URL = 'https://assets.streampdf.com/PDF32000_2008.pdf'; // 22.5 MB, supports Range
async function demo() {
const controller = new AbortController();
// 1. 发起全量请求:收到响应头,fetch 就 resolve 了
const response = await fetch(PDF_URL, { signal: controller.signal });
console.log('status:', response.status); // 200,此时 body 可能还在陆续到达
const fileSize = Number(response.headers.get('Content-Length'));
console.log('content-length:', fileSize);
// 2. 拿到 body 的流式 reader
const reader = response.body.getReader();
// 3. 只读一小块,模拟解析器发现「我还缺文件尾部的 xref」
const { value } = await reader.read();
console.log('实际只读取了', value.byteLength, '字节');
// 4. 中断
await reader.cancel(); // 关闭数据流,浏览器随即中断底层连接
controller.abort(); // 兜底:让 signal 进入 aborted 状态
// 5. 换成 Range 请求,只取文件最后 1 KB
const rangeResp = await fetch(PDF_URL, {
headers: { Range: `bytes=${fileSize - 1024}-${fileSize - 1}` },
});
console.log('range status:', rangeResp.status); // 206 Partial Content
}在线 demo(带日志与 DevTools 对照):fetch-abort-demo
两个中断动作的区别:
reader.cancel()关闭的是数据流。此时fetch的 promise 早已 resolve,所以不会再 reject;但浏览器会立刻停止接收剩余字节并关闭连接。controller.abort()中止的是整个请求。如果fetch的 promise 还没 resolve(连响应头都没拿到),它会被 reject 成AbortError。
顺带一提,PDF.js 里两个都调,顺序是 this._reader?.cancel(reason) 之后紧接 this._abortController.abort(),与上面的写法一致。之所以要两个都用,是因为 cancel() 可能在 _reader 还没被赋值(响应头未到)时就被触发,这时只能靠 abort() 兜底。
在 DevTools 里观察
打开 Network 面板,用 disableStream: true 加载一个支持 Range 的大型 PDF,会看到两条记录:
| 请求 | 状态 | Size | 说明 |
|---|---|---|---|
demo.pdf | 200 + (canceled) | 只收到几十 KB 就停住 | 全量请求,拿到响应头后立即被中断 |
demo.pdf | 206 Partial Content | 精确等于请求的 chunk 大小 | 后续按需请求的字节区间 |
第一条就是上文中「发起了 200 请求,却没有响应数据」的现象:响应体还没传完,连接就被 reader.cancel() 关掉了。
回到 PDF.js 源码,触发这次中断的入口是 PDFFetchStreamReader 构造函数里的这几行:
// We need to stop reading when range is supported and streaming is
// disabled.
if (!this._isStreamingSupported && this._isRangeSupported) {
this.cancel(new AbortException('Streaming is disabled.'));
}当 disableStream: true 且服务器支持 Range 时,PDF.js 拿到响应头后立即放弃全量流,后续所有数据都交给 PDFFetchStreamRangeReader。整个判断过程可以概括为:
Notes: 如果你想对 fetch 以及底层的 ReadableStream 有深入了解,请参考本系列文章的第四篇文章。
validateRangeRequestCapabilities 方法
回到 PDFFetchStreamReader.constructor -> fetchUrl(...).then -> validateRangeRequestCapabilities
function validateRangeRequestCapabilities({
responseHeaders,
isHttp,
rangeChunkSize,
disableRange,
}) {
if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
assert(
Number.isInteger(rangeChunkSize) && rangeChunkSize > 0,
'rangeChunkSize must be an integer larger than zero.',
);
}
const rv = {
contentLength: 0,
isRangeSupported: false,
};
const length = parseInt(responseHeaders.get('Content-Length'), 10);
if (!Number.isInteger(length)) {
return rv;
}
rv.contentLength = length;
if (length <= 2 * rangeChunkSize) {
// The file size is smaller than the size of two chunks, so it does not
// make any sense to abort the request and retry with a range request.
return rv;
}
if (disableRange || !isHttp) {
return rv;
}
if (responseHeaders.get('Accept-Ranges') !== 'bytes') {
return rv;
}
const contentEncoding = responseHeaders.get('Content-Encoding') || 'identity';
if (contentEncoding === 'identity') {
rv.isRangeSupported = true;
}
return rv;
}在该方法中,对是否启用 Range 做了四道判断:
- 文件必须大于两个 chunk(
length <= 2 * rangeChunkSize直接放弃,因为切断再重试没有收益); - 未禁用 Range(
disableRange),且资源必须是 http(s)(isHttp); - 服务器返回
Accept-Ranges: bytes; - 响应未做传输压缩,即
Content-Encoding为identity。
第四道判断乍看有点多余:服务器都已经返回 Accept-Ranges: bytes 了,为什么还要再检查一次 Content-Encoding?
const contentEncoding = responseHeaders.get('Content-Encoding') || 'identity';
if (contentEncoding === 'identity') {
rv.isRangeSupported = true;
}原因在于:Range 的字节区间是相对于「传输时」的实体字节,而 PDF 解析需要的偏移是相对于「原始文件」的字节。
PDF 内部的交叉引用表(xref)记录的是每个对象在文件中的绝对字节偏移,文件尾部还有 startxref 指向 xref 表的位置。PDF.js 正是按这些偏移发起 Range 请求的,比如读取文件最后 1024 字节来定位 startxref:
fetch(url, {
headers: { Range: `bytes=${fileSize - 1024}-${fileSize - 1}` },
});这段代码成立的前提有两个:fileSize 等于 PDF 的真实字节数,且 Range: bytes=N-M 返回的就是文件里第 N 到 M 个字节。一旦响应带有 Content-Encoding: gzip(或 br、deflate),这两个前提都会失效:
Content-Length变成压缩后的大小。validateRangeRequestCapabilities会把它当作contentLength存下来,后续所有基于它的偏移计算(如fileSize - 1024)全部错位。- Range 切的是压缩流。
bytes=1000-2000取到的是 gzip 流的第 1000~2000 个字节,而不是 PDF 的第 1000~2000 个字节。gzip 是流式格式,中间片段缺少字典与头部信息,无法独立解压,拿到的数据没法用。 - 浏览器还会「帮倒忙」。
fetch在自动协商压缩时会透明解压响应体,于是response.body吐出来的是原始字节,而Content-Length/Content-Range仍然是压缩尺寸,两边对不上。
所以只有 Content-Encoding: identity(即未做传输压缩)时,传输字节才等于文件字节,Content-Length 才可信,Range 偏移才有意义。
顺带一提,PDF 内部已经用了 FlateDecode 等压缩,服务器再套一层 gzip 收益本就有限,因此实际部署中 PDF 往往也不会开启传输压缩,这也是这个分支在绝大多数情况下都能通过的原因。
虽然 PDF 内部已经 FlateDecode 压缩了,但是还是存在很多字符串(传统的 xref 表, 元数据等),压缩收益在某些特殊场景下会很有用,但是 Range 模式下, 完全无法采用压缩(这是规范确定的, 参考: https://github.com/whatwg/fetch/issues/747), 对于此, StreamPDF 在云端会检查 PDF 结构来判断是否适用压缩,并通过操作原始二进制数据流, 重新 拆分 -> 组装 -> 压缩 -> 解压. 将各种结构的 PDF 稳定到同一个加载速度, 而不是依赖用户做一些特殊处理才能快速加载.
PDFWorkerStream 类
在继续 Range 之前,先认识一个在两条链路里都会出现的类:PDFWorkerStream。它运行在 worker 线程,位于 src/core/worker_stream.js:
class PDFWorkerStream extends BasePDFStream {
constructor(source) {
super(source, PDFWorkerStreamReader, PDFWorkerStreamRangeReader);
}
}它和 PDFFetchStream 完全对称:同样继承 BasePDFStream,同样只是把两个 Reader 类交给父类。区别在于 source 里没有 url,只有 msgHandler。它自己不发网络请求,而是把请求封装成消息,交给主线程去发。
这就是整个传输层的分工:
- 主线程负责真正的 I/O(
PDFFetchStream),拿到字节流; - worker 负责解析,但它看到的仍然是一个「流」,也就是
PDFWorkerStream; - 两者之间靠
MessageHandler的sendWithStream打通,数据以Transferable形式迁移,不复制。
PDFWorkerStreamReader 类
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")对应主线程的on("GetReader", (data, sink) => ...),建立一条从主线程 body 到 worker 的管道;sendWithPromise("ReaderHeadersReady")对应主线程的on("ReaderHeadersReady"),只取一次元数据,返回{ isStreamingSupported, isRangeSupported, contentLength }。
主线程那条链路的 onProgress 在 worker 侧没有对应物:元数据通过 sendWithPromise("ReaderHeadersReady") 一次拿全;进度则只在 !fullReader.isStreamingSupported(即关闭流式)时,由 readData 循环通过 handler.send("DocProgress", ...) 上报。
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 };
}主线程那侧是 sink.enqueue(new Uint8Array(value), 1, [value]):为了让缓冲区能以 Transferable 形式零拷贝迁移,数据被包成了 Uint8Array;到了 worker 就得把 value.buffer 拆出来,还原成上层期望的 ArrayBuffer。
PDFWorkerStreamRangeReader 类
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 原样塞进消息里,主线程收到后再拼成 Range: bytes=begin-end-1。
cancel() 也只是 this._reader.cancel(reason):消息流的 onCancel 会级联到主线程的 sink.onCancel,进而取消 PDFFetchStreamRangeReader 并 abort 对应的 fetch。取消动作从头到尾只需要在最上层调用一次。
在主线程与 worker 之间
getPdfManager 拿到 fullReader 后并行做两件事。第一件是等 headers 来决定用哪个 pdfManager:
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);
for (const chunk of cachedChunks) {
newPdfManager.sendProgressiveData(chunk);
}
cachedChunks = null;
resolve(newPdfManager);
});这里的 disableAutoFetch ||= fullReader.isStreamingSupported 值得单独说一句:流式开启时就关掉 auto-fetch,避免 Range 把整个文件的 chunk 全部预取一遍,和正在下载的全量流重复请求。
第二件是一个 readData() 循环,持续把全量流已经到达的字节喂给这个 pdfManager:
if (newPdfManager) {
newPdfManager.sendProgressiveData(value);
} else {
cachedChunks.push(value);
}所以在流式(默认)模式下,全量流并没有因为 Range 而「白开」:它一直把响应体的字节塞进 ChunkedStream,Range 请求只负责补上解析抢跑时还没到的那部分,两边共同填充同一块字节数组。这也解释了 _progressiveDataLength 为什么取的是 _fullReader?._loaded,已经读到的长度就是判断「这段还要不要发 Range 请求」的依据。
而在 disableStream: true 时,全量 reader 已经在上一节被 cancel(),readData() 第一次 read() 就拿到 done 并退出,所有字节都由 Range 请求补齐。
如果 headers 显示不支持 Range,newPdfManager 就不会被创建,循环一路读到 done 后走 LocalPdfManager:
if (!newPdfManager) {
pdfManagerArgs.source = arrayBuffersToBytes(cachedChunks);
cachedChunks = null;
newPdfManager = new LocalPdfManager(pdfManagerArgs);
resolve(newPdfManager);
}最后还有一个兜底:cancelXHRs = reason => pdfStream.cancelAllRequests(reason),在 NetworkPdfManager 尚未建好之前(比如用户立刻关闭文档),由它一次性取消全量 reader 和所有 range reader。
cancelAllRequests(reason) {
this._fullReader?.cancel(reason);
// Always create a copy of the rangeReaders.
for (const reader of new Set(this._rangeReaders)) {
reader.cancel(reason);
}
}Range 请求的完整链路
回到本系列第二篇文章的 getPdfManager 部分。如果 Range 支持,则在 worker 中使用 NetworkPdfManager 来处理文档。
整条 Range 请求链路跨越 worker 与主线程两侧,先看全貌:
对于 NetworkPdfManager,其使用了 ChunkedStreamManager。职责划分上,这两个类的分工是:
ChunkedStream负责按 chunk 判断数据是否已加载,缺失时抛出MissingDataException;ChunkedStreamManager负责把缺失的 chunk 转换成 Range 请求发出去。
worker 侧的 getPdfManager 先创建 PDFWorkerStream(上一节已展开),作为 source 传给 NetworkPdfManager,再传给 ChunkedStreamManager:
const pdfStream = new PDFWorkerStream({ msgHandler: handler }),解析过程中一旦读到未加载的 chunk,MissingDataException 会冒泡到 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);
}
}requestRange 直接转给 ChunkedStreamManager.requestRange,后者用 groupChunks 把缺失的 chunk 合并成尽可能少的连续区间,再逐个 sendRequest(begin, end)。这里的 begin / end 已经是 chunk 对齐后的字节偏移(begin = beginChunk * chunkSize),这正是后面 HTTP Range 头要用的区间:
async sendRequest(begin, end) {
const rangeReader = this.pdfStream.getRangeReader(begin, end);
}所以 NetworkPdfManager 并不是「默认」就去请求,而是按需:只有解析真的读到了还没下载的字节时才触发。PDFWorkerStream 没有重写 getRangeReader,因此这里的 pdfStream.getRangeReader 指向 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;
}实例化 PDFWorkerStreamRangeReader,它把请求打包成消息发往主线程:
constructor(stream, begin, end) {
super(stream, begin, end);
const { msgHandler } = stream._source;
const readableStream = msgHandler.sendWithStream("GetRangeReader", {
begin,
end,
});
this._reader = readableStream.getReader();
}消息到达主线程,进入 on:GetRangeReader 方法:
messageHandler.on('GetRangeReader', (data, sink) => {
assert(this.#networkStream, 'GetRangeReader - no `BasePDFStream` instance available.');
const rangeReader = this.#networkStream.getRangeReader(data.begin, data.end);
// When streaming is enabled, it's possible that the data requested here
// has already been fetched via the `#fullReader` implementation.
// However, given that the PDF data is loaded asynchronously on the
// main-thread and then sent via `postMessage` to the worker-thread,
// it may not have been available during parsing (hence the attempt to
// use range requests here).
//
// To avoid wasting time and resources here, we'll thus *not* dispatch
// range requests if the data was already loaded but has not been sent to
// the worker-thread yet (which will happen via the `#fullReader`).
if (!rangeReader) {
sink.close();
return;
}
sink.onPull = () => {
rangeReader
.read()
.then(function ({ value, done }) {
if (done) {
sink.close();
return;
}
assert(value instanceof ArrayBuffer, 'GetRangeReader - expected an ArrayBuffer.');
sink.enqueue(new Uint8Array(value), 1, [value]);
})
.catch(reason => {
sink.error(reason);
});
};
sink.onCancel = reason => {
rangeReader.cancel(reason);
sink.ready.catch(readyReason => {
if (this.destroyed) {
return; // Ignore any pending requests if the worker was terminated.
}
throw readyReason;
});
};
});先看 if (!rangeReader) 这个分支。
getRangeReader 在 end <= this._progressiveDataLength 时会返回 null:意思是这段区间其实已经通过全量流收到了,只是主线程还来不及 postMessage 给 worker,worker 端的解析才会以为数据缺失。这时主线程直接 sink.close(),不再发 Range 请求。
worker 侧对应的是 ChunkedStreamManager.sendRequest 里的这段:
if (chunks.length === 0 && this.disableAutoFetch) {
// The range request wasn't dispatched, see the "GetRangeReader" handler
// in the `src/display/api.js` file.
return;
}拿到 done 却一个 chunk 都没有,说明 Range 请求根本没被派发,这部分数据会由全量流补上,于是直接返回,避免陷入无意义的重试。
而 this.#networkStream.getRangeReader(data.begin, data.end) 里的 #networkStream 就是主线程创建的 PDFFetchStream 实例。PDFFetchStream 同样没有重写 getRangeReader,所以这里走的是与上面完全相同的 BasePDFStream.getRangeReader,最终实例化的就是下面这一节的主角。
PDFFetchStreamRangeReader 类
class PDFFetchStreamRangeReader extends BasePDFStreamRangeReader {
_abortController = new AbortController();
_readCapability = Promise.withResolvers();
_reader = null;
constructor(stream, begin, end) {
super(stream, begin, end);
const { url, withCredentials } = stream._source;
// Always create a copy of the headers.
const headers = new Headers(stream.headers);
headers.append('Range', `bytes=${begin}-${end - 1}`);
fetchUrl(url, headers, withCredentials, this._abortController)
.then(response => {
const responseOrigin = getResponseOrigin(response.url);
ensureResponseOrigin(responseOrigin, stream._responseOrigin);
ensureResponseStatus(response.status, url);
this._reader = response.body.getReader();
this._readCapability.resolve();
})
.catch(this._readCapability.reject);
}
async read() {
await this._readCapability.promise;
const { value, done } = await this._reader.read();
if (done) {
return { value, done };
}
return { value: getArrayBuffer(value), done: false };
}
cancel(reason) {
this._reader?.cancel(reason);
this._abortController.abort();
}
}到这里,真正的 Range 请求终于发出去了。这类里有四处值得注意:
1. end - 1 是闭区间
headers.append('Range', `bytes=${begin}-${end - 1}`);BasePDFStream.getRangeReader 收到的 begin / end 是 chunk 对齐后的半开区间 [begin, end),而 HTTP 的 Range: bytes=N-M 是闭区间,所以减去 1 正好边界对齐。
2. 响应源必须与全量响应一致
ensureResponseOrigin(responseOrigin, stream._responseOrigin);stream._responseOrigin 是全量请求时的 response.url 的 origin。如果服务器把某个 Range 请求重定向到了另一个源,拼出来的字节流就可能是错的,所以这里直接抛错而不是静默拼接。
3. _readCapability 控制 read() 的时序
构造函数只发请求,不等响应;read() 一开始先 await this._readCapability.promise,所以即使调用方在响应头到达之前就 read() 或 cancel(),也不会访问到 null 的 _reader。这和全量 Reader 的 _headersCapability 是同一套思路,只是名字换成了「可以读了」。
4. 取消仍然是「关流 + abort」两连
cancel(reason) {
this._reader?.cancel(reason);
this._abortController.abort();
}顺序与 PDFFetchStreamReader.cancel 相同:先关掉 ReadableStream,再 abort 底层请求。
总结
把这一篇涉及的两个 Stream 放在一起看,BasePDFStream 像是一个模板,具体实现只是往模板里填了两个 Reader:
| 位置 | Stream | 全量 Reader | Range Reader |
|---|---|---|---|
| 主线程 | PDFFetchStream | PDFFetchStreamReader(fetch) | PDFFetchStreamRangeReader(Range 头) |
| worker | PDFWorkerStream | PDFWorkerStreamReader(postMessage) | PDFWorkerStreamRangeReader(postMessage) |
几个值得记住的设计点:
-
Reader 类作为构造参数注入。
getFullReader/getRangeReader/cancelAllRequests都写在BasePDFStream里,子类只决定「用什么 Reader」,于是上层(ChunkedStreamManager)完全不需要知道数据来自 fetch 还是消息通道。 -
fetch 的 resolve 时机决定了整个设计。响应头一到就能拿到
Content-Length/Accept-Ranges,于是可以在读完第一块之前就决定「要不要切到 Range」,而不是等文件下完。 -
Range 的四道门槛(
validateRangeRequestCapabilities):文件必须大于两个 chunk、未禁用 Range 且是 http(s) 资源、服务器返回Accept-Ranges: bytes、且Content-Encoding: identity。最后一条最容易被忽略,却决定了偏移量是否可信。 -
切断全量流是一次主动取舍。
disableStream: true且支持 Range 时,全量 reader 被立即cancel(),这就是 DevTools 里那个只有响应头、没有响应体的200 (canceled);默认配置下则不会切断。 -
Range 请求是按需触发的。
MissingDataException在解析真正读到缺口时才抛出,由NetworkPdfManager.ensure捕获后转成 chunk 对齐的Range请求;已经由全量流覆盖的区间直接返回null,不会重复请求。 -
取消是双向级联的。
AbortController中止 fetch,ReadableStream.cancel()关闭数据流,消息流的onCancel把 worker 侧的取消传回主线程,cancelAllRequests再兜住「manager 还没建好」的窗口期。
下一篇我们将进入 ChunkedStream 与 ChunkedStreamManager,看看这些按 chunk 请求回来的字节,是如何被拼装成一个可以随机读取的连续字节流的。