StreamPDFStreamPDF
返回博客

PDF.js 深入浅出 5: PDFFetchStream

2026年10月25日 · StreamPDF Team

PDF.js 深入浅出 5: PDFFetchStream

前言

PDF.js 系列文章的第五篇,解读 PDF.js 的 PDFFetchStream 类:它如何从网络上取回 PDF 的二进制数据,以及全量请求与 Range 请求之间的关系。

本系列分析基于 PDF.js v6.3.289 源码。

PDFFetchStream 类用来从网络上请求 PDF 文件的二进制数据,包括全量请求和 Range 请求,分别通过 PDFFetchStreamReader 和 PDFFetchStreamRangeReader 来实现。

本文会依次回答三个问题:

  1. 为什么 PDF.js 发出一个 200 请求后,有时会立刻把它切断?
  2. Range 请求是谁触发的,为什么它要跨过 worker 与主线程两侧?
  3. 为什么 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 上,可以逐块读取。

这意味着:

  1. PDF.js 在文件刚开始下载时就能拿到 Content-Length、Accept-Ranges 等关键元数据,无需等待整个文件;
  2. 通过 response.body.getReader() 可以边下边解析,实现 progressive rendering(渐进式渲染);之所以能在第一页数据到达后就开始解析渲染,是因为解析器会用 Range 请求提前取文件尾的 xref,而不是顺序等下载;
  3. 如果服务器支持 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.pdf200 + (canceled)只收到几十 KB 就停住全量请求,拿到响应头后立即被中断
demo.pdf206 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 做了四道判断:

  1. 文件必须大于两个 chunk(length <= 2 * rangeChunkSize 直接放弃,因为切断再重试没有收益);
  2. 未禁用 Range(disableRange),且资源必须是 http(s)(isHttp);
  3. 服务器返回 Accept-Ranges: bytes;
  4. 响应未做传输压缩,即 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),这两个前提都会失效:

  1. Content-Length 变成压缩后的大小。validateRangeRequestCapabilities 会把它当作 contentLength 存下来,后续所有基于它的偏移计算(如 fileSize - 1024)全部错位。
  2. Range 切的是压缩流。bytes=1000-2000 取到的是 gzip 流的第 1000~2000 个字节,而不是 PDF 的第 1000~2000 个字节。gzip 是流式格式,中间片段缺少字典与头部信息,无法独立解压,拿到的数据没法用。
  3. 浏览器还会「帮倒忙」。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 与主线程两侧,先看全貌:

Range 请求链路

对于 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全量 ReaderRange Reader
主线程PDFFetchStreamPDFFetchStreamReader(fetch)PDFFetchStreamRangeReader(Range 头)
workerPDFWorkerStreamPDFWorkerStreamReader(postMessage)PDFWorkerStreamRangeReader(postMessage)

几个值得记住的设计点:

  1. Reader 类作为构造参数注入。getFullReader / getRangeReader / cancelAllRequests 都写在 BasePDFStream 里,子类只决定「用什么 Reader」,于是上层(ChunkedStreamManager)完全不需要知道数据来自 fetch 还是消息通道。

  2. fetch 的 resolve 时机决定了整个设计。响应头一到就能拿到 Content-Length / Accept-Ranges,于是可以在读完第一块之前就决定「要不要切到 Range」,而不是等文件下完。

  3. Range 的四道门槛(validateRangeRequestCapabilities):文件必须大于两个 chunk、未禁用 Range 且是 http(s) 资源、服务器返回 Accept-Ranges: bytes、且 Content-Encoding: identity。最后一条最容易被忽略,却决定了偏移量是否可信。

  4. 切断全量流是一次主动取舍。disableStream: true 且支持 Range 时,全量 reader 被立即 cancel(),这就是 DevTools 里那个只有响应头、没有响应体的 200 (canceled);默认配置下则不会切断。

  5. Range 请求是按需触发的。MissingDataException 在解析真正读到缺口时才抛出,由 NetworkPdfManager.ensure 捕获后转成 chunk 对齐的 Range 请求;已经由全量流覆盖的区间直接返回 null,不会重复请求。

  6. 取消是双向级联的。AbortController 中止 fetch,ReadableStream.cancel() 关闭数据流,消息流的 onCancel 把 worker 侧的取消传回主线程,cancelAllRequests 再兜住「manager 还没建好」的窗口期。

下一篇我们将进入 ChunkedStream 与 ChunkedStreamManager,看看这些按 chunk 请求回来的字节,是如何被拼装成一个可以随机读取的连续字节流的。

相关文章