“JS 离线切片上传大文件” 通常是指在无网络连接状况下,将大文件切片后暂存至本地,待网络恢复再完成上传。其可借由本地存储记录切片数据与上传进度,达成断点续传。
实现思路
- 文件切片:利用 File.prototype.slice 方法把大文件切分成多个小切片。
- 离线存储切片:借助 localStorage、IndexedDB 等把切片存储至本地。考虑到 localStorage
- 存储容量通常局限于 5MB 左右,大容量文件宜优先选用 IndexedDB。
- 记录上传进度:用本地存储记录已存储至本地的切片索引等进度信息。
- 网络恢复后上传:检测到网络恢复时,读取本地切片与进度数据,逐片或并行向服务器上传,并于上传成功后清理相应的本地切片数据。
function chunkFile(file, chunkSize) {
const chunks = Math.ceil(file.size / chunkSize);
const chunksList = [];
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
chunksList.push(chunk);
}
return chunksList;
}
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', function (e) {
const file = e.target.files[0];
const chunkSize = 5 * 1024 * 1024; // 设定 5MB 为一片
const chunksList = chunkFile(file, chunkSize);
// 后续对切片执行存储等操作
});
详细代码
1. 初始化 IndexedDB(用于存储切片和状态)
IndexedDB 适合存储大量二进制数据(切片),容量通常在几十 MB 到 GB 级别,远超 localStorage。
// 初始化IndexedDB数据库
class UploadDB {
constructor(dbName = 'uploadDB', version = 1) {
this.dbName = dbName;
this.version = version;
this.db = null;
this.init();
}
// 初始化数据库
init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
// 数据库版本升级时触发(首次创建或版本号提高)
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 创建存储切片的表(key为切片索引)
if (!db.objectStoreNames.contains('chunks')) {
db.createObjectStore('chunks', { keyPath: 'id' }); // id格式:fileId_chunkIndex
}
// 创建存储文件上传状态的表(key为fileId)
if (!db.objectStoreNames.contains('fileStatus')) {
db.createObjectStore('fileStatus', { keyPath: 'fileId' });
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = (event) => {
console.error('IndexedDB初始化失败:', event.target.error);
reject(event.target.error);
};
});
}
// 保存切片到数据库
saveChunk(fileId, chunkIndex, chunkBlob) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['chunks'], 'readwrite');
const store = transaction.objectStore('chunks');
// 转换Blob为ArrayBuffer(IndexedDB更高效存储二进制)
const reader = new FileReader();
reader.onload = () => {
const request = store.add({
id: `${fileId}_${chunkIndex}`, // 唯一标识:文件ID_切片索引
fileId,
chunkIndex,
content: reader.result, // ArrayBuffer格式
size: chunkBlob.size
});
request.onsuccess = resolve;
request.onerror = reject;
};
reader.readAsArrayBuffer(chunkBlob);
});
}
// 保存文件上传状态(已上传的切片索引)
saveFileStatus(fileId, totalChunks, uploadedIndexes = []) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['fileStatus'], 'readwrite');
const store = transaction.objectStore('fileStatus');
const request = store.put({
fileId,
totalChunks, // 总切片数
uploadedIndexes, // 已上传的切片索引数组
lastUpdate: new Date().getTime()
});
request.onsuccess = resolve;
request.onerror = reject;
});
}
// 获取文件上传状态
getFileStatus(fileId) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['fileStatus'], 'readonly');
const store = transaction.objectStore('fileStatus');
const request = store.get(fileId);
request.onsuccess = () => resolve(request.result || null);
request.onerror = reject;
});
}
// 获取本地未上传的切片
getUnuploadedChunks(fileId) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['chunks'], 'readonly');
const store = transaction.objectStore('chunks');
const chunks = [];
const cursor = store.openCursor(); // 遍历所有切片
cursor.onsuccess = (event) => {
const cur = event.target.result;
if (cur) {
if (cur.value.fileId === fileId) {
chunks.push({
index: cur.value.chunkIndex,
content: cur.value.content // ArrayBuffer格式
});
}
cur.continue();
} else {
resolve(chunks);
}
};
cursor.onerror = reject;
});
}
// 删除已上传的切片(释放本地空间)
deleteUploadedChunk(fileId, chunkIndex) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['chunks'], 'readwrite');
const store = transaction.objectStore('chunks');
const request = store.delete(`${fileId}_${chunkIndex}`);
request.onsuccess = resolve;
request.onerror = reject;
});
}
}
// 实例化数据库
const uploadDB = new UploadDB();
2. 文件切片与本地存储(断网时触发)
将文件切片后,先保存到 IndexedDB,再尝试上传;若断网,则仅保存到本地。
// 生成文件唯一标识(避免同文件重复处理)
function getFileId(file) {
// 结合文件名、大小、最后修改时间生成唯一ID
return `${file.name}_${file.size}_${file.lastModified}`;
}
// 分割文件为切片
function splitFile(file, chunkSize = 5 * 1024 * 1024) { // 5MB/片
const chunks = [];
const totalChunks = Math.ceil(file.size / chunkSize);
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
chunks.push({
index: i,
blob: file.slice(start, end) // 切片的Blob对象
});
}
return { chunks, totalChunks };
}
// 处理文件(切片并保存到本地)
async function handleFile(file) {
const fileId = getFileId(file);
const { chunks, totalChunks } = splitFile(file);
// 先保存文件状态(初始无已上传切片)
await uploadDB.saveFileStatus(fileId, totalChunks, []);
// 保存所有切片到IndexedDB(断网时仅保存,联网后上传)
for (const chunk of chunks) {
await uploadDB.saveChunk(fileId, chunk.index, chunk.blob);
console.log(`切片 ${chunk.index} 已保存到本地`);
}
// 若当前联网,直接开始上传
if (navigator.onLine) {
uploadChunks(fileId);
} else {
alert('当前无网络,切片已保存,联网后将自动上传');
}
}
3. 联网时上传切片(断点续传逻辑)
检测到网络恢复后,读取本地切片,跳过已上传的部分,继续上传剩余切片。
// 上传单个切片到服务器
async function uploadSingleChunk(fileId, chunkIndex, chunkBuffer) {
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('chunkIndex', chunkIndex);
formData.append('chunk', new Blob([chunkBuffer])); // 转换为Blob上传
try {
const response = await fetch('/api/upload-chunk', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('上传失败');
return true;
} catch (error) {
console.error(`切片 ${chunkIndex} 上传失败:`, error);
return false;
}
}
// 批量上传本地切片
async function uploadChunks(fileId) {
// 获取文件状态(已上传的切片索引)
const fileStatus = await uploadDB.getFileStatus(fileId);
if (!fileStatus) {
console.error('未找到文件状态记录');
return;
}
const { totalChunks, uploadedIndexes } = fileStatus;
// 获取本地未上传的切片
const unuploadedChunks = await uploadDB.getUnuploadedChunks(fileId);
if (unuploadedChunks.length === 0) {
console.log('所有切片已上传,请求服务器合并文件');
// 通知服务器合并切片(需后端配合)
await fetch('/api/merge-chunks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId, totalChunks })
});
return;
}
// 逐个上传未上传的切片(可优化为并发上传)
for (const chunk of unuploadedChunks) {
if (!navigator.onLine) {
console.log('网络中断,暂停上传');
return; // 断网时暂停
}
const success = await uploadSingleChunk(fileId, chunk.index, chunk.content);
if (success) {
// 上传成功:删除本地切片,更新已上传索引
await uploadDB.deleteUploadedChunk(fileId, chunk.index);
uploadedIndexes.push(chunk.index);
await uploadDB.saveFileStatus(fileId, totalChunks, uploadedIndexes);
console.log(`切片 ${chunk.index} 上传成功,进度:${uploadedIndexes.length}/${totalChunks}`);
}
}
// 所有切片上传完成后,通知服务器合并
if (uploadedIndexes.length === totalChunks) {
console.log('所有切片上传完成,请求合并');
await fetch('/api/merge-chunks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId, totalChunks, fileName: file.name })
});
}
}
4. 监听网络状态,自动恢复上传
通过 online 和 offline 事件检测网络变化,联网时自动触发上传。
// 监听网络状态变化
window.addEventListener('online', () => {
console.log('网络已恢复,开始自动上传');
// 读取所有未完成的文件,继续上传
(async () => {
const transaction = uploadDB.db.transaction(['fileStatus'], 'readonly');
const store = transaction.objectStore('fileStatus');
const cursor = store.openCursor();
cursor.onsuccess = (event) => {
const cur = event.target.result;
if (cur) {
const fileId = cur.value.fileId;
uploadChunks(fileId); // 继续上传该文件
cur.continue();
}
};
})();
});
window.addEventListener('offline', () => {
console.log('网络已断开,暂停上传');
});
5. 前端页面调用示例
<input type="file" id="fileInput" />
<script>
// 绑定文件选择事件
document.getElementById('fileInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
handleFile(file); // 处理文件(切片+保存)
}
});
</script>
后端配合要点(简要说明)
接收切片:
提供 /api/upload-chunk 接口,接收 fileId、chunkIndex 和切片二进制数据,保存到服务器临时目录。
合并切片:
提供 /api/merge-chunks 接口,接收 fileId 和 totalChunks,按索引顺序合并所有切片为完整文件。
断点续传支持:
服务器需记录 fileId 对应的已接收切片,避免重复存储。
关键优化点
并发上传:将切片上传改为并发(如同时上传 3-5 个),提高速度(需控制并发数,避免服务器压力过大)。
切片哈希校验:对每个切片计算哈希,确保上传前后数据一致性(可用 spark-md5 库)。
过期清理:定期清理长时间未完成上传的本地切片(通过 lastUpdate 时间判断)。
大文件适配:对于 GB 级文件,可在切片时用 Web Worker 避免阻塞主线程。