背景
如果业务开发中,有一个接口需要返回的信息比较多,比较比较耗时。那么可以采用流式获取信息的方式去更快的处理数据,让用户知道页面正在响应处理中,增加用户页面留存率,提高用户体验。
模拟场景
- 假设用户需要查询一个内容,通过点击发送请求接口
- 后端接收到请求,处理后响应给前端对应的内容,并且内容比较大量
- 模拟用户网络慢的场景:将浏览器开发者工具设置成为Slow 3G或者自定义创建一个更慢,效果更明显,如上传/下载10b,延迟800ms
- 客户端通过接口返回获取到内容进行页面展示
如果用户等待时间过长,用户可能会误以为页面死机而刷新页面或者关闭页面,体验极差
服务器部分代码:
// server.js
const http = require("http");
const fs = require("fs"); // 引入文件系统模块
const hostname = "localhost";
const port = 3006;
// mock文案
const text = `Node.js 是一个开源和跨平台的 JavaScript 运行时环境。
它是几乎任何类型项目的流行工具!Node.js 在浏览器之外运行 V8 JavaScript 引擎(Google Chrome 的内核)。
这使得 Node.js 非常高效。
Node.js 应用在单个进程中运行,无需为每个请求创建新线程。
Node.js 在其标准库中提供了一组异步 I/O 原语,以防止 JavaScript 代码阻塞,并且通常,Node.js 中的库是使用非阻塞范例编写的,这使得阻塞行为成为异常而不是常态。
当 Node.js 执行 I/O 操作时,如从网络读取、访问数据库或文件系统,Node.js 不会阻塞线程和浪费 CPU 周期等待,而是会在响应返回时恢复操作。
这使得 Node.js 可以使用单个服务器处理数千个并发连接,而不会引入管理线程并发的负担(这可能是错误的重要来源)。
Node.js 具有独特的优势,因为数百万为浏览器编写 JavaScript 的前端开发者现在除了客户端代码之外,还能够编写服务器端代码,而无需学习完全不同的语言。
在 Node.js 中,可以毫无问题地使用新的 ECMAScript 标准,因为你不必等待所有用户更新他们的浏览器 - 你负责通过更改 Node.js 版本来决定使用哪个 ECMAScript 版本, 你还可以通过运行带有标志的 Node.js 来启用特定的实验性特性。`;
const server = http.createServer((req, res) => {
if (req.method === "GET" && (req.url === "/" || req.url === "/index")) {
fs.readFile("./index.html", (err, data) => {
if (err) {
console.log(err);
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain");
res.end("Internal Server Error");
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
res.end(data);
});
} else if (req.method === "GET" && req.url === "/push") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(text + text + text + text); // 为模拟大体量数据 可以增加
} else {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain");
res.end("Not Found");
}
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
客户端两种方案对比
一、全量返回后完整读取
全量获取方案源码
这种方案也是我们非常常用的方案,获取到响应体全部内容后再进行处理。
// index.html截取部分核心代码
const textArea = document.getElementById("textArea");// textView 展示内容
const fetchFunction = async () => {
const url = "http://localhost:3006/push";
const textArea = document.getElementById("textArea");
const res = await fetch(url, {
method: "GET",
});
// 执行到这里,此时响应头已经响应了,但是响应体还没完全到完
// 使用该方法,意思是等待响应完全到达,这一步如果内容很大,响应时间会耗时很长
console.time("全量耗时");
const data = await res.text();
console.timeEnd("全量耗时"); // 全量: 3606.439208984375 ms
// 此时响应消息已经全部返回
textArea.value += data;
}
从这个demo可知用户点击按钮请求后得等待3.6秒左右才能看到内容区变化。
二、流式获取方案源码
这个demo是通过不断的解码res.body可读流,将解码后的内容通过while循环不断的存入,最后完成。
// index.html截取部分核心代码
const textArea = document.getElementById("textArea");// textView 展示内容
const fetchFunction = async () => {
const url = "http://localhost:3006/push";
const textArea = document.getElementById("textArea");
const res = await fetch(url, {
method: "GET",
});
// 我们可以通过打印发现,res.body是一个可读流,那么是可以进行分块读取的
console.log(res.body)
console.time("fetch流式耗时");
const reader = res.body.getReader();
// 需要将字节数组解码成文字
const decoder = new TextDecoder();
// 不断循环解析块内容,并且设置进内容区
while (true) {
// done代表是否读完,布尔值 value代表当前读到哪一块,是一个字节数组
const { done, value } = await reader.read();
// console.log(`当前块的大小: ${value.byteLength}`);
if (done === true) {
// 完成全量响应解析,中断解析
break;
}
console.timeEnd("fetch流式耗时"); // fetch流式耗时: 1199.889892578125 ms
const decodeText = decoder.decode(value);
textArea.value += decodeText;
}
}
从这个demo可知用户点击按钮请求后只需要等待1.2秒左右就可以看到内容区变化。所需耗时只有全量获取方案1/3。
-
流式获取中每一块的数据量大小,和系统的缓冲区有关。 如果测试demo没有生效,那么可以将服务端返回内容文本增加、也可以将网络速度调慢。
-
另外你还可以自己创建一个缓冲区,将以及解码的数据放入到缓冲区中,再从缓冲区取数据,使得展示顺序更加丝滑有序
不支持fetch的兼容方案-ajax
该方案比较复杂,用于兼容不支持fetch的浏览器方案,因为ajax的onprogress方法的回调每次都不是返回当前块,而是返回一个包含当前块内容以及之前块内容的块。所以需要添加一个变量,用来记录上次切割结束的位置,使其每次回调都做切割处理,保证和fetch一样返回当前块,用于流式数据展示。
// index.html截取部分核心代码
const fetchFunction = async () => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
let lastProcessedIndex = 0; // 记录上次处理的数据片段位置
xhr.onloadstart = () => {
// 计时
console.time("ajax流式耗时");
};
xhr.onprogress = function (event) {
if (event.lengthComputable) {
const loaded = event.loaded;
const total = event.total;
const progress = (loaded / total) * 100;
console.log(`Progress: ${progress}%`);
}
const responseData = event.target.response;
// 每次返回的都得包含当前切片和之前切片的数据,所以需要记录上次处理位置,处理新的数据片段
processNewData(responseData);
};
function processNewData(accumulatedData) {
// 检查是否有新的数据片段需要处理
if (lastProcessedIndex < accumulatedData.length) {
// 从已经处理过的整块中截取新的数据片段
const newData = accumulatedData.substring(lastProcessedIndex);
console.timeEnd("ajax流式耗时"); // ajax流式耗时: 2882.178955078125 ms
// 在这里处理 newData,例如添加到 textarea 中
textArea.value += newData;
// 更新上次处理的位置
lastProcessedIndex = accumulatedData.length;
}
}
xhr.onerror = function () {
console.error("Error:", xhr.statusText);
};
xhr.send();
}
不过该方案的耗时不太理想,与字符串的操作耗时有关系,不过对比全量获取方案,依然更优,该方案所需耗时为全量获取方案的80%。
方案优缺点对比选用
- 流式获取能更快让响应数据得到处理
- 流式获取的响应快慢会受网络状态、返回数据量体积的影响,非常适合大体量数据和网络状态差的情况下使用
- 流式获取不太适合需要序列格式化内容返回,比如json只有部分返回的话,解析JSON.parse()会报错。
- 流式获取兼容良好,也可以通过封装ajax来兼容不支持fetch的浏览器