大文件上传?其实真的没有那么难!(一)


theme: fancy

作为一名前端练习生,经常看到有关大文件上传的文章或视频,但从未动手去实现过,今天终于鼓起勇气,认真的分析了一下,发现其实并没有那么复杂。

源码地址 (kakachake/BigFile: 大文件上传示例 (github.com))

一、问题分析

试想,如果在项目中遇到大文件上传,应该怎么办?一次性将它上传到服务器吗,不太合适,整个上传过程耗时漫长,万一上传失败,只能进行重传。那么如果去解决呢,一个简单的思想就是分而治之,将大文件切割成小块,分别上传给服务端,最后通知服务端合并。这么一说,是不是觉得并没有那么难了?

二、我们要做什么?

根据前面的分析,我们这里可以列举一些需要做的事情

前端:

  • 大文件分片
  • 分片文件上传
  • 通知后端合并

后端:

  • 接收并存储分片文件
  • 合并分片文件

接下来本文也将通过分而治之的思想,逐个击破各个模块,实现大文件上传。


三、实现

1. 前端

(1)基本框架搭建

前端这里我是用react进行搭建的,不过无所谓,大家可以自行选择自己喜欢的框架进行搭建。

首先是页面结构:

<div>
  <input ref={inputRef} type="file" onChange={handleChange} />
  <button onClick={handleUpload}>上传</button>
  <button onClick={handleStop}>暂停</button>
  <button onClick={clear}>清除</button>
  <div>
    hash计算进度:
    <Progress
      style={{
        backgroundColor: "blue",
      }}
      progress={hashProgress}
    />
  </div>
  <div>
    上传进度:
    <ProgressGroup progresses={progresses}></ProgressGroup>
  </div>
</div>

这里包括input来进行文件上传,另外添加了几个按钮做上传、暂停、清除的操作。
除此之外,由于hash计算和文件上传都需要时间,这里做了一个progress组件用来展示进度,具体实现可以到git仓库查看,这里就不多说了。效果如下:

上传前:
image.png
上传完成:
image.png

接着是监听inputonChange事件:

  const bigFileRef = useRef<BigFile>();
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const [file] = e.target.files ?? [];
    if (file) {
      bigFileRef.current = new BigFile(file, {
        hooks: {
          onHashProgress: (progress: number) => {
            setHashProgress(progress);
          },
        },
      });
    }
  };

可以看到这里我们获取到了file文件,然后实例化了一个BigFile对象,并将这个对象挂载到了bigFileRef上,没错,大文件的分片逻辑就藏在这个BigFile中。

(2)大文件分片-BigFile实现

想想对于BigFile, 我们应该做什么?

首先是接收参数,必不可少的是file参数,因为我们的核心是要把它分片,其次是两个可选参数sizehooks,用来控制分片的大小以及钩子函数,这里的钩子函数用来实现进度条功能。

那么BigFile基本的雏形就是这样:

// 类型定义
interface Hooks {
  onHashProgress?: ((progress: number) => void)[];
  onHashCompleted?: ((hash: string) => void)[];
}

interface FileChunkSize {
  chunkSize: number;
  hashSize: number;
}

interface BigFileOptions {
  size: FileChunkSize;
  hooks?: {
    onHashProgress?: (progress: number) => void;
    onHashCompleted?: (hash: string) => void;
  };
}

// 默认参数定义
const defalutOptions = {
  size: {
    chunkSize: 1024 * 1024 * 20,
    hashSize: 1024 * 1024 * 20,
  },
};

export class BigFile {
  file: File;
  // 分片大小
  size: FileChunkSize;
  hooks: Hooks;
  // 分片结果缓存
  chunks?: IFileChunk[];
  // 用于判断hash计算是否完成
  hashPromise?: Promise<string>;

  constructor(file: File, userOptions: Partial<BigFileOptions>) {
    // 将默认参数与用户参数合并
    const options: BigFileOptions = { ...defalutOptions, ...userOptions };
    const { size, hooks } = options;
    // 初始化参数
    this.file = file;
    this.size = size;
    this.hooks = {
      onHashProgress: hooks?.onHashProgress
        ? (<any>[]).concat(hooks.onHashProgress)
        : [],
      onHashCompleted: hooks?.onHashCompleted
        ? (<any[]>[]).concat(hooks.onHashCompleted)
        : [],
    };
  }
}

计算hash值:

既然要涉及到文件上传,而且是切片上传那么我们最好是创建一个hash来作为文件的唯一标识,这样即使文件名改变,只要内容不变,hash就不会变化,这也对后续的断点续传、秒传功能有很大的帮助:

我们新建一个utils.ts文件,并构造了一个getHash函数,接收filesize以及一个cb回调,使用spark-md5进行hash计算,由于计算hash是很耗时的操作,所以我们这里使用切片,读取每个切片的 ArrayBuffer 并不断传入 spark-md5 中,每计算完一个切片就发送一个回调,用于前端的进度监控,最后返回一个promise控制异步:

// 计算文件hash值
export const getHash = (
  file: File,
  size: number,
  cb?: (progress: number) => void
): Promise<{
  code: number;
  hash?: string;
  message?: string;
}> => {
  const blobSlice = File.prototype.slice;
  const chunkSize = size;
  const chunks = Math.ceil(file.size / chunkSize);
  let currentChunk = 0;
  const spark = new SparkMD5.ArrayBuffer();
  const fileReader = new FileReader();

  // 切片
  function loadNext() {
    var start = currentChunk * chunkSize,
      end = start + chunkSize >= file.size ? file.size : start + chunkSize;
    fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
  }

  const promise = new Promise<{
    code: number;
    hash?: string;
    message?: string;
  }>((resolve) => {
    // 读取完成
    fileReader.onload = function (e) {
      const progress = ((currentChunk + 1) / chunks) * 100;
      console.log(progress);
      // 
      cb?.(progress);

      console.log("read chunk nr", currentChunk + 1, "of", chunks);
      spark.append(e.target!.result as ArrayBuffer); // Append array buffer
      currentChunk++;

      if (currentChunk < chunks) {
        loadNext();
      } else {
        console.log("finished loading");
        const hash = spark.end();
        console.info("computed hash", hash); // Compute hash
        resolve({ code: 1, hash });
      }
    };

    fileReader.onerror = function () {
      console.warn("oops, something went wrong.");
      resolve({ code: 0, message: "oops, something went wrong." });
    };
  });

  loadNext();

  return promise;
};

接下来给BigFile调用即可,我们首先获取到所需的一些参数:filehashSize,然后传给getHash,除此之外,我们还会定义一个回调函数,回调内部再调用当前hooks中的onHashProgress,即可完成hash进度的监听操作。

export class BigFile {
  //...省略
  constructor(file: File, userOptions: Partial<BigFileOptions>) {
    //...
    this.calcHash();
  }
  
  async calcHash() {
    return (this.hashPromise = new Promise<string>(async (resolve, reject) => {
      try {
        // 获取文件及hash分片
        const {
          file,
          size: { hashSize },
        } = this;
        const hashRes = await getHash(file, hashSize, (progress) => {
          // 触发onHashProgress钩子函数
          this.hooks.onHashProgress?.forEach((cb) => cb(progress));
        });
        // 得到hash计算结果
        this.hash = hashRes.hash || "";
        // 触发钩子函数
        this.hooks.onHashCompleted?.forEach((cb) => cb(this.hash));
        resolve(this.hash || "");
      } catch (error) {
        reject(error);
      }
    }));
  }
}

除此之外,我们注意到最后将返回值还赋给了this.hashPromise,这里原因是我们的calcHash操作必须在切片前完成,而我们计算hash函数的操作是在constructor中被调用的,所以这里用hashPromise保存calcHash的异步信息,在后续切片操作时会等待hash计算完成,即(这个下文就会提到):

this.hashPromise.then((hash)=>{
    //xxx
})

创建切片:
切片的原理其实很简单,在File对象的原型上有个slice方法专门用来切片,我们可以把它想象成数组切割,假设有个长度为10的数组,我们要将其切割为长度为3的数组,那么我们可以这么做:

const arr = Array(10).fill(0)
// 设置初始偏移量指针
let cur = 0;
// 设置切片大小
const size = 3;
const len = arr.length;
// 创建一个chunks数组,存放切片后的结果
const chunks = []
while(cur < len){
    // 切割数组,从cur -> cur + size
    chunks.push(arr.slice(cur, cur + size))
    // 移动指针
    cur += size
}
console.log(chunks) //[Array(3), Array(3), Array(3), Array(1)]

这样看是不是很简单,其实文件切割也是一样的思路,只不过为了后续使用方便,这里会把chunk文件做一层封装:cb(chunk),即每一个切片文件不仅包含chunk,还包含源文件的hash以及当前chunk的下标,这些信息会在后续的工作中用到。

而且这个cb并不是固定的,他有一个默认值,当然开发者也可以自行决定chunk的返回结果。

我们在utils.ts文件中继续编写createFileChunk函数:

// utils.ts-切割文件
export interface IFileChunk {
  chunk: Blob;
  hash: string; // 文件hash值
  index: number; // 文件切片索引
  complete?: boolean;
}

export const createFileChunk = <T = IFileChunk>(
  file: File & {
    hash?: string;
  },
  hash: string,
  size: number,
  cb: (file: Blob, index?: number) => T = (blob: Blob, index) => {
    return {
      chunk: blob,
      hash: hash + "_" + index,
      index: index,
    } as T;
  }
): T[] => {
  const chunks = [];
  const totalSize = file.size;
  let cur = 0;
  while (cur < totalSize) {
    chunks.push(cb(file.slice(cur, cur + size), cur / size));
    cur += size;
  }
  return chunks;
};

编写完成后,就可以在BigFile中使用了:

export class BigFile{
    // ……
  async createChunks() {
    return (
      // 先等待hash计算完成
      this.hashPromise?.then((hash) => {
        // 获取file及chunkSize参数
        const {
          file,
          size: { chunkSize },
        } = this;
        // 创建切片
        this.chunks = createFileChunk(file, hash, chunkSize);
        return this.chunks;
      }) || Promise.reject("chunks not created")
    );
  }
}

到此,BigFile的编写基本就结束了,不过我们还要定义一个getChunks函数,避免每次都要重新进行切片:

async getChunks() {
    // 如果this.chunks有值说明已经切片,直接返回,否则创建切片
    return this.chunks || (await this.createChunks());
}

接下来我们回到前端页面,构造一个handleUpload函数,测试一下切片功能:

  const handleUpload = async () => {
    const bigFile = bigFileRef.current!;
    bigFile.getChunks().then(async (chunks) => {
      console.log(chunks);
    });
  };

image.png

符合预期!

时间不短了,今天我们解决了大文件上传中的分片问题,我们可以先思考下接下来要做什么:

  • 分片文件的上传(需要做并行限制)
  • 服务端的分片文件接收与合并

期待下篇文章吧~

© 版权声明
THE END
喜欢就支持一下吧
点赞9 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容