在前端世界中,性能一直是一场持续不断的战争。RSC(React Server Components)目前在Next.js应用程序路由器中实现,被视为旨在改善启动性能的官方武器。然而,在这场战斗中,似乎我们忽略了一些基本原理,导致了开发者体验(DX)的下降。本文旨在提供当前Next.js API中此类DX问题的示例。最后,我将提出一个更好的建议。
首先,我要明确表示,我并不打算责怪、贬低或冒犯React和Next.js的维护者。我尊重每一位维护者,并感谢他们的工作。感谢你们的贡献。然而,与此同时,我希望以清晰和建设性的方式呈现我认为当前API设计存在的问题。这样,我们可以基于具体示例展开讨论。
本文松散地延续了我之前的一篇文章,React和RSC的概念模型。我强烈建议在阅读本文之前先阅读那篇文章,以便更好地理解接下来的内容。
DX > 性能
首先,让我为DX比性能在框架API设计方面更重要的观念辩护。
作为一个应用程序开发者,当变更需求到来时,我会在屏幕上添加一些组件并开始开发。我添加基本的可视化效果,连接真实数据,使其具有交互性,改进样式,添加加载、错误和空白UI,编写测试,重构组件结构,解决bug,优化性能。
我通常是按照这个顺序来做的。我相信我们每个人都有不同的偏好。有些人偏爱测试驱动开发,有些人喜欢从一个像素完美的设计开始,使用模拟数据。这都没问题。然而,我所知道的开发者中没有一个在一开始就关心性能。实际上,有些人认为这是不好的做法,应该避免。唐纳德·E·克努特(Donald E. Knuth)认为“过早优化是万恶之源”,迈克尔·A·杰克逊(Michael A. Jackson)说过,“优化的第一条规则:别做”。
要非常清楚:我不是在试图说服你不要采取性能优先的方法。我试图说服你,许多开发者不会在一开始就进行优化。而且对我们来说效果很好。一个通用的框架应该至少支持,最好是更好地支持这种流程。快速交付,配合出色的DX以及尽可能好的性能。然后提供像重构模式、优化提示、更激进的缓存配置等易于优化的工具。
就我个人而言,如果一个框架由于性能优化而带来一些语法开销,我是可以接受的。但是,让我伤害清晰的概念模型和基本原则是不可接受的。
基本原则
在之前的文章中,我描述了我认为任何前端框架都应该遵循的一组基本属性,以提供良好的DX。让我们快速回顾一下它们。
可组合 - 或者同质性。将组件组合成完整的UI的能力。能够将任何组件放置在任何其他组件中而不改变其行为。
可重用 - 能够定义一个名称并在不同位置重用组件。还涉及传递任意配置(可能是响应式状态)以调整其行为(props)。
同位 - 将所有依赖项放置在组件内的能力:名称、渲染逻辑、必要的状态连接、状态派生逻辑、样式、文档等。如果不符合这一点,开发者将不得不在代码库中来回跳转。
封装 - 能够独立地工作在一个组件上。默认情况下,应该阻止从外部影响组件的所有路径。破坏这个障碍,例如通过暴露props,应该是一种选择性的功能。如果不符合这一点,开发者必须在头脑中考虑其他代码片段。
响应式 - 组件必须对开发者选择的连接状态作出反应。任何组件都必须能够连接到任何状态,派生钩子也是如此。如果需要特定规则,开发过程将会受阻。
任意 - 组件的边界必须符合开发者的需求。没有什么能够迫使一个组件分裂或不分裂,因为那会破坏它的目的。
我觉得Next对RSC的实现违背了这些基本原则之一,因为它们首先考虑了性能。接下来是一些示例。
示例组件
假设有一个需求,要在每次页面刷新时创建一个显示编
程相关随机引用的页面。引用保存在远程数据库中。你会如何在Next.js应用程序路由器中实现它?
Next.js的数据获取文档建议在组件渲染函数内使用简单的异步等待函数。为了简洁起见,假设我们有类似lodash的random(from, to)
函数。
async function ProgrammingQuotes() {
// 在这里我想连接到引用的外部数据库状态
const quotes = await fetch('/quotes')
// 在这里我想连接到一个随机数状态
const index = random(0, quotes.length)
return <div>{quotes[index]}</div>
}
只需将其放置在页面上的某个地方,我们就完成了。到目前为止,DX还是很出色的。默认情况下它是一个仅服务器的组件。导致一个小捆包,没有客户端-服务器往返,出色的FCP和LCP,没有CLS。正如所承诺的那样,启动性能非常出色。
1. 被迫提升状态
假设有一个变更需求。我们的UX部门喜欢这个组件,并希望将其放在现有的评论反馈模态框中。这个模态框目前是一个客户端组件。有人之前将其编写为客户端,因为它需要一些客户端状态。
'use client'
async function FeedModal() {
const [open, setOpen] = useState(false)
return <>
<Button toggle={setOpen}>Show comments</Button>
<Modal opened={opened} >
<Feed/>
<ProgrammingQuotes/>
</Modal>
</>
}
…糟糕。"错误:客户端组件不能是异步的"。发生了什么? 'use client'
使其所有后代都成为客户端组件。但是我们的ProgrammingQuotes
假设它是一个服务器组件。我看到了三种解决方法。
a. 使用客户端数据获取解决方案,比如tanstack-query
、useSWC
或新的use
React API。这意味着放弃RSC及其性能优势。
b. 将fetch提升到第一个服务器祖先。仅仅将引用获取提升到FeedModal
是不够的。我们需要进一步提升并通过props将获取的引用数据传递下来。代码的同位属性被撕裂了。
c. 重构模块,使ProgrammingQuotes
作为子组件传递给FeedModal
。然而,这迫使我以某种方式组合组件。它不符合我的需求,因此破坏了任意边界属性。此外,我被迫暴露了一个children prop。这破坏了封装属性。
所有的选择都对我的工作产生了负面影响。要么是性能,要么是更糟糕的项目可维护性。
2. 被迫拆分组件
假设有一个变更需求。引用不是随机的,而是应该每五秒按照从数据库接收到的顺序更改一次。访问一些计数状态的方法是使用setInterval
,因此让我们使用它。我会调整我们的组件如下:
async function ProgrammingQuotes() {
const quotes = await fetch('/quotes')
const index = useCounter(5000)
return <div>{quotes[index]}</div>
}
function useCounter(delay) {
// 为简洁起见,这个hook被简化了
const [count, setCount] = useState(0)
useEffect(() => setInterval(() => setCount(count + 1), delay))
return count
}
…糟糕。"错误:服务器组件不能使用'useState'和'useEffect'钩子"。由于服务器组件无法对时间变化做出反应(换句话说,它们无法将时间状态响应连接),我们需要使用一个客户端组件。所以也许在文件顶部添加"use client"
? …糟糕。"错误:客户端组件不能是异步的"。我们该怎么办?我看到以下几种选择:
a. 使用客户端数据获取,但如上所述,我们放弃了RSC及其性能。
b. 将获取提升,但如上所述,它破坏了同位代码。
c. 将组件拆分成服务器父级和客户端子级。父级是通过fetch
进行异步处理的,并且子级使用useCounter
。然而,我们实际上被迫将它们放入单独的文件模块中。因此,我们的代码是分散的。同位仍然被破坏了。
再次,我没有好的选择。我不得不做出牺牲。在这一点上,让我提醒一下丹·阿布拉莫夫(Dan Abramov)在他的钩子介绍(YouTube)中谈到的关于同位性重要性的讨论。
3. 被迫传递Props
假设有一个变更需求。我们想要添加按作者过滤的引用。作者放在URL查询参数中,因此可以共享,并且只有这样的作者的引用才会出现。在前一个示例之后,我们得到了两个组件。
async function ProgrammingQuotesServer() {
// 在这里我想连接到URL查询参数状态
const author = useSearchParam('author');
const quotes = await fetch(`/quotes?author=${author}`)
return <ProgrammingQuotesClient quotes={quotes}/>
}
'use client'
async function ProgrammingQuotesClient({ quotes }) {
const index = useCounter(5000)
return <div>{quotes[index]}</div>
}
Next.js文档建议使用useSearchParam
钩子或searchParams
属性。
那么我们首先尝试useSearchParam
吧。…糟糕。"错误:服务器组件不能使用'useSearchParam'钩子" 好的。searchParams
属性会有帮助。…糟糕。这个属性只能在顶级页面组件中访问。我看到以下几种选择:
a. 使用客户端数据获取意味着没有RSC的好处。
b. 从顶级页面向下传递props。在具有深层组件树的大型应用中,这是不可维护的。
c. 为URL参数创建上下文,然后从服务器组件中使用useContext
访问它。哎呀,为了一个基本的用例而做这么多工作。但是解决了,而且我可以将此上下文用于所有其他URL参数和所有组件,对吧?
4. 被迫放弃服务器
假设最后有一个变更需求。这个小组件应该移动到页脚组件中。页脚在页面布局组件树中。…糟糕。在布局中无法访问searchParams
属性。我该怎么办?这非常令人沮丧,但似乎我需要重构并将其转换为客户端组件,以便能够使用useSearchParam
。…糟糕。"错误:客户端组件不能是异步的"。我是不是被迫选择服务器端获取或Next.js布局特性?
总结
这些简单的示例违反了大部分定义的规则。ProgrammingQuotes
组件无法轻松地在模态框或页脚中重用。它的代码被分散到了几个模块中。作者搜索查询无法从组件本身访问。我被迫将children作为组件prop暴露,并创建人工客户端和服务器组件。
我想强调的是,这些示例不是一些边缘情况的废话。类似的需求每天都会出现。我百分之百肯定,每个在Next.js应用程序路由器中创建一些非平凡应用的开发者都至少遇到过一个例子。我真诚地相信,同位性、封装性和任意组合是高效开发的关键属性。它们对团队合作和维护中大型代码库至关重要。特别是对于多年来有许多变更需求的长期项目,许多开发者阅读和操作代码,并且需要快速理解正在发生的事情。这就是我写这篇文章的原因。所以我们可以设计更好的框架API。
你们中的许多人认为我可以使用旧的客户端方法,然后逐步转移到服务器。首先使用完全客户端的应用程序,如果需要性能,则将客户端边界通过组件树向下推。你们很聪明。我看到了三个问题。1. 这不是Next.js团队推荐或提到的。2. 我认为这太费力了。无论是写作,但更重要的是精神。不仅仅是在模块顶部添加'use client'
,还要修改异步代码,使用不同的方法访问URL,重构组件边界等等。3. 我相信有一种方法可以提供更好的开箱即用的性能,而不会牺牲DX。并且仍然保持实现相同性能的能力,就像当前的Next.js一样,但有更简单的优化路径。
更好的方法
在Next.js对RSC的实现中,我们在模块级别定义了组件是服务器还是客户端。如果编译器看到'use client'
,则代码将被捆绑并发送到客户端。否则,它将保持服务器端。换句话说,我们选择组件在哪里渲染,或者说它的运行时环境。然后,这个组件运行时限制了它的功能,例如使用异步获取或客户端状态。
最后一个示例中,使用布局限制了组件能力的方式也是基于模块的。如果编译器看到一个名为layout.tsx
的文件,则其组件被认为是“更静态的”。一些更动态的状态,比如搜索参数,是无法使用的。
我认为组件的能力不应受到模块级别配置的限制。任何组件都必须能够使用任何功能。组件的渲染位置和重新渲染时间应由组件内部使用的功能自动推断。
运行时环境和重新渲染频率与我的业务目标无关。理想情况下,这是一个实现细节,框架应该为我屏蔽这些细节。最佳性能策略应由框架自动选择。这与当今客户端的重新渲染工作方式相同。开发者在组件中使用一些状态,当状态改变时,组件重新渲染。无需手动调整shouldComponentUpdate。同样适用于信号。
具有讽刺意味的是,Next.js启发了我这个想法。它根据组件中使用的动态函数选择构建时或请求时渲染。由于某些原因,他们在布局中限制了这种推断。我相信这个自动推断的概念应该被更广泛地使用。
组件经过运行时,如果需要某些状态,则重新渲染。否则传递运行时。
最后,让我们一起思考一下这个自动运行时环境推断算法。首先,我们有哪些可用的环境,分别可以在哪里渲染组件?当然可以在浏览器的客户端上。也可以在服务器上。我们应该区分构建期间的静态渲染和服务器请求期间的动态渲染。还有更多的变体,比如边缘运行时或DSG,但现在让我们暂时只考虑这三种。让我们按性能排序。对于大多数情况,它是这样的:构建,服务器,客户端。如果没有其他的要求,我希望自动选择最优性能。
是什么导致组件使用性能较低的运行时环境?当组件需要一些更动态或交互式的功能时,或者连接到某种动态或交互式状态时。当一个组件连接到searchParam
、cookie
或headers
时,这些状态在构建时是未知的,它必须在服务器请求时渲染。这就是我上面提到的Next.js在布局中使用的动态函数所做的。同样地,当一个组件连接到useState
、setInterval
或onMouseEnter
时,它必须在客户端上渲染,因为这些状态在服务器上是不可访问的。
我希望你现在的脑海中会响起一声:“但是Ondrej,这行不通!捆绑是一个编译时特性!”如果是这样,那么我已经很好地解释了我的想法。恭喜。你理解了这个概念。还有许多问题有待解答。如果将回调函数作为prop传递给组件会怎样?有时一个组件会以多个运行时环境渲染吗?还有很多细节没有解决。但是让我们就到这里吧。
我计划写一篇接下来的文章,深入探讨一个新的虚构框架的API,并触及一些实现可能性。在那之前,我邀请你考虑一下它的DX(开发者体验)好处以及你如何实现它。试着想想如何做,而不是为什么不能做。然后告诉我你的想法。