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 点值得注意:
- 我们使用了
application/x-ndjson内容类型。
NDJSON,全拼 Newline Delimited JSON,其实就是一种换行符分割的 JSON,其中每一行都是一个有效的 JSON 对象。这允许我们在单个响应中发送多个 JSON 对象,并以换行符分隔。
- 我们使用了
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 应用感知性能的有效方法,尤其适用于处理大型数据集或动态生成数据。
通过在数据可用时立即发送部分数据,我们可以让客户端更早地开始渲染内容,从而带来更佳的用户体验。