react+node实现大文件的断点续传

2,365 阅读5分钟

实际项目中可能有这样的需求,如上传入库比较大的Excel表格数据、上传影音文件等。如果文件体积比较大,或者网络条件不好时,上传的时间会比较长,有的时候用户遇到断网,甚至有的用户文件传到一半不想继续等待,想回家再继续上传,这时候就需要用到断点续传的功能。因此本文将从零搭建前端和服务端,实现一个大文件断点续传功能。

项目源码 : github.com/545119639/b…

整体思路

前端:

  • 切割文件,为每一个文件切割块添加不同的标识
  • 当上传成功的之后,记录上传成功的标识
  • 当我们暂停或者发送失败后,可以重新发送没有上传成功的切割文件

微信图片_20210513211733.png

服务端

  • 接收每一个切割文件,并在接收成功后,存到指定位置,并告诉前端接收成功
  • 收到合并信号,将所有的切割文件排序,合并,生成最终的大文件,然后删除临时文件夹,并告知前端大文件的地址

微信图片_20210513202105.png

前端实现

首先创建选择文件的控件,监听 change 事件以及上传按钮,

const SIZE = 10 * 1024 * 1024;

class App extends React.Component {

  state = {
    loading: false
  };

  //点击上传函数
  uploadFile = () => {
    this.createChunkFile(this.inputFile.files[0]);
  };

  //点击暂停或者继续的函数
  onClick = () => {
    if (!this.inputFile.files[0]) return;
    if (this.state.loading) {
      //继续上传
      this.setState({
          loading: false
        });
    } else {
      //暂停上传
      this.setState({
        loading: true
      });
    }
  };
  
  render() {
    return (
      <div className="App">
       <input
          type="file"
          ref={(el) => (this.inputFile = el)}
       />    
       <Button
          type="primary"
          onClick={this.uploadFile}
        >
          上传
       </Button>
       {loading ? (
          <Button onClick={this.onClick}>继续</Button>
        ) : (
          <Button onClick={this.onClick}>暂停</Button>
       )}
      </div>
     )
  }
}

export default App;

接着实现比较重要的上传功能,上传需要做两件事: a. 对文件进行切片 b. 将切片传输给服务端

  • 首先将文件变成二进制,方便后续分片, js常见的二进制格式有 Blob,ArrayBuffer和Buffe,这里采用了ArrayBuffer,又因为我们解析过程比较久,所以我们采用 promise,异步处理的方式。
  //转换文件类型(解析为BUFFER数据)
  fileParse = (file) => {
    return new Promise((resolve) => {
      let fileRead = new FileReader();
      fileRead.readAsArrayBuffer(file);
      fileRead.onload = (ev) => {
        resolve(ev.target.result);
      };
    });
  };
  • 在拿到具体的二进制流之后我们就可以进行分块了,我们在拆分切片文件的时候,调用 createChunkFile函数将文件切片,切片数量通过文件大小控制,这里设置 10MB,也就是说 100 MB 的文件会被分成 10 个切片, createChunkFile函数内使用 for 循环和 slice 方法将切片放入 partList 数组中返回。 在生成文件切片时,需要给每个切片一个唯一的标识,所以引入了 spark-md5 ,根据具体文件内容,生成hash值, 这里就是使用文hash + 下标,这样后端可以知道当前切片是第几个切片,用于之后的合并切片。
  createChunkFile = async (file) => {
    if (!file) return;

    // 解析为BUFFER数据
    let buffer = await this.fileParse(file);
    let spark = new SparkMD5.ArrayBuffer();
    let hash;
    let suffix;
    spark.append(buffer);
    hash = spark.end();
    suffix = /\.([0-9a-zA-Z]+)$/i.exec(file.name)[1];

    // 把一个文件分割成为好几个部分(固定数量/固定大小),每一个切片有自己的部分数据和自己的名字
    let partList = [];
    let count = Math.ceil(file.size / SIZE);
    let partSize = file.size / count;
    let cur = 0;
    for (let i = 0; i < count; i++) {
      let item = {
        chunk: file.slice(cur, cur + partSize),
        filename: `${hash}_${i}.${suffix}`,
      };
      cur += partSize;
      partList.push(item);
    }
    this.setState(
      {
        partList: partList,
        hash: hash,
      },
      () => {
        //判断是否曾经上传过该文件
        this.getLoadingFiles(hash);
      }
    );
  };
  • 这里需要注意上传文件之前首先判断是否曾经上传,如果上传且没有上传完成就把返回来的hash数组存储下来供后续再次上传时调用。
//检查当前文件是否曾经上传
  getLoadingFiles = async (hash) => {
    let result = await axios.get("/loadingUpload", {
      params: {
        hash: hash,
      },
    });
    let res = result.data;
    if (res.code === 0) {
      if (res.data.length === 0) {
        this.uploadFn();
      } else {
        //计算续传时进度条的百分比
        let p = parseInt(Math.ceil(100 / this.state.partList.length));
        this.setState(
          {
            total: res.data.length * p,
          },
          () => {
            this.uploadFn(res.data);
          }
        );
      }
    }
  };
  • 随后调用 uploadFn 上传所有的文件切片,这里需要注意的就是,我们发出去的数据采用的是FormData数据格式, 根据切片的数量发送相对应数量的上传请求。
  uploadFn = async (list) => {
    let p = parseInt(Math.ceil(100 / this.state.partList.length));
    //根据切片数创造切片数个请求
    let requestList = [];
    let _this = this;
    this.state.partList.forEach((item, index) => {
      let fn = () => {
        let formData = new FormData();
        formData.append("chunk", item.chunk);
        formData.append("filename", item.filename);
        return axios
          .post("/upload", formData, {
            headers: { "Content-Type": "multipart/form-data" },
          })
          .then((result) => {
            result = result.data;
            if (result.code === 0) {
              let c = _this.state.total + p;
              // 传完的切片我们把它移除掉
              let l = [..._this.state.partList];
              l.splice(index, 1);
              _this.setState({
                partList: l,
                total: c >= 100 ? 100 : c,
              });
            }
          })
          .catch(function () {
          //网络出现问题时暂停上传
            _this.setState({
              loading: true,
              abort: true,
            });
          });
      };
      requestList.push(fn);
    });
    this.setState(
      {
        computedFileSize: false,
      },
      () => {
        //根据已经上传完成的切片数来决定继续上传时从哪一个切片开始上传
        let i = list ? list.length - 1 : 0;
        this.uploadSend(i, requestList);
      }
    );
  };

  //上传单个切片
  uploadSend = async (c, requestList) => {
    // 已经中断则不再上传
    if (this.state.abort) return;
    if (c >= requestList.length) {
      // 都传完了
      this.uploadComplete();
      return;
    }
    await requestList[c]();
    c++;
    this.uploadSend(c, requestList);
  };
  • 这里需要注意的是在最后一个切片上传完成之后会调用uploadComplete合并切片,即前端主动通知服务端进行合并,所以前端还需要额外发请求,服务端接受到这个请求时合并切片,并告知前端文件上传成功。
  //最后一个切片上传完成,合并切片
  uploadComplete = async () => {
    let result = await axios.get("/merge", {
      params: {
        hash: this.state.hash,
      },
    });
    result = result.data;
    if (result.code === 0) {
      message.success("上传成功");
    }
  };

服务端实现

这里使用express搭建服务端,使用 multiparty 包处理前端传来的 FormData接受切片, 在 multiparty.parse 的回调中,files 参数保存了 FormData 中文件,fields 参数保存了 FormData 中非文件的字段。

const multiparty = require("multiparty"),
uploadDir = `${__dirname}/upload`;

function handleMultiparty(req, res, temp) {
return new Promise((resolve, reject) => {
  // multiparty的配置
  let options = {
    maxFieldsSize: 200 * 1024 * 1024,
  };
  !temp ? (options.uploadDir = uploadDir) : null;
  let form = new multiparty.Form(options);
  // multiparty解析
  form.parse(req, function (err, fields, files) {
    if (err) {
      res.send({
        code: 1,
        reason: err,
      });
      reject(err);
      return;
    }
    resolve({
      fields,
      files,
    });
  });
});
}

  • 在接受文件切片时,需要先创建存储切片的 upload 文件夹,由于前端在发送每个切片时额外携带了唯一值 hash,所以以 hash 作为文件名,将切片放到临时切片文件夹中,如下图:

微信图片_20210513202105.png

  • 上传之前我们首先判断当前文件是否曾经上传,这里的设计思想是如果没有完成上传临时文件夹不会清除,所以根据这个特点我们检查临时文件夹并获取里面的切片名称存储下来,然后返回已经上传的文件切片名称的集合给前端。
//查找上传过程中的文件
function findLoadingSync(startPath, hash) {
  let result = [];

  function finder(path, isChild) {
    let files = fs.readdirSync(path);

    files.forEach((val) => {
      let fPath = join(path, val);

      let stats = fs.statSync(fPath);

      if (stats.isDirectory() && hash == val) finder(fPath, val);

      if (stats.isFile() && isChild == hash) result.push(val);
    });
  }

  finder(startPath);

  return result;
}

//判断当前文件是否曾经上传,如果上传且没有上传完成返回已经上传的文件切片名称
app.get("/loadingUpload", (req, res) => {
  let { hash } = req.query;
  let hasUpList = findLoadingSync(uploadDir, hash);
  res.send({
    code: 0,
    data: hasUpList,
  });
});
  • 使用 fs.createWriteStream 创建一个可写流,可写流文件名就是切片文件夹名 + 后缀名组合而成,随后将切片通过 fs.createReadStream 创建可读流,传输合并到目标文件中。
app.post("/upload", async (req, res) => {
  let { fields, files } = await handleMultiparty(req, res, true);

  let [chunk] = files.chunk,
    [filename] = fields.filename;
  let hash = /([0-9a-zA-Z]+)_\d+/.exec(filename)[1],
    path = `${uploadDir}/${hash}`;
    
  //判断文件是否已经完成上传
  let hasUpList = findSync(uploadDir);
  if (hasUpList.indexOf(hash) > -1) {
    res.send({
      code: 2,
      msg: "当前文件已经上传!",
    });
    return;
  }
  
  !fs.existsSync(path) ? fs.mkdirSync(path) : null;
  path = `${path}/${filename}`;
  fs.access(path, async (err) => {
    // 存在的则不再进行任何的处理
    if (!err) {
      res.send({
        code: 0,
        path: path.replace(__dirname, `http://127.0.0.1:${PORT}`),
      });
      return;
    }

    // 为了测试出效果,延迟1秒钟
    await new Promise((resolve) => {
      setTimeout((_) => {
        resolve();
      }, 200);
    });

    // 不存在的再创建
    let readStream = fs.createReadStream(chunk.path),
      writeStream = fs.createWriteStream(path);
    readStream.pipe(writeStream);
    readStream.on("end", function () {
      fs.unlinkSync(chunk.path);
      res.send({
        code: 0,
        path: path.replace(__dirname, `http://127.0.0.1:${PORT}`),
      });
    });
  });
});
  • 在接收到前端发送的合并请求后,服务端将文件夹下的所有切片进行合并,合并完成以后会生成上传的文件,返回该文件的服务器地址,并清除临时文件夹。
app.get("/merge", (req, res) => {
  let { hash } = req.query;

  let path = `${uploadDir}/${hash}`,
    fileList = fs.readdirSync(path),
    suffix;
  fileList
    .sort((a, b) => {
      let reg = /_(\d+)/;
      return reg.exec(a)[1] - reg.exec(b)[1];
    })
    .forEach((item) => {
      !suffix ? (suffix = /\.([0-9a-zA-Z]+)$/.exec(item)[1]) : null;
      fs.appendFileSync(
        `${uploadDir}/${hash}.${suffix}`,
        fs.readFileSync(`${path}/${item}`)
      );
      fs.unlinkSync(`${path}/${item}`);
    });
  fs.rmdirSync(path);
  res.send({
    code: 0,
    path: `http://127.0.0.1:${PORT}/upload/${hash}.${suffix}`,
  });
});

写在最后

基本的断点续传功能已经完成了,但是离真正的实现一个实际业务场景还有不少的距离,需要不断的丰富和完善,这里仅给大家提供一个思路,希望对各位有所帮助。