使用 fetch 方法来上传文件相当容易。

连接断开后如何恢复上传?这里没有对此的内建选项,但是我们有实现它的一些方式。

对于大文件(如果我们可能需要恢复),可恢复的上传应该带有上传进度提示。由于 fetch 不允许跟踪上传进度,我们将会使用 XMLHttpRequest

不太实用的进度事件

要恢复上传,我们需要知道在连接断开前已经上传了多少。

我们有 xhr.upload.onprogress 来跟踪上传进度。

不幸的是,它不会帮助我们在此处恢复上传,因为它会在数据 被发送 时触发,但是服务器是否接收到了?浏览器并不知道。

或许它是由本地网络代理缓冲的(buffered),或者可能是远程服务器进程刚刚终止而无法处理它们,亦或是它在中间丢失了,并没有到达服务器。

这就是为什么此事件仅适用于显示一个好看的进度条。

要恢复上传,我们需要 确切地 知道服务器接收的字节数。而且只有服务器能告诉我们,因此,我们将发出一个额外的请求。

算法

  1. 首先,创建一个文件 id,以唯一地标识我们要上传的文件:

    1. let fileId = file.name + '-' + file.size + '-' + +file.lastModifiedDate;

    在恢复上传时需要用到它,以告诉服务器我们要恢复的内容。

    如果名称,或大小,或最后一次修改事件发生了更改,则将有另一个 fileId

  2. 向服务器发送一个请求,询问它已经有了多少字节,像这样:

    1. let response = await fetch('status', {
    2. headers: {
    3. 'X-File-Id': fileId
    4. }
    5. });
    6. // 服务器已有的字节数
    7. let startByte = +await response.text();

    这假设服务器通过 X-File-Id header 跟踪文件上传。应该在服务端实现。

    如果服务器上尚不存在该文件,则服务器响应应为 0

  3. 然后,我们可以使用 Blobslice 方法来发送从 startByte 开始的文件:

    1. xhr.open("POST", "upload", true);
    2. // 文件 id,以便服务器知道我们要恢复的是哪个文件
    3. xhr.setRequestHeader('X-File-Id', fileId);
    4. // 发送我们要从哪个字节开始恢复,因此服务器知道我们正在恢复
    5. xhr.setRequestHeader('X-Start-Byte', startByte);
    6. xhr.upload.onprogress = (e) => {
    7. console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
    8. };
    9. // 文件可以是来自 input.files[0],或者另一个源
    10. xhr.send(file.slice(startByte));

    这里我们将文件 id 作为 X-File-Id 发送给服务器,所以服务器知道我们正在上传哪个文件,并且,我们还将起始字节作为 X-Start-Byte 发送给服务器,所以服务器知道我们不是重新上传它,而是恢复其上传。

    服务器应该检查其记录,如果有一个上传的该文件,并且当前已上传的文件大小恰好是 X-Start-Byte,那么就将数据附加到该文件。

这是用 Node.js 写的包含客户端和服务端代码的示例。

在本网站上,它只有部分能工作,因为 Node.js 位于另一个服务 Nginx 后面,该服务器缓冲(buffer)上传的内容,当完全上传后才将其传递给 Node.js。

但是你可以下载这些代码,在本地运行以进行完整演示:

结果

server.js

uploader.js

index.html

  1. let http = require('http');
  2. let static = require('node-static');
  3. let fileServer = new static.Server('.');
  4. let path = require('path');
  5. let fs = require('fs');
  6. let debug = require('debug')('example:resume-upload');
  7. let uploads = Object.create(null);
  8. function onUpload(req, res) {
  9. let fileId = req.headers['x-file-id'];
  10. let startByte = +req.headers['x-start-byte'];
  11. if (!fileId) {
  12. res.writeHead(400, "No file id");
  13. res.end();
  14. }
  15. // 我们将“无处”保存文件
  16. let filePath = '/dev/null';
  17. // 可以改用真实路径,例如
  18. // let filePath = path.join('/tmp', fileId);
  19. debug("onUpload fileId: ", fileId);
  20. // 初始化一个新上传
  21. if (!uploads[fileId]) uploads[fileId] = {};
  22. let upload = uploads[fileId];
  23. debug("bytesReceived:" + upload.bytesReceived + " startByte:" + startByte)
  24. let fileStream;
  25. // 如果 startByte 为 0 或者没设置,创建一个新文件,否则检查大小并附加到现有的大小
  26. if (!startByte) {
  27. upload.bytesReceived = 0;
  28. fileStream = fs.createWriteStream(filePath, {
  29. flags: 'w'
  30. });
  31. debug("New file created: " + filePath);
  32. } else {
  33. // 我们也可以检查磁盘上的文件大小以确保
  34. if (upload.bytesReceived != startByte) {
  35. res.writeHead(400, "Wrong start byte");
  36. res.end(upload.bytesReceived);
  37. return;
  38. }
  39. // 附加到现有文件
  40. fileStream = fs.createWriteStream(filePath, {
  41. flags: 'a'
  42. });
  43. debug("File reopened: " + filePath);
  44. }
  45. req.on('data', function(data) {
  46. debug("bytes received", upload.bytesReceived);
  47. upload.bytesReceived += data.length;
  48. });
  49. // 将 request body 发送到文件
  50. req.pipe(fileStream);
  51. // 当请求完成,并且其所有数据都以写入完成
  52. fileStream.on('close', function() {
  53. if (upload.bytesReceived == req.headers['x-file-size']) {
  54. debug("Upload finished");
  55. delete uploads[fileId];
  56. // 可以在这里对上传的文件进行其他操作
  57. res.end("Success " + upload.bytesReceived);
  58. } else {
  59. // 连接断开,我们将未完成的文件保留在周围
  60. debug("File unfinished, stopped at " + upload.bytesReceived);
  61. res.end();
  62. }
  63. });
  64. // 如果发生 I/O error —— 完成请求
  65. fileStream.on('error', function(err) {
  66. debug("fileStream error");
  67. res.writeHead(500, "File error");
  68. res.end();
  69. });
  70. }
  71. function onStatus(req, res) {
  72. let fileId = req.headers['x-file-id'];
  73. let upload = uploads[fileId];
  74. debug("onStatus fileId:", fileId, " upload:", upload);
  75. if (!upload) {
  76. res.end("0")
  77. } else {
  78. res.end(String(upload.bytesReceived));
  79. }
  80. }
  81. function accept(req, res) {
  82. if (req.url == '/status') {
  83. onStatus(req, res);
  84. } else if (req.url == '/upload' && req.method == 'POST') {
  85. onUpload(req, res);
  86. } else {
  87. fileServer.serve(req, res);
  88. }
  89. }
  90. // -----------------------------------
  91. if (!module.parent) {
  92. http.createServer(accept).listen(8080);
  93. console.log('Server listening at port 8080');
  94. } else {
  95. exports.accept = accept;
  96. }
  1. class Uploader {
  2. constructor({file, onProgress}) {
  3. this.file = file;
  4. this.onProgress = onProgress;
  5. // 创建唯一标识文件的 fileId
  6. // 我们还可以添加用户会话标识符(如果有的话),以使其更具唯一性
  7. this.fileId = file.name + '-' + file.size + '-' + +file.lastModifiedDate;
  8. }
  9. async getUploadedBytes() {
  10. let response = await fetch('status', {
  11. headers: {
  12. 'X-File-Id': this.fileId
  13. }
  14. });
  15. if (response.status != 200) {
  16. throw new Error("Can't get uploaded bytes: " + response.statusText);
  17. }
  18. let text = await response.text();
  19. return +text;
  20. }
  21. async upload() {
  22. this.startByte = await this.getUploadedBytes();
  23. let xhr = this.xhr = new XMLHttpRequest();
  24. xhr.open("POST", "upload", true);
  25. // 发送文件 id,以便服务器知道要恢复哪个文件
  26. xhr.setRequestHeader('X-File-Id', this.fileId);
  27. // 发送我们要从哪个字节开始恢复,因此服务器知道我们正在恢复
  28. xhr.setRequestHeader('X-Start-Byte', this.startByte);
  29. xhr.upload.onprogress = (e) => {
  30. this.onProgress(this.startByte + e.loaded, this.startByte + e.total);
  31. };
  32. console.log("send the file, starting from", this.startByte);
  33. xhr.send(this.file.slice(this.startByte));
  34. // return
  35. // true —— 如果上传成功,
  36. // false —— 如果被中止
  37. // 出现 error 时将其抛出
  38. return await new Promise((resolve, reject) => {
  39. xhr.onload = xhr.onerror = () => {
  40. console.log("upload end status:" + xhr.status + " text:" + xhr.statusText);
  41. if (xhr.status == 200) {
  42. resolve(true);
  43. } else {
  44. reject(new Error("Upload failed: " + xhr.statusText));
  45. }
  46. };
  47. // onabort 仅在 xhr.abort() 被调用时触发
  48. xhr.onabort = () => resolve(false);
  49. });
  50. }
  51. stop() {
  52. if (this.xhr) {
  53. this.xhr.abort();
  54. }
  55. }
  56. }
  1. <!DOCTYPE HTML>
  2. <script src="uploader.js"></script>
  3. <form name="upload" method="POST" enctype="multipart/form-data" action="/upload">
  4. <input type="file" name="myfile">
  5. <input type="submit" name="submit" value="Upload (Resumes automatically)">
  6. </form>
  7. <button onclick="uploader.stop()">Stop upload</button>
  8. <div id="log">Progress indication</div>
  9. <script>
  10. function log(html) {
  11. document.getElementById('log').innerHTML = html;
  12. console.log(html);
  13. }
  14. function onProgress(loaded, total) {
  15. log("progress " + loaded + ' / ' + total);
  16. }
  17. let uploader;
  18. document.forms.upload.onsubmit = async function(e) {
  19. e.preventDefault();
  20. let file = this.elements.myfile.files[0];
  21. if (!file) return;
  22. uploader = new Uploader({file, onProgress});
  23. try {
  24. let uploaded = await uploader.upload();
  25. if (uploaded) {
  26. log('success');
  27. } else {
  28. log('stopped');
  29. }
  30. } catch(err) {
  31. console.error(err);
  32. log('error');
  33. }
  34. };
  35. </script>

正如我们所看到的,现代网络方法在功能上已经与文件管理器非常接近 —— 控制 header,进度指示,发送文件片段等。

我们可以实现可恢复的上传等。