柯倪的实验室
返回文章列表

开箱即用的大文件上传库

前言

large-file-upload。重点功能是如何计算文件唯一标识大量请求使cpu过高时如何避免浏览器卡顿。基本已经算最佳实践。

npm i large-file-upload

api

  • 文件切片 createFileChunks
  • 计算hash generateFileMd5,generateUUID
  • 并发控制器 UploadHelper

为什么需要大文件切片上传

首先如果一个5G的文件直接整体上传,中途出现网络问题就会白白的浪费时间,客户体验也不好,而且单次上传到后台一般会等待时间过长,客户可能不愿意等这么久直接就退出了。

整体思路

要先给文件切片分成更小的块,通常是5M到10M,之后看有没有秒传或者断点续传的需求,如果有的话,需要获得文件的唯一标识,方式有很多,看了B站和抖音的上传,B站用的是一个uuid,反复测试后应该是用时间+用户+文件大小+文件名组合计算的方式,和文件内容无关,抖音应该是有一套自己的计算文件指纹的算法。看到部分方案有计算文件分片hash的功能,这部分功能可能会在验证分片完整性用到,但是这个太浪费资源,不管服务端还是客户端都不太建议,B站和抖音的上传都没用到分片hash,B站应该是发送了分片的起始和截至位置,我伪造过一份文件发现是可以骗过去的,目前是没有严格的验证文件完整性的功能,估计也不需要。如果有明确的验证文件完整性的需求,需要服务端在合并完后和客户端用同一种算法看得出的hash值是否一致。之后需要一个请求的并发控制器,以避免浏览器卡顿,同时这一步可以带上切片的序号以便后端使用,我封装的api可以自定义请求,可扩展性非常高。所有切片上传完成后前端可以发送请求通知后台,可以按照序号进行合并,具体看需求

续传的需求,我试过indexdDb方案,把切片存储到浏览器,但是有缺点,准确性很差,突然关闭浏览器后,后台已接收的切片和浏览器存储的剩余切片不一致,localstorge方案保存切片序号也不够好,因为这是同步操作,正确的做法应该是请求后台已存储的切片,然后前端过滤已上传的切片,一般是按序号

创建文件切片

文件的切片非常简单,拿到file文件后直接调用原生的slice方法即可。

import { createFileChunks } from 'large-file-upload';

// 可以传入指定的分片大小,没传会根据文件自动指定。
const { fileChunks, chunkSize } = createFileChunks(file)

源码如下:

export function createFileChunks(file: File, customChunkSize?: number): FileChunkResult {
  if (!file || !file.size) {
    throw new Error('File not found or size is 0');
  }

  const size = file.size;
  let chunkSize: number;

  if (typeof customChunkSize === 'number' && customChunkSize >= 1) {
    chunkSize = Math.floor(customChunkSize) * BASESIZE;
  } else if (customChunkSize !== undefined) {
    chunkSize = 4 * BASESIZE;
  } else {
    if (size < 100 * BASESIZE) {
      chunkSize = 1 * BASESIZE;
    } else if (size < 1024 * BASESIZE) {
      chunkSize = 5 * BASESIZE;
    } else {
      chunkSize = 10 * BASESIZE;
    }
  }

  const fileChunks: Blob[] = [];
  for (let start = 0; start < size; start += chunkSize) {
    const end = Math.min(start + chunkSize, size);
    fileChunks.push(file.slice(start, end));
  }
  return { fileChunks, chunkSize: chunkSize / BASESIZE };
}

计算文件uuid

计算uuid应该是目前比较好的方案,通过输入一系列参数来生成文件的标识,因为不涉及hash,计算非常快速

import { generateUUID } from 'large-file-upload';

async function hashLargeFile(file: File) {
  // 可以输入任意参数
  const uuid = await generateUUID(file.name, file.size);
  console.log('Generated uuid for the large file:', uuid);
}

计算文件md5

部分业务可能采用计算文件md5的这种方案,这种方案最大的优势就是可以方便后端验证文件完整性,后端计算合并后的文件hash和前端发来的hash做比对。小文件可以用,但是大于1G的文件等待计算时间就比较长了,目前我还没看到哪家用这种方案,在这里推荐用stream流加载文件,1G大概5秒,看到很多把blob转换成arraybuffer的方式,这个过程非常耗时,光是把1G的文件转换成arraybuffer大概就10秒左右,非常不推荐

import { generateFileMd5 } from 'large-file-upload';

async function hashLargeFile(file: File) {
  const hash = await generateFileMd5(file);
  console.log('Generated hash for the large file:', hash);
}

计算文件指纹

一般是通过抽取部分文件的方式得到文件标识,也算是一个比较通用的办法,优点就是前后端如果采用同一种算法,可以快速的计算文件完整性,缺点是这个要和后端协商好,在这里提供一种多线程计算hash加抽样的方法。1G的文件1秒算完,30G的文件需要6秒左右。

import { generateFileFingerprint } from 'large-file-upload';

async function hashLargeFile(file: File) {
  const hash = await generateFileFingerprint(file);
  console.log('Generated hash for the large file:', hash);
}

核心思路是计算部分移到web worker,让主线程专注其他工作,同时借用了wasm加速计算,在这附上源码地址

核心代码

image.png

多开web worker充分利用并发能力,之后将文件等分,每部分抽样后分配到不同的线程后计算hash,然后发回主线程合并。具体的实现可以参考源码,这个功能一定要快,不然等太久只会降低用户体验。

核心代码

image.png

多开web worker充分利用并发能力,之后将文件等分,每部分抽样后分配到不同的线程后计算hash,然后发回主线程合并。具体的实现可以参考源码,这个功能一定要快,不然等太久只会降低用户体验。

大量请求的并发上传

请求的并发控制方案有很多,我这边有试过发布订阅模式,Promise.all也试过,最终决定参考流行的并发库p-limit再改一份并发方案,添加暂停,继续,错误请求重试等功能,比较重点的是低优先级模式,用来解决当大量请求发起的时候,导致cpu上升,如果爆满后浏览器必然卡顿,无法优化,在有动画业务的时候会严重影响用户体验。附上源码地址

import { UploadHelper, createFileChunks } from 'large-file-upload';

async function uploadFile(file: File) {
  const { fileChunks } = await createFileChunks(file);
  const hash = await generateFileHash(file);

  const fileArr = fileChunks.map((chunk, index) => {
    return {
      blob: chunk,
      index,
    };
  });
  const uploadHelper = new UploadHelper(fileArr, {
    maxConcurrentTasks: 3,
  });

  uploadHelper.onProgressChange(progress => {
    console.log(`Progress: ${progress}/${fileChunks.length}`);
  });

  // Execute the upload tasks with an async function passed to run
  const { results, errorTasks } = await uploadHelper.run(async ({ data, signal }) => {
    const formData = new FormData();
    formData.append('chunk', data.blob);
    formData.append('index', data.index);
    formData.append('hash', hash);
    // Simulate an upload request using fetch or any HTTP client
    const response = await fetch('/upload', {
      method: 'POST',
      body: formData,
      signal,
    });

    if (!response.ok) {
      throw new Error(`Upload failed with status ${response.status}`);
    }

    return await response.json();
  });

  if (errorTasks.length > 0) {
    console.log('Some chunks failed to upload:', errorTasks);
    // Optionally retry failed tasks
    // await uploadHelper.retryTasks(errorTasks);
  } else {
    console.log('All chunks uploaded successfully');
  }
}

请求队列的实现参考的p-limit,使用链表进行实现,性能要更好一点。当单个任务完成后会自动请求下一个直至完成。基本主流的并发控制方案,我测过三四种,在时间上差不多太多,可能有些逻辑会更合理一点,如果需要自己实现的话,不用纠结。另外在这里指出用web worker发请求的方案,基本完全没用,时间上是基本一样的,性能,内存均没有太大提升,不用浪费时间,这里可以参考这个web worker方案实现

重点 并发方案实践下来最重要的点是如何避免cpu爆满导致电脑的卡顿,而不是浏览器的,这里采用requestIdleCallback来降低请求任务的优先级,同时设置保底时间,避免任务长时间不运行。

image.png

结尾

基本的流程已经总结完毕,这个库可以直接使用,如果用vite框架的话需要避免预构建,因为采用了web worker,作为库安装时会有资源路径不对的问题。 这个库可为各位做个技术参考,实际项目中企业一般会采用阿里云oss的方案,前端安装ali-oss的库实现分片上传功能。

项目地址

并发上传我看了下抖音和b站上传一个3g的视频大概要5分钟左右,目测应该是慢速模式,基本不会导致cpu过高,但是对于公司来说,稍微有点慢,开4并发数的话大概需要30秒即可上传完一个3g视频

这个库可能还有不足的地方,非常欢迎掘友们提出建议。如果有什么需求需要实现的,我也很乐意研究然后分享出来,可以的话帮忙给项目点点star,感激不尽。

前端