Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

  1. 前言

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

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

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

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

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

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

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

{  "user": {    "id": 1,    "name": "John Doe",    "posts": "_$1"  }}

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

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

{  "_$1": [    { "id": 101, "title": "First Post", "content": "..." },    { "id": 102, "title": "Second Post", "content": "..." }  ]}

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

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

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

200 行就可以!

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

  1. 服务端实现

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

首先是服务端函数。

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)时,我们将其存储到队列中,并在准备就绪后,将其作为单独的数据块发送。

函数如下:

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] ofObject.entries(node)) {        out[key] = walk(val);      }      return out;    }    return node;  }return walk(value);}

函数递归遍历数据对象。

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

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

这是注册 Promise 的代码:

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函数负责将解析后的数据发送给客户端:

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,它就会结束响应,从而关闭与客户端的连接。

完整的实现代码点击这里。

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

const data = {user: {    id: 1,    name: "John Doe",    posts: fetchPostsFromDatabase(), // 返回一个 promise  },};asyncfunction 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 对象。意味着评论数据将在文章数据发送后,作为单独的片段发送。

  1. 客户端实现

那客户端该如何实现呢?

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

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

核心逻辑如下:

try {    const res = await fetch(endpoint);    const reader = res.body.getReader();    const decoder = new TextDecoder();    asyncfunction 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);    thrownewError(`Failed to fetch data from Streamson endpoint ${endpoint}`);  }}

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

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

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

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

function walk(node) {if (isPromisePlaceholder(node)) {    returnnewPromise((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] ofObject.entries(node)) {      out[key] = walk(val);    }    return out;  }return node;}function isPromisePlaceholder(val) {returntypeof val === "string" && val.match(/^_\$(\d)/);}

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

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

  1. NPM 包

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

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

通过 npm 安装:npm intall streamson

服务端上使用:

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 文件,地址:https://unpkg.com/streamson@latest/dist/streamson.min.js

客户端使用如下:

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
  1. 最后

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

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

  • 我是 ssh,工作 6 年 +,阿里云、字节跳动 Web infra 一线拼杀出来的资深前端工程师 + 面试官,非常熟悉大厂的面试套路,Vue、React 以及前端工程化领域深入浅出的文章帮助无数人进入了大厂。

  • 欢迎长按图片加 ssh 为好友,我会第一时间和你分享前端行业趋势,学习途径等等。2025 陪你一起度过!

  • 关注公众号,发送消息:

    指南,获取高级前端、算法学习路线,是我自己一路走来的实践。

    简历,获取大厂简历编写指南,是我看了上百份简历后总结的心血。

    面经,获取大厂面试题,集结社区优质面经,助你攀登高峰

因为微信公众号修改规则,如果不标星或点在看,你可能会收不到我公众号文章的推送,请大家将本公众号星标,看完文章后记得点下赞或者在看,谢谢各位!