线上传输 JSX:探索 **JSX Over The Wire** 的奥秘
by overreacted
JSX Over The Wire
April 16, 2025 Pay what you like
假设你有一个 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 组件:
functionLikeButton({
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>
);
}
你如何将这些数据传递给该组件? 你可以使用某种数据获取库,从父组件传递它:
functionPostLikeButton({ 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 返回 你的组件。
你为什么要这样做?
Part 1: JSON as Components
Model, View, ViewModel
我们希望 存储 信息的方式与我们希望 显示 信息的方式之间存在着根本的矛盾。通常,我们希望存储比我们显示的更多的东西。
例如,考虑 Post 上的 Like 按钮。当我们存储给定 Post 的 Likes 时,我们可能希望将它们表示为 Like
行的表,如下所示:
type Like ={
createdAt:string,// Timestamp
likedById:number,// User ID
postId:number// Post 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
},/* ... */]
但是,我们希望 向用户显示 的内容是不同的。 我们想要显示的是该 Post 的 Likes 数量, 用户是否已经喜欢它 以及 他们的朋友也喜欢它 的姓名。例如,Like 按钮可以显示为按下状态(这意味着你已经喜欢这篇文章),并显示“You, Alice, and 13 others liked this.”或 “Alice, Bob, and 12 others liked this.”。
type LikeButtonProps ={
totalLikeCount:number,
isLikedByUser:boolean,
friendLikes:string[]
}
让我们将这种数据称为“ViewModel”。
type ViewModel =LikeButtonProps;
ViewModel 以 UI (即 view) 可以直接使用的方式表示数据。它通常与原始 Model 有很大不同。在我们的示例中:
- ViewModel 的
totalLikeCount
是从单个Like
模型聚合的。 - ViewModel 的
isLikedByUser
是个性化的,并且取决于用户。 - ViewModel 的
friendLikes
既是聚合的又是个性化的。要计算它,你必须获取此 post 的 Likes,将其过滤到来自朋友的 likes,并获取前几个朋友的姓名(这些姓名可能存储在不同的表中)。
显然,Models 迟早需要转换为 ViewModels。问题是 在哪里 和 何时 在代码中发生这种情况,以及该代码如何随时间演变。
REST and JSON API
解决此问题的最常见方法是公开某种 JSON API,客户端可以点击该 API 以组装 ViewModel。设计这种 API 有不同的方法,但最常见的方法是通常所说的 REST。 REST 的典型方法(假设我们从未读过这篇文章)是选择一些“Resources”——例如 Post 或 Like——并提供 JSON API 端点来列出、创建、更新和删除这些 Resources。当然,REST 没有指定你应该如何 塑造 这些 Resources,因此有很多灵活性。 通常,你可以从返回 Model 的形状开始:
// GET /api/post/123
{
title: 'My Post',
content: 'Hello world...',
authorId: 123,
createdAt: '2025-04-13T02:04:40.668Z'
}
到目前为止一切顺利。但是你将如何将 Likes 纳入其中?也许 totalLikeCount
和 isLikedByUser
可以成为 Post Resource 的一部分:
// 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 添加过多的内容来滥用 Post 的概念?好的,怎么样,也许我们可以为 Post 的 Likes 提供一个单独的端点:
// 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,
},/* ... */]
}
因此,Post 的 Like 成为其自身的“Resource”。 这在理论上是不错的,但是我们需要知道 likers 的姓名,并且我们不想为每个 Like 发出请求。因此,我们需要在此处“扩展”用户:
// 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'
}
}]
}
我们还“忘记”了这些 Likes 中哪些来自朋友。我们是否应该通过拥有单独的 /api/post/123/friend-likes
端点来解决此问题?还是应该先按朋友排序,然后将 isFriend
包含到 likes
数组项中,以便我们可以区分朋友和其他 likes?还是应该添加 ?filter=friends
?
还是应该将朋友的 likes 直接包含到 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 端点是很痛苦的。
通常是这样:
- 最初,你必须决定如何构造 JSON 输出。所有选项都不是 明显更好 的;主要是你只是猜测应用程序将如何发展。
- 最初的决定往往会在几次来回迭代后安定下来……直到下一次 UI 重新设计导致 ViewModels 具有略有不同的形状。现有的 REST 端点并没有完全满足新的需求。
- 可以添加新的 REST API 端点,但是在某个时候,你实际上不应该添加更多,因为你已经定义了所有可能的 Resources。例如,如果
/posts/123
存在,你可能不会添加另一个“get post”API。 - 现在,你遇到了计算和发送 不够 或 太多 数据的问题。你要么积极地“扩展”现有 Resources 中的字段,要么提出一套精心设计的按需执行的约定。
- 某些 ViewModels 仅由一部分屏幕需要,但它们始终包含在响应中,因为这比使其可配置更容易。
- 某些屏幕会尝试从多个 API 调用中拼凑它们的 ViewModels,因为没有单个响应包含所有必要的信息。
- 然后,你的产品的设计和功能再次发生变化。重复。
显然这里存在一些根本的矛盾,但是是什么导致了它? 首先,请注意 ViewModel 的形状是如何由 UI 确定的。它不是对 Like 的某种柏拉图式想法的反映;而是由设计决定的。我们希望显示 “You, Ann, and 13 others liked this”, 因此 我们需要这些字段:
type LikeButtonProps ={
totalLikeCount:number,
isLikedByUser:boolean,
friendLikes:string[]
}
如果此屏幕的设计或功能发生更改(例如,如果你要显示喜欢该帖子的朋友的头像),ViewModel 也会发生更改:
type LikeButtonProps ={
totalLikeCount:number,
isLikedByUser:boolean,
friendLikes:{
firstName:string
avatar:string
}[]
}
但是这里的关键在于。
REST(或者更确切地说,REST 的广泛使用方式)鼓励你从 Resources 而不是 Models 或 ViewModels 的角度进行思考。起初,你的 Resources 开始镜像 Models。但是单个 Model 很少有足够的屏幕数据,因此你开发了用于在 Resource 中嵌套 Models 的临时约定。但是,通常无法或不切实际地包含 所有 相关 Models(例如 Post 的所有 Likes),因此你开始向 Resources 添加 ViewModel 式字段,例如 friendLikes
。
但是,将 ViewModels 放入 Resources 中也不能很好地工作。ViewModels 不是像“post”这样的抽象概念;每个 ViewModel 都描述了 特定的 UI 部分。结果,你的 “Post” Resource 的形状不断增长,以涵盖显示 post 的每个屏幕的需求。但是这些需求也 随时间而变化, 因此 “Post” Resource 的形状充其量是在不同屏幕现在需要什么之间的折衷方案,在最坏的情况下是它们过去曾经需要的所有内容的化石记录。
让我更直白地说:
REST Resources 在现实中没有牢固的基础。 它们的形状没有受到足够的约束——我们主要是在凭空捏造概念。与 Models 不同,它们不是基于数据的存储方式的现实。而且与 ViewModels 不同,它们不是基于数据的呈现方式的现实。不幸的是,朝着任一方向推动它们只会使情况变得更糟。
如果你使 REST Resources 靠近 Models,你将损害用户体验。现在,可以在单个请求中获取的内容将需要几个或,天哪,N 次调用。在来自后端团队将 REST API “移交给” 前端团队并且不接受反馈的公司的产品中,这一点尤其明显。API 可能看起来简单而优雅,但使用起来完全不切实际。
另一方面,如果你推动 REST Resources 靠近 ViewModels,你将损害可维护性。ViewModels 是反复无常的!大多数 ViewModels 将在下次重新设计相应的 UI 部分时发生更改。但是更改 REST Resources 的形状很困难——许多屏幕都在获取相同的 Resources。因此,它们的形状逐渐偏离了当前 ViewModels 的需求,并且变得难以演变。后端团队通常拒绝向响应添加 UI 特定的字段是有原因的:它们可能会过时!
这不一定意味着 REST 本身(如其广泛理解的那样)已损坏。当 Resources 定义明确且其字段选择得当,使用起来可能会非常不错。但是,这通常与客户端的需求背道而驰,客户端的需求是 为特定屏幕 获取所有数据。中间缺少一些东西。
我们需要一个翻译层。
API for ViewModels
有一种方法可以解决此矛盾。 对于你如何处理它,你有一些自由度,但是主要思想是你的客户端应该能够 一次请求特定屏幕的所有数据。 这是一个如此简单的想法! 而不是从客户端请求“规范” REST Resources,例如:
GET/data/post/123# 获取 Post Resource
GET/data/post/123/likes# 获取 Post Likes Resource
你请求 特定屏幕(即路由)的 ViewModel:
GET/screens/post-details/123# 获取 PostDetails 屏幕的 ViewModel
此数据将包括该屏幕需要 的一切。 不同之处是微妙但意义深远的。你不再尝试定义 Post 的通用规范形状。而是发送 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
}[]
}
}
请注意,你只需更新 该屏幕的端点。你不再被迫平衡一个屏幕的需求与另一个屏幕的需求。没有像“此字段属于哪个 Resource?”之类的问题,或者它是否应该被 “扩展”。如果某些屏幕需要比其他屏幕更多的数据,你只需在 该 屏幕的响应中包含更多数据——它不必是通用的或可配置的。 服务器响应的形状完全由每个屏幕的需求决定。 这 确实 解决了 REST 的既定问题。 它还引入了一些新颖的问题:
- 端点的数量将比 REST Resources 多 很多 ——每个屏幕一个端点。如何构造和保持这些端点的可维护性?
- 如何在端点之间重用代码?据推测,这些端点之间会有大量重复的数据访问和其他业务逻辑。
- 你如何说服后端团队从他们的 REST APIs 转到此模式?
最后一个问题可能是我们需要解决的第一个问题。后端团队可能会对此方法抱有非常合理的保留意见。至少,如果此方法证明很糟糕,最好有一种方法可以迁移回去。 幸运的是,无需丢弃任何东西。
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 适应返回 ViewModels。
如果某些屏幕需要更多数据,BFF 允许你为其提供更多数据,而无需更改整个数据模型。它可以使特定于屏幕的更改保持在范围内。至关重要的是,它可以让你在单次往返中传递任何屏幕所需的所有数据。
BFF 不必使用与你的 REST API 相同的语言编写。出于我们稍后将要讨论的原因,最好使用与你的前端代码相同的语言编写 BFF。你可以将其视为 在服务器上运行的前端的一部分。它就像前端的服务器“大使”。它将 REST 响应“调整”为前端 UI 的每个屏幕实际想要的形状。
虽然你可以通过仅客户端的按路由加载程序(例如 React Router 中的 clientLoader
)获得 BFF 的某些好处,但通过实际将此层部署在靠近 REST 端点的服务器上,你可以解锁很多东西。
例如,即使你 确实 必须依次进行多个 REST API 请求才能加载屏幕所需的所有必要数据,BFF 和你的 REST API 之间的延迟也会比从客户端发出多个串行请求时低得多。如果你的 REST API 响应在内部网络上很快,则可以减少客户端/服务器瀑布的实际秒数,而无需实际并行化(有时不可避免的)串行调用。
BFF 还允许你在将数据发送到客户端 之前 应用数据转换,这可以显着提高低端客户端设备的性能。你甚至可以缓存或持久化磁盘上的一些计算,即使是在不同的用户 之间,因为你可以访问磁盘和服务器缓存(如 Redis)。从这个意义上讲,BFF 让前端团队拥有 他们自己的一小部分服务器。
重要的是,BFF 为你提供了一种试验 REST APIs 替代方案的方法,而不会影响客户端应用程序。例如,如果你的 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] = 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
调用意味着可以批量处理数据库读取)。它还允许你在需要时“降低” 抽象级别——例如,运行一个微调的存储数据库过程,该过程不会通过 REST API 整齐地公开。
关于 BFF 模式有很多值得喜欢的地方。它解决了相当多的问题,但同时也提出了新的问题。例如,你如何组织它的代码?如果每个屏幕本质上都是自己的 API 方法,你如何避免代码重复?以及如何使你的 BFF 与前端的数据需求保持同步?
让我们尝试在回答这些问题方面取得一些进展。
Composable 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
的数据?
你的第一个倾向可能是从客户端向现有的 /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:awaitPromise.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:awaitPromise.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>
组件,因此它们需要类似的代码来加载它的数据。
Extracting a ViewModel
让我们提取一个 PostDetailsViewModel
函数:
import{ getPost, getFriendLikes, getRecentPostIds }from'@your-company/data-layer';
asyncfunctionPostDetailsViewModel({ 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:awaitPromise.all(postIds.map(postId =>
PostDetailsViewModel({ postId })
))
};
res.json(viewModel);
});
这使我们的 BFF 端点更加简单。
实际上,我们可以更进一步。看一下 PostDetailsViewModel
的这一部分:
asyncfunctionPostDetailsViewModel({ 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——即,此字段是 LikeButton
的 ViewModel:
functionLikeButton({
totalLikeCount,
isLikedByUser,
friendLikes
}){
// ...
}
因此,让我们提取准备这些 props 的逻辑到 LikeButtonViewModel
中:
import{ getPost, getFriendLikes, getRecentPostIds }from'@your-company/data-layer';
asyncfunctionLikeButtonViewModel({ 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)
};
}
asyncfunctionPostDetailsViewModel({ 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 格式的数据——我们的 ViewModels。 根据你的背景,这可能会让你想起其他一些东西。它可能会让你想起将 Redux reducers 从较小的 reducers 中组合出来。它也可能会让你想起从较小的片段中组合 GraphQL 片段。或者它可能会让你想起从其他 React 组件中组合 React 组件。 尽管现在的代码风格有点冗长,但是将屏幕的 ViewModel 分解为较小的 ViewModels 有着一种奇怪的满足感。感觉类似于编写 React 组件树,只不过我们正在分解后端 API。就像 数据有其自身的形状,但它大致与你的 React 组件树一致。 让我们看看当 UI 需要演变时会发生什么。
Evolving a ViewModel
假设 UI 设计发生变化,并且我们也要显示朋友的头像:
type LikeButtonProps ={
totalLikeCount:number,
isLikedByUser:boolean,
friendLikes:{
firstName:string
avatar:string
}[]
}
假设我们使用 TypeScript,我们将立即在 ViewModel 中收到类型错误:
asyncfunctionLikeButtonViewModel(
{ postId } :{ postId:number}
):LikeButtonProps{
const [post,friendLikes] = await Promise.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)
};
}
让我们修复它:
asyncfunctionLikeButtonViewModel(
{ 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
React 组件想要的。无需进行进一步的更改—— 它就可以工作了。我们 知道 它可以工作,因为 LikeButtonViewModel
是生成