前端JS流式处理接口响应方案和使用场景对比

1,101 阅读5分钟

背景

如果业务开发中,有一个接口需要返回的信息比较多,比较比较耗时。那么可以采用流式获取信息的方式去更快的处理数据,让用户知道页面正在响应处理中,增加用户页面留存率,提高用户体验。

模拟场景

  1. 假设用户需要查询一个内容,通过点击发送请求接口
  2. 后端接收到请求,处理后响应给前端对应的内容,并且内容比较大量
  3. 模拟用户网络慢的场景:将浏览器开发者工具设置成为Slow 3G或者自定义创建一个更慢,效果更明显,如上传/下载10b,延迟800ms
  4. 客户端通过接口返回获取到内容进行页面展示

如果用户等待时间过长,用户可能会误以为页面死机而刷新页面或者关闭页面,体验极差

服务器部分代码:

// 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没有生效,那么可以将服务端返回内容文本增加、也可以将网络速度调慢。

  • 另外你还可以自己创建一个缓冲区,将以及解码的数据放入到缓冲区中,再从缓冲区取数据,使得展示顺序更加丝滑有序

image.png

不支持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%。

方案优缺点对比选用

  1. 流式获取能更快让响应数据得到处理
  2. 流式获取的响应快慢会受网络状态返回数据量体积的影响,非常适合大体量数据和网络状态差的情况下使用
  3. 流式获取不太适合需要序列格式化内容返回,比如json只有部分返回的话,解析JSON.parse()会报错。
  4. 流式获取兼容良好,也可以通过封装ajax来兼容不支持fetch的浏览器