前端性能革命:200 行 JavaScript 代码实现 Streaming JSON

1. 前言

5 月的时候,React 的核心开发者 Dan 发表了一篇名为《Progressive JSON》 的文章,介绍了一种将 JSON 数据从服务器流式传输到客户端的技术,允许客户端在接收到全部数据之前就开始渲染部分数据。

这可以显著提升用户体验,尤其是处理大型数据集时。

让我们以"获取用户文章"这个场景为例。

这是一个完整的数据结构:

json 复制代码
{
  "user": {
    "id": 1,
    "name": "John Doe",
    "posts": [
      { "id": 101, "title": "First Post", "content": "..." },
      { "id": 102, "title": "Second Post", "content": "..." }
    ]
  }
}

假设我们能够很快获取用户信息,但文章数据还需要一段时间从数据库获取。

与其等待数据完全加载完毕,不如先发送一个占位符表示文章字段:

json 复制代码
{
  "user": {
    "id": 1,
    "name": "John Doe",
    "posts": "_$1"
  }
}

客户端收到数据后,先将用户信息渲染出来。

然后,当文章数据准备完毕后,我们将文章数据作为一个单独的 chunk 发送:

json 复制代码
{
  "_$1": [
    { "id": 101, "title": "First Post", "content": "..." },
    { "id": 102, "title": "Second Post", "content": "..." }
  ]
}

客户端收到数据后,最后将文章数据渲染出来。

要实现这样一个功能,客户端需要具备处理这些占位符的能力,并在最终数据到达时替换为实际数据。

如果要实现这样一个单独的功能需要多少代码呢?

200 行就可以!

本篇文章和大家介绍下实现思路,供大家学习和思考使用。

2. 服务端实现

让我们来看下服务器端实现。

首先是服务端函数。

javascript 复制代码
function serve(res, data) {
  res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
  res.setHeader("Transfer-Encoding", "chunked");

  // 向客户端发送 chunks
  res.write(JSON.stringify(...) + "\n");
  res.write(JSON.stringify(...) + "\n");

  // 当完成的时候
  res.end();
}

这里有 2 点值得注意:

  1. 我们使用了 application/x-ndjson内容类型。

NDJSON,全拼 Newline Delimited JSON,其实就是一种换行符分割的 JSON,其中每一行都是一个有效的 JSON 对象。这允许我们在单个响应中发送多个 JSON 对象,并以换行符分隔。

  1. 我们使用了 Transfer-Encoding: chunked响应头。

使用该响应头,可以通知客户端,响应将分块发送。在调用 res.end()之前,请保持连接活跃状态。

其次,我们需要对数据进行分块。

实现方式也很简单,遍历数据对象,并用占位符替代那些暂时没有准备好的部分。

当遇到需要稍后发送的部分(一个 Promise)时,我们将其存储到队列中,并在准备就绪后,将其作为单独的数据块发送。

函数如下:

javascript 复制代码
function normalize(value) {
  function walk(node) {
    if (isPromise(node)) {
      const id = getId();
      registerPromise(node, id);
      return id;
    }
    if (Array.isArray(node)) {
      return node.map((item) => walk(item));
    }
    if (node && typeof node === "object") {
      const out = {};
      for (const [key, val] of Object.entries(node)) {
        out[key] = walk(val);
      }
      return out;
    }
    return node;
  }
  return walk(value);
}

函数递归遍历数据对象。

当遇到 Promise 时,它会生成一个唯一的占位符 ID,注册该 Promise 以便稍后解析,并返回该占位符。

对于数组和对象,它会递归处理它们的元素或属性。原始值将按原样返回。

这是注册 Promise 的代码:

javascript 复制代码
let promises = [];

function registerPromise(promise, id) {
  promises.push({ promise, id });
  promise.then((value) => {
    send(id, value);
  }).catch((err) => {
    console.error("Error resolving promise for path", err);
    send(id, { error: "promise error", timeoutMs: TIMEOUT });
  });

这是 send 的代码,send函数负责将解析后的数据发送给客户端:

javascript 复制代码
function send(id, value) {
  res.write(JSON.stringify({ i: id, c: normalize(value) }) + "\n");
  promises = promises.filter((p) => p.id !== id);
  if (promises.length === 0) res.end();
}

send 函数会向响应中写入一个新的数据块,其中包括占位符 ID 和 normalize 后的值。然后它会从队列中移除已经 resolve 的 Promise。如果没有其他要处理的 Promise,它就会结束响应,从而关闭与客户端的连接。

完整的实现代码点击这里

最后,我们举一个从服务端发送的对象示例:

javascript 复制代码
const data = {
  user: {
    id: 1,
    name: "John Doe",
    posts: fetchPostsFromDatabase(), // 返回一个 promise
  },
};

async function fetchPostsFromDatabase() {
  const posts = await database.query("SELECT * FROM posts WHERE userId = 1");
  return posts.map((post) => ({
    id: post.id,
    title: post.title,
    content: post.content,
    comments: fetchCommentsForPost(post.id), // 返回一个 promise
  }));
}

每篇文章还有一个评论字段(comments),该字段是一个 Promise 对象。意味着评论数据将在文章数据发送后,作为单独的片段发送。

3. 客户端实现

那客户端该如何实现呢?

在客户端,我们处理传入的数据块,并将占位符替换为实际数据。

我们可以使用 Fetch API 向服务器发送请求,并将响应读取为流。每当遇到占位符时,我们都会将其替换为一个 Promise,该 Promise 将在实际数据到达时解析。

核心逻辑如下:

javascript 复制代码
try {
    const res = await fetch(endpoint);
    const reader = res.body.getReader();
    const decoder = new TextDecoder();

    async function process() {
      let done = false;
      while (!done) {
        const { value, done: readerDone } = await reader.read();
        done = readerDone;
        if (value) {
          try {
            const chunk = JSON.parse(decoder.decode(value, { stream: true }));
            chunk.c = walk(chunk.c);
            if (promises.has(chunk.i)) {
              promises.get(chunk.i)(chunk.c);
              promises.delete(chunk.i);
            }
          } catch (e) {
            console.error(`Error parsing chunk.`, e);
          }
        }
      }
    }
    process();
  } catch (e) {
    console.error(e);
    throw new Error(`Failed to fetch data from Streamson endpoint ${endpoint}`);
  }
}

对流的处理,你可能感到陌生,可以拓展阅读我的这篇文章:《如何用 Next.js v14 实现一个 Streaming 接口?》

process 函数逐块读取响应流。每个数据块都被解析为 JSON,并调用 walk 函数将占位符替换为 Promise。

如果数据块包含先前注册的占位符 ID ,则相应的 Promise 会被解析为接收到的数据。关键在于 await reader.read(),它允许我们等待新数据到来。

walk函数用于将占位符替换为 Promise:

javascript 复制代码
function walk(node) {
  if (isPromisePlaceholder(node)) {
    return new Promise((done) => {
      promises.set(node, done);
    });
  }
  if (Array.isArray(node)) {
    return node.map((item) => walk(item));
  }
  if (node && typeof node === "object") {
    const out = {};
    for (const [key, val] of Object.entries(node)) {
      out[key] = walk(val);
    }
    return out;
  }
  return node;
}
function isPromisePlaceholder(val) {
  return typeof val === "string" && val.match(/^_\$(\d)/);
}

类似于服务端的 normalize 函数。当遇到占位符的时候,它会返回一个新的 Promise,该 Promise 将在实际数据到达时解析。对于数组和对象,它会递归处理它们的元素或属性。原始值则直接返回。当然,ID 必须与服务器端生成的 ID 匹配。

完整的实现代码点击这里。两个文件加起来一共 155 行代码。

4. NPM 包

本篇文章整理翻译自 Streaming JSON in just 200 lines of JavaScript

作者还将代码整理成了一个 NPM 包:Streamson

通过 npm 安装:npm intall streamson

服务端上使用:

javascript 复制代码
import { serve } from "streamson";
import express from "express";

const app = express();
const port = 5009;

app.get("/data", async (req, res) => {
  const myData = {
    title: "My Blog",
    description: "A simple blog example using Streamson",
    posts: getBlogPosts(), // this returns a Promise
  };
  serve(res, myData);
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

客户端是一个 1KB 的 JavaScript 文件,地址:unpkg.com/streamson@l...

客户端使用如下:

javascript 复制代码
const request = Streamson("/data");

const data = await request.get();
console.log(data.title); // "My Blog"

const posts = await request.get("posts");
console.log(posts); // Array of blog posts

5. 最后

作为准前端开发专家的你,第一时间获取前端资讯、技术干货、AI 课程,那不得关注下我的公众号「冴羽」。

流式传输 JSON 数据是一种提升 Web 应用感知性能的有效方法,尤其适用于处理大型数据集或动态生成数据。

通过在数据可用时立即发送部分数据,我们可以让客户端更早地开始渲染内容,从而带来更佳的用户体验。

相关推荐
2501_944711435 小时前
构建 React Todo 应用:组件通信与状态管理的最佳实践
前端·javascript·react.js
困惑阿三6 小时前
2025 前端技术全景图:从“夯”到“拉”排行榜
前端·javascript·程序人生·react.js·vue·学习方法
苏瞳儿6 小时前
vue2与vue3的区别
前端·javascript·vue.js
weibkreuz7 小时前
收集表单数据@10
开发语言·前端·javascript
hboot7 小时前
别再被 TS 类型冲突折磨了!一文搞懂类型合并规则
前端·typescript
在西安放羊的牛油果7 小时前
浅谈 import.meta.env 和 process.env 的区别
前端·vue.js·node.js
鹏北海7 小时前
从弹窗变胖到 npm 依赖管理:一次完整的问题排查记录
前端·npm·node.js
布列瑟农的星空7 小时前
js中的using声明
前端
薛定谔的猫27 小时前
Cursor 系列(2):使用心得
前端·ai编程·cursor
用户904706683577 小时前
后端问前端:我的接口请求花了多少秒?为啥那么慢,是你慢还是我慢?
前端