overreactedby Dan Abramov

JSX 跨网络传输

April 16, 2025

假设你有一个 API 路由,它以 JSON 的形式返回一些数据:

app.get('/api/likes/:postId', async (req, res) => {
  const postId = req.params.postId;
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  const json = {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes,
  };
  res.json(json);
});

你还有一个 React 组件需要用到这些数据:

function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  let buttonText = 'Like';
  if (totalLikeCount > 0) {
    // 例如:“你、Alice 和其他13人点赞了”
    buttonText = formatLikeText(totalLikeCount, isLikedByUser, friendLikes);
  }
  return (
    <button className={isLikedByUser ? 'liked' : ''}>
      {buttonText}
    </button>
  );
}

你如何把这些数据传递给这个组件?

你可以通过父组件,结合某个数据获取库来传递:

function PostLikeButton({ postId }) {
  const [json, isLoading] = useData(`/api/likes/${postId}`);
  // ...
  return (
    <LikeButton
      totalLikeCount={json.totalLikeCount}
      isLikedByUser={json.isLikedByUser}
      friendLikes={json.friendLikes}
    />
  );
}

这是一种常见的思路。

但请再看看你的 API:

app.get('/api/likes/:postId', async (req, res) => {
  const postId = req.params.postId;
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  const json = {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes,
  };
  res.json(json);
});

这些代码是不是让你想起了什么?

Props。你正在传递 props。 只是你还没有指定 传给谁

但你已经知道它们的最终归宿——LikeButton

为什么不直接把它填进去呢?

app.get('/api/likes/:postId', async (req, res) => {
  const postId = req.params.postId;
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  const json = (
    <LikeButton
      totalLikeCount={post.totalLikeCount}
      isLikedByUser={post.isLikedByUser}
      friendLikes={friendLikes}
    />
  );
  res.json(json);
});

现在,LikeButton 的“父组件”变成了 API 本身

等等,什么?

确实有点怪。我们稍后再讨论这样做是否合理。但现在请注意,这样做颠倒了组件与 API 之间的关系。这有时被称为好莱坞原则:“别打电话给我,我会打给你。”

你的组件不会去调用 API。

相反,你的 API 返回 你的组件。

你为什么要这么做?



第一部分:JSON 作为组件

模型、视图、视图模型

我们在存储信息和展示信息之间存在着根本性的张力。通常我们希望存储的信息比展示的要多。

比如,考虑一篇帖子上的点赞按钮。我们存储点赞时,可能会用一张 Like 表来表示:

type Like = {
  createdAt: string, // 时间戳
  likedById: number, // 用户ID
  postId: number     // 帖子ID
};

我们把这种数据称为“模型(Model)”。它代表了数据的原始结构。

type Model = Like;

所以我们的 Likes 数据库表可能长这样:

[{
  createdAt: '2025-04-13T02:04:41.668Z',
  likedById: 123,
  postId: 1001
}, {
  createdAt: '2025-04-13T02:04:42.668Z',
  likedById: 456,
  postId: 1001
}, {
  createdAt: '2025-04-13T02:04:43.668Z',
  likedById: 789,
  postId: 1002
}, /* ... */]

但我们想要展示给用户的内容是不同的。

我们想展示的是该帖子被点赞的数量用户是否已点赞,以及朋友中谁也点赞了。比如,点赞按钮可能会高亮(表示你已点赞),并显示“你、Alice 和其他13人点赞了”或“Alice、Bob 和其他12人点赞了”。

type LikeButtonProps = {
  totalLikeCount: number,
  isLikedByUser: boolean,
  friendLikes: string[]
}

我们把这种数据称为“视图模型(ViewModel)”。

type ViewModel = LikeButtonProps;

视图模型以 UI(即视图)可以直接消费的方式表示数据。它通常与原始模型有很大不同。在我们的例子中:

  • ViewModel 的 totalLikeCount 是从多个 Like 模型聚合而来。
  • ViewModel 的 isLikedByUser 是个性化的,依赖于当前用户。
  • ViewModel 的 friendLikes 既是聚合的也是个性化的。要计算它,你需要筛选出朋友的点赞,并获取前几个朋友的名字(这些名字很可能存储在另一张表中)。

显然,模型最终需要转化为视图模型。问题在于,这种转化在代码的什么地方、什么时候发生,以及这段代码如何随时间演进。


REST 与 JSON API

最常见的解决方式是暴露某种 JSON API,客户端可以通过它组装视图模型。API 的设计方式有很多,但最常见的是所谓的 REST。

通常的 REST 设计思路(假设我们没看过这篇文章)是选定一些“资源”——比如帖子或点赞——然后提供 JSON API 端点来列出、创建、更新和删除这些资源。当然,REST 并不规定资源的具体结构,所以有很大灵活性。

你可能一开始会直接返回模型的结构:

// GET /api/post/123
{
  title: 'My Post',
  content: 'Hello world...',
  authorId: 123,
  createdAt: '2025-04-13T02:04:40.668Z'
}

到目前为止还不错。但如果要把点赞信息加进来呢?也许 totalLikeCountisLikedByUser 可以作为 Post 资源的一部分:

// GET /api/post/123
{
  title: 'My Post',
  content: 'Hello world...',
  authorId: 123,
  createdAt: '2025-04-13T02:04:40.668Z',
  totalLikeCount: 13,
  isLikedByUser: true
}

friendLikes 也要放进去吗?客户端需要这些信息。

// GET /api/post/123
{
  title: 'My Post',
  authorId: 123,
  content: 'Hello world...',
  createdAt: '2025-04-13T02:04:40.668Z',
  totalLikeCount: 13,
  isLikedByUser: true,
  friendLikes: ['Alice', 'Bob']
}

或者说,我们是不是已经开始滥用 Post 的概念,把太多东西塞进去了?那不如单独提供一个获取帖子点赞的端点:

// GET /api/post/123/likes
{
  totalCount: 13,
  likes: [{
    createdAt: '2025-04-13T02:04:41.668Z',
    likedById: 123,
  }, {
    createdAt: '2025-04-13T02:04:42.668Z',
    likedById: 768,
  }, /* ... */]
}

这样,帖子的点赞就成了独立的“资源”。

理论上不错,但我们还需要知道点赞者的名字,又不想为每个点赞单独请求一次用户信息,所以需要在这里“展开”用户:

// GET /api/post/123/likes
{
  totalCount: 13,
  likes: [{
    createdAt: '2025-04-13T02:04:41.668Z',
    likedBy: {
      id: 123,
      firstName: 'Alice',
      lastName: 'Lovelace'
    }
  }, {
    createdAt: '2025-04-13T02:04:42.668Z',
    likedBy: {
      id: 768,
      firstName: 'Bob',
      lastName: 'Babbage'
    }
  }]
}

我们还“忘了”哪些点赞来自朋友。要不要再加一个 /api/post/123/friend-likes 端点?还是在 likes 数组里加 isFriend 字段以区分?或者加 ?filter=friends

或者直接把 friendLikes 放进 Post,避免多次 API 调用?

// GET /api/post/123
{
  title: 'My Post',
  authorId: 123,
  content: 'Hello world...',
  createdAt: '2025-04-13T02:04:40.668Z',
  totalLikeCount: 13,
  isLikedByUser: true,
  friendLikes: [{
    createdAt: '2025-04-13T02:04:41.668Z',
    likedBy: {
      id: 123,
      firstName: 'Alice',
      lastName: 'Lovelace'
    }
  }, {
    createdAt: '2025-04-13T02:04:42.668Z',
    likedBy: {
      id: 768,
      firstName: 'Bob',
      lastName: 'Babbage'
    }
  }]
}

这看起来很有用,但如果 /api/post/123 被其他不需要这些信息的页面调用,岂不是拖慢了速度?也许可以加个参数 /api/post/123?expand=friendLikes

总之,我想表达的不是设计一个好的 REST API 不可能,实际上绝大多数应用都是这么做的,至少可行。但任何设计过 REST API 并维护了几个月的人都知道——演进 REST 端点是一件很痛苦的事

通常流程如下:

  1. 一开始你得决定 JSON 输出的结构。没有哪个方案明显更好,大多只是猜测应用未来如何发展。
  2. 初始决策经过几轮迭代后趋于稳定……直到下次 UI 改版,导致 ViewModel 结构略有变化,现有的 REST 端点又不太适用了。
  3. 可以新增 REST API 端点,但到某个阶段你“不应该”再加了,因为所有资源都已定义。例如 /posts/123 已有,就不会再加另一个“获取帖子”的 API。
  4. 于是你要么计算和发送的数据不够,要么太多。你要么在现有资源里激进地“展开”字段,要么想出一套复杂的按需展开约定。
  5. 有些 ViewModel 只被部分页面需要,但总是包含在响应里,因为这样比做成可配置的更简单。
  6. 有些页面只好通过多次 API 调用拼凑出自己的 ViewModel,因为没有单个响应包含所有需要的信息。
  7. 然后产品设计和功能又变了。重复上述过程。

这里显然有根本性的张力,但原因是什么?

首先,注意 ViewModel 的结构是由 UI 决定的。它不是某种“点赞”的理想抽象,而是由设计驱动的。我们想展示“你、Ann 和其他13人点赞了”,因此需要这些字段:

type LikeButtonProps = {
  totalLikeCount: number,
  isLikedByUser: boolean,
  friendLikes: string[]
}

如果页面设计或功能变了(比如要展示朋友的头像),ViewModel 也会随之改变:

type LikeButtonProps = {
  totalLikeCount: number,
  isLikedByUser: boolean,
  friendLikes: {
    firstName: string
    avatar: string
  }[]
}

但问题来了。

REST(或者说,大家普遍理解的 REST)鼓励你以资源为中心思考,而不是模型或视图模型。一开始你的资源和模型结构类似。但单个模型很难满足一个页面的数据需求,于是你会为资源嵌套模型开发各种约定。然而,把所有相关模型都包含进来往往不现实,于是你又开始在资源里加 ViewModel 风格的字段,比如 friendLikes

但把 ViewModel 放进资源也不理想。ViewModel 不是“帖子”这种抽象概念,每个 ViewModel 描述的是某个具体的 UI 片段。结果就是,“Post” 资源的结构不断膨胀,以适应所有页面的需求。而这些需求还会不断变化,所以“Post” 资源的结构最终变成了各页面历史需求的化石。

直白点说:

REST 资源缺乏现实基础。 它们的结构不够受约束——我们大多凭空造概念。它们不像模型那样扎根于数据存储的现实,也不像视图模型那样扎根于数据展示的现实。不幸的是,无论往哪边靠都只会让问题更糟。

如果 REST 资源更接近模型,会损害用户体验。原本一次请求能拿到的数据,现在需要多次甚至 N 次调用。这在后端团队“交付” REST API 给前端团队后端就不管的公司尤为明显。API 可能很优雅,但前端用起来极其不便。

反过来,如果 REST 资源更接近 ViewModel,会影响可维护性。ViewModel 很“善变”!UI 一改,ViewModel 就变。但 REST 资源结构难以变动——同一资源被多个页面用。结果,资源结构逐渐偏离当前 ViewModel 的需求,难以演进。后端团队常常抗拒为 UI 添加特定字段:这些字段很快就会过时!

这并不意味着 REST 本身有问题。如果资源定义得好,字段选得妙,REST 用起来很舒服。但这往往和客户端的需求相悖——客户端想要的是某个页面需要的所有数据。中间缺了点什么。

我们需要一个“翻译层”。


针对 ViewModel 的 API

有一种方法可以解决这种张力。

你可以选择多种实现方式,但核心思想是:客户端应该能一次性请求某个页面所需的所有数据

这个想法其实很简单!

与其让客户端请求“标准” REST 资源,比如:

GET /data/post/123       # 获取 Post 资源
GET /data/post/123/likes # 获取 Post Likes 资源

不如直接请求某个页面的 ViewModel:

GET /screens/post-details/123 # 获取 PostDetails 页面所需 ViewModel

这个数据会包含该页面需要的所有内容。

区别微妙却深远。你不再试图定义“帖子”的通用标准结构,而是发送PostDetails 页面当前需要的数据。如果 PostDetails 页面被删了,这个端点也可以删。如果另一个页面(比如 PostLikedBy 弹窗)需要相关信息,它会有自己的路由:

GET /screens/post-details/123 # 获取 PostDetails 页面 ViewModel
GET /screens/post-liked-by/123 # 获取 PostLikedBy 页面 ViewModel

这有什么好处?

它避免了“无根抽象”的陷阱。每个页面的 ViewModel 接口精确指定了服务器响应的结构。你可以随时调整它,而不会影响其他页面。

比如,PostDetails 页面 ViewModel 可能是这样:

type PostDetailsViewModel = {
  postTitle: string,
  postContent: string,
  postAuthor: {
    name: string,
    avatar: string,
    id: number
  },
  friendLikes: {
    totalLikeCount: number,
    isLikedByUser: boolean,
    friendLikes: string[]
  }
};

所以服务器会为 /screens/post-details/123 返回这样的数据。以后如果要展示朋友点赞的头像,只需在这个 ViewModel 里加字段:

type PostDetailsViewModel = {
  postTitle: string,
  postContent: string,
  postAuthor: {
    name: string,
    avatar: string,
    id: number
  },
  friendLikes: {
    totalLikeCount: number,
    isLikedByUser: boolean,
    friendLikes: {
      firstName: string
      avatar: string
    }[]
  }
}

注意,你只需更新该页面的端点。不再需要平衡各页面的需求,也没有“这个字段属于哪个资源?”、“要不要展开?”之类的问题。如果某页面需要更多数据,只需在页面响应中加即可——无需通用或可配置。服务器响应的结构完全由每个页面的需求决定。

确实解决了 REST 的问题。

但也带来了新问题:

  1. 端点数量会比 REST 多得多——每个页面一个。如何组织和维护这些端点?
  2. 如何在端点之间复用代码?显然会有很多数据访问和业务逻辑重复。
  3. 如何说服后端团队从 REST API 转向这种方式?

最后一个问题可能最需要先解决。后端团队对此有充分理由担忧。至少,如果这种方式行不通,最好能方便地回退。

幸运的是,你无需丢弃现有的任何东西。


前端专属后端(Backend For Frontend)

你无需替换现有 REST API,可以在其前面加一层:

// 你在添加新的页面专属端点...
app.get('/screen/post-details/:postId', async (req, res) => {
  const [post, friendLikes] = await Promise.all([
    // ...这里调用现有 REST API
    fetch(`/api/post/${postId}`).then(r => r.json()),
    fetch(`/api/post/${postId}/friend-likes`).then(r => r.json()),
  ]);
  const viewModel = {
    postTitle: post.title,
    postContent: parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes: {
      totalLikeCount: post.totalLikeCount,
      isLikedByUser: post.isLikedByUser,
      friendLikes: friendLikes.likes.map(l => l.firstName)
    }
  };
  res.json(viewModel);
});

这不是新想法。这一层通常叫 BFF(Backend for Frontend)。此时,BFF 的职责就是把 REST API 转换为返回 ViewModel。

如果某页面需要更多数据,BFF 可以只为它提供,无需改动整个数据模型。页面专属的变更被局部化。最关键的是,任何页面所需的数据都能一次性返回。

BFF 不必和 REST API 用同一种语言实现。正如后文会讲,BFF 用和前端相同的语言更有优势。你可以把它看作运行在服务器上的前端一部分,是前端在服务器上的“使者”。它把 REST 响应“适配”为前端 UI 各页面真正需要的结构。

虽然用像 React Router 的 clientLoader 这样的客户端每路由加载器也能获得部分 BFF 的好处,但如果把这层真正部署在服务器、靠近 REST 端点,会解锁更多能力。

比如,即使你必须串行请求多个 REST API 才能加载页面所有数据,BFF 和 REST API 之间的延迟远低于客户端多次串行请求。如果 REST API 在内网响应很快,你可以省下客户端/服务器串行请求的秒级延迟,而无需真正并行化(有时串行是不可避免的)。

BFF 还能在把数据发给客户端之前做转换,这对低端设备性能提升很大。甚至可以把部分计算结果缓存或持久化到磁盘,甚至跨用户缓存,因为你有磁盘和 Redis 这类服务端缓存。某种意义上,BFF 让前端团队拥有了自己的一小块服务器

更重要的是,BFF 让你可以在不影响客户端的情况下试验 REST API 的替代方案。例如,如果 REST API 没有其他消费者,可以把它变成内部微服务,不对外暴露。甚至可以变成数据访问层,直接在 BFF 进程内import

import { getPost, getFriendLikes } from '@your-company/data-layer';
 
app.get('/screen/post-details/:postId', async (req, res) => {
  const postId = req.params.postId;
  const [post, friendLikes] = await Promise.all([
    // 直接读 ORM 并应用业务逻辑
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  const viewModel = {
    postTitle: post.title,
    postContent: parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes: {
      totalLikeCount: post.totalLikeCount,
      isLikedByUser: post.isLikedByUser,
      friendLikes: friendLikes.likes.map(l => l.firstName)
    }
  };
  res.json(viewModel);
});

(当然,只有你能用 JS 写底层后端逻辑时才行。)

这样可以避免多次从数据库加载同一信息(没有 fetch 调用意味着数据库读取可批量处理)。也能在需要时“降级”到更底层的抽象,比如运行某些数据库存储过程。

BFF 模式有很多优点,但也带来新问题。例如,如何组织代码?如果每个页面都是一个 API 方法,如何避免代码重复?如何让 BFF 与前端数据需求保持同步?

让我们尝试解答这些问题。


可组合的 BFF

假设你要添加一个新的 PostList 页面。它会渲染一组 <PostDetails> 组件,每个都需要和之前一样的数据:

type PostDetailsViewModel = {
  postTitle: string,
  postContent: string,
  postAuthor: {
    name: string,
    avatar: string,
    id: number
  },
  friendLikes: {
    totalLikeCount: number,
    isLikedByUser: boolean,
    friendLikes: string[]
  }
};

所以 PostList 的 ViewModel 包含一个 PostDetailsViewModel 数组:

type PostListViewModel = {
  posts: PostDetailsViewModel[]
};

你会如何加载 PostList 的数据?

你可能会想到让客户端针对每个帖子 ID 多次请求 /screen/post-details/:postId,每次都组装一个 ViewModel。

但等等,这不就违背了 BFF 的初衷吗!为一个页面多次请求效率低下,正是我们想避免的。我们应该为新页面新增一个 BFF 端点。

新端点可能最初长这样:

import { getPost, getFriendLikes, getRecentPostIds } from '@your-company/data-layer';
 
app.get('/screen/post-details/:postId', async (req, res) => {
  const postId = req.params.postId;
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  const viewModel = {
    postTitle: post.title,
    postContent: parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes: {
      totalLikeCount: post.totalLikeCount,
      isLikedByUser: post.isLikedByUser,
      friendLikes: friendLikes.likes.map(l => l.firstName)
    }
  };
  res.json(viewModel);
});
 
app.get('/screen/post-list', async (req, res) => {
  // 获取最新帖子ID
  const postIds = await getRecentPostIds();
  const viewModel = {
    // 并行加载每个帖子ID的数据
    posts: await Promise.all(postIds.map(async postId => {
      const [post, friendLikes] = await Promise.all([
        getPost(postId),
        getFriendLikes(postId, { limit: 2 }),
      ]);
      const postDetailsViewModel = {
        postTitle: post.title,
        postContent: parseMarkdown(post.content),
        postAuthor: post.author,
        postLikes: {
          totalLikeCount: post.totalLikeCount,
          isLikedByUser: post.isLikedByUser,
          friendLikes: friendLikes.likes.map(l => l.firstName)
        }
      };
      return postDetailsViewModel;
    }))
  };
  res.json(viewModel);
});

但你会发现端点间有大量重复代码:

import { getPost, getFriendLikes, getRecentPostIds } from '@your-company/data-layer';
 
app.get('/screen/post-details/:postId', async (req, res) => {
  const postId = req.params.postId;
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  const viewModel = {
    postTitle: post.title,
    postContent: parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes: {
      totalLikeCount: post.totalLikeCount,
      isLikedByUser: post.isLikedByUser,
      friendLikes: friendLikes.likes.map(l => l.firstName)
    }
  };
  res.json(viewModel);
});
 
app.get('/screen/post-list', async (req, res) => {
  const postIds = await getRecentPostIds();
  const viewModel = {
    posts: await Promise.all(postIds.map(async postId => {
      const [post, friendLikes] = await Promise.all([
        getPost(postId),
        getFriendLikes(postId, { limit: 2 }),
      ]);
      const postDetailsViewModel = {
        postTitle: post.title,
        postAuthor: post.author,
        postContent: parseMarkdown(post.content),
        postLikes: {
          totalLikeCount: post.totalLikeCount,
          isLikedByUser: post.isLikedByUser,
          friendLikes: friendLikes.likes.map(l => l.firstName)
        }
      };
      return postDetailsViewModel;
    }))
  };
  res.json(viewModel);
});

很明显,“PostDetails ViewModel” 这个概念呼之欲出。其实很正常——两个页面都渲染 <PostDetails> 组件,所以都需要类似的数据加载代码。


提取 ViewModel

我们来提取一个 PostDetailsViewModel 函数:

import { getPost, getFriendLikes, getRecentPostIds } from '@your-company/data-layer';
 
async function PostDetailsViewModel({ postId }) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  return {
    postTitle: post.title,
    postContent: parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes: {
      totalLikeCount: post.totalLikeCount,
      isLikedByUser: post.isLikedByUser,
      friendLikes: friendLikes.likes.map(l => l.firstName)
    }
  };
}
 
app.get('/screen/post-details/:postId', async (req, res) => {
  const postId = req.params.postId;
  const viewModel = await PostDetailsViewModel({ postId });
  res.json(viewModel);
});
 
app.get('/screen/post-list', async (req, res) => {
  const postIds = await getRecentPostIds();
  const viewModel = {
    posts: await Promise.all(postIds.map(postId =>
      PostDetailsViewModel({ postId })
    ))
  };
  res.json(viewModel);
});

这样 BFF 端点就简洁多了。

实际上还能更进一步。看 PostDetailsViewModel 里的这部分:

async function PostDetailsViewModel({ postId }) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  return {
    postTitle: post.title,
    postContent: parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes: {
      totalLikeCount: post.totalLikeCount,
      isLikedByUser: post.isLikedByUser,
      friendLikes: friendLikes.likes.map(l => l.firstName)
    }
  };
}

我们知道 postLikes 字段最终会作为 LikeButton 组件的 props——也就是它的 ViewModel:

function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  // ...
}

那我们把准备这些 props 的逻辑提取到 LikeButtonViewModel

import { getPost, getFriendLikes, getRecentPostIds } from '@your-company/data-layer';
 
async function LikeButtonViewModel({ postId }) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  return {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes.likes.map(l => l.firstName)
  };
}
 
async function PostDetailsViewModel({ postId }) {
  const [post, postLikes] = await Promise.all([
    getPost(postId), // 这里再次 getPost() 没问题,数据层会用内存缓存去重
    LikeButtonViewModel({ postId }),
  ]);
  return {
    postTitle: post.title,
    postContent: parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes
  };
}

现在我们有了一棵以 JSON 形式加载数据的 ViewModel 树。

如果你有相关经验,这可能让你联想到 Redux reducer 的组合、GraphQL fragment 的组合,或者 React 组件的组合。

虽然代码风格有点啰嗦,但把页面 ViewModel 拆分成更小的 ViewModel 有种莫名的满足感。这很像写 React 组件树,只不过我们是在拆解后端 API。数据有自己的结构,但大致和 React 组件树对齐

看看 UI 需要演进时会发生什么。


ViewModel 的演进

假设 UI 设计变了,我们还想展示朋友的头像:

type LikeButtonProps = {
  totalLikeCount: number,
  isLikedByUser: boolean,
  friendLikes: {
    firstName: string
    avatar: string
  }[]
}

假如用 TypeScript,ViewModel 会立马报错:

async function LikeButtonViewModel(
  { postId } : { postId: number }
) : LikeButtonProps {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  return {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    // 🔴 Type 'string[]' is not assignable to type '{ firstName: string; avatar: string; }[]'.
    friendLikes: friendLikes.likes.map(l => l.firstName)
  };
}

我们来修正它:

async function LikeButtonViewModel(
  { postId } : { postId: number }
) : LikeButtonProps {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  return {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: l.avatar,
    }))
  };
}

现在,所有包含 LikeButton ViewModel 的页面 BFF 响应都会用新的 friendLikes 格式,正好是 LikeButton 组件需要的。无需其他改动——直接可用。我们知道它可用,因为 LikeButtonViewModel 是唯一生成 LikeButton props 的地方,无论是哪个页面请求 BFF。(暂且假设确实如此,后面我们会讨论如何绑定它们。)

请注意这一点,这其实很深刻。

你上一次能清楚追踪服务器深处某段代码生成的数据片段,和客户端深处消费该数据的代码之间的对应关系是什么时候?我们确实抓住了点什么


ViewModel 参数

你可能注意到 ViewModel 函数可以接收参数。重要的是,这些参数可以由“父” ViewModel 函数指定并向下传递——客户端无需关心。

比如,你想让帖子列表页面只显示每篇帖子的第一段内容。我们给 ViewModel 加个参数:

async function PostDetailsViewModel({
  postId,
  truncateContent
}) {
  const [post, postLikes] = await Promise.all([
    getPost(postId),
    LikeButtonViewModel({ postId }),
  ]);
  return {
    postTitle: post.title,
    postContent: parseMarkdown(post.content, {
      maxParagraphs: truncateContent ? 1 : undefined
    }),
    postAuthor: post.author,
    postLikes
  };
}
 
app.get('/screen/post-details/:postId', async (req, res) => {
  const postId = req.params.postId;
  const viewModel = await PostDetailsViewModel({
    postId,
    truncateContent: false
  });
  res.json(viewModel);
});
 
app.get('/screen/post-list', async (req, res) => {
  const postIds = await getRecentPostIds();
  const viewModel = {
    posts: await Promise.all(postIds.map(postId =>
      PostDetailsViewModel({
        postId,
        truncateContent: true
      })
    ))
  };
  res.json(viewModel);
});

post-details 端点的 JSON 响应仍然包含完整内容,而 post-list 端点只返回摘要。这是视图模型的关注点,现在有了自然的代码表达方式。


ViewModel 参数的传递

再比如,你只想在详情页展示朋友的头像。我们让 LikeButtonViewModel 支持 includeAvatars 参数:

async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  return {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))
  };
}

现在你可以从 BFF 端点一路传递下去:

async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const [post, postLikes] = await Promise.all([
    getPost(postId),
    LikeButtonViewModel({ postId, includeAvatars }),
  ]);
  return {
    postTitle: post.title,
    postContent: parseMarkdown(post.content, {
      maxParagraphs: truncateContent ? 1 : undefined
    }),
    postAuthor: post.author,
    postLikes
  };
}
 
app.get('/screen/post-details/:postId', async (req, res) => {
  const postId = req.params.postId;
  const viewModel = await PostDetailsViewModel({
    postId,
    truncateContent: false,
    includeAvatars: true
  });
  res.json(viewModel);
});
 
app.get('/screen/post-list', async (req, res) => {
  const postIds = await getRecentPostIds();
  const viewModel = {
    posts: await Promise.all(postIds.map(postId =>
      PostDetailsViewModel({
        postId,
        truncateContent: true,
        includeAvatars: false
      })
    ))
  };
  res.json(viewModel);
});

同样,客户端无需通过 ?includeAvatars=true 这类参数来确保 JSON 响应包含头像。post-list BFF 端点自己知道列表页不该有头像,于是传递 includeAvatars: falsePostDetailsViewModel,再传给 LikeButtonViewModel。客户端完全不用关心服务端逻辑——只要拿到想要的 props 就行。

如果我们确实要展示朋友头像,可能想显示五个而不是两个。直接在 LikeButtonViewModel 里改就行:

async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
  ]);
  return {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))
  };
}

由于 LikeButtonViewModel 只负责生成 LikeButton 的 props,把更多展示逻辑放这里很自然。这就是视图模型嘛!如果另一个视图需要不同数量的头像,也可以这么做。不像 REST,没有“帖子”的标准结构——任何 UI 都能精确指定自己需要的数据,从页面到按钮都可以。

我们的 ViewModel 会和客户端需求同步演进


组合 ViewModel

有趣的事情正在发生。我们开始把 BFF 端点拆成可复用的逻辑单元,发现这些单元让我们像封装 UI 一样封装数据加载。如果你把 ViewModel 看作组件,可能会发现它们有很多相似之处。

但最终 ViewModel 树的结果不是 UI 树——而只是 JSON。

// GET /screen/post-list
{
  /* 开始 screen/post-list ViewModel */
  posts: [{
    /* 开始 PostDetailsViewModel */
    postTitle: "JSX 跨网络传输",
    postAuthor: "Dan",
    postContent: "假设你有一个 API 路由,它以 JSON 的形式返回一些数据。",
    postLikes: {
      /* 开始 LikeButtonViewModel */
      totalLikeCount: 8,
      isLikedByUser: false,
      friendLikes: [{
        firstName: "Alice"
      }, {
        firstName: "Bob"
      }]
      /* 结束 LikeButtonViewModel */
    }
    /* 结束 PostDetailsViewModel */
  }, {
    /* 开始 PostDetailsViewModel */
    postTitle: "React for Two Computers",
    postAuthor: "Dan",
    postContent: "我已经尝试写这篇文章至少十几次了。",
    postLikes: {
      /* 开始 LikeButtonViewModel */
      totalLikeCount: 13,
      isLikedByUser: true,
      friendLikes: [{
        firstName: "Bob"
      }]
      /* 结束 LikeButtonViewModel */
    }
    /* 结束 PostDetailsViewModel */
  }]
}

但我们要如何处理这些 JSON?

最终,我们希望 LikeButtonViewModel 生成的 props 能传给 LikeButton 组件。同理,PostDetailsViewModel 生成的 props 也要传给 PostDetails 组件。我们不想生成一棵庞大的 ViewModel JSON 树,然后手动一层层把数据传给每个需要它的组件。

我们正在构建两棵平行的层级树。

但这两棵树还没有连接起来。

缺了点什么。


小结:JSON 作为组件

  • 对于任何 UI,数据从模型开始,最终变成视图模型。模型到视图模型的转化必须在某处发生。
  • 视图模型的结构完全由 UI 设计决定,这意味着它们会随设计同步演进。不同页面需要从同一模型聚合出不同的视图模型。
  • 用 REST 资源建模服务器数据会产生张力。REST 资源贴近模型时,获取页面所需 ViewModel 可能需要多次请求和复杂约定。贴近 ViewModel 时,又会和初始页面强耦合,难以随客户端需求演进。
  • 可以通过增加一层*前端专属后端(BFF)*来解决这种张力。BFF 的职责是把客户端的需求(“我要这个页面的数据”)转化为后端的 REST 调用。BFF 还可以直接用进程内数据层加载数据。
  • 既然 BFF 的工作是为每个页面返回所有所需数据的 JSON,自然可以把数据加载逻辑拆成可复用单元。页面的 ViewModel 可以拆成一棵 ViewModel 树,每个节点对应客户端某个组件需要的服务器数据。这些 ViewModel 可以组合重用。
  • 这些 ViewModel 函数可以互相传递信息。这让我们能根据页面定制 JSON。与 REST 不同,我们不再设计通用结构如“帖子对象”,而是随时为不同页面提供不同 ViewModel——只要它们需要。这些 ViewModel 真正是视图模型,可以(应该?)包含展示逻辑。
  • 我们逐渐意识到 ViewModel 的结构和 React 组件很像。ViewModel 就像组件,但用于生成 JSON。但我们还没解决如何把服务器生成的 JSON 传给客户端需要的组件。维护两棵平行树也很烦。我们已经接近真相,但还差点什么。

我们到底缺了什么?


第二部分:组件作为 JSON

HTML、SSI 与 CGI

JSON、MVVM、BFF,这都是什么鬼?!

这也太复杂了吧。React 这帮人真是脱离实际。如果他们懂点历史就好了。

我们那个年代,直接写点 HTML 就完事了。

我的 index.html 首页长这样:

<html>
  <body>
    <h1>欢迎来到我的博客!</h1>
    <h2>最新文章</h2>
    <h3>
      <a href="/jsx-over-the-wire.html">
        JSX 跨网络传输
      </a>
    </h3>
    <p>
      假设你有一个 API 路由,它以 JSON 的形式返回一些数据。[…]
    </p>
    <h3>
      <a href="/react-for-two-computers.html">
        React for Two Computers
      </a>
    </h3>
    <p>
      我已经尝试写这篇文章至少十几次了。[…]
    </p>
    ...
  </body>
</html>

然后我的 jsx-over-the-wire.html 文章详情页长这样:

<html>
  <body>
    <h1>JSX 跨网络传输</h1>
    <p>
      假设你有一个 API 路由,它以 JSON 的形式返回一些数据。
    </p>
    ...
  </body>
</html>

把这些文件放到 Apache 服务器上就搞定了!

现在我想给所有页面加个页脚。很简单,先创建一个 includes/footer.html 文件:

<marquee>
  <a href="/">overreacted</a>
</marquee>

然后用 服务器端包含(SSI) 在任何页面引入页脚:

<html>
  <body>
    <h1>欢迎来到我的博客!</h1>
    <h2>最新文章</h2>
    ...
    <!--#include virtual="/includes/footer.html" -->
  </body>
</html>

实际上,我也不想把每篇文章的第一段内容手动复制到 index.html,所以可以用 SSI 配合 CGI 动态生成首页:

<html>
  <body>
    <h1>欢迎来到我的博客!</h1>
    <h2>最新文章</h2>
    <!--#include virtual="/cgi-bin/post-details.cgi?jsx-over-the-wire&truncateContent=true" -->
    <!--#include virtual="/cgi-bin/post-details.cgi?react-for-two-computers&truncateContent=true" -->
    <!--#include virtual="/includes/footer.html" -->
  </body>
</html>

详情页同理,调用同一个 post-details.cgi 脚本:

<html>
  <body>
    <!--#include virtual="/cgi-bin/post-details.cgi?jsx-over-the-wire&truncateContent=false" -->
    <!--#include virtual="/includes/footer.html" -->
  </body>
</html>

最后,post-details.cgi 脚本可以访问数据库:

#!/bin/sh
echo "Content-type: text/html"
echo ""
 
POST_ID="$(echo "$QUERY_STRING" | cut -d'&' -f1 | tr -cd '[:alnum:]._-')"
TRUNCATE="$(echo "$QUERY_STRING" | grep -c "truncateContent=true")"
 
TITLE=$(mysql -u admin -p'password' -D blog --skip-column-names -e \
  "SELECT title FROM posts WHERE url='$POST_ID'")
CONTENT=$(mysql -u admin -p'password' -D blog --skip-column-names -e \
  "SELECT content FROM posts WHERE url='$POST_ID'")
 
if [ "$TRUNCATE" = "1" ]; then
  FIRST_PARAGRAPH="$(printf "%s" "$CONTENT" | sed '/^$/q')"
  echo "<h3><a href=\"/$POST_ID.html\">$TITLE</a></h3>"
  echo "<p>$FIRST_PARAGRAPH [...]</p>"
else
  echo "<h1>$TITLE</h1>"
  echo "<p>"
  echo "$CONTENT"
  echo "</p>"
fi

我们还在九十年代,好吗?

到目前为止一切都很简单,虽然写起来有点繁琐。我们得到的是:服务器一次性返回页面所需的所有数据

嗯……

当然,不同页面可能需要相同的数据,我们不想重复逻辑。幸运的是,可以复用动态包含,比如 post-details.cgi。还能传递参数,如 truncateContent

唯一烦人的是用 Bash 写代码实在不太友好。我们看看能不能改进下。


PHP 与 XHP

我们可以把整个例子翻译成老派 PHP,这样有更好的流程控制、函数、变量等等。但我想直接跳到重点。

不是现代 PHP MVC 框架。

我要说的是 XHP

你看,早期 PHP 程序的问题在于它们依赖字符串操作 HTML。这样其实没比 Bash 好多少:

if ($truncate) {
  $splitContent = explode("\n\n", $content);
  $firstParagraph = $splitContent[0];
  echo "<h3><a href=\"/$postId.php\">$title</a></h3>";
  echo "<p>$firstParagraph [...]</p>";
} else {
  echo "<h1>$title</h1>";
  echo "<p>$content</p>";
}

字符串操作 HTML 容易出错、不安全、难维护。大多数 Web 开发者因此转向了 Rails 风格的 MVC,把所有 HTML 都放到模板文件里(数据加载放到控制器)。

但 Facebook 另辟蹊径。

Facebook 工程师认为,PHP 的问题不是操作标记本身,而是无原则地把标记当字符串处理。标记有自己的结构——嵌套关系。我们需要一种方式来安全地构建和操作标记:

if ($truncate) {
  $splitContent = explode("\n\n", $content);
  $firstParagraph = $splitContent[0];
  echo
    <x:frag>
      <h3><a href={"/{$postId}.php"}>{$title}</a></h3>
      <p>{$firstParagraph} [...]</p>
    </x:frag>;
} else {
  echo
    <x:frag>
      <h1>{$title}</h1>
      <p>{$content}</p>
    </x:frag>;
}

这些标签不是 HTML 字符串!它们是对象,可以转化为 HTML。

有了可维护的标记操作,我们可以创建自己的抽象,比如 <ui:post-details>

class :ui:post-details extends :x:element {
  protected function render(): XHPRoot {
    if ($this->:truncateContent) {
      $splitContent = explode("\n\n", $this->:content);
      $firstParagraph = $splitContent[0];
      return
        <x:frag>
          <h3><a href={"/{$postId}.php"}>{$this->:title}</a></h3>
          <p>{$firstParagraph} [...]</p>
        </x:frag>;
    } else {
      return
        <x:frag>
          <h1>{$this->:title}</h1>
          <p>{$this->:content}</p>
        </x:frag>;
    }
  }
}

然后渲染到页面:

echo
  <ui:post-details
    postId="jsx-over-the-wire"
    truncateContent={true}
    title="JSX 跨网络传输"
    content="假设你有一个 API 路由,它以 JSON 的形式返回一些数据……"
  />;

实际上可以用这种方式构建整个 Web 应用。标签渲染标签,层层嵌套。我们没有用 MVC,而是回归了函数式组合。

XHP 的一个缺点是对客户端交互支持不佳。它在服务器上生成 HTML,最多只能通过更新某个 DOM 节点的 innerHTML 来替换部分内容。

但仅仅替换 innerHTML 并不适合高度交互的产品——这让一位工程师(不是我)思考,能否把 XHP 风格的“标签渲染标签”模式直接搬到客户端,并在多次渲染间保留状态。你猜到了,这就催生了 JSX 和 React

不过我们今天不聊 React。

我们要吹爆 XHP。


异步 XHP

之前 <ui:post-details>titlecontent 都是外部传进来的:

echo
  <ui:post-details
    postId="jsx-over-the-wire"
    truncateContent={true}
    title="JSX 跨网络传输"
    content="假设你有一个 API 路由,它以 JSON 的形式返回一些数据……"
  />;

它不会自己读 titlecontent——毕竟从数据库读取理想上是异步的,而 XHP 标签是同步的。

曾经是

后来 Facebook 工程师意识到,如果 XHP 标签能自己加载数据会更强大。异步 XHP 标签 诞生了:

class :ui:post-details extends :x:element {
  use XHPAsync;
 
  protected async function asyncRender(): Awaitable<XHPRoot> {
    $post = await loadPost($this->:postId);
    $title = $post->title;
    $content = $post->content;
    // ...
  }
}

现在 <ui:post-details> 只需 postId 就能自助加载数据

class :ui:post-list extends :x:element {
  protected function render(): XHPRoot {
    return
      <x:frag>
        <ui:post-details
          postId="jsx-over-the-wire"
          truncateContent={true}
        />
        <ui:post-details
          postId="react-for-two-computers"
          truncateContent={true}
        />
        ...
      </x:frag>;
  }
}

这样你可以用异步标签渲染异步标签,直到生成最终 HTML。这种方式极其强大。你可以写自包含的组件,自己加载所需数据,然后只需一行就能插入树中。由于 XHP 标签运行在服务器,整个页面一次请求就能解决

<ui:post-list /> // 整个页面的 HTML

再次强调,异步 XHP 允许自包含组件自助加载数据——但!——展示页面只需一次前后端交互。 很少有 UI 框架能同时满足这两点。

如果你想做类似框架,有几点要考虑:

  1. 兄弟节点应并行解析。比如上面两个 <ui:post-details> 应该同时 loadPost。异步 XHP 做到了。
  2. 还需要一种方式,如果某个树枝太慢,能解锁页面其他部分。Facebook 有 BigPipe “pagelet” 系统,能分批刷新树,并用专门的 loading 状态作为分界。
  3. 最好有能批量读取、全请求共享内存缓存的数据访问层。这样即使树深处的标签晚点才“fetch”,你也能充分利用 CPU 和 IO——总有标签在渲染,总有 DB 在等。

总的来说,异步 XHP 是非常高效的思维模型——只要你的应用不太交互。可惜对于高度交互的应用,仅仅输出 HTML 不够。你需要能导航、处理变更、刷新内容且不丢失客户端状态。XHP 只面向 HTML,不适合复杂界面,React 逐渐取而代之。

但当界面迁移到 React 时,概念上的简单性明显丢失了。UI 和它需要的数据——这两者本应天然绑定,却被拆分成了两个代码库。

GraphQL 和 Relay 在一定程度上弥补了这个鸿沟,也带来了重要创新,但用起来始终没有异步 XHP 那么直接


原生模板

XHP 在 Facebook 迎来了意外的复兴。

它的思维模型如此高效,以至于人们不仅想用它写 Web 界面,还想用它写原生应用

想象一下。

这段 XHP 是个对象

<x:frag>
  <h1>{$this->:title}</h1>
  <p>{$this->:content}</p>
</x:frag>

可以转成 HTML:

<h1>JSX 跨网络传输</h1>
<p>假设你有一个 API 路由,它以 JSON 的形式返回一些数据</p>

但也可以转成 JSON:

{
  type: 'x:frag',
  props: {
    children: [{
      type: 'h1',
      props: {
        children: 'JSX 跨网络传输'
      }
    },
    {
      type: 'p',
      props: {
        children: '假设你有一个 API 路由,它以 JSON 的形式返回一些数据'
      }
    }]
  }
}

实际上你完全不受限于 HTML 的原语。例如,<ui:post-details> 也可以输出 iOS 视图

<x:frag>
  <ios:UITextView>{$this->:title}</ios:UITextView>
  <ios:UITextView>{$this->:content}</ios:UITextView>
</x:frag>

这些标签可以作为 JSON 通过网络传给原生 iOS 应用,由它解析成原生视图树。

{
  type: 'x:frag',
  props: {
    children: [{
      type: 'ios:UITextView',
      props: {
        children: 'JSX 跨网络传输'
      }
    },
    {
      type: 'ios:UITextView',
      props: {
        children: '假设你有一个 API 路由,它以 JSON 的形式返回一些数据'
      }
    }]
  }
}

与此同时,服务器端可以定义自己的标签来渲染这些标签:

class :ui:post-list extends :x:element {
  protected function render(): XHPRoot {
    return
      <x:frag>
        <ui:post-details
          postId="jsx-over-the-wire"
          truncateContent={true}
        />
        <ui:post-details
          postId="react-for-two-computers"
          truncateContent={true}
        />
        ...
      </x:frag>
  }
}

换句话说,你可以有一个端点,一次性返回某个页面所需的所有数据。而“数据”就是原生 UI。

<ui:post-list /> // 一屏 iOS 组件

你可能觉得这不现实,因为原生应用不能依赖后端在关键路径上。但其实只要你在和 API 调用一样的场景下请求更多 UI,不要更频繁即可。你还可以在应用包里直接内置部分页面的 JSON 作为初始数据。

实际上,像 ios:UITextView 这样的原语太底层,不适合做 UI 格式的基础。你需要一套更丰富的交互原语,因为有些交互应该完全本地。比如实现一个 ios:ColorPicker 原语,颜色选择跟手指走,但保存颜色时才发 API 请求,服务端再返回下一个页面的 JSON。

如果你让这些原语平台无关(Facebook 就这么做了),就能用同一套服务端代码组装 iOS 和 Android 屏幕:

<nt:flexbox flex-direction="column">
  <nt:text font-size={24} font-weight={FontWeight::BOLD}>
    {$this->:title}
  </nt:text>
  <nt:text font-size={18}>
    {$this->:content}
  </nt:text>
</nt:flexbox>

那么,直接返回整个页面的 JSON,有没有人这么做过?


SDUI

这不是新点子。

甚至不是有争议的点子。

你听说过 HTML 吧?这就像 HTML,只不过用你自己的设计系统。想象一个 API 端点返回 UI 的 JSON 树。我们用 JSX 语法:

app.get('/app/profile/:personId', async (req, res) => {
  const [person, featureFlags] = await Promise.all([
    findPerson(req.params.personId),
    getFeatureFlags(req.user.id)
  ]);
 
  const json = (
    <Page title={`${person.firstName}'s Profile`}>
      <Header>
        <Avatar src={person.avatarUrl} />
        {person.isPremium && <PremiumBadge />}
      </Header>
 
      <Layout columns={featureFlags.includes('TWO_COL_LAYOUT') ? 2 : 1}>
        <Panel title="用户信息">
          <UserDetails user={person} />
          {req.user.id === person.id && <EditButton />}
        </Panel>
 
        <Panel title="动态">
          <ActivityFeed userId={person.id} limit={3} />
        </Panel>
      </Layout>
    </Page>
  );
 
  res.json(json);
}

但既然你本质上是在写 API 端点,就可以做任何API 能做的事——检查功能开关、运行服务端逻辑、读取数据层。

再次强调,这不是新点子。

事实上,很多顶级原生应用都是这么做的。InstagramAirbnbUberReddit 都有类似方案。这些公司都有内部框架实现这种模式。很多 Web 开发者对此一无所知,讽刺的是,这种模式极其“Web范儿”。

在原生领域,这种模式叫“SDUI”——服务端驱动 UI。其实就是 API 端点返回 UI 树的 JSON:

// /app/profile/123
{
  type: "Page",
  props: {
    title: "Jae's Profile",
    children: [{
      type: "Header",
      props: {
        children: [{
          type: "Avatar",
          props: {
            src: "https://example.com/avatar.jpg"
          }
        }, {
          type: "PremiumBadge",
          props: {},
        }]
      }
    }, {
      type: "Layout",
      props: {
        columns: 2,
        children: [
          // ...
        ]
      }
    }]
  }
}

然后原生端有这些原语的具体实现——PageHeaderAvatarPremiumBadgeLayout 等等。

归根结底,这就像把 props 从服务器传递给客户端。

所以如果你发现自己有一堆服务端准备好的数据,需要把它们分发给客户端的函数,这种格式可能很有用。

记住这一点。


小结:组件作为 JSON

  • 从互联网诞生起,做 Web 应用就是响应某个页面请求,返回该页面所需的所有数据。(HTML 也是数据。)
  • 从一开始,人们就在想办法让“数据”生成更灵活、可复用、可传参。
  • 早期 Web 常用字符串拼接 HTML,但容易出错。
  • 于是很多人把标记放到模板里。但 Facebook 的 XHP 提出了另一种方式:让标记成为对象。
  • 事实证明,把标记作为一等公民自然而然会产生“标签返回标签”——不是 MVC,而是函数式组合。
  • XHP 发展到异步 XHP,让渲染 UI 的逻辑和加载数据的逻辑紧密结合,非常强大。
  • 可惜只输出 HTML 是死胡同,无法在交互应用中“原地刷新”而不丢失状态,而状态很重要。
  • 但其实没必要局限于 HTML。如果标签是对象,就能转成 JSON。很多顶级原生应用就是这么做的。(需要 HTML 时,随时可以把 JSON 转成 HTML。)
  • 返回一棵客户端原语的 JSON 树,是“把 props 传给客户端”的优雅方式。

第三部分:JSX 跨网络传输

我们要构建什么

到目前为止,我们探索了两条思路:

  • 直接让客户端调用 REST API 忽略了 UI 演进的现实。可以通过增加一层后端,让服务器按页面需求组装数据。这一层可以拆成函数,每个函数指定如何加载页面某部分的数据,然后组合起来。但我们还没解决如何绑定这些函数和组件。
  • 也可以从纯 HTML 和“服务器包含”出发。如果不早早用 MVC,而是把标记当对象处理,最终会发明 异步标签,标签加载数据并返回标签。这种方式强大,因为能构建自包含组件且一次请求就能拿到页面所有数据。只输出 HTML 是死路,但如许多原生应用所示,输出 JSON 保留了所有优点。你只需要一套客户端原语即可。

其实这两条路说的是同一件事。归根结底,我们想要一个具备以下五点的系统:

Dan 的异步 UI 框架清单

  1. 系统能把 UI 拆成丰富的交互组件。
  2. 组件应与其服务端数据计算逻辑直接关联。如果组件接收服务端数据,你应该能一键跳转或“查找所有引用”到所有为该组件准备 props 的服务端代码。调整组件接收哪些数据应当直观易懂。
  3. 应有办法让 UI 片段真正自包含——包括其服务端数据依赖和对应逻辑。你可以随意嵌套 UI,无需关心它需要什么数据。
  4. 跳转到新页面应能一次前后端交互完成。即使有上百个组件各自加载数据,对客户端来说,页面应作为单一响应到达。系统甚至应阻止你制造前后端瀑布流。
  5. 系统应完全支持丰富交互。即使部分逻辑在服务端,也不能要求跳转或变更后整页刷新。应支持在交互树中原地刷新服务端数据。组件应能“接收新 props”而不丢失任何客户端状态。

你知道有哪个系统满足这些吗?(可以给你熟悉的框架打个分。)

如果没有,我们现在就来发明一个。


再谈 ViewModel

回到之前的 LikeButtonViewModel

async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
  ]);
  return {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))
  };
}

这个函数是后端的一部分,负责为 LikeButton 组件准备 props:

{
  totalLikeCount: 8,
  isLikedByUser: false,
  friendLikes: [{
    firstName: 'Alice',
    avatar: 'https://example.com/alice.jpg'
  }, {
    firstName: 'Bob',
    avatar: 'https://example.com/bob.jpg'
  }]
}

最终我们希望 LikeButton 能收到这些 props:

function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  // ...
}

但我们还没有机制把 LikeButtonViewModel 返回的 JSON 传给 LikeButton 组件。如何把 ViewModel 和组件绑定起来?

如果我们借鉴 SDUI,直接返回一个标签呢:

async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
  ]);
  return (
    <LikeButton
      totalLikeCount={post.totalLikeCount}
      isLikedByUser={post.isLikedByUser}
      friendLikes={friendLikes.likes.map(l => ({
        firstName: l.firstName,
        avatar: includeAvatars ? l.avatar : null,
      }))}
    />
  );
}

前文所述,我们可以把这个 JSX 表达为 JSON 树。其实和原来的 JSON 很像,只是现在指定了接收组件:

{
  type: "LikeButton",
  props: {
    totalLikeCount: 8,
    isLikedByUser: false,
    friendLikes: [{
      firstName: 'Alice',
      avatar: 'https://example.com/alice.jpg'
    }, {
      firstName: 'Bob',
      avatar: 'https://example.com/bob.jpg'
    }]
  }
}

然后客户端 React 就知道要把这些 props 传给 LikeButton

function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  // ...
}

这样我们终于把 ViewModel 和组件连接起来了!

我们把生成 props 的代码和消费 props 的代码绑定在了一起。现在 ViewModel 和组件只需 Ctrl+点击即可互相跳转。由于 JSX 表达式有类型检查,也能自动获得类型安全。

来看完整示例:

async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
  ]);
  return (
    <LikeButton
      totalLikeCount={post.totalLikeCount}
      isLikedByUser={post.isLikedByUser}
      friendLikes={friendLikes.likes.map(l => ({
        firstName: l.firstName,
        avatar: includeAvatars ? l.avatar : null,
      }))}
    />
  );
}
function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  let buttonText = 'Like';
  if (totalLikeCount > 0) {
    // 例如:“你、Alice 和其他13人点赞了”
    buttonText = formatLikeText(totalLikeCount, isLikedByUser, friendLikes);
  }
  return (
    <button className={isLikedByUser ? 'liked' : ''}>
      {buttonText}
    </button>
  );
}

我们的 ViewModel 就像 异步 XHP 标签,把数据传给客户端的 <LikeButton> 原语(就像 SDUI)。它们共同构成了一个自包含的 UI 片段,知道如何加载自己的数据。

我们再来做一次。


再来一次

现在回顾下 这里的 PostDetailsViewModel

async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const [post, postLikes] = await Promise.all([
    getPost(postId),
    LikeButtonViewModel({ postId, includeAvatars }),
  ]);
  return {
    postTitle: post.title,
    postContent: parseMarkdown(post.content, {
      maxParagraphs: truncateContent ? 1 : undefined
    }),
    postAuthor: post.author,
    postLikes
  };
}

虽然我们没写出来,但假设有个匹配的 PostDetails 组件能消费这些 JSON:

function PostDetails({
  postTitle,
  postContent,
  postAuthor,
  postLikes,
}) {
  // ...
}

我们来把它们连接起来。

首先,把 PostDetailsViewModel 改为返回 PostDetails 标签

async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const [post, postLikes] = await Promise.all([
    getPost(postId),
    LikeButtonViewModel({ postId, includeAvatars }),
  ]);
  return (
    <PostDetails
      postTitle={post.title}
      postContent={parseMarkdown(post.content, {
        maxParagraphs: truncateContent ? 1 : undefined
      })}
      postAuthor={post.author}
      postLikes={postLikes}
    />
  );
}

这样返回的 JSON 就会包一层 PostDetails JSX 元素:

{
  type: "PostDetails",
  props: {
    postTitle: "JSX 跨网络传输",
    postAuthor: "Dan",
    postContent: "假设你有一个 API 路由,它以 JSON 的形式返回一些数据。",
    postLikes: {
      type: "LikeButton",
      props: {
        totalLikeCount: 8,
        isLikedByUser: false,
        friendLikes: [{
          firstName: "Alice"
        }, {
          firstName: "Bob"
        }]
      }
    }
  }
}

客户端 React 会把这些 props 传给 PostDetails

function PostDetails({
  postTitle,
  postContent,
  postAuthor,
  postLikes,
}) {
  return (
    <article>
      <h1>{postTitle}</h1>
      <div dangerouslySetInnerHTML={{ __html: postContent }} />
      <p>by {postAuthor.name}</p>
      <section>
        {postLikes}
      </section>
    </article>
  );
}

这样 ViewModel 和组件就绑定了!


再谈组合 ViewModel

注意上例中 postLikes 直接渲染到 UI:

<section>
  {postLikes}
</section>

因为它就是 <LikeButton>,props 已由 LikeButtonViewModel 预设好。JSON 里也能看到:

{
  type: "PostDetails",
  props: {
    // ...
    postLikes: {
      type: "LikeButton",
      props: {
        totalLikeCount: 8,
        // ...
      }
    }
  }
}

你可能记得它是通过调用 LikeButtonViewModel 得到的:

async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const [post, postLikes] = await Promise.all([
    getPost(postId),
    LikeButtonViewModel({ postId, includeAvatars }),
  ]);
  // ...

但让 ViewModel 之间手动调用 Promise.all 很快会变得繁琐。我们可以采用新约定:ViewModel 可以通过返回 JSX 标签嵌套另一个 ViewModel。

这样代码就能简化:

async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const post = await getPost(postId);
  return (
    <PostDetails
      postTitle={post.title}
      postContent={parseMarkdown(post.content, {
        maxParagraphs: truncateContent ? 1 : undefined
      })}
      postAuthor={post.author}
      postLikes={
        <LikeButtonViewModel
          postId={postId}
          includeAvatars={includeAvatars}
        />
      }}
    />
  );
}

这样调用 PostDetailsViewModel 会返回“未完成”的 JSON:

{
  type: "PostDetails", // ✅ 这是客户端组件
  props: {
    postTitle: "JSX 跨网络传输",
    // ...
    postLikes: {
      type: LikeButtonViewModel, // 🟡 还没运行这个 ViewModel
      props: {
        postId: "jsx-over-the-wire",
        includeAvatars: false,
      }
    }
  }
}

负责发送 JSON 给客户端的代码会发现这是个 ViewModel(还需运行),于是会调用 LikeButtonViewModel 得到更多 JSON:

{
  type: "PostDetails", // ✅ 这是客户端组件
  props: {
    postTitle: "JSX 跨网络传输",
    // ...
    postLikes: {
      type: "LikeButton", // ✅ 这是客户端组件
      props: {
        totalLikeCount: 8,
        // ...
      }
    }
  }
}

ViewModel 会递归展开,最终生成完整的 JSON,客户端再转成 React 组件树。

<PostDetails
  postTitle="JSX 跨网络传输"
  // ...
  postLikes={
    <LikeButton
      totalLikeCount={8}
      // ...
    />
  }
/>

数据总是向下流动

为了让 JSX 更优雅,我们可以把 postLikes 改名为 children,这样就能把 LikeButtonViewModel 作为 PostDetails 的 JSX 子节点嵌套。

来看完整代码,注意数据是如何向下流动的:

async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const post = await getPost(postId);
  return (
    <PostDetails
      postTitle={post.title}
      postContent={parseMarkdown(post.content, {
        maxParagraphs: truncateContent ? 1 : undefined
      })}
      postAuthor={post.author}
    >
      <LikeButtonViewModel
        postId={postId}
        includeAvatars={includeAvatars}
      />
    </PostDetails>
  );
}
 
async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
const [post, friendLikes] = await Promise.all([
  getPost(postId),
  getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
]);
return (
  <LikeButton
    totalLikeCount={post.totalLikeCount}
    isLikedByUser={post.isLikedByUser}
    friendLikes={friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))}
  />
);

以上所有服务端逻辑都会在生成 JSON 时执行,包括 getPostparseMarkdowngetFriendLikes。响应会包含整个页面的数据,满足我们关键需求

{
  type: "PostDetails", // ✅ 这是客户端组件
  props: {
    postTitle: "JSX 跨网络传输",
    // ...
    children: {
      type: "LikeButton", // ✅ 这是客户端组件
      props: {
        totalLikeCount: 8,
        // ...
      }
    }
  }
}

对客户端来说,一切都是预先计算好的:

function PostDetails({
  postTitle,
  postContent,
  postAuthor,
  children,
}) {
  return (
    <article>
      <h1>{postTitle}</h1>
      <div dangerouslySetInnerHTML={{ __html: postContent }} />
      <p>by {postAuthor.name}</p>
      <section>
        {children}
      </section>
    </article>
  );
}
 
function LikeButton({ totalLikeCount, isLikedByUser, friendLikes }) {
  // ...
}

特别地,当 PostDetails 运行时,children 就是预设好 props 的 <LikeButton> 标签。ViewModel 配置了客户端的 props。所以客户端拿到的 props 都是“现成的”。

仔细体会下上面的代码。

是的,这确实很怪。

但也很美妙。

我们找到了跨前后端组合标签的方法,服务端和客户端的部分可以任意嵌套,而且都能正常工作——所有服务端数据加载都在一次请求内完成。

事实上,这种方式满足了我的所有清单

现在我们来收尾,解决一些细节。


路由 ViewModel

随着我们把 ViewModel 用 JSX 重构(对 JSX 持怀疑态度的读者——这里的重点不是语法,而是惰性求值),我们会发现其实不需要为每个页面单独写 Express 路由。

我们可以这样做:

app.get('/*', async (req, res) => {
  const url = req.url;
  const json = await toJSON(<RouterViewModel url={url} />); // 评估 JSX
  res.json(json);
});

然后有个 Router ViewModel 匹配路由:

function RouterViewModel({ url }) {
  let route;
  if (matchRoute(url, '/screen/post-details/:postId')) {
    const { postId } = parseRoute(url, '/screen/post-details/:postId');
    route = <PostDetailsRouteViewModel postId={postId} />;
  } else if (matchRoute(url, '/screen/post-list')) {
    route = <PostListRouteViewModel />;
  }
  return route;
}

每个路由也是一个 ViewModel:

function PostDetailsRouteViewModel({ postId }) {
  return <PostDetailsViewModel postId={postId} />
}
 
async function PostListRouteViewModel() {
  const postIds = await getRecentPostIds();
  return (
    <>
      {postIds.map(postId =>
        <PostDetailsViewModel key={postId} postId={postId} />
      )}
    </>
  );
}

服务端就是一层层的 ViewModel。

这看起来有点多余。但把路由逻辑放进 ViewModel 世界,可以让 RouterViewModel 把输出包进客户端的 <Router>,这样导航到其他页面时可以重新请求 JSON。

function RouterViewModel({ url }) {
  let route;
  if (matchRoute(url, '/screen/post-details/:postId')) {
    const { postId } = parseRoute(url, '/screen/post-details/:postId');
    route = <PostDetailsRouteViewModel postId={postId} />;
  } else if (matchRoute(url, '/screen/post-list')) {
    route = <PostListRouteViewModel />;
  }
  return (
    <Router>
      {route}
    </Router>
  );
}
function Router({ children }) {
  const [tree, setTree] = useState(children);
  // ... 以后可以加点逻辑 ...
  return tree;
}

这样我们甚至可以实现更细粒度的路由,把路径分段,为每段并行准备 ViewModel,甚至在导航时只重新请求部分段。这样就不必每次跳转都重新请求整个页面。当然,这种逻辑最好由框架实现。


服务端组件与客户端组件

现在可以揭晓了——我们说的其实就是 React Server Components:

  • “ViewModel” 就是服务端组件(Server Components)。
  • “组件” 就是客户端组件(Client Components)。

两者都叫组件是有道理的。虽然本文第一部分中服务端组件起源于 ViewModel,但也可以追溯到 异步 XHP 标签。既然它们不必只返回 JSON,对实际项目来说你常常会两边都 import 相同组件,叫组件更合理。(事实上,本文例子里的所有客户端组件都可以搬到服务端。)

本文没有讨论如何“连接”服务端和客户端的模块系统。这是另一个话题,简而言之,当你从 'use client' 的模块 import 时,你拿到的不是实体——而是一个引用,描述如何加载它。

import { LikeButton } from './LikeButton';
 
console.log(LikeButton);
// "src/LikeButton.js#LikeButton"
 
async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
const [post, friendLikes] = await Promise.all([
  getPost(postId),
  getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
]);
return (
  <LikeButton
    totalLikeCount={post.totalLikeCount}
    isLikedByUser={post.isLikedByUser}
    friendLikes={friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))}
  />
);
'use client';
 
export function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  let buttonText = 'Like';
  if (totalLikeCount > 0) {
    // 例如:“你、Alice 和其他13人点赞了”
    buttonText = formatLikeText(totalLikeCount, isLikedByUser, friendLikes);
  }
  return (
    <button className={isLikedByUser ? 'liked' : ''}>
      {buttonText}
    </button>
  );
}

所以生成的 JSON 会包含加载 LikeButton 的指令:

{
  type: "src/LikeButton.js#LikeButton", // ✅ 这是客户端组件
  props: {
    totalLikeCount: 8,
    // ...
  }
}

React 会读取这个指令并加载对应 <script>(或从打包缓存读取)。格式依赖打包器,这也是 React Server Components 需要打包器集成的原因。(Parcel 刚发布了自己的实现,不依赖框架,非常适合玩 RSC。)

React Server Components 输出 JSON 而不是 HTML 很重要:

  • 服务端树可以原地重新获取,不会丢失状态。(React 会用“虚拟 DOM”机制,把新 props 应用到现有组件。)
  • 可以支持 Web 以外的平台。(这里有个很酷的演示。)
  • 你仍然可以把 JSON 转成 HTML,只需在服务端执行所有客户端组件即可!这不是 RSC 的强制要求,但完全可行。这也是为什么“客户端”组件有时会在“服务器”上运行——为了输出 HTML,你需要两边都跑。

最后说一句。我知道 React Server Components 不是所有人都喜欢。它确实很烧脑,但我认为这是好事。我会继续写更多关于 RSC 的文章,也会尝试把这些解释精炼成短文。希望本文能为你提供 RSC 的历史背景、它能做什么,以及如何通过自己的思考走到 RSC。

(顺便,如果你喜欢更哲学、更随性的长文,可以看看我的上一篇,它从第一性原理出发推导 RSC,没有历史包袱。)


小结:JSX 跨网络传输

  • React Server Components 用第二部分的技术解决了第一部分的问题。它让你把 API 的 UI 部分“组件化”,确保它们和 UI 一起演进。
  • 这意味着组件和准备其 props 的服务端代码直接关联。你随时可以“查找所有引用”,追踪数据从服务端流向每个组件的路径。
  • 因为 React Server Components 输出 JSON,页面刷新时不会“丢失”状态。组件可以从服务器接收新 props。
  • React Server Components 输出 JSON,但也可以(可选)转为 HTML 用于首屏渲染。JSON 转 HTML 很容易,反之则不然。
  • React Server Components 让你创建自包含的 UI 片段,自己准备服务端数据。但所有准备都在一次请求内完成。代码可模块化,执行却能合并。
  • RSC 很烧脑,不骗你。有时你得反过来思考。但我个人觉得 RSC 很棒。工具链还在演进,我对它的未来很期待。希望有更多技术能巧妙融合前后端边界。

最终代码(略有调整)

虽然这不是可运行的应用(你可以用 NextParcel 实现),也可能有些小错误,但这里是完整代码示例。我做了一些重命名,去掉了“ViewModel”术语,更贴近主流风格。

import { PostDetails, LikeButton } from './client';
 
export function PostDetailsRoute({ postId }) {
  return <Post postId={postId} />
}
 
export async function PostListRoute() {
  const postIds = await getRecentPostIds();
  return (
    <>
      {postIds.map(postId =>
        <Post key={postId} postId={postId} />
      )}
    </>
  );
}
 
async function Post({
  postId,
  truncateContent,
  includeAvatars
}) {
  const post = await getPost(postId);
  return (
    <PostLayout
      postTitle={post.title}
      postContent={parseMarkdown(post.content, {
        maxParagraphs: truncateContent ? 1 : undefined
      })}
      postAuthor={post.author}
    >
      <PostLikeButton
        postId={postId}
        includeAvatars={includeAvatars}
      />
    </PostLayout>
  );
}
 
async function PostLikeButton({
  postId,
  includeAvatars
}) {
const [post, friendLikes] = await Promise.all([
  getPost(postId),
  getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
]);
return (
  <LikeButton
    totalLikeCount={post.totalLikeCount}
    isLikedByUser={post.isLikedByUser}
    friendLikes={friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))}
  />
);
'use client';
 
export function PostLayout({
  postTitle,
  postContent,
  postAuthor,
  children,
}) {
  return (
    <article>
      <h1>{postTitle}</h1>
      <div dangerouslySetInnerHTML={{ __html: postContent }} />
      <p>by {postAuthor.name}</p>
      <section>
        {children}
      </section>
    </article>
  );
}
 
export function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  let buttonText = 'Like';
  if (totalLikeCount > 0) {
    buttonText = formatLikeText(totalLikeCount, isLikedByUser, friendLikes);
  }
  return (
    <button className={isLikedByUser ? 'liked' : ''}>
      {buttonText}
    </button>
  );
}

祝你“缝合”愉快!

Pay what you like

Discuss on Bluesky  ·  Edit on GitHub