React 19 再进化:编写过去无法实现的组件
React 19 不久前发布了 beta 版,终于与大家见面了。那么它给我们带来了什么呢?有很多复杂而灵活的新特性、一堆出色的优化改进。而本文要说的是最后一项,一种全新的应用思维方式。
但是在构建营销网站时,React 19 的特性是不是过度设计了?这些努力都是值得的吗?
在过去的 18 个月里,这些特性已在 React Canary 和 Next.js App Router 中一点点退出来了。而且在过去的 18 个月里,我们一直在自己的文档和营销网站中使用它们,所以我们的团队有一些使用体验可以同大家分享。
这篇文章就让我们来谈谈 React 19 的一众新特性,谈谈它们为什么会让 React 开发的未来变得更加光明——即使你只是在编写一个营销网站。
上面我提到了“一种全新的应用思维方式”。这个新范式中最重要的部分是服务器组件。你可能听说过它们——它们在 React 社区中引起了巨大的震动。下面先简单过一下这个概念:
过去,当我们编写 React 代码时,我们会将 React 代码发送到客户端,然后代码会生成 HTML(同样是在客户端上)。现在我们有两种类型的组件。客户端组件的工作方式与 React 过去一样,还是跑在客户端上。而新的服务器组件运行在服务器上,并在服务器上输出 HTML(注)。换句话说,我们可以选择哪些代码跑在客户端上,哪些代码可以留在服务器上。通常,需要交互的东西(即响应用户输入并随时间变化的内容)会被发送到客户端。另一方面,可以运行一次并且永远不需要再次运行的东西,如<h1>或<p>这样的内容,会留在服务器上。
这里我们忽略服务端渲染和静态站点生成技术,它们是在服务器上执行一些工作,但还是会将所有代码发送到客户端合并,并且需要你学习一些框架专属的方法。我们还要忽略这样一个事实,即在某些框架(如 Next.js)中,客户端组件仍会进行 SSR 和合并。
四个框代表四个 React 组件。其中两个是绿色的(代表客户端组件):InteractiveSidebar 和 VideoPlayer。两个是蓝色的(代表服务器组件):VideoTitle 和 VideoDescription。 交互式组件(绿色)发送到客户端,而静态组件(蓝色)留在服务器上。
服务器组件可以做的不仅仅是让你选择代码运行的位置,还允许你直接在组件内获取数据。以前,你必须运行框架专属的函数(如 getServerSideProps)或构建某种框架 GraphQL 数据层才行。现在呢?简单又好用。你的评论组件需要与你的评论数据库对话?将你的评论数据库代码放在你的评论组件中就好了。
你可以使用服务器组件数据获取特性来玩另一个魔术。假设你的评论数据库真的很慢。以前,你的整个页面都必须等待数据库调用完成才行。现在,如果你将评论组件包装在 Suspense 中,它将在准备就绪时流式传输到你的客户端。换句话说,你的用户可以立即开始阅读你的页面,当你的评论数据库最终完成其工作时,这个评论组件可以在那时候才弹出来。
加载一小段时间后,会弹出一个蓝色组件(标记为 SlowComponent)。这个服务器组件直接获取自己的数据,并在准备就绪时进行流式传输。
服务器组件给人留下了极为深刻的第一印象。当我们首次实现它们时,我们立即削减了 20%-90% 的页面包大小,太疯狂了。但这是有道理的——我们的营销网站和文档网站主要是静态内容,如段落标签和标题,以及高亮显示的代码块和一个 markdown 加载器。这些组件不需要响应用户交互,意味着它们可以留在服务器上。仅凭这一点就值得我们考虑把它用在营销网站上。
话虽如此,服务器组件也要求你攀登陡峭的学习曲线。蜜月期过后,现实情况是程序员很难一次性记住所有内容。你会很难避免客户端和服务器组件混在一起带来的麻烦。学习曲线如此陡峭,以至于我不得不写一篇 4,000 字的博客文章才能把所有东西都记在脑子里。如果你也正在攀登曲线,那么你应该看看那篇文章(https://www.mux.com/blog/what-are-react-server-components)。
由于我们已经爬完了曲线,因此我想关注服务器组件现在为我们做了什么贡献。服务器组件怎样改变了我们编写网站的方式呢?
现在我们已经将营销和文档网站迁移到服务器组件上了,我们可以编写一些以前不可能写出来的新组件。
很早以前,我们就利用了服务器组件新的数据获取超能力。例如:过去我们的整个文档页面必须等待变更日志侧边栏从我们的数据库获取它的那部分数据,即使这个侧边栏一开始一般都是在页面外面的。
export default async function Page() {
const changelogData = await getChangelogData()
/* all of this has to wait for changelogData to load */
return (
<Layout>
<Sidebar>
{/* other sidebar stuff */}
<Changelog data={changelogData} />
</Sidebar>
{/* other page stuff */}
</Layout>
)
}
现在,我们不再让整个页面等待更改日志数据获取操作,而是将获取操作移到组件内,并将该组件包装在 Suspense 中。
import { Suspense } from 'react';
/* We've moved data fetching into the changlog compoment */
async function ChangelogWithDataFetching() {
const changelogData = await getChangelogData()
return <Changelog data={changelogData} />
}
/*
...and wrapped that component in Suspense.
The user can see the page immediately, while the changelog component loads
*/
export default function Page() {
return (
<Layout>
<Sidebar>
{/* other sidebar stuff */}
<Suspense fallback={<ChangelogPlaceholder />}>
<ChangelogWithDataFetching />
</Suspense>
</Sidebar>
{/* other page stuff */}
</Layout>
)
}
这项优化只是我们服务器组件之旅的开始。随着我们不断构建代码,我们意识到有些组件(例如营销页面上的定价组件)需要在数据库中有自己的文档。以前,如果我们将定价组件添加到页面,我们还必须将定价组件的文档检索功能添加到页面或其他地方的 API 端点上。现在,由于定价组件会获取自己的数据,我们只需放入组件并继续操作即可。服务器端数据获取曾经是和组件分离的;现在它们位于同一位置。对某些人来说,React 鼓励我们将逻辑、标记和样式都放在同一个文件中是有争议的。对他们来说,这种单文件方法违反了我们编程领域中许多人珍视的“关注点分离”原则。然而,正如 Cristiano Rastelli 用他们的标志性图表所论证的那样,React 组件仍然是“关注点分离”框架,但这是按上下文而不是按技术来分的。当 React 组件让你在一个地方看到有关它们的所有内容时,它们就处于最佳状态。将服务端数据添加到我们的组件中的做法扩展了这一点,并为 Rastelli 的图表添加了另一层:
Cristiano Rastelli 的标志性“关注点分离”图自然地扩展到了 React 服务器组件和服务器端数据。
一旦这种思维方式植根下来,我们就开始考虑写一些以前从未写过的组件。这里以我们的新 VideoGlossaryHoverCard 组件为例。我们经常要谈论一些普通人无法立即掌握的视频术语,例如 HDCP 和 LL-HLS。我们希望在访问者将鼠标悬停在这些术语上时能够看到我们添加的定义。完成的组件效果如下所示:
我们希望添加一个组件,当你将鼠标悬停在视频词汇表术语上时,它会显示这些术语的定义。用服务器组件可以轻松完成这个效果。
下面是它的内部结构。首先,我编写了一个 VideoGlossaryHoverCard 服务器组件:给定一个词汇表链接,在数据库中查询这个词汇表术语并将其显示在悬停卡片中。(我们可以在一个位置完成所有这些操作,这不是很棒吗?)。接下来,我为 Link 组件添加了一个条件:如果你检测到视频词汇表链接,则显示视频词汇表悬停卡片。(悬停卡片包装在 Suspense 中,以确保此组件不会影响主要体验。)
import 'server-only';
import { Suspense } from 'react';
import NextLink from 'next/link';
import VideoGlossaryHoverCard from './VideoGlossaryHoverCard';
export default function Link({ href, ...rest }) {
const linkComponent = <BaseNextLink href={href} {...rest} ref={ref} />;
if (href?.includes('/video-glossary/')) {
return (
<Suspense fallback={linkComponent}>
<VideoGlossaryHoverCard href={href}>{linkComponent}</VideoGlossaryHoverCard>
</Suspense>
);
}
return linkComponent;
}
我这里强调一下为什么这个组件以前会成为一场灾难。如果我们想在客户端上执行这项工作,就必须编写整个 API 端点,然后在 VideoGlossaryHoverCard 中的 useEffect 中查询该端点,并执行与此相关的所有样板。我们都做过这样的事情,也都厌倦了。或者,想象一下在页面级别的服务器上执行这项工作!我们必须检查标记并检测页面上的每个视频词汇表术语,然后针对我们的数据库编写查询来查找所有这些术语,再将这些术语深入到链接层 prop-drill 起来……然后……呃。太麻烦了。
一个简单的 const glossaryTerm = await getGlossaryTerm(href),一个简单的组件就搞定。样板最少,都在一个地方。我喜欢它。一旦你开始用服务器组件思考,你就再也回不去了。
简而言之,如果你以一种特殊的方式调用一个异步函数(注),将免费获得三样东西。首先(也是最重要的),你可以访问一个 isPending 状态,无需再自行管理 setIsPending。其次,动作与 React 的原生错误边界特性挂钩。这意味着当出现问题时,你可以轻松地向用户显示错误状态。最后,动作与一个名为 useOptimistic 的新钩子配合使用,后者允许你在动作完成之前向用户显示结果。例如,如果你单击一个 🩷 按钮,你可以在 API 成功响应之前显示可爱的 💖 动画。
从技术上来说,“以特殊方式”指的是“如果你将异步函数作为一个变换(transition)来调用”。在 React 中,一种调用变换的方式是使用 useTransition 钩子,另一种方式是使用 React 的新的服务器动作功能。
当 React 为你处理这一切时,许多样板代码都消失了。想了解详情,请查看 React 博客上的精彩示例(https://react.dev/blog/2024/04/25/react-19#actions)。但动作只是融化样板代码的冰山一角。我们发现真正改变营销网站的是服务器动作(Server Actions)。
服务器动作为融化样板代码的冰山添加了最后一层蛋糕。(我承认:这些比喻已经失控了。)服务器动作也消除了编写 API 端点的样板的需求。
假设你想在网站上构建一个小型的“联系支持”框,目前你必须编写一个端点来处理后端的评论。然后,你需要为网站编写一个带 textarea 的小表单;当该表单提交时,你需要将该数据发布到你的新端点并管理所有待处理和乐观的 UI 和错误处理。
但等一下!其实动作已经管理了待处理和乐观的 UI 和错误处理……这就只剩下了那个烦人的 API 端点。而这就是服务器动作的用武之地。你无需编写 API 端点,编写服务器动作就行了。然后,你无需使用 fetch 来 POST,与该 API 端点交互,只需像调用函数一样调用服务器动作即可。
我们来看一个实际的服务器动作示例。将“use server”放在一个文件的顶部时,你是在告诉打包器“嘿,将其转换为服务器动作。将其变成我的客户端可以 POST 信息的端点。”
"use server"
// the "use server" directive tells React to make this its own bundle
// that can be called as a Server Action.
// under the hood, React turns this into its own API endpoint for you.
export async function saveContactFormAction(formData) {
const name = formData.get('name')
const text = formData.get('text')
try {
await saveToDatabase(name, text)
return { message: 'Text saved successfully' }
} catch (error) {
return { message: error.message }
}
}
这与 API 端点并没什么不同……但一旦我们到达前端,魔法就开始了。我们可以导入该服务器动作并将其传递给表单的 action prop。然后,为了获得我提到的那个简单的 isPending 状态,我们利用 React 的新 useActionState 钩子:
"use client"
import { useActionState } from "react"
import saveContactFormAction from "./actions"
const initialState = { message: '' }
export default function ContactForm() {
const [response, formAction, isPending] = useActionState(saveContactForm)
return (
<form action={formAction}>
<label>Name: <input type="text" name="name" required /></label>
<label>Text: <textarea name="text" required /></label>
<button type="submit" disabled={isPending}>
{isPending ? "Submitting..." : "Submit"}
</button>
<p aria-live="polite">{response?.message}</p>
</form>
}
没有 setIsLoading。没有 fetch('/api/contact', { method: 'POST' })。只需调用一个函数,即可免费获得其他所有内容。这只是一个简单的示例;要了解更多信息,请务必查看这个 Next.js 文档(https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations)。
我们使用服务器组件的时间比使用服务器动作的时间长得多。使用服务器组件一年多后,我们开始看到很酷的新模式,例如我们之前讨论过的视频词汇表悬停框。我认为我们开始看到服务器动作同样发生了这种情况。
在上面的示例中,我们使用了一个包含一个成功条件和一条消息的对象来响应这个动作。但你知道还有什么也是一个对象吗?服务器组件只是对象而已。如果我们使用服务器组件来响应我们的服务器动作会怎样?例如,下面是我们上周添加到变更日志中的一个很酷的分页特性:
https://stream.mux.com/C47695XRftGhdJSU38hQTZqST0002FCHYL/high.mp4
使用服务器动作后,我们变更日志上的这个新分页特性就非常容易编写了。而这背后是极其简单的操作:
'use server'
import { getPosts } from './data'
import Posts from './posts'
export async function getPostPageAction(formData) {
const page = formData.get('page')
const posts = await getPosts(page)
return <Posts posts={posts} />
}
然后我们在更新日志底部添加了一个按钮。当这个动作响应时,该按钮将被服务器动作中的新帖子替换:
'use client';
import { useActionState } from 'react';
import Button from 'components/button';
import { getPostPageAction } from './actions';
const defaultComponent = null;
export default function LoadPostPageButton({ page }) {
const [component, formAction, isPending] = useActionState(getPostPageAction, defaultComponent);
return component || (
<form action={formAction}>
<input type="hidden" name="page" value={page} />
<Button cta disabled={isPending} type="submit">
{isPending ? 'Loading...' : 'Load more'}
</Button>
</form>
);
}
编写这个分页交互就是这么简单。无需客户端数据管理,几乎无需将任何代码发送到客户端,没有 API 端点,没有待处理状态管理,一切只是一种很棒的全新用户体验。
React 19 中还有更多特性将改善我们 Web 开发人员的生活。React 正在添加对文档元数据、样式表、异步脚本标记和预加载其他资源的组件级支持。之前,我们谈到了当 React 允许你在同一位置查看有关组件的所有内容时,它就处于最佳状态。这些新特性可让你为自己的组件搭配更多相关特性。
还有一些让我非常兴奋的更高级别的特性!更少更好的水合错误、更好的上下文和转发引用开发体验,更不要提一流的 Web Components 支持——我们太兴奋了,Dylan 写了一整篇博客文章谈论这些内容(https://www.mux.com/blog/facebook-just-updated-it-s-relationship-status-with-web-components)。
是时候开始总结一下了。
技术市场研究领域有一个概念,叫做 Gartner 炒作周期。它展示了当我们试图将一项新技术应用于世界上所有问题时,它是如何进入市场并开始爆发式增长的。然后,随着我们对这项技术越来越熟悉,我们首先会感到失望,因为它无法解决世界上所有的问题。接下来,随着这项技术成为我们的老朋友,我们开始看到它的本质:它是解决一组特定问题的好工具。
特别是对于服务器组件来说,蜜月期是如此美好,它有更小的包和时髦的新数据加载特性。然后,我们经历了幻灭的低谷,因为我们意识到服务器组件的开发体验有其挑战性。
但是现在呢?我们正在学习如何思考和教授这些新概念。我们正在看到它们的真正潜力。作为 Web 开发人员,我们大部分时间都花在向客户端发送数据,并将数据从客户端发送回服务器的操作上。我们的工作很多都是围绕这两个任务的样板展开的。React 19 看到了这一点,并融化了那些样板。React 19 是一个很棒的工具,它使服务器和客户端之间的通信变得更容易,从而提高我们的工作效率。
原文链接:
https://www.mux.com/blog/react-19-server-components-and-actions#fnref:2
微信扫码关注该文公众号作者