前言
在开发中,我们经常会有上传和下载的需求,大多数时候并没有实现对接口进度的检测,但是把进度条做出来,对用户的体验无疑是加分的
在浏览器中,请求接口有两个对象,分别是 fetch、XMLHttpRequest。这两个对象目前支持的能力,如下图所示:
可以看到 XHR 和 Fetch 都可以监控响应进度,借此可以实现下载进度的监控。但是对于请求进度只有 XHR 才可以做到,也就是说想做上传进度监控,只能用 XHR。
在现代前端编程,用的最多的 axios,而在浏览器环境中,axios 就是基于 xhr 对象,所以 axios 也就具有监控上传进度和下载进度的能力。
new Axios({
url: '/',
method: 'post',
onUploadProgress:()=>{},
onDownloadProgress:()=>{}
})
在 request config 中传入两个 progress 回调函数,就可以实现了,非常简单。
然而这篇文章不是分享用 axios 来做监控进度,而是用原生的 API 来做
Fetch
首先用大家熟悉的 Fetch 对象来实现下载进度的监控
先创建一个非常简单的 Demo,不用服务器就能实现一个下载的功能--下载图片--即用 fetch 请求一个图片。
在网上随便找了一个图片:
然后准备一个 html,用来显示进度条和图片:
<div class="process-container">
<div class="process">
<div class="process-line"></div>
</div>
<span class="process-text"></span>
</div>
静态状态下是这样:
我想做的是,这个图片在页面打开的时候,就开始用 fetch 请求该图片,然后监控同步更新进度条的长度。先准备一个 fetch 请求的函数:
function fetchRequest(
url,
stepCallback = () => null,
completeCallback = () => null
) {
//...
}
这个函数接受三个参数:
- 请求的 url
- 每个进度反馈的回调函数,每拿到一个响应的 chunk,这个函数就会被调用
- 响应结束的回调函数
调用这个函数,并且传入改变进度条的回调函数:
// 处理进度条长度,以及进度文字更新
function calcLine(percent) {
const line = document.querySelector(".process-line");
line.style.width = percent * 300 + "px";
const processText = document.querySelector(".process-text");
if (percent == 1) {
processText.innerHTML = "100%";
} else {
processText.innerHTML = (percent * 100 + "").substring(0, 2) + "%";
}
}
// 负责显示图片
function displayImg(chunks) {
const blob = new Blob(chunks, { type: "image/jpeg" });
const url = URL.createObjectURL(blob);
const img = document.createElement("img");
img.src = url;
document.body.appendChild(img);
}
// 初始函数
async function action() {
fetchRequest(img, calcLine, displayImg);
}
action();
在 action 函数中调用fetchRequest函数,然后传入进度监控的回调函数,以及响应完成的回调函数。
看看效果:
GIF 录制工具有点偏色,大概就是这个效果
想让进度条走得慢些,有个小妙招,在开发者工具里设置NetWork的网络环境,设为Slow 3G,进度条就很慢了
函数实现:
function fetchRequest(
url,
stepCallback = () => null,
completeCallback = () => null
) {
let totalSize = 0;
return new Promise((resolve, reject) => {
fetch(url)
.then((res) => {
if (!res.ok) reject();
totalSize = res.headers.get("content-length");
totalSize = totalSize ? parseInt(totalSize, 10) : 0;
return res.body;
})
.then((body) => body.getReader())
.then(async (reader) => {
let result = [];
let loadSize = 0;
while (1) {
const { done, value } = await reader.read();
if (done) {
console.log("success");
break;
}
loadSize += value.byteLength;
stepCallback(loadSize / totalSize);
result.push(value);
}
// loadImg(result);
completeCallback(result);
resolve(result);
});
});
}
函数的定义和调用都不难,一看就懂,难的是如何在 chunk 的粒度上接收后端的响应数据,或者说采用流的方式接收响应数据。
fetch 中提供了这样的能力,通过body.getReader(),就可以得到一个浏览器环境下的可读流。开发者就可以通过这个可读流来一段一段地接收数据
可读流的用法:
while (1) {
const { done, value } = await reader.read();
if (done) {
console.log("success");
break;
}
loadSize += value.byteLength;
stepCallback(loadSize / totalSize);
result.push(value);
}
通过reader.read(),在死循环中不断读取可读流中的内容。返回的结果有点像迭代器,有 done,表示内容是否被读取完,value 表示读取到的具体内容,value 的类型是 arrayBuffer,所以要通过value.byteLength读取内容的长度,而不是value.length。
在循环的尾部,用一个数组将一段一段的 value 收集起来,等收集完毕,就全部传递给completeCallback
可读流的参考链接:ReadableStream - Web API | MDN
XMLHttpRequest
XMLHttpRequest 相对就要更古老了,从 Api 就可以看出,该对象是古老的事件触发机制,还支持神奇的同步请求。
简单用法
Get 请求:
const url = 'http://example';
const xhr = new XMLHttpRequest();
xhr.open('get', url, true);
xhr.send(null);
open 第三个参数为布尔值,如果为 true, 表示请求是异步;如果为 false,表示请求是同步的。这很有意思。
Post 请求:
const url = 'http://example/upload';
const xhr = new XMLHttpRequest();
xhr.open('post', url, true);
const form = document.getElementById('form');
xhr.send(new FormData(form));
xhr.onload = () => {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
const res = xhr.response;
console.log('successful: ',res);
} else {
reject(xhr.status);
}
};
下载监控
下载监控和 Fetch 实现用的例子相同,还是请求一张图片,并且在页面上显示出来。html 和之前一致,只需要修改请求函数:
function xmlRequest(
url,
stepCallback = () => null,
completeCallback = () => null
) {
//...
}
xmlRequest 函数是用 xmlHttpRequest 发起的请求,接收的参数和上文的 fetchRequest 函数一致。所以调用方法也和 fetchRequest 一致
async function action() {
// const chunk = await fetchRequest(img, calcLine);
let chunk = await xmlRequest(img, calcLine); // 调用XMLHttpRequest
chunk = [chunk];
displayImg(chunk);
}
action();
函数实现:
export default function xmlRequest(
url,
stepCallback = () => null,
completeCallback = () => null
) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.responseType = "blob";
xhr.onload = () => {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
const res = xhr.response;
completeCallback(res);
resolve(res);
} else {
reject(xhr.status);
}
};
xhr.onprogress = (e) => {
if (e.lengthComputable) {
stepCallback(e.loaded / e.total);
}
};
xhr.open("get", url, true);
xhr.send(null);
});
}
要点:
- 用 onprogress 回调函数监控进度,每收到后端一段响应数据,progress 事件就会被触发
- 当响应结束, onload 就会被调用
- xmlRequest 中采用了 Promise,所以也可以通过
.then方式获取响应数据
最后页面效果和上面的一致:
上传监控
实现上传监控的,就是 XHR 的拿手好戏了
我们准备一个服务器用来接收前端上传的文件:
const http = require("http");
const { formidable } = require("formidable");
const path = require("path");
const { getFilesName } = require("./utils");
/**
* @param {http.IncomingMessage} req
* @param {http.ServerResponse<http.IncomingMessage>} res
*/
async function setCors(req, res) {
res.setHeader("Access-Control-Allow-Origin", "*");
}
/**
* @param {http.IncomingMessage} req
* @param {http.ServerResponse<http.IncomingMessage>} res
*/
async function handlePost(req, res) {
const form = formidable({ allowEmptyFiles: true, minFileSize: 0 });
form.uploadDir = path.join(__dirname, "./upload");
form.keepExtensions = true;
try {
await form.parse(req);
res.statusCode = 200;
res.end(JSON.stringify({ status: "success", files: getFilesName(files) }));
} catch (error) {
console.error(error);
res.writeHead(400, { "Content-Type": "text/plain" });
res.end(JSON.stringify(error));
}
}
/**
* @param {http.IncomingMessage} req
* @param {http.ServerResponse<http.IncomingMessage>} res
*/
function handleGet(req, res) {
function renderFile(filePath) {
filePath = path.resolve(__dirname, filePath);
if (fs.existsSync(filePath)) {
// 如果是js文件,需要设置响应头
if (filePath.endsWith("js")) {
res.setHeader("content-type", "text/javascript");
}
fs.createReadStream(filePath).pipe(res);
} else {
renderFile("../index.html");
}
}
let filePath = req.url == "/" ? "/index.html" : req.url;
renderFile(".." + filePath);
}
const server = http.createServer((req, res) => {
setCors(req, res);
if (req.method.toLowerCase() == "post") {
handlePost(req, res);
} else {
handleGet(req, res);
}
});
const port = 3000;
server.listen(port, () => {
console.log("server listen: ", port);
});
handleGet专门用来处理 Get 请求,函数中的主要用来向前端返回 HTML 文件以及文件中依赖的 script 文件。
handlePost转门用来处理 Post 请求。函数中借助了第三方库formidable来解析 form 表单,并且将表单中文件自动保存至uploadDir的目录中。
前端上传代码:
html 内容:
<form id="formEl">
<div class="form-item">
<span class="label">Select Png: </span>
<input type="file" multiple name="png" accept=".png,.jpg,.jpeg" />
</div>
<div class="form-item">
<span class="label">Select MD: </span>
<input type="file" multiple name="md" accept=".md" />
</div>
<div class="form-item">
<input type="submit" value="提交" class="submit-input" />
</div>
</form>
<script type="module" src="./upload.js"></script>
为了代码更清晰,这里利用了浏览器支持的 ESModule 模块化方式构建 js 代码。
前端代码
upload.js
import xmlRequest from "./xmlRequest.js";
const formEl = document.getElementById("formEl");
formEl.onsubmit = async (e) => {
// 阻止默认行为
e.preventDefault();
// 当前同源下的路径
const url = "/upload";
await xmlRequest(url, new FormData(formEl), {
startCallback: displayProcess,
stepCallback: calcLine,
});
finishProcess();
};
/**
* 以下皆为控制进度条的内容
*/
function displayProcess() {
// ...
}
function calcLine(percent) {
// ...
}
function finishProcess() {
// ...
}
要点:
- 因为 html 文件是后端直接返回的,所以上传的路径可以直接用同源的
/upload,即实际访问localhost:3000/upload。一旦同源,也就没有了跨域的烦恼了 - 上传表单的时候,直接用
FormData构造函数处理 form dom,这是原生 js 所支持的,极大地简化了表单上传的代码
xmlRequest.js
/**
*
* @param {string} url
* @param {{stepCallback: Function, startCallback: Function}} reqData
* @param {{stepCallback: Function, completeCallback: Function}} resData
*/
export default function xmlRequest(url, data, reqData = {}, resData = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// 如果是下载二进制文件,需要指定responseType为blob
// xhr.responseType = "blob";
xhr.upload.onloadstart = () => {
console.log("request start");
reqData?.startCallback?.();
};
// 上传进度触发函数
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
reqData?.stepCallback?.(e.loaded / e.total);
}
};
xhr.onload = () => {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
const res = xhr.response;
resData?.completeCallback?.(res);
resolve(res);
console.log("response success");
} else {
reject(xhr.status);
}
};
xhr.onprogress = (e) => {
if (e.lengthComputable) {
resData?.stepCallback?.(e.loaded / e.total);
}
};
xhr.open("post", url, true);
xhr.send(data);
});
}
上面是xmlRequest的完整代码,其实上传进度监控的核心代码就只有几行:
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
reqData?.stepCallback?.(e.loaded / e.total);
}
};
能如此简单地实现,也是依托于 xmlHttpRequest 能够提供这样的 API 啊🙏🙏
最终效果:
流式展示数据
进度条监控的本质是在于可以一段一段地拿到数据,既然如此,就可以做点更多的事情,比如说流式展示数据
最经典的就是 GPT 问答对话,GPT 回答问题输出文字的时候,是一点一点输出的,很像人在交流的时候,一个字一个字地蹦出来
我们可以模拟下这样的效果:
代码实现
function action() {
const url = "http://localhost:3002";
const container = document.querySelector(".container");
// 接收两个参数,一个百分比进度,一个是当前接收到的chunk
fetchRequest(url, (percent, data) => {
console.log(percent, data);
// 每获取到内容,就要输出到屏幕上
container.innerHTML += data;
});
}
action();
fetchRequest函数里面基本保持不变,只对其中的 stepCallback 的入参做点修改:
while (1) {
const { done, value } = await reader.read();
if (done) {
console.log("success");
break;
}
loadSize += value.byteLength;
+ var decoder = new TextDecoder("utf-8");
+ stepCallback(loadSize / totalSize, decoder.decode(value));
- stepCallback(loadSize / totalSize);
result.push(value);
}
传入 chunk 内容给 stepCallback 的同时,需要讲 araryBuffer 类型的数据转成字符串,这里利用了TextDecoder做处理
流式获取只能用
Fetch对象来做,XMLHttpRequest不支持。
后端代码实现
后端可以控制文字出现的速度:
/**
* @param {http.IncomingMessage} req
* @param {http.ServerResponse<http.IncomingMessage>} res
*/
async function handleGet(req, res) {
const filePath = path.resolve(__dirname, "./welcome.txt");
const fileContent = fs.readFileSync(filePath, "utf-8");
const fileSize = fs.statSync(filePath).size;
let currentIndex = 0;
const chunkSize = 10; // 每次发送10个字符
// 定义一个函数,用来发送数据块
const sendChunk = () => {
if (currentIndex < fileContent.length) {
const chunk = fileContent.slice(currentIndex, currentIndex + chunkSize);
res.write(chunk);
currentIndex += chunkSize;
} else {
console.log("file be sent finish");
clearInterval(intervalId); // 全部发送完毕,清除定时器
res.end(); // 结束响应
}
};
res.setHeader(
"content-length",
fileSize
);
// 使用 setInterval 来控制发送的频率
const intervalId = setInterval(sendChunk, 300); // 每300毫秒发送一次
// 如果客户端中断连接,则清除定时器
res.on("close", () => {
clearInterval(intervalId);
});
}
要点:
- 这里控制的速度是,每 300 毫秒发送一次数据
- 使用
res.write()传递一次数据,而不是res.end() - 响应数据之前,需要设置响应头
content-length, 该值需要是整个文本的字节长度,而不是字符长度。该值被前端用来计算响应的进度。
本篇文章所有的代码:
总结
在前端开发中,经常会遇见上传和下载。而对该过程的进度监控并显示出来,无疑能极大地提高用户的体验。所以这篇文章与大家分享了,通过前端两大原生的请求对象Fetch、XMLHttpRequest,实现上传和下载的进度监控。
Fetch 中的进度监控是用可读流来实现的,文末又利用了可读流来实现 GPT 对话式的流式展示数据,相信大家对可读流会有更深的理解