cover_image

JSX Over The Wire,Part 1: JSON as Components

ikoofe KooFE前端团队
2025年04月18日 02:17

本文翻译自 Dan Abramov 的 JSX Over The Wire,原文分为三个部分这里是第一部分 「JSON as Components」,主要讲数据从 Models 到 ViewModels 的转换


假设有一个 API 路由,它返回一些JSON 格式数据:

app.get('/api/likes/:postId',async(req, res)=>{
const postId = req.params.postId;
const[post, friendLikes]=awaitPromise.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){
    // e.g. "Liked by You, Alice, and 13 others"
    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]=awaitPromise.all([
    getPost(postId),
    getFriendLikes(postId,{ limit:2}),
]);
const json ={
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes,
};
  res.json(json);
});

上面的代码让你想起了什么吗?Props. 你正在传递属性。你只是没有指定到哪里,但你已经知道他们的最终目的地 —— LikeButton。

那为什么不把 LikeButton 也塞进去呢?

app.get('/api/likes/:postId',async(req, res)=>{
const postId = req.params.postId;
const[post, friendLikes]=awaitPromise.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 返回你的组件。这么做有什么好处呢?

Part 1: JSON as Components

Model, View, ViewModel

在数据存储与数据展示之间,存在着根本性的矛盾:我们存储的信息量往往远超需要展示的内容。

以帖子的「点赞」功能为例,当我们为给定的帖子存储“赞”时,我们可能希望将它们表示为这样的 Like 数据:

type Like = {
  createdAt: string, // Timestamp
  likedById: number, // User ID
  postId: number     // Post ID
};

让我们把这种数据称为“模型(Model)”。它代表数据的原始形态。

type Model = Like;

所以我们的“点赞”数据库表可能包含这样的数据:

[{
  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
},/* ... */]

然而,我们想要向用户展示的内容是不同的。

我们想要展示的是该帖子的点赞数、用户是否已经点赞以及也点赞了该帖子的用户的朋友的名字。例如,点赞按钮可能会显示为按下状态(这意味着你已经点赞了这个帖子),并显示“你、爱丽丝和其他 13 人点赞了这个帖子。”或者“爱丽丝、鲍勃和其他 12 人点赞了这个帖子。”

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

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

type ViewModel = LikeButtonProps;

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

  • ViewModel 的 totalLikeCount 是从单个 Like 模型中聚合而来的。
  • ViewModel 的 isLikedByUser 是个性化的,并且取决于用户。
  • ViewModel 的 friendLikes 既经过聚合又具有个性化。要计算它,你必须获取此帖子的点赞数,将其筛选为来自朋友的点赞,并获取前几个朋友的名字(这些名字可能存储在不同的表中)。

显然,模型在某些时候需要转变为视图模型。问题是这在代码中的何处以及何时发生,以及该代码如何随时间演变。

REST 与 JSON API

解决这个问题最常见的方法是 JSON API,客户端可以调用该 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'
}

到目前为止一切都没问题。但是你如何将点赞数据放入其中呢?也许 totalLikeCount 和 isLikedByUser 可以成为帖子资源的一部分:

// 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']
}

或者我们是否开始滥用“帖子”的概念,在其中添加了太多内容?好吧,那这个怎么样,也许我们可以为帖子的“赞”提供一个单独的接口。

// 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 接口来解决这个问题吗?或者我们应该先按朋友排序,并将 isFriend 包含在 likes 数组项中,以便我们能够区分朋友的“赞”和其他“赞”吗?或者我们应该添加?filter=friends 吗?

或者我们应该直接将朋友的点赞包含在帖子中以避免两次 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

总之,我在这里想说的重点并不是设计一个好的 REST API 是不可能的。我见过的绝大多数应用都是这样工作的,所以至少这是可行的。但是任何设计了一个 REST API 然后在上面工作了几个月以上的人都知道其中的门道。“演进 REST 是一件令人头疼的事情。”

它通常是这样的:

  1. 首先,你需要决定如何构建 JSON 输出的结构。这些选项中没有哪个明显优于其他;大多数时候你只是在猜测应用程序将如何发展。

  2. 最初的决策通常会在几次来回迭代后趋于稳定……直到下一次 UI 改版,导致 ViewModel 的结构发生细微变化。而现有的 REST 接口已无法完全满足新的需求。

  3. 虽然可以新增 REST API 接口,但某种程度上你"不应该"继续添加——因为所有可能的资源(Resources)理论上都已定义完毕。比如当 /posts/123 接口存在时,你不太可能再额外创建一个"获取帖子"的接口。

  4. 于是你开始陷入两难:返回的数据要么不足,要么冗余。要么粗暴地在现有资源里"扩展"字段,要么设计一套复杂的按需加载规范。

  5. 某些 ViewModel 明明只有少数页面用到,却始终包含在响应中——毕竟比起实现动态配置,直接返回更省事。

  6. 还有些页面不得不拼凑多个 API 调用的结果,因为再也找不到一个接口能提供完整所需数据。

  7. 然后产品设计和功能再次变更,循环重启。

这里显然存在根本性矛盾,但根源是什么? (矛盾在于:REST 接口的静态资源模型与前端动态多变的视图需求之间,存在不可调和的架构断层。后端试图用有限的标准化接口应对千变万化的前端场景,而前端则不断为每个新页面发明临时解决方案。)

首先要注意: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模式)鼓励开发者从"资源"角度思考,而非"数据模型"或"视图模型"。最初,资源只是数据模型的镜像。但单个数据模型往往无法满足界面需求,于是开发者开始临时约定在资源中嵌套模型。然而包含所有相关模型(比如帖子的所有点赞数据)通常不现实,于是资源中逐渐混入了类似视图模型的字段(如friendLikes)。

但将视图模型塞进资源同样问题重重。视图模型并非"帖子"这类抽象概念,每个视图模型都对应着具体界面模块。这导致"帖子"资源的结构不断膨胀,试图满足所有相关界面的需求。而这些需求本身也在变化,最终"帖子"资源的结构要么是不同界面当前需求的折中方案,要么沦为记录历史需求的化石。

让我说得更直白些:

REST资源缺乏现实根基。其结构缺乏有效约束——我们基本是在凭空捏造概念。不同于扎根于存储结构的数据模型,也不同于服务于界面展示的视图模型。更糟的是,无论向哪个方向靠拢都会适得其反。

若让REST资源贴近数据模型,用户体验将受损。本可单次请求获取的数据需要多次(甚至N次)调用。这在前后端团队缺乏协作的产品中尤为明显——看似优雅的API实际使用起来极其低效。

若让REST资源贴近视图模型,则维护性将崩塌。视图模型本就善变!每次界面改版都可能改变其结构。但REST资源的修改成本极高,因为多个界面共享相同资源。最终资源结构逐渐偏离实际需求,演变成难以维护的庞然大物。后端团队抵制添加界面专属字段是有道理的——这些字段很快就会过时!

这未必是REST本质的问题。当资源定义清晰、字段设计恰当时,REST依然好用。但问题在于:客户端真正需要的是获取整屏数据。现有方案缺失了关键环节。

我们需要一个转换层。

API for ViewModels

有一种方法可以化解这种矛盾。

具体实现方式你可以灵活掌握,但核心理念是:客户端应当能够一次性获取特定屏幕所需的所有数据。

这个想法非常简单!

不同于从客户端请求"规范化的"REST 资源,例如:

GET /data/post/123       # 获取帖子资源
GET /data/post/123/likes # 获取帖子点赞资源

而是直接请求特定屏幕(即路由)的视图模型:

GET /screens/post-details/123 # 获取PostDetails屏幕的视图模型
返回数据将包含该屏幕所需的一切内容

这个差异看似细微却影响深远。你不再需要定义通用的帖子数据结构规范,而是直接发送 PostDetails 屏幕当前展示组件所需的任何数据。如果 PostDetails 屏幕被删除,这个端点也随之删除。如果其他屏幕需要显示相关信息(比如 PostLikedBy 弹窗),它会拥有自己的路由:

GET /screens/post-details/123 # 获取PostDetails屏幕的视图模型
GET /screens/post-liked-by/123 # 获取PostLikedBy屏幕的视图模型
这具体有什么好处呢?

这种方法避免了"无根基"抽象的陷阱。每个屏幕的视图模型接口都精确指定了服务器响应的数据结构。如需调整或优化,可以单独修改而不会影响其他屏幕。

例如,PostDetails 屏幕的视图模型可能如下:

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

这就是服务器对/screens/post-details/123 的返回结构。后续如果想展示点赞好友的头像,只需更新该视图模型:

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

注意你只需要更新该屏幕的端点。不再需要权衡不同屏幕的需求差异,也不会出现"这个字段属于哪个资源?"或"是否应该扩展?"之类的问题。如果某个屏幕需要更多数据,直接在该屏幕的响应中包含即可——不必追求通用性或可配置性。服务器响应的数据结构完全由每个屏幕的具体需求决定。

这确实解决了 REST 架构的上述问题。

但也带来了几个新问题:

• 相比 REST 资源,端点数量会大幅增加(每个屏幕对应一个端点)。如何组织这些端点并保持可维护性?
• 如何在端点之间复用代码?这些端点之间可能存在大量重复的数据访问和其他业务逻辑
• 如何说服后端团队从 REST API 转向这种模式?

最后一个问题可能是最需要优先解决的。后端团队很可能会对这种做法提出合理质疑。至少,如果这种方法被证明不可行,我们需要有回退方案。

幸运的是,我们不需要推翻现有架构。

Backend For Frontend,BFF

与其替换现有的 REST API,你可以在它前面加一个新层:

// 新增针对特定屏幕的端点...
app.get("/screen/post-details/:postId",async(req, res)=>{
const[post, friendLikes]=awaitPromise.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 没有其他消费者,你可以把它变成内部微服务,避免直接暴露给外部。更进一步,你甚至可以把它变成数据访问层(而非 HTTP 服务),直接在 BFF 进程中导入:

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]=awaitPromise.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 调用意味着数据库读取可以批量优化)。同时,它允许你在必要时“降级”抽象层级,例如运行一个未通过 REST API 暴露的精细调优存储过程。

BFF 模式有很多优点,但也带来了新问题:

  • 代码组织
    :如果每个屏幕本质上都是自己的 API 方法,如何避免代码重复?
  • 数据同步
    :如何确保 BFF 与前端的数据需求保持同步?

接下来,我们尝试探讨这些问题的解决方案。

Composable BFF

假设你正在添加一个新的 PostList 页面。这个页面需要渲染一组组件,每个组件需要和之前相同的数据结构:

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 的数据?

你的第一反应可能是从客户端向现有的/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]=awaitPromise.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.firstName)
    }
};
  res.json(viewModel);
});

app.get('/screen/post-list',async(req, res)=>{
// 获取近期帖子ID
const postIds =awaitgetRecentPostIds();
const viewModel ={
    // 为每个帖子ID并行加载数据
    posts:awaitPromise.all(postIds.map(async postId =>{
      const[post, friendLikes]=awaitPromise.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.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]=awaitPromise.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.firstName)
    }
};
  res.json(viewModel);
});

app.get('/screen/post-list',async(req, res)=>{
const postIds =awaitgetRecentPostIds();
const viewModel ={
    posts:awaitPromise.all(postIds.map(async postId =>{
      const[post, friendLikes]=awaitPromise.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.firstName)
        }
      };
      return postDetailsViewModel;
    }))
};
  res.json(viewModel);
});

似乎存在一个亟待提取的"PostDetails ViewModel"概念。这并不奇怪——两个页面都渲染相同的组件,因此它们需要类似的代码来加载数据。

提取 ViewModel

让我们提取一个 PostDetailsViewModel 函数:

import { getPost, getFriendLikes, getRecentPostIds }from'@your-company/data-layer';

asyncfunctionPostDetailsViewModel({ postId }){
const[post, friendLikes]=awaitPromise.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.firstName)
    }
};
}

app.get('/screen/post-details/:postId',async(req, res)=>{
const postId = req.params.postId;
const viewModel =awaitPostDetailsViewModel({ postId });
  res.json(viewModel);
});

app.get('/screen/post-list',async(req, res)=>{
const postIds =awaitgetRecentPostIds();
const viewModel ={
    posts:awaitPromise.all(postIds.map(postId =>
      PostDetailsViewModel({ postId })
    ))
};
  res.json(viewModel);
});

这使得我们的 BFF 端点显著简化。

实际上,我们还可以更进一步。看看 PostDetailsViewModel 的这个部分:

async functionPostDetailsViewModel({ postId }){
const[post, friendLikes]=awaitPromise.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.firstName)
    }
};
}

我们知道 postLikes 字段的最终目的是成为 LikeButton 组件的 props——也就是说这个字段就是 LikeButton 的 ViewModel:

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

因此让我们将准备这些 props 的逻辑提取到 LikeButtonViewModel 中:

import { getPost, getFriendLikes, getRecentPostIds }from'@your-company/data-layer';

asyncfunctionLikeButtonViewModel({ postId }){
const[post, friendLikes]=awaitPromise.all([
    getPost(postId),
    getFriendLikes(postId,{ limit:2}),
]);
return{
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes.likes.map(=> l.firstName)
};
}

asyncfunctionPostDetailsViewModel({ postId }){
const[post, postLikes]=awaitPromise.all([
    getPost(postId),// 这里再次调用getPost()没问题。我们的数据层通过内存缓存去重
    LikeButtonViewModel({ postId }),
]);
return{
    postTitle: post.title,
    postContent:parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes
};
}

现在我们有了一个加载数据为 JSON 的函数树——我们的 ViewModels。

根据你的背景,这可能会让你联想到其他一些东西。它可能让你想起将 Redux reducer 拆分为更小的 reducer 进行组合。也可能让你想起将 GraphQL 片段组合成更大的片段。或者让你想起将 React 组件组合成更大的组件。

虽然现在的代码风格有些冗长,但将页面的 ViewModel 拆分为更小的 ViewModel 有种奇特的满足感。这感觉就像编写 React 组件树,只不过我们是在分解后端 API。就像数据有自己的形状,但它大致与你的 React 组件树对齐。

让我们看看当 UI 需要演进时会发生什么。

ViewModel 的演进

假设 UI 设计变更,我们需要同时展示好友的头像:

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

若使用 TypeScript,ViewModel 会立即抛出类型错误:

async functionLikeButtonViewModel({
  postId,
}:{
  postId:number;
}): LikeButtonProps {
const[post, friendLikes]=awaitPromise.all([
    getPost(postId),
    getFriendLikes(postId,{ limit:2}),
]);
return{
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    // 🔴 类型'string[]'不能赋值给类型'{ firstName: string; avatar: string; }[]'
    friendLikes: friendLikes.likes.map((l)=> l.firstName),
};
}

修复如下:

async functionLikeButtonViewModel({
  postId,
}:{
  postId:number;
}): LikeButtonProps {
const[post, friendLikes]=awaitPromise.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格式——这正是 React 组件所需。无需额外修改即可直接运行。我们确信其可靠性,因为无论请求哪个 BFF 接口,LikeButtonViewModel都是生成LikeButton props 的唯一入口。(暂假设该前提成立,具体绑定方式后续讨论)

这一特性值得特别关注,因为它意义深远。

上一次你能清晰追溯服务端深层嵌套代码生成的数据片段与客户端深层嵌套代码消费该数据的对应关系是什么时候?我们显然找到了关键模式。

ViewModel 参数

你可能注意到 ViewModel 函数可接收参数。关键在于这些参数可由"父级"ViewModel 指定并向下传递——客户端无需感知它们。

例如,若需在帖子列表页仅展示每篇帖子的首段内容,可为其 ViewModel 添加参数:

async functionPostDetailsViewModel({ postId, truncateContent }){
const[post, postLikes]=awaitPromise.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 =awaitPostDetailsViewModel({
    postId,
    truncateContent:false,
});
  res.json(viewModel);
});

app.get("/screen/post-list",async(req, res)=>{
const postIds =awaitgetRecentPostIds();
const viewModel ={
    posts:awaitPromise.all(
      postIds.map((postId)=>
        PostDetailsViewModel({
          postId,
          truncateContent:true,
        })
      )
    ),
};
  res.json(viewModel);
});

此时post-details接口仍返回完整内容,而post-list接口仅返回摘要。这属于视图模型逻辑,现在我们有天然的位置来表达这种差异。

参数传递链

假设只需在详情页显示头像,可修改LikeButtonViewModel接收includeAvatars参数:

async functionLikeButtonViewModel({ postId, includeAvatars }){
const[post, friendLikes]=awaitPromise.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 functionPostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars,
}){
const[post, postLikes]=awaitPromise.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 =awaitPostDetailsViewModel({
    postId,
    truncateContent:false,
    includeAvatars:true,
});
  res.json(viewModel);
});

app.get("/screen/post-list",async(req, res)=>{
const postIds =awaitgetRecentPostIds();
const viewModel ={
    posts:awaitPromise.all(
      postIds.map((postId)=>
        PostDetailsViewModel({
          postId,
          truncateContent:true,
          includeAvatars:false,
        })
      )
    ),
};
  res.json(viewModel);
});

客户端无需通过?includeAvatars=true等临时参数控制响应内容。BFF 接口自身知晓列表页不应包含头像,因此直接传递includeAvatars: false。客户端代码完全无需感知服务端逻辑——它只需确保获得所需的 props。

当需要显示头像时,我们可能希望展示 5 个而非 2 个头像。可直接修改LikeButtonViewModel

async functionLikeButtonViewModel({ postId, includeAvatars }){
const[post, friendLikes]=awaitPromise.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的唯一职责是生成按钮 props,在此添加展示逻辑非常自然。毕竟这是视图模型(ViewModel)。如果其他视图需要不同数量的头像,也可自由定制。与 REST 不同,这里不存在所谓的"标准帖子数据"——任何 UI 都可以精确指定其需要的数据,从整个页面到单个按钮皆然。

我们的 ViewModel 始终与客户端需求保持同步演进。

组合 ViewModel

有趣的结构正在形成。我们已经开始将 BFF 接口拆分为可复用的逻辑单元,并发现这些单元能让我们以封装用户界面的类似方式来封装数据加载。如果你仔细观察 ViewModel,甚至会发现它们与组件有某些相似之处。

然而 ViewModel 树的最终产物并非 UI 树——它只是 JSON。

// GET /screen/post-list
{
/* screen/post-list ViewModel开始 */
"posts":[
    {
      /* PostDetailsViewModel开始 */
      "postTitle":"JSX Over The Wire",
      "postAuthor":"Dan",
      "postContent":"假设你有一个返回JSON数据的API路由",
      "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 生成的属性传递给 LikeButton 组件,同样也需要将 PostDetailsViewModel 生成的属性传递给 PostDetails 组件。我们不想仅仅为了手动将每个 ViewModel 的数据精确传递到对应组件,而生成庞大的 JSON ViewModel 树。

我们正在两个世界中构建两条平行的层级结构。但这两个世界尚未连通。还缺少某些关键部分。

回顾:JSON as Components

对于任何 UI,数据始于 Models,终于 ViewModels。Models 到 ViewModels 的转换必须发生在某个环节。
ViewModel 的结构完全由用户界面设计决定,这意味着它们会随着设计迭代而演进。同时,不同屏幕需要从相同底层 Models 聚合出不同的 ViewModels。

将服务端数据建模为 REST 资源会产生矛盾:若 REST 资源接近原始 Models,可能需要多次往返请求和复杂的临时约定才能获取屏幕所需 ViewModel;若 REST 资源接近 ViewModel,又会与初始设计的屏幕过度耦合,无法随客户端需求演进。

通过引入新层——前后端对接层(BFF)可解决这一矛盾。BFF 的职责是将客户端需求("给我这个屏幕的数据")转换为后端 REST 调用。BFF 还能突破 REST 门面的局限,直接通过进程内数据层加载数据。

由于 BFF 需要将每个屏幕所需数据作为 JSON 返回,很自然会将数据加载逻辑拆分为可复用单元。屏幕的 ViewModel 可分解为 ViewModel 树,对应客户端组件需要接收的不同服务端数据块,这些独立 ViewModel 可被重新组合。

ViewModel 函数能相互传递信息,这使得我们可以根据屏幕需求定制 JSON。与 REST 不同,我们不再试图设计像"文章对象"这样用于所有响应的规范结构。我们可以随时根据不同屏幕需求,为相同信息提供不同的 ViewModel——无论它们需要什么。这些 ViewModel 就是视图模型,它们可以(也应该?)包含展示逻辑。

我们开始意识到 ViewModel 与 React 组件有着极其相似的结构。ViewModel 就像是生成 JSON 的组件。但如何将服务端生成的 JSON 传递给客户端需要的组件,这个问题仍未解决。同时维护两条平行层级结构也很麻烦。我们已经有所发现,但仍缺少关键拼图。

到底缺少什么?

继续滑动看下一个
KooFE前端团队
向上滑动看下一个