核心思想
根据网络速度智能调整分片大小,从第一个分片开始,下一个分片从此分片结束位置开始,每个分片信息都放进chunks中,然后存进浏览器IndexedDB 数据库,键值对形式存储json.
断点续传时前端判断已上传的分片index,重复上传也没关系,因为加密后后的二进制数据会被存储进服务端中,服务端会验证分片数据的唯一性。
至于自动重试上传,是写在分片上传的逻辑中的,如果此分片没有上传,那就执行上传,在循环中上传分片,如果成功后就跳出循环,失败后就循环执行重试。
/**
** 智能分片上传工具(支持断点续传、自动重试、加密)
** 核心逻辑:
** 1. 动态分片大小(基于网络速度)
** 2. 加密前计算分片唯一标识(解决加密后识别问题)
** 3. 使用IndexedDB存储分片元数据
** 4. 自动重试 + 断点续传
*/
{
"fileId": "abc123",
"uploadedChunks": [0, 1, 3],
"encryptionKey": "...",
"ivMap": [...]
}
断点续传的核心
- 性能优化
前端用索引判断,避免重复计算哈希
服务端用哈希校验,确保存储唯一性- 安全性保障
加密密钥和IV始终在前端管理
服务端只存储加密后的内容- 网络容错
断点续传时只需校验索引,恢复速度快
服务端哈希校验防止数据重复
这种双层级校验机制(前端索引 + 服务端哈希)完美平衡了性能与数据一致性,是断点续传设计的经典模式。
1.初始化数据
class FileUploader {
constructor(file, options = {}) {
this.file = file;
this.options = {
chunkSize: 5 * 1024 * 1024, // 默认分片大小5MB
maxRetries: 3, // 最大重试次数
...options
};
this.fileId = this.generateFileId(); // 唯一文件标识(不依赖加密)
this.chunks = []; // 分片元数据(包含加密前哈希)
this.uploadedChunks = new Set(); // 已上传分片索引
this.encryptionKey = null; // AES加密密钥(全程不变)
this.ivMap = new Map(); // 存储每个分片的IV(初始化向量)
}
// 生成唯一文件ID(基于文件名+大小+修改时间)
generateFileId() {
return btoa(`${this.file.name}-${this.file.size}-${this.file.lastModified}`);
}
// 主流程入口
async start() {
await this.prepareChunks(); // 1. 分片准备
await this.loadProgress(); // 2. 加载上传进度
await this.uploadAllChunks(); // 3. 上传分片
await this.sendMergeRequest(); // 4. 合并文件
}
2.基于网络速度调整分片
计算当前速度,然后根据速度调整分片大小。如果当前速度小于1MB/s,就减少分片大小,最低1MB;如果速度大于5MB/s,就增加分片大小,最高20MB。
// 基于网络速度动态调整分片大小(示例)
let chunkSize = 5 * 1024 * 1024; // 默认5MB
let lastUploadSpeed = 0;
function adjustChunkSize(uploadTimeMs) {
const currentSpeed = chunkSize / (uploadTimeMs / 1000); // MB/s
if (currentSpeed < 1) {
// 降速时减小分片,如果大于1M,那可能是5*0.8=4M,但如果是1M,可能只是0.8M,保证最小是1M,所以使用Math.max()
chunkSize = Math.max(1 * 1024 * 1024, chunkSize * 0.8);
} else if (currentSpeed > 5) {
chunkSize = Math.min(20 * 1024 * 1024, chunkSize * 1.2); // 加速时增大分片
}
}
分片大小乘以0.8或1.2可能会让分片变化不够平滑,或者导致分片大小在边界值附近震荡。比如,当速度在1MB/s附近波动时,分片大小可能会频繁调整,还有偶发的网络波动导致分片大小频繁变化,影响上传效率,我们可以对以上代码进行优化。
可优化代码如下:
// 基于网络速度动态调整分片大小(修正版)
let chunkSize = 5 * 1024 * 1024; // 默认5MB
let lastUploadSpeed = 5 * 1024 * 1024; // 初始假设5MB/s
function adjustChunkSize(uploadTimeMs) {
// 1. 安全处理,时间为0无法准确测出网速
if (uploadTimeMs <= 0) uploadTimeMs = 1;
// 2. 计算当前速度(字节/秒)
const currentSpeed = (chunkSize / uploadTimeMs) * 1000;
// 3. 加入历史速度平滑(加权平均),基于历史速度做动态调整
const SMOOTH_FACTOR = 0.3;
lastUploadSpeed = lastUploadSpeed * (1 - SMOOTH_FACTOR) + currentSpeed * SMOOTH_FACTOR;
// 4. 转换为MB/s
const currentSpeedMB = lastUploadSpeed / (1024 * 1024);
// 5. 动态调整逻辑
const MAX_CHANGE_RATE = 0.2; // 最大变化幅度20%
if (currentSpeedMB < 1) {
const targetSize = Math.max(1 * 1024 * 1024, chunkSize * 0.8);
chunkSize = chunkSize * (1 - MAX_CHANGE_RATE) + targetSize * MAX_CHANGE_RATE;
} else if (currentSpeedMB > 5) {
const targetSize = Math.min(20 * 1024 * 1024, chunkSize * 1.2);
chunkSize = chunkSize * (1 - MAX_CHANGE_RATE) + targetSize * MAX_CHANGE_RATE;
}
// 6. 取整到1MB倍数
chunkSize = Math.round(chunkSize / (1024 * 1024)) * 1024 * 1024;
}
3.分片准备与分片处理
// 存储内容包含:
// - fileId: 文件唯一标识
// - chunks: 全部分片元数据
// - uploadedChunks: 已上传分片索引
// - encryptionKey: 加密密钥(安全导出)
// - ivMap: 所有分片的IV值
await this.saveProgressToDB();
// 1. 分片准备(包含智能分片逻辑)
async prepareChunks() {
// 动态调整分片大小(基于网络速度),保留此代码为方便理解思路
// 测试网络速度
const networkSpeed = await this.testNetworkSpeed();
// 调整网络分片
this.options.chunkSize = Math.min(
20 * 1024 * 1024, // 最大20MB
Math.max(
1 * 1024 * 1024, // 最小1MB
Math.round(networkSpeed * 0.5) // 网络速度(MB/s) * 0.5
)
);
// 2.分片处理核心逻辑
// 初始化分片切割位置
let offset = 0;
let chunkIndex = 0;
// 循环切割文件
while (offset < this.file.size) {
// 动态计算分片结束位置(处理文件末尾)
const chunkEnd = Math.min(offset + this.options.chunkSize, this.file.size);
const chunkBlob = this.file.slice(offset, chunkEnd);
// 生成分片唯一标识(SHA-256哈希)
// 用途:服务端解密后校验数据完整性
const originalHash = await this.calculateSHA256(chunkBlob);
// 生成加密初始化向量(IV)
// - 每个分片使用不同IV增强安全性
// - AES-CBC要求16字节不可预测值
const iv = crypto.getRandomValues(new Uint8Array(16));
// 使用AES-CBC加密分片
// 加密密钥通过generateAESKey()生成,全程保持一致
const encryptedBlob = await this.encryptChunk(chunkBlob, iv);
// 存储分片元数据
this.chunks.push({
index: chunkIndex,
originalHash, // 加密前哈希值
encryptedBlob, // 加密后的二进制数据
iv: Array.from(iv), // 转换IV为普通数组(IndexedDB兼容)
startByte: offset, // 分片起始字节
endByte: chunkEnd // 分片结束字节
});
// 记录IV用于后续解密
this.ivMap.set(chunkIndex, iv);
// 更新切割位置
offset = chunkEnd;
chunkIndex++;
}
// 持久化存储分片信息到IndexedDB
// 存储内容包含:文件ID、分片列表、已上传索引、加密密钥、IV映射表
await this.saveProgressToDB();
4.分片上传
对于每个分片,先检查是否已上传,若未上传,则进入while循环,尝试上传,失败时等待并增加重试次数,直到成功或超过重试次数。成功后跳出while循环,继续下一个分片。自动重试是在同一个分片上传失败时立即触发的,而不是在所有分片遍历完之后。
graph TD
A[开始遍历分片] --> B{分片已上传?}
B -- 是 --> C[跳过上传]
B -- 否 --> D[初始化重试计数器 retryCount=0]
D --> E{重试次数未超限?}
E -- 是 --> F[尝试上传分片]
F --> G{上传成功?}
G -- 是 --> H[记录状态并处理下一个分片]
G -- 否 --> I[等待并增加 retryCount]
I --> E
E -- 否 --> J[抛出错误]
async uploadAllChunks() {
// 遍历所有分片(this.chunks是prepareChunks生成的元数据数组)
for (const chunk of this.chunks) {
// 关键判断:如果分片索引在已上传集合中,跳过上传
if (this.uploadedChunks.has(chunk.index)) continue;
let retryCount = 0;
// 自动重试循环(最多重试maxRetries次)
while (retryCount <= this.options.maxRetries) {
try {
// 尝试上传当前分片
await this.uploadChunk(chunk);
// 上传成功后更新状态
this.uploadedChunks.add(chunk.index); // 添加索引到已上传集合
await this.saveProgressToDB(); // 立即持久化存储进度
break; // 跳出重试循环,处理下一个分片
} catch (error) {
// 达到最大重试次数时抛出错误
if (retryCount === this.options.maxRetries) throw error;
// 指数退避策略:第1次等1秒,第2次等2秒,第3次等4秒...
await new Promise(resolve =>
setTimeout(resolve, 1000 * (2 ** retryCount))
);
retryCount++;
}
}
}
}
// 上传单个分片
async uploadChunk(chunk) {
const formData = new FormData();
formData.append('fileId', this.fileId);
formData.append('chunkIndex', chunk.index);
formData.append('encryptedData', chunk.encryptedBlob);
formData.append('iv', JSON.stringify(chunk.iv)); // 加密使用的IV
formData.append('originalHash', chunk.originalHash); // 加密前的哈希
const response = await fetch('/upload-chunk', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error(`上传失败: ${response.status}`);
const result = await response.json();
if (!result.success) throw new Error('服务端验证失败');
}
4. 合并请求
async sendMergeRequest() {
const response = await fetch('/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileId: this.fileId,
fileName: this.file.name,
totalChunks: this.chunks.length,
encryptionKey: await this.exportKey(this.encryptionKey), // 导出加密密钥
ivMap: Array.from(this.ivMap.entries()).map(([k, v]) => [k, Array.from(v)])
})
});
if (!response.ok) throw new Error('合并失败');
}
工具方法 ------------------------------------------------------
// 测试网络速度(粗略估算)
async testNetworkSpeed() {
const start = Date.now();
await fetch('https://httpbin.org/stream-bytes/1048576'); // 下载1MB测试文件
const duration = (Date.now() - start) / 1000; // 秒
return 1 / duration; // MB/s
}
// 生成AES密钥(全程不变)
async generateAESKey() {
return crypto.subtle.generateKey(
{ name: 'AES-CBC', length: 256 },
true, // 是否可导出
['encrypt', 'decrypt']
);
}
// 加密分片
async encryptChunk(blob, iv) {
const data = await blob.arrayBuffer();
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-CBC', iv },
this.encryptionKey,
data
);
return new Blob([encrypted]);
}
// 计算分片哈希(SHA-256)
async calculateHash(blob) {
const data = await blob.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
}
// 存储进度到IndexedDB
async saveProgressToDB() {
const db = await this.openDB();
const tx = db.transaction('uploads', 'readwrite');
tx.objectStore('uploads').put({
fileId: this.fileId,
uploadedChunks: Array.from(this.uploadedChunks),
encryptionKey: await this.exportKey(this.encryptionKey),
ivMap: Array.from(this.ivMap.entries()).map(([k, v]) => [k, Array.from(v)])
});
}
// 打开IndexedDB
openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('FileUploadDB', 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('uploads')) {
db.createObjectStore('uploads', { keyPath: 'fileId' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = reject;
});
}
// 导出密钥(用于服务端存储)
async exportKey(key) {
const exported = await crypto.subtle.exportKey('raw', key);
return Array.from(new Uint8Array(exported));
}
}
服务端实现
const express = require('express');
const multer = require('multer');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const app = express();
const upload = multer({ dest: 'uploads/tmp/' });
// 内存中存储加密信息(生产环境用数据库)
const fileMetadata = new Map();
// 上传分片接口
app.post('/upload-chunk', upload.single('encryptedData'), async (req, res) => {
const { fileId, chunkIndex, originalHash, iv } = req.body;
// 1. 读取加密分片
const encryptedData = await fs.promises.readFile(req.file.path);
// 2. 检查是否已上传(基于originalHash)
if (await isChunkUploaded(fileId, originalHash)) {
fs.unlinkSync(req.file.path); // 删除临时文件
return res.json({ success: true, skipped: true });
}
// 3. 存储分片(实际存储到正式目录)
const chunkDir = path.join('uploads', fileId);
await fs.promises.mkdir(chunkDir, { recursive: true });
const chunkPath = path.join(chunkDir, `${chunkIndex}.dat`);
await fs.promises.rename(req.file.path, chunkPath);
// 4. 记录分片哈希(用于去重)
recordChunkHash(fileId, originalHash);
res.json({ success: true });
});
// 合并文件接口
app.post('/merge', express.json(), async (req, res) => {
const { fileId, fileName, totalChunks, encryptionKey, ivMap } = req.body;
// 1. 准备解密参数
const key = Buffer.from(encryptionKey);
const ivs = new Map(ivMap.map(([k, v]) => [k, Buffer.from(v)]));
// 2. 合并文件
const outputPath = path.join('uploads', fileName);
const writeStream = fs.createWriteStream(outputPath);
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join('uploads', fileId, `${i}.dat`);
const encryptedData = await fs.promises.readFile(chunkPath);
// 3. 解密分片
const iv = ivs.get(i);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const decrypted = Buffer.concat([
decipher.update(encryptedData),
decipher.final()
]);
writeStream.write(decrypted);
}
writeStream.end();
await fs.promises.rmdir(path.join('uploads', fileId), { recursive: true });
res.json({ success: true, path: outputPath });
});
// 工具方法 ------------------------------------------------------
async function isChunkUploaded(fileId, hash) {
const metadata = fileMetadata.get(fileId);
return metadata?.hashes?.includes(hash);
}
function recordChunkHash(fileId, hash) {
if (!fileMetadata.has(fileId)) {
fileMetadata.set(fileId, { hashes: [] });
}
fileMetadata.get(fileId).hashes.push(hash);
}
app.listen(3000, () => console.log('Server running on port 3000'));