为fetch添加上传进度指示器?

200

我正在努力寻找使用fetch实现上传进度指示器的文档或示例。

到目前为止,这是我找到的唯一参考资料,其中指出:

进度事件是一个高级特性,暂时不会在fetch中出现。您可以通过查看 Content-Length 头并使用透传流来监视接收的字节来创建自己的内容。

这意味着您可以显式处理响应而没有Content-Length。当然,即使有Content-Length,它也可能是一个谎言。使用流,您可以按照任何想要的方式处理这些谎言。

如何编写“透传流以监视发送的字节”? 如果有任何区别,我正在尝试用此方法从浏览器上传图像到Cloudinary

注意:我 Cloudinary JS库感兴趣,因为它依赖于jQuery,而我的应用程序不依赖于它。我只关心使用本机javascript和Github的fetch polyfill所需的流处理。


https://fetch.spec.whatwg.org/#fetch-api


6
@Magix 请查看中止获取:下一代 #447 - guest271314
@guest271314 上面的链接是关于在HTTP 响应 中使用流,而不是请求。 - Armen Michaeli
6
很失望地发现,四年过去了,仍然没有使用fetch API的解决方案:https://fetch.spec.whatwg.org/#fetch-api。就请求进度而言,它目前还存在缺陷(不是响应进度)。 - fguillen
现代浏览器,不支持IE:https://developer.mozilla.org/zh-CN/docs/Web/API/ReadableStream - Phil Tune
这个链接似乎实现了OP所要求的一些概念:https://dev.to/tqbit/how-to-monitor-the-progress-of-a-javascript-fetch-request-and-cancel-it-on-demand-107f - pebox11
13个回答

67

流(Streams)正在陆续进入 Web 平台(https://jakearchibald.com/2016/streams-ftw/),但现在还处于早期阶段。

不久之后,您将能够提供一个流作为请求的主体,但关键问题是该流的使用是否与上传的字节数有关。

特定的重定向可能会导致数据被重新传输到新位置,但是流无法“重新启动”。我们可以通过将请求体转换为可多次调用的回调来解决这个问题,但我们需要确保暴露重定向的数量不会成为安全漏洞,因为这将是平台上 JavaScript 第一次可以检测到它。

一些人质疑将流消耗与上传的字节数相关联是否有意义。

长话短说:目前还不可能实现,但将来要么用流处理,要么将其处理为某种更高级别的回调传递给 fetch()


10
太遗憾了。暂且接受,但当这成为现实时,我希望有其他人发布更新的解决方案! :) - neezer
8
但问题是关于上传进度的,而不是下载。 - John Balvin Arias
15
现在是2020年,为什么还没有办法做到这一点 :( - mh-alahdadian
8
现在已经是2021年了,还是没有什么进展吗? - Steve Moretz
3
2023年必须是那一年。 - Leafyshark
显示剩余7条评论

62

fetch:仅限Chrome

浏览器正在努力支持将ReadableStream作为fetch的body。对于Chrome来说,这自v105以来已经实现了。对于其他浏览器,目前尚未实现

(请注意,duplex: "half"目前是必需的,以便使用流正文进行获取。)

可以使用自定义TransformStream来跟踪进度。以下是一个有效的示例:

警告:此代码在Chrome之外的浏览器中不起作用

async function main() {
  const blob = new Blob([new Uint8Array(10 * 1024 * 1024)]); // any Blob, including a File
  const uploadProgress = document.getElementById("upload-progress");
  const downloadProgress = document.getElementById("download-progress");

  const totalBytes = blob.size;
  let bytesUploaded = 0;

  // Use a custom TransformStream to track upload progress
  const progressTrackingStream = new TransformStream({
    transform(chunk, controller) {
      controller.enqueue(chunk);
      bytesUploaded += chunk.byteLength;
      console.log("upload progress:", bytesUploaded / totalBytes);
      uploadProgress.value = bytesUploaded / totalBytes;
    },
    flush(controller) {
      console.log("completed stream");
    },
  });
  const response = await fetch("https://httpbin.org/put", {
    method: "PUT",
    headers: {
      "Content-Type": "application/octet-stream"
    },
    body: blob.stream().pipeThrough(progressTrackingStream),
    duplex: "half",
  });
  
  // After the initial response headers have been received, display download progress for the response body
  let success = true;
  const totalDownloadBytes = response.headers.get("content-length");
  let bytesDownloaded = 0;
  const reader = response.body.getReader();
  while (true) {
    try {
      const { value, done } = await reader.read();
      if (done) {
        break;
      }
      bytesDownloaded += value.length;
      if (totalDownloadBytes != undefined) {
        console.log("download progress:", bytesDownloaded / totalDownloadBytes);
        downloadProgress.value = bytesDownloaded / totalDownloadBytes;
      } else {
        console.log("download progress:", bytesDownloaded, ", unknown total");
      }
    } catch (error) {
      console.error("error:", error);
      success = false;
      break;
    }
  }
  
  console.log("success:", success);
}
main().catch(console.error);
upload: <progress id="upload-progress"></progress><br/>
download: <progress id="download-progress"></progress>

解决方法:老牌的XMLHttpRequest

可以使用XMLHttpRequest而不是fetch()来跟踪上传进度,xhr.upload对象会发出progress事件

async function main() {
  const blob = new Blob([new Uint8Array(10 * 1024 * 1024)]); // any Blob, including a File
  const uploadProgress = document.getElementById("upload-progress");
  const downloadProgress = document.getElementById("download-progress");

  const xhr = new XMLHttpRequest();
  const success = await new Promise((resolve) => {
    xhr.upload.addEventListener("progress", (event) => {
      if (event.lengthComputable) {
        console.log("upload progress:", event.loaded / event.total);
        uploadProgress.value = event.loaded / event.total;
      }
    });
    xhr.addEventListener("progress", (event) => {
      if (event.lengthComputable) {
        console.log("download progress:", event.loaded / event.total);
        downloadProgress.value = event.loaded / event.total;
      }
    });
    xhr.addEventListener("loadend", () => {
      resolve(xhr.readyState === 4 && xhr.status === 200);
    });
    xhr.open("PUT", "https://httpbin.org/put", true);
    xhr.setRequestHeader("Content-Type", "application/octet-stream");
    xhr.send(blob);
  });
  console.log("success:", success);
}
main().catch(console.error);
upload: <progress id="upload-progress"></progress><br/>
download: <progress id="download-progress"></progress>


1
https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream - Phil Tune
请求体或响应体,两者都可以吗? - Armen Michaeli
2
如果您运行上面的XHR示例代码,您将看到它适用于请求和响应体进度。这些是XMLHttpRequest上的单独事件侦听器。对于fetch()response.body是一个流,可用于跟踪下载进度。 - jtbandes
WTF?!有一个的函数fetch()可以查看真实的服务器响应(例如,重定向将被显示为重定向,而不是被静默隐藏和跟随),但它不能像XHR那样跟踪上传进度?这是某种委员会失败的结果还是发生了什么事情? - undefined

55

我的解决方案是使用axios,它在这方面支持得相当不错:

axios.request({
    method: "post", 
    url: "/aaa", 
    data: myData, 
    onUploadProgress: (p) => {
      console.log(p); 
      //this.setState({
          //fileprogress: p.loaded / p.total
      //})
    }
}).then (data => {
    //this.setState({
      //fileprogress: 1.0,
    //})
})

我在github上有一个在react中使用它的示例。


6
axios在底层使用XMLHttpRequest,而不是fetch - Dai
13
XMLHttpRequest。如果您在使用React Native时使用它,请注意,与fetch相比,XMLHttpRequest在解析大型JSON响应时似乎非常非常慢(慢大约10倍),并且会冻结整个UI线程。 - Cristiano Coelho
15
这并未回答问题,特别是因为 axios 并不在底层使用 fetch,也没有这样的支持。我现在正在原地为他们撰写此功能。 - Sam Gammon
10
我同意这不是特定问题的解决方案,但考虑到没有特定问题的解决方案,我会给这个答案点赞。 - fguillen
8
如果真正的答案是“你无法在y中做x”,那么“在z中做x”可能对许多人有用。 - ssp
显示剩余3条评论

22

如其他答案中已经解释的那样,使用fetch不可能实现这一功能,但可以使用XHR。以下是我更简洁的XHR解决方案:

const uploadFiles = (url, files, onProgress) =>
  new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.upload.addEventListener('progress', e => onProgress(e.loaded / e.total));
    xhr.addEventListener('load', () => resolve({ status: xhr.status, body: xhr.responseText }));
    xhr.addEventListener('error', () => reject(new Error('File upload failed')));
    xhr.addEventListener('abort', () => reject(new Error('File upload aborted')));
    xhr.open('POST', url, true);
    const formData = new FormData();
    Array.from(files).forEach((file, index) => formData.append(index.toString(), file));
    xhr.send(formData);
  });

适用于一个或多个文件。

如果您有一个像这样的文件输入元素:

<input type="file" multiple id="fileUpload" />

像这样调用函数:

document.getElementById('fileUpload').addEventListener('change', async e => {
  const onProgress = progress => console.log('Progress:', `${Math.round(progress * 100)}%`);
  const response = await uploadFiles('/api/upload', e.currentTarget.files, onProgress);
  if (response.status >= 400) {
    throw new Error(`File upload failed - Status code: ${response.status}`);
  }
  console.log('Response:', response.body);
}

当构建文件拖放区域时,e.dataTransfer.filesdrop事件中获取的内容也可以使用。


当您想要显示文件上传和响应的进度时,它可能没有用处(典型情况是上传大型CSV文件,然后服务器进行一些缓慢的转换,我们也想显示其进度)。 - Nir O.

17

更新:正如被接受的答案所说,现在不可能了。但是下面的代码解决了我们的问题一段时间。我应该补充说,至少我们不得不切换到使用基于XMLHttpRequest的库。

const response = await fetch(url);
const total = Number(response.headers.get('content-length'));

const reader = response.body.getReader();
let bytesReceived = 0;
while (true) {
    const result = await reader.read();
    if (result.done) {
        console.log('Fetch complete');
        break;
    }
    bytesReceived += result.value.length;
    console.log('Received', bytesReceived, 'bytes of data so far');
}

感谢这个链接:https://jakearchibald.com/2016/streams-ftw/


10
好的,但这是否也适用于上传呢? - kernel
3
content-length 不等于消息体长度。当使用 HTTP 压缩(下载大文件时很常见)时,content-length 是经过 HTTP 压缩后的大小,而 length 是文件解压缩后的大小。 - Ferrybig
2
你的代码假设内容头长度指定了要下载的字节数量。但这并不总是正确的,因此你的代码无法向用户显示进度,因为“bytesReceived”变得比“total”更大。 - Ferrybig
2
此外,即使是浏览器也无法事先知道实际内容的长度。你所得到的只是一个压缩后的进度指示器。例如,如果你正在下载一个具有不均匀分布压缩比率的 zip 文件(一些文件是随机的,一些是低熵的),你会注意到进度指示器明显偏斜。 - cutsoy
@Ferrybig totalBytes = response.headers.get('Content-Encoding') !== 'gzip' ? Number(response.headers.get('Content-Length')) : null; - yyny
显示剩余2条评论

10

使用 fetch:现在在 Chrome >= 105 中可行

操作步骤: https://developer.chrome.com/articles/fetch-streaming-requests/

目前其他浏览器不支持(如果您在阅读时已经有支持的情况,请相应地编辑我的回答)

特性检测(来源

const supportsRequestStreams = (() => {
  let duplexAccessed = false;

  const hasContentType = new Request('', {
    body: new ReadableStream(),
    method: 'POST',
    get duplex() {
      duplexAccessed = true;
      return 'half';
    },
  }).headers.has('Content-Type');

  return duplexAccessed && !hasContentType;
})();

需要HTTP版本>=2

如果连接的HTTP版本是HTTP/1.x,则请求将被拒绝。


这个回答并没有回答到所提出的问题。这里没有跟踪上传进度。 - undefined
@Andria 答案并没有提供一个可行的例子,没错。但是链接的文章中有。随意编辑我的答案。 - undefined

6
我认为这是不可能的。草案中写道:

与XHR相比,它在请求进度方面目前还存在缺陷。


(旧答案):
Fetch API章节中的第一个示例提供了一些关于如何执行以下操作的见解:

新答案: Fetch API章节中的第一个示例提供了一些关于如何执行以下操作的见解:

If you want to receive the body data progressively:

function consume(reader) {
  var total = 0
  return new Promise((resolve, reject) => {
    function pump() {
      reader.read().then(({done, value}) => {
        if (done) {
          resolve()
          return
        }
        total += value.byteLength
        log(`received ${value.byteLength} bytes (${total} bytes in total)`)
        pump()
      }).catch(reject)
    }
    pump()
  })
}

fetch("/music/pk/altes-kamuffel.flac")
  .then(res => consume(res.body.getReader()))
  .then(() => log("consumed the entire body without keeping the whole thing in memory!"))
  .catch(e => log("something went wrong: " + e))
除了使用Promise构造函数反模式之外,你可以看到response.body是一个流,你可以使用Reader逐字节读取它,并且你可以为每个字节触发事件或执行任何你想要的操作(例如记录进度)。

然而,Streams规范似乎还没有完全完成,我不知道这是否已经在任何fetch实现中起作用。


22
如果我正确理解了那个示例,那么它是用于通过 fetch 下载文件的。我对上传文件的进度指示符感兴趣。 - neezer
哎呀,那个引用讲的是接收字节,把我搞糊涂了。 - Bergi
1
@Bergi 注意,Promise 构造函数不是必需的。Response.body.getReader() 返回一个 Promise。请参见 如何解决下载大型 JSON 时出现的 Uncaught RangeError - guest271314
3
是的,我已经在引用的源代码处进行修正了。至于你链接的帖子与此无关,getReader并不返回一个Promise。 - Bergi
@Bergi 是的,您说得对,.getReader().read() 方法返回一个 Promise。这就是我想传达的内容。链接旨在提醒大家,如果可以检查下载进度,也可以检查上传进度。组合一个模式,以相当程度返回预期结果;这就是 fetch() 上传进度的进展。在 jsfiddle 上找不到回显 BlobFile 对象的方法,可能错过了一些简单的东西。在本地主机上测试上传文件非常快,没有模拟网络条件; 虽然刚刚想到了 Network throttling - guest271314

4

由于没有一个答案可以解决问题。

仅供实现参考,您可以检测上传速度使用一些已知大小的小块,上传时间可以用content-length/upload-speed计算。您可以将此时间用作估计。


3
非常聪明,这是一种很好的技巧,在我们等待实时解决方案的同时使用 :) - Magix
32
对我来说风险太大了,不想像Windows文件复制进度条一样最终失败。 - Jack G
2
不可靠,复杂且会显示错误的值。 - zdm

0
一种可能的解决方法是利用new Request()构造函数,然后检查Request.bodyUsed Boolean属性。

bodyUsed属性的getter如果disturbed则返回true,否则返回false。

通过判断流是否distributed来确定是否使用.then()链接到递归.read()调用的ReadableStream中的fetch() Promise

实现Body mixin的对象如果body非空且其streamdisturbed,则称为disturbed

请注意,该方法不会读取Request.body的字节,因为字节会被流式传输到终点。此外,在完全返回响应之前,上传可能已经完成。

const [input, progress, label] = [
  document.querySelector("input")
  , document.querySelector("progress")
  , document.querySelector("label")
];

const url = "/path/to/server/";

input.onmousedown = () => {
  label.innerHTML = "";
  progress.value = "0"
};

input.onchange = (event) => {

  const file = event.target.files[0];
  const filename = file.name;
  progress.max = file.size;

  const request = new Request(url, {
    method: "POST",
    body: file,
    cache: "no-store"
  });

  const upload = settings => fetch(settings);

  const uploadProgress = new ReadableStream({
    start(controller) {
        console.log("starting upload, request.bodyUsed:", request.bodyUsed);
        controller.enqueue(request.bodyUsed);
    },
    pull(controller) {
      if (request.bodyUsed) {
        controller.close();
      }
      controller.enqueue(request.bodyUsed);
      console.log("pull, request.bodyUsed:", request.bodyUsed);
    },
    cancel(reason) {
      console.log(reason);
    }
  });

  const [fileUpload, reader] = [
    upload(request)
    .catch(e => {
      reader.cancel();
      throw e
    })
    , uploadProgress.getReader()
  ];

  const processUploadRequest = ({value, done}) => {
    if (value || done) {
      console.log("upload complete, request.bodyUsed:", request.bodyUsed);
      // set `progress.value` to `progress.max` here 
      // if not awaiting server response
      // progress.value = progress.max;
      return reader.closed.then(() => fileUpload);
    }
    console.log("upload progress:", value);
    progress.value = +progress.value + 1;
    return reader.read().then(result => processUploadRequest(result));
  };

  reader.read().then(({value, done}) => processUploadRequest({value,done}))
  .then(response => response.text())
  .then(text => {
    console.log("response:", text);
    progress.value = progress.max;
    input.value = "";
  })
  .catch(err => console.log("upload error:", err));

}

5
这完全没有任何作用。这只是一种非常复杂的语法,用于显示进度/旋转图标,并在请求完成后将其隐藏。 - Vočko

-1

目前(2023年)有一个NPM包可以升级fetch,使其监控进度变得非常简单。它被称为fetch-progress,可以通过npmjs获取。我发现它非常有帮助。

以下是他们文档中给出的示例,说明了它的简洁性:

fetch(this.props.src)
    .then(
      fetchProgress({
        // implement onProgress method
        onProgress(progress) {
          console.log({ progress });
          // A possible progress report you will get
          // {
          //    total: 3333,
          //    transferred: 3333,
          //    speed: 3333,
          //    eta: 33,
          //    percentage: 33
          //    remaining: 3333,
          // }
        },
      })
    )

1
这是用于下载进度,而不是上传。 - Guilherme Bernal

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接