【一步一个脚印】前端文件上传二:大文件快速上传

931 阅读4分钟

【一步一个脚印】前端上拉加载,下拉刷新

【一步一个脚印】前端文件上传一

【一步一个脚印】前端文件上传二:大文件快速上传

大文件快速上传

要实现大文件快速上传,就先要了解文件在JavaScript中是以什么形式保存的,文件处理有哪些可以用的api

【扩展】File和Blob对象

对于不知道的东西,查mdn是一个很好的方法,除了mdn外,有时候利用console.log你会有不一样的发现,以下为File和Blob打印的结果

FileBlob

分析:

  1. File:保存的文件名,大小,类型,最后修改时间,其是继承Blob的,了解关于File的更多信息请看官方文档,传送门
  2. Blob:保存文件的大小和类型,有slice/stream/arrayBuffer/text方法,了解关于Blob的更多信息请看官方文档,传送门

对File/Blob/arrayBuffer感兴趣的,可以看参考这篇文章,传送门

Blob的slice方法和本文要讲的大文件快速上传有关,接下来我们举例重点说明

`Blob.slice(start,end)`//返回一个新的 `Blob` 对象,包含了源 `Blob` 对象中指定范围内的数据

start //文件开始裁剪的字节

end  //文件截止的字节

Blob.slice(10*1024,20*1024)//截取文件10kb-20kb这段

最简单的文件切割

<template>
  <div id="app">
      
    <input type="file" name="" id="file" ref="input" @change="handleChange" />
    <button class="btn" @click="handleClick">点击上传</button>
      
    <div v-if='!!file'>
      <span>上传的文件大小:</span>
      {{file.size}}
    </div>
      
    <ul>
      <div>切割成文件块</div>
      <li v-for="(item, index) in fileChuck" :key="index">
        文件块{{ index + 1 }}:{{ item.size }}
      </li>
    </ul>
      
  </div>
</template>
export default {
  name: "App",
  data() {
    return {
      file: null,
      fileChuck: [],//存放被切割后的文件块
    };
  },
  methods: {
    handleClick() {
      this.$refs.input.click();
    },
    handleChange(e) {
      const files = e.target.files;//获得input需要上传的文件
      this.file = files[0];
      const SIZE = 10 * 1024;//截取的文件大小
      this.fileChuck = [];
      files.forEach((file) => {
        let curSize = 0;
        const fileSize = file.size;
        while (curSize <= fileSize) {//文件切割
          let end = (curSize + SIZE <= fileSize) ? (curSize + SIZE) : fileSize;
          this.fileChuck.push(file.slice(curSize, end));
          curSize += SIZE;
        }
      });
    },
  },
};

大文件快速上传

先抛开文件上传,思考下面的问题

在A市有100t的货物,要送往B市,怎么缩短运输时间?

  • 交通工具的选择
  • 同一交通工具的载重量
  • 同一运输时间内的使用某一交通工具的数量

文件上传也可以用上述问题类比,第一可以理解为网络传输环境,比如光纤,第二点可以理解为带宽(这个可能不太恰当),第三点就是文件切片,一次性传递完成

前两个在数据传递过程中都是固定的,剩下的就只有第三点文件切片了,但是传递文件不是运输货物,到达目的地就行,文件到了后端还需要组装成原来的部分,所以针对文件切片,另外的难点就是1.后端怎怎么知道文件已经传递完成了;2.后端怎么进行文件还原

思路总结

  1. 问题点一:前端将文件做成切片进行传递,那么后端怎么知道已经全部接收到所有的文件切片

    方法:前端主动通知,当所有的切片传递完成后(Promise.all),再发送一个请求通知后端已经完成切片传递,后端进行切片合并

  2. 问题点二:文件切片传递到后端,后端怎么将文件进行还原

    方法:文件编号,前后端思路如下

    前端: 通过Blob.slice()进行文件切片,给每一个切片按顺序进行编号(index),将编号信息一并传递给后端(通过异步Promise发送请求)

    后端:nodejs搭建服务,接收切片,在本地或者静态资源服务器新建切片文件夹,将切片保存到文件夹中(fs.createReadStream/fs.createWriteStream/pipe),得到合并通知读取文件按前端传递的文件顺序进行切片合并

下面用一种图说明下整个过程

两次请求分别对应不同的后端接口

代码实现

效果如下图,重点观察左边目录的变化,前端核心代码在文件切片 后端代码用的是nodejs,核心是对文件的操作,要求对fs和stream模块有了解

前端代码

前端文件上传的两个核心api是form.append()和blob.slice()方法,不熟悉的请看上文

前端需要做的事情按照触发顺序分为以下几个方面:

  1. click 事件触发上传框打开
  2. change事件触发文件切片和文件上传
    1. 通过blob.slice()方法将文件按照一定大小进行切片
    2. 通过form.append()方法将切片放入formData对象内存储
  3. 切片全部完成后通知后端进行切片合并

以下代码是按照上述思路完成的,供参考,不合理之处也可留言讨论

<!--App.vue -->
  <div id="app">
    <upload>
      <button class="btn" >点击上传</button>
    </upload>
  </div>

<!--Upload.vue -->
<template>
  <div class="upload">
    <input
      type="file"
      name=""
      id="file"
      ref="input"
      @change="handleChange"
    />
      
    <div @click="handleClick">
      <slot></slot>
    </div>
      
    <div v-if="!!file">
      <span>上传的文件大小:</span>
      {{ file.size }}
    </div>
      
    <ul>
      <div>切割成文件块</div>
      <li v-for="(item, index) in fileChunks" :key="index">
        文件块{{ index + 1 }}:{{ item.size }}
      </li>
    </ul>
  </div>
</template>
export default {
  name: "Upload",
  data() {
    return {
      file: null, //保存需要上传的文件
      fileChunks: [], //保存所有的切片
      chunksNameList: [], //保存切片名称,用于切片合并
    };
  },
  methods: {
    handleClick() {
      //用户点击
      this.$refs.input.click();
    },

    handleChange(e) {
      //input:file change事件,
      const files = e.target.files;
      this.file = files[0];
      this.fileChunks = this.createFileChunk(files); //文件转变为切片
      this.uploadFile(this.fileChunks); //切片上传
    },

    /**
     * @description: 切片函数
     * @param {file} files
     * @return {Array} 切片数组
     */
    createFileChunk(files) {
      if (!files.length) return;
      const SIZE = 1 * 1024 * 1024; //1M,切片大小
      const fileChunks = [];
      files.forEach((file) => {
        let curSize = 0;
        let index = 0;
        const fileSize = file.size;
        while (curSize <= fileSize) {
          let end = curSize + SIZE <= fileSize ? curSize + SIZE : fileSize;
          index++;
          fileChunks.push(file.slice(curSize, end));
          curSize += SIZE;
        }
      });
      return fileChunks;
    },

    /**
     * @description: 切片上传
     * @param {Blob} fileChunks
     * @return {*}
     */
    uploadFile(fileChunks) {
      if (!fileChunks.length) return;
      const uploadFileQuene = [];
      this.chunksNameList = [];
      fileChunks.forEach((chunks, index) => {
        const chunksName = this.file.name + "_" + index;
        const form = new FormData();
        form.append(`chunks`, chunks);
        form.append("fileName", this.file.name);
        form.append("chunksNameList", chunksName);
        this.chunksNameList.push(chunksName);
        uploadFileQuene.push(
          this.uploadApi({
            url: "/api/upload", //上传切片
            data: form,
          })
        );
      });
      Promise.all(uploadFileQuene).then((res) => {
        console.log(res);
        this.uploadApi({
          url: "/api/merge", //合并切片
          data: JSON.stringify({
            fileName: this.file.name,
            chunksNameList: this.chunksNameList,
          }),
        });
      });
    },

    /**
     * @description:切片及合并切片请求接口
     * @param {String} url
     * @param {Object} data
     * @return {*}
     */
    uploadApi({ url, data }) {
      return new Promise((resolve) => {
        const xhr = new XMLHttpRequest();
        xhr.open("POST", url);
        xhr.onload = function () {
          resolve(JSON.parse(xhr.response));
        };
        xhr.send(data);
      });
    },
  },
};

后端代码

后端代码核心就是切片保存和切片还原为文件,代码仅供参考

后端完成的功能按照执行顺序,分为以下步骤

  1. 接收到前端发送的切片(包含文件信息),创建文件夹(已文件或者hash命名),将切片保存到已经创建的文件夹下
  2. 接收到合并文件请求,读取切片,创建stream,切片为可读流,要合并的文件为可写流,通过pipe()方法,最终转变成文件
  3. 切片完成后删除切片所在的文件夹(可选)

ps:nodejs用的不是很熟悉,代码在文件流操作完成后,准备删除文件的时候偶尔会有bug

const Koa = require('koa');
const Router = require('koa-router');
const koabody = require('koa-body');
const fs = require('fs');
const path = require('path');

const app = new Koa();
const router = new Router();

app.use(koabody({
    multipart: true
}))

router.post('/upload', ctx => { //切片保存接口
    const chunks = {
        ...ctx.request.files,
        ...ctx.request.body
    }
    if (!Object.keys(chunks).length) {
        ctx.body = JSON.stringify({
            data: {
                message: '未传递数据'
            }
        })
        return;
    }
    uploadCtr(chunks).then(res => {
        ctx.body = JSON.stringify({
            data: { ...chunks, message: '上传成功' }
        });
        ctx.set("Access-Control-Allow-Origin", " * ");
    })

})

router.post('/merge', ctx => { //切片合并接口
    const mergeInfo = JSON.parse(ctx.request.body);
    const fileName = mergeInfo.fileName;
    const chunksNameList = mergeInfo.chunksNameList;
    mergeChunks(fileName, chunksNameList);

    ctx.body = mergeInfo;

})

app.use(router.routes())
app.listen(3000);

/**
 * @description: 创建切片文件夹,并开启切片保存
 * @param {Blob} chunks
 * @param {String} fileName
 * @param {Array} chunksNameList
 * @return {*}
 */
function uploadCtr({ chunks, fileName, chunksNameList }) {
    //创建保存切片的文件夹
    !fs.existsSync(fileName) && fs.mkdirSync(fileName);
    return new Promise(async resolve => {
        const result = await saveChunks({ chunks, fileName, chunksNameList });
        resolve(result);
    });
}


/**
 * @description: 保存切片
 * @param {Blob} chunks
 * @param {String} fileName
 * @param {Array} chunksNameList
 * @return {*}
 */
function saveChunks({ chunks, fileName, chunksNameList }) {
    return new Promise(resolve => {
        const chunksSavePath = path.resolve(__dirname, fileName, chunksNameList)
        const readStream = fs.createReadStream(chunks.path);
        const writeStream = fs.createWriteStream(chunksSavePath);
        readStream.pipe(writeStream);

        resolve({
            chunksSaveDir: path.resolve(__dirname, fileName),
        })

    })
}

/**
 * @description: 合并切片
 * @param {*} fileName
 * @param {*} chunksNameList
 * @return {*}
 */
function mergeChunks(fileName, chunksNameList) {
    const saveFilePath = path.resolve(__dirname, "img", fileName)
    const chunksStream = chunksNameList.map(chunks => {
        const chunksPath = path.resolve(__dirname, fileName, chunks);
        const readStream = fs.createReadStream(chunksPath);
        return readStream
    })
    const chunksLength = chunksStream.length;
    const writeStream = fs.createWriteStream(saveFilePath);
    let isEnd = false
    for (let index = 0, i = 0; index < chunksLength; index++) {
    //这段代码bug出没
        chunksStream[index].pipe(writeStream, {
            end: isEnd
        })
        i++;
        chunksStream[index].on('end', () => {
            if (i == chunksLength) {
                writeStream.end();
                delDir(fileName);
            }
        });
    }

}

/**
 * @description: 删除文件夹
 * @param {String} path
 * @return {*}
 */
function delDir(path) {
    const dirs = fs.readdirSync(path);//读取当前路径下的文件及文件夹
    console.log(dirs)
    dirs.forEach(dir => {
        let curPath = path + '/' + dir//获得当前路径
        console.log(curPath)
        if (fs.statSync(curPath).isDirectory()) {//是否为文件夹
            delDir(curPath);//遍历
        } else if (fs.statSync(curPath).isFile()) {//是否为文件
            fs.unlinkSync(curPath)
        }
    })
    fs.rmdirSync(path)//删除空文件夹
}

同一文件切片与非切片上传时间对比

切片上传耗时 非切片上传耗时 有点凌乱了,猜测原因,这个时间其实是硬盘保存文件/切片的耗时,多个切片需要保存多次,造成切片耗时比非切片还要长,上述代码的优势体现在网络传输上,因为是本地起的服务,网络传输忽略不计,以上是我的猜想,欢迎大神留言指导

代码github地址

参考文档:

  1. mdn Blob和File说明

  2. 字节跳动面试官:请你实现一个大文件上传和断点续传