简单实现一下断点续传

1,508 阅读3分钟

前言

我们在上传文件的时候,如果文件内容较小,我们可以直接采用将文件转化为字节流传输到服务端。但是如果遇到大文件,这样的方式是非常折磨用户的,而且万一中途传输中断,又要重新从 0 开始。

0_jVvFr4qLr4YQ48xo.gif

所以我们需要采用分片上传/断点续传来优化用户的使用体验。


断点续传

断点续传 就是将跳过上一次上传的文件内容,采用 Blob.slice 方法分割文件内容,直接上传剩余的字节数。

监听进度

如果要实现断点续传,我们就需要知道我们之前上传了多少进度。我们采用 XMLHttpRequest 来进行上传,因为 fetch 是无法监听 progress 事件的(也可能是怪我菜)。

我们采用 xhr.upload.onprogress 来实现进度监听。要实现断点续传,我们就需要知道服务端接收的字节数。所以除了上传请求,我们还需要增加一个请求来询问服务器上传了多少字节。

实现思路

首先我们先创建一个唯一的标识 id 来标识我们要上传的文件。

let fileId = file.name + '-' + file.size + '-' + file.lastModified;

我们这里简单判定一下,当文件名,大小,最近修改日期发生更改的话,断点续传的时候就会判定这个文件就是一个新的文件,重新生成一个新的 fileId 。也就是说不会进行续传。

向服务端发送一个请求,请求服务端已经接收了多少个字节。

let response = await fetch('status', {
  headers: {
    'X-File-Id': fileId
  }
});

// 服务端已有的字节数
let startByte = +await response.text();

服务器通过获取 X-File-Id 头信息获取我们刚刚设定的 fileId 来处理当前文件,当服务器中没有文件的时候,响应应为 0

然后我们采用 Blob.slice 方法发送 startByte 之后的文件。

xhr.open("POST", "upload", true);

//文件唯一标识
xhr.setRequestHeader('X-File-Id', fileId);

// 上传开始的字节数
xhr.setRequestHeader('X-Start-Byte', startByte);

xhr.upload.onprogress = (e) => {
  console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
};


xhr.send(file.slice(startByte));

在这里,我们将文件唯一标识 fileId 作为 X-File-Id 发送给服务端,这样它就知道我们要上传的文件,并且将 startByte 作为 X-Start-Byte 告知服务器,我们是最初上传还是在续传,主要取决于 startByte 是否为 0

服务端如果发现 startByte 不为 0 ,应该向 field 文件追加字节流。

这里的核心代码引用了一下 GiHub 中的代码。

index.html

<!DOCTYPE HTML>

<script src="uploader.js"></script>

<form name="upload" method="POST" enctype="multipart/form-data" action="/upload">
  <input type="file" name="myfile">
  <input type="submit" name="submit" value="Upload (Resumes automatically)">
</form>

<button onclick="uploader.stop()">Stop upload</button>


<div id="log">Progress indication</div>

<script>
  function log(html) {
    document.getElementById('log').innerHTML = html;
    console.log(html);
  }

  function onProgress(loaded, total) {
    log("progress " + loaded + ' / ' + total);
  }

  let uploader;

  document.forms.upload.onsubmit = async function(e) {
    e.preventDefault();

    let file = this.elements.myfile.files[0];
    if (!file) return;

    uploader = new Uploader({file, onProgress});

    try {
      let uploaded = await uploader.upload();

      if (uploaded) {
        log('success');
      } else {
        log('stopped');
      }

    } catch(err) {
      console.error(err);
      log('error');
    }
  };

</script>

uploader.js

class Uploader {

  constructor({file, onProgress}) {
    this.file = file;
    this.onProgress = onProgress;

    // create fileId that uniquely identifies the file
    // we could also add user session identifier (if had one), to make it even more unique
    this.fileId = file.name + '-' + file.size + '-' + file.lastModified;
  }

  async getUploadedBytes() {
    let response = await fetch('status', {
      headers: {
        'X-File-Id': this.fileId
      }
    });

    if (response.status != 200) {
      throw new Error("Can't get uploaded bytes: " + response.statusText);
    }

    let text = await response.text();

    return +text;
  }

  async upload() {
    this.startByte = await this.getUploadedBytes();

    let xhr = this.xhr = new XMLHttpRequest();
    xhr.open("POST", "upload", true);

    // send file id, so that the server knows which file to resume
    xhr.setRequestHeader('X-File-Id', this.fileId);
    // send the byte we're resuming from, so the server knows we're resuming
    xhr.setRequestHeader('X-Start-Byte', this.startByte);

    xhr.upload.onprogress = (e) => {
      this.onProgress(this.startByte + e.loaded, this.startByte + e.total);
    };

    console.log("send the file, starting from", this.startByte);
    xhr.send(this.file.slice(this.startByte));

    // return
    //   true if upload was successful,
    //   false if aborted
    // throw in case of an error
    return await new Promise((resolve, reject) => {

      xhr.onload = xhr.onerror = () => {
        console.log("upload end status:" + xhr.status + " text:" + xhr.statusText);

        if (xhr.status == 200) {
          resolve(true);
        } else {
          reject(new Error("Upload failed: " + xhr.statusText));
        }
      };

      // onabort triggers only when xhr.abort() is called
      xhr.onabort = () => resolve(false);

    });

  }

  stop() {
    if (this.xhr) {
      this.xhr.abort();
    }
  }

}

总结

这种断点续传的方式在传输大文件的时候,其实依然有一些缺点,比如当服务器如果有上传大小限制的时候,还是会无法上传。

既然采用了 Blob.slice 方法,干脆直接就对文件进行分块。并为每个文件块添加规则的文件头,然后分别上传。

在全部分块发送完成之后发送一个 merge 请求,让服务端拼接这些文件块。在这里也整理了几个比较好用的上传组件分享一下:

觉得整理的不错的点个赞吧😂。