几百 MB 的文件为什么等几分钟才能打开?文件分片下载与渐进预览原理

0 阅读16分钟

几百 MB 的文件为什么等几分钟才能打开?文件分片下载与渐进预览原理

大文件(几百 MB ~ 几 GB)下载慢的瓶颈不在于带宽,而在于下载模型——浏览器默认「下载完整文件后才能打开」,用户干等。本文从 HTTP 下载的底层原理出发,逐步拆解 7 个方案(HTTP Range 分片 → 多连接并发 → 流式渐进预览 → 断点续传 → 自适应分片 → CDC 增量同步),实现「边下边看、断网可恢复、改动只同步增量」的文件下载预览引擎。


Part 1 · 底层原理:HTTP 下载是怎么工作的

1.1 传统下载模型:请求 → 等完整响应 → 打开

浏览器下载一个文件,默认流程:

浏览器                              服务器
  │                                   │
  │  GET /big-file.pdf                │
  │ ─────────────────────────────────→ │
  │                                   │
  │     ← 200 OK + 整个文件(500MB)→  │
  │     (浏览器收完所有数据才能打开)    │
  │                                   │
  │  收完了 → 打开                     │

问题:500MB 文件,网速 10MB/s → 下载 50 秒 → 用户干等 50 秒才能打开。

根因:浏览器要收完整文件(所有字节到齐)才能处理。就像寄一个 500kg 的包裹,你必须等全部到货才能拆——不能边到边拆。

1.2 关键概念:HTTP Range 请求

HTTP/1.1 支持一个叫 Range 的请求头——可以只请求文件的指定字节范围

GET /big-file.pdf
Range: bytes=0-1048575    ← 只要前 1MB

服务器返回:
206 Partial Content
Content-Range: bytes 0-1048575/524288000    ← 总共 500MB,返回了 0-1MB
Accept-Ranges: bytes                         ← 告诉你「我支持分片」

这意味着:500MB 的文件,可以分成 500 个 1MB 的片,一片一片下,不用等全部。

核心矛盾:传统下载 = 等全部到齐才打开(慢);分片下载 = 边下边看(快感知)。

1.3 浏览器怎么接收数据?(流的概念)

浏览器收到 HTTP 响应时,数据不是「一次性全到」,而是像水流一样一段一段到

服务器发送:[chunk1][chunk2][chunk3]...[chunkN]
浏览器接收:←────── 流(Stream)──────←

传统 <a download>fetch().then(blob):等所有 chunk 到齐 → 拼成完整 Blob → 打开。

流式处理:每个 chunk 到了就处理(不用等全部)→ 边收边看

JavaScript 里用 ReadableStream 处理流(response.body.getReader()),逐块读取数据。


Part 2 · 方法演进:7 步从干等到边下边看

Step 1 · 整体下载后打开(传统做法)

方法

const res = await fetch('/big-file.pdf')
const blob = await res.blob()  // 等所有数据到齐
window.open(URL.createObjectURL(blob))  // 打开

问题:500MB 文件 → 用户干等 50 秒(网速 10MB/s)→ 全部下完才能打开。

效果:❌ 体验差(干等)。


Step 2 · HTTP Range 分片下载

上一步的问题:等全部下完才能打开。

方法:用 Range 请求分片下载——先下前面一部分,立刻预览

// 只下前 1MB(PDF 前几页通常在这范围内)
const res = await fetch('/big-file.pdf', {
  headers: { Range: 'bytes=0-1048575' },
})
const firstChunk = await res.arrayBuffer()  // 1MB
// 立刻用这 1MB 预览(PDF 前 N 页)
preview(firstChunk)

// 后台继续下剩余部分

原理:HTTP Range 请求让服务器只返回指定字节范围(206 Partial Content),不用等全部。

效果 + 量化

维度整体下载(Step 1)Range 分片(Step 2)
首次预览50 秒(下完 500MB)1 秒(下完 1MB)
用户感受干等立刻看到前几页

为什么还不够:单连接下载,带宽没吃满(一条线传,慢)。


Step 3 · 多连接并发下载

上一步的问题:单连接下载,带宽利用率低。

方法:开多个连接,每个下不同范围,并行下载(像多车道运货)。

const FILE_SIZE = 524288000  // 500MB
const CHUNK_SIZE = 10485760   // 每片 10MB
const CONCURRENCY = 4          // 4 个并发连接

// 把文件分成 50 个 10MB 的片
const chunks = planChunks(FILE_SIZE, CHUNK_SIZE)

// 4 个连接并行下载(Semaphore 控制并发数)
await parallelDownload(chunks, CONCURRENCY, async (chunk) => {
  const res = await fetch('/big-file.pdf', {
    headers: { Range: `bytes=${chunk.start}-${chunk.end}` },
  })
  chunk.data = await res.arrayBuffer()
})

并发控制(Semaphore):限制同时只有 4 个连接(不是越多越好——服务器限流、浏览器限制 6 个/域名)。

效果 + 量化

维度单连接(Step 2)4 连接并发(Step 3)
带宽利用~30%(单线)~90%(多线拉满)
500MB 下载~50 秒~15 秒
首次预览1 秒(1MB)0.3 秒(4 倍速下 1MB)

为什么还不够:下完后才能拼合预览(非流式);断网要重下(无断点续传)。


Step 4 · 流式渐进预览(边下边看)

上一步的问题:分片下完了才拼合预览(不是真正的「边下边看」)。

方法:用 ReadableStream 逐块接收数据,每个 chunk 到了就渲染

const res = await fetch('/big-file.pdf')
const reader = res.body.getReader()  // 流式读取

let received = 0
while (true) {
  const { done, value } = await reader.read()  // 读一块(几 KB ~ 几十 KB)
  if (done) break

  received += value.length

  // 每收到一块就尝试渐进渲染(如 PDF 渲染引擎收到足够数据就渲染下一页)
  appendChunk(value)
  updateProgress(received)  // 实时进度(字节级精确)
}

ReadableStream:浏览器原生流 API,reader.read() 每次返回一块数据(Uint8Array),不用等全部到齐。

渐进预览怎么做(以 PDF 为例):

PDF 渲染引擎(如 pdf.js):
  收到前 100KB → 解析 PDF header + 目录 → 显示「共 N 页」
  收到前 1MB → 渲染第 1-3 页(通常在前部)
  收到前 5MB → 渲染第 4-10 页
  ...渐进渲染,用户边看边等后续页

效果 + 量化

维度下完才打开(Step 3)流式渐进预览(Step 4)
首次看到内容等 15 秒下完0.5 秒(收到第一块就渲染)
进度反馈无(或假进度)字节级精确(received / total)
用户感受干等边看边等(有内容看了)

类比:看 YouTube 视频——不是等视频全部缓冲完才播放,而是边缓冲边播放。流式预览就是文件的「边下边播」。

为什么还不够:网络断了 → 全部白下(无断点续传);网速变化时不会自适应。


Step 5 · 断点续传

上一步的问题:网络中断 → 已下的数据丢了 → 重新从头下。

方法记录已下载的分片(IndexedDB / localStorage),中断后恢复时跳过已下

怎么做?

// ① 记录已下的分片到 IndexedDB
async function saveChunkProgress(fileId, chunkIndex, data) {
  const db = await openDB()
  await db.put('chunks', { id: `${fileId}-${chunkIndex}`, data })
}

// ② 恢复时检查哪些片已下
async function getDownloadedChunks(fileId) {
  const db = await openDB()
  return await db.getAllKeys('chunks', IDBKeyRange.bound(`${fileId}-`, `${fileId}.￿`))
}

// ③ 只下缺失的片
const downloaded = await getDownloadedChunks(fileId)
const missing = chunks.filter(c => !downloaded.includes(`${fileId}-${c.index}`))
await parallelDownload(missing, CONCURRENCY, downloadChunk)

校验完整性(Merkle root)

分片下完后,怎么保证没有损坏?用 SHA-256 校验:

每个分片算 SHA-256 → 叶子哈希
两两配对算父哈希 → 一层层往上
最终得到 Merkle root(根哈希)

任何一片损坏 → 它的哈希变 → 父哈希变 → root 变 → 立刻发现

效果 + 量化

维度无断点续传(Step 4)断点续传(Step 5)
网络中断全部白下,从头来恢复时跳过已下(省时间)
500MB 下了 300MB 断了重下 500MB(50 秒)只下剩余 200MB(20 秒)
数据完整性不保证SHA-256 校验(任何损坏可检测)

为什么还不够:分片大小固定(不适应网络变化);网速差时大片失败率高。


Step 6 · 自适应分片

上一步的问题:分片大小固定(如 10MB),网速差时大片失败率高、网速好时小片开销大。

方法根据网络状况动态调整分片大小

怎么感知网络?

// ① HEAD 预探测:发 3 个小请求,取中位 RTT
const rtts = []
for (let i = 0; i < 3; i++) {
  const t0 = performance.now()
  await fetch('/big-file.pdf', { method: 'HEAD' })
  rtts.push(performance.now() - t0)
}
rtts.sort((a, b) => a - b)
const medianRTT = rtts[1]  // 中位数(抗极端值)

// ② 根据 RTT 算初始分片大小
// RTT 高(网络差)→ 小片(1MB,失败重传代价低)
// RTT 低(网络好)→ 大片(16MB,减少请求数)
const chunkSize = medianRTT > 500 ? 1MB : medianRTT > 200 ? 4MB : 16MB

运行期自适应

// 下载过程中监控每片耗时,动态调整
function adjustChunkSize(history) {
  if (连续超时) return chunkSize * 0.5      // 网络差了 → 缩小
  if (连续快速) return chunkSize * 1.5      // 网络好了 → 放大
  // 硬区间 [128KB, 16MB]
}

效果 + 量化

维度固定 10MB 分片(Step 5)自适应分片(Step 6)
好网络(10MB/s)10MB 够大但可以更大16MB(请求数更少)
差网络(1MB/s + 高延迟)10MB 超时率高1MB(失败重传代价低)
切换网络(WiFi → 4G)分片大小不变自动缩(避免大片超时)

为什么还不够:文件更新后要重新全量下载(不知道哪些变了)。


Step 7 · CDC 增量同步

上一步的问题:文件更新后(改了一行),要重新下载全部(500MB),即使只改了 1KB。

方法CDC(Content-Defined Chunking,内容定义分块)——按内容哈希切变长块,只下载改动的块。

什么是 CDC?

传统分片(固定 10MB):改一个字 → 从改动点往后所有块都变 → 几乎全量重下。

CDC:按内容哈希切变长块(边界由内容决定)。改一个字 → 只影响改动点附近的块,其他块哈希不变 → 只下改动的块

原文件(切成块 A B C D E):
  哈希: [hashA] [hashB] [hashC] [hashD] [hashE]

改了 C 块里一个字:
  哈希: [hashA] [hashB] [hashC'] [hashD] [hashE]
                         ↑ 变了

客户端已有 A B C D E → 问服务器「哪些块变了?」
服务器:只有 C 变了
客户端:只下 C(几百 KB),其他复用

效果 + 量化

维度全量重下(Step 6)CDC 增量(Step 7)
文件改了 1KB重下 500MB只下改动的块(~几百 KB)
文件没变重下 500MB零传输(哈希一致,跳过)
带宽节省0最高 99.9%(只传增量)

CDC 是 rsync / Dropbox / Git LFS 的核心技术。

业界标准:rsync(1996 年提出,CDC 的鼻祖)、Dropbox(增量同步)、Git LFS(大文件版本管理)。


Part 3 · 全景量化对比

Step方法首次预览500MB 下载断网恢复适用
1整体下载50 秒50 秒从头来小文件
2Range 分片1 秒50 秒从头来首次预览
3多连接并发0.3 秒15 秒从头来加速下载
4流式渐进预览0.5 秒15 秒从头来边下边看
5断点续传0.5 秒15 秒只下剩余弱网
6自适应分片0.3 秒自适应只下剩余网络变化
7CDC 增量同步只传增量只传增量文件版本同步

Part 4 · 不同文件类型的分片预览差异

同样是分片下载,图片、视频、音频、文档的「边下边看」方式完全不同——因为文件格式结构不同。有的能从头流式渲染,有的必须先跳到文件尾部读元数据。

核心差异一览

维度图片视频音频文档(PDF/DOCX)
分片单位像素(瓦片)时间(秒 / GOP)时间(帧)页 / sheet / 行
元数据位置文件头moov atom(头或尾)文件头文件尾(xref / ZIP 目录)
Range 策略先头 → 按瓦片先尾部 moov → 按时间先头 → 按时间先尾部元数据 → 按页加载
渐进预览渐进 JPEG(低频→高频)HLS / DASH 自适应码率流式播放按页渲染(先前几页)
天然流式需格式支持✅ 时间维度天然流式❌ 元数据在尾部

图片:渐进式解码

渐进式 JPEG(progressive):先传低频(模糊全图)→ 后传高频(细节)。收到部分数据就显示模糊版,数据多了变清晰。浏览器原生支持,不需要特殊代码。

img.src = '/photo.jpg'  // 渐进式 JPEG:浏览器自动渐进渲染(模糊→清晰)

PNG 隔行扫描(Adam7):先 1/8 分辨率 → 逐步填充 7 遍。类似渐进 JPEG。

不支持渐进的格式(BMP / RAW):必须完整才能显示 → 只能用瓦片金字塔(大图渲染那篇讲过)。

视频:按时间分片(天然适合流式)

视频有时间维度,天然适合「按时间分片、边下边播」:

  • HLS.m3u8 + .ts 分片):每片 2-10 秒,边下边播。自适应码率——网好下高清片,网差自动切低清片。
  • DASH.mpd):类似 HLS,国际标准(YouTube 用这个)。
  • MP4 faststart:moov atom 前置(放文件头)→ 浏览器立刻播放。如果 moov 在尾部 → 要先 Range 下尾部再播放。
  • MSE(Media Source Extensions):JS 手动分片喂给 <video> 元素。
// HLS:video 元素直接播放 m3u8(Safari 原生 / Chrome 用 hls.js)
video.src = '/stream.m3u8'  // 天然分片,边下边播

音频:跟视频类似

  • MP3 / AAC:按帧分片,每帧独立解码,可以按帧流式播放。
  • 浏览器原生<audio> 元素天然流式(跟 video 一样)。
  • 波形预览:提取波形数据(不需解码全部音频,只取峰值)。
const audio = new Audio('/audio.mp3')  // 浏览器原生流式
audio.play()

文档:元数据在尾部(最反直觉)

关键洞察:PDF 和 DOCX 的元数据在文件尾部。所以分片下载要先跳到文件尾部读结构,再按需加载内容。

PDF 的结构

PDF 文件
├── 页面数据(页 1, 2, 3... 的渲染指令)
└── 交叉引用表(xref)  ← 告诉你「第 N 页在文件的第 X 字节」
    (xref 在文件尾部!)

分片预览策略(pdf.js 就这么做):

Range 请求文件尾部(最后 1KB,含 xref)
② 从 xref 解析出「第 1 页在第 X-Y 字节」
③ Range 请求只下第 1 页的数据
④ pdf.js 渲染第 1 页 → 用户立刻看到
⑤ 后台继续按需加载其他页

DOCX / XLSX 的结构(本质是 ZIP)

DOCX 文件(= ZIP)
├── word/document.xml(正文)
├── word/media/(图片)
└── 中央目录(Central Directory)  ← 告诉你「每个 XML 在 ZIP 的第 X 字节」
    (中央目录在 ZIP 尾部!)

分片预览策略

Range 请求文件尾部(ZIP 中央目录)
② 从目录解析出 document.xml 的位置
③ Range 请求只下 document.xml
④ 解析 XML → 渲染文档内容

TXT / CSV / JSON(纯文本)

天然流式——从头逐行读取,不需要跳尾部(没有尾部元数据)。

const reader = res.body.getReader()
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  appendText(new TextDecoder().decode(value))  // 每收到一块就追加显示
}

为什么文档最特殊?

图片 / 视频 / 音频的元数据在文件头(从头读就行)。但 PDF / DOCX 的元数据在文件尾——你必须先跳到尾部才能知道结构。这是 Range 请求最有价值的地方:

图片/视频/音频:Range 0-1MB(从头部开始)
文档(PDF/DOCX):Range 最后 1KB(跳到尾部读元数据)→ 再按结构 Range 加载

这个「先跳尾部」的洞察是文档分片预览的核心——也是为什么 pdf.js 能实现「500 页 PDF 瞬间打开第 1 页」的原因。


Part 5 · 场景选型:怎么选对方法

决策树

你的文件多大?
│
├─ 小文件(< 10MB)
│   └─ 整体下载(Step 1,简单够用)
│
├─ 中文件(10-100MB)
│   ├─ 需要快速预览 → Range 分片先下前面(Step 2)
│   └─ 加速下载 → 多连接并发(Step 3)
│
├─ 大文件(100MB-1GB)
│   ├─ 需要边下边看 → 流式渐进预览(Step 4)
│   ├─ 弱网环境 → 断点续传(Step 5)
│   └─ 网络不稳定 → 自适应分片(Step 6)
│
└─ 超大文件 / 频繁更新(> 1GB / 版本同步)
    └─ CDC 增量同步(Step 7

按业务场景选

业务场景文件特点推荐方案
网页图片/小 PDF< 10MB整体下载(不需要优化)
PDF 文档预览10-100MBRange 分片 + 流式渐进(pdf.js 边收边渲染)
视频点播几百 MB-几 GB流式渐进(HLS/DASH 分片 + 边下边播)
大文件下载(安装包)几百 MB-几 GB多连接并发 + 断点续传
网盘文件同步任意 + 频繁更新CDC 增量同步(Dropbox 模式)
弱网环境(移动端)任意断点续传 + 自适应分片
协同编辑(如 Figma)频繁小改动CDC + 实时增量同步
Git LFS(大文件版本管理)大文件 + 多版本CDC 增量(只拉变化的部分)

选型原则

  1. 小文件不优化——整体下载够用,过度优化是浪费
  2. 中文件先预览——Range 请求先下前面一部分
  3. 大文件边下边看——流式渐进预览(不用等全部)
  4. 弱网要断点续传——记录已下,中断不白费
  5. 频繁更新要增量——CDC 只传改动的块

Part 6 · 业界怎么做(对标)

产品用了什么对应 Step
下载管理器(IDM/迅雷)多连接并发 + 断点续传Step 3 + 5
视频点播(YouTube/B站)HLS/DASH 分片 + 流式播放Step 4
Dropbox / OneDriveCDC 增量同步(只传改动块)Step 7
Git LFSCDC + 按需拉取大文件版本Step 7
rsyncCDC 鼻祖(1996,按内容分块增量传输)Step 7
百度网盘多连接 + 断点续传 + MD5 校验Step 3 + 5

共同点分片(Range/CDC)+ 渐进预览(流式/边下边看)+ 断点续传(记录已下)——大文件下载预览的业界标准。


Part 7 · 引用出处

Web API 标准

业界架构


结语

核心矛盾

传统下载 = 等全部到齐才打开(用户干等)vs 分片下载 = 边下边看(快感知)。

7 步路径

Step 1  整体下载             → 干等(下完才开)
Step 2  HTTP Range 分片      → 先下前面预览
Step 3  多连接并发           → 带宽拉满加速
Step 4  流式渐进预览         → 边下边看(字节级进度)
Step 5  断点续传             → 断网不白下(IndexedDB + SHA-256Step 6  自适应分片           → 网络感知动态调整大小
Step 7  CDC 增量同步         → 只传改动块(rsync/Dropbox)

工程原则(五句话)

  1. 小文件不优化——整体下载够用
  2. 中文件先预览——Range 先下前面
  3. 大文件边下边看——流式渐进(不用等全部)
  4. 弱网要断点续传——记录已下,中断不白费
  5. 频繁更新要增量——CDC 只传改动的块

这就是文件分片下载与渐进预览的完整技术地图——从 HTTP 下载底层原理到分片并发到流式预览到断点续传到 CDC 增量同步,到场景选型到业界标准。全部基于通用 Web API(fetch Range / ReadableStream / IndexedDB / SubtleCrypto),可直接用于任何文件下载预览 / 网盘同步 / 视频点播场景。