JS 离线切片上传大文件

“JS 离线切片上传大文件” 通常是指在无网络连接状况下,将大文件切片后暂存至本地,待网络恢复再完成上传。其可借由本地存储记录切片数据与上传进度,达成断点续传。

实现思路

  1. 文件切片:利用 File.prototype.slice 方法把大文件切分成多个小切片。
  2. 离线存储切片:借助 localStorage、IndexedDB 等把切片存储至本地。考虑到 localStorage
  3. 存储容量通常局限于 5MB 左右,大容量文件宜优先选用 IndexedDB。
  4. 记录上传进度:用本地存储记录已存储至本地的切片索引等进度信息。
  5. 网络恢复后上传:检测到网络恢复时,读取本地切片与进度数据,逐片或并行向服务器上传,并于上传成功后清理相应的本地切片数据。
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 避免阻塞主线程。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。