为什么是 RSC (一)
React 19 会正式引入 React Server Component(RSC) 的概念,Client Component 和 Server Component 从此将会正式分离。Next.js 从 13 版本就开始支持 Server Component。那么为什么是 RSC?优势到底何在?这一章节我们来探讨一下这个问题。
规避水合错误
RSC 的出现减少了 水合错误 (Hydration Error) 的发生,如果你只使用 Server Component 去描述所有的组件的,那么水合错误也不会发生。
首先我们来复习一下,为什么会出现水合错误。
我们知道在传统 SSR 架构中,代码是同构的,即页面渲染前服务器需要渲染一遍并返回 HTML 给到浏览器做一遍静态渲染,等待 JS 加载完成后,浏览器在执行 JS 代码重新运行这段代码,将状态和事件交互绑定到 UI 上。如果这一步的状态和服务器渲染时状态不一致,那么就会出现水合错误。
我们来看一个简单的例子 -- 显示当前的服务器时间。假设我们需要 UI 呈现当前的时间。我们很快就写出了这样的代码。
import { useEffect, useState } from 'react'
export default function Home() {
return <div>{Date.now()}</div>
}
由于水合时,浏览器的时间和服务器渲染时不同,导致数据不一致。就得到了水合错误。
Error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.
在 Next.js 14.2.x 以上版本,你可以更加明确的知道为什么出现这个问题。
由于传统 SSR 需要同构,数据水合就需要手动处理。例如上面的例子中,我们需要显示服务器时间。我们就需要使用 getServerSideProps
去确保数据的恒定。
import { useEffect, useState } from 'react'
export default function Home({ props: time }: { props: number }) {
return <div>{time}</div>
}
export const getServerSideProps = async () => {
return {
props: Date.now(),
}
}
这种分离式写法,如果在大量状态的情况下,将会变的非常难以管理,并且服务器数据的获取必须都集中在当前页面中,而不是在单个组件中,这让开发体验也会更加复杂。
例如,当你需要获取更多服务器数据时并且组件依赖服务器数据时,你需要把服务器数据从页面顶层传入到每个组件中,如果组件层级很深,你就不得不用 Context 或者状态库去传递了,即便组件逻辑和页面并没有强关联。这种方式限制了组件的复用,因为这类组件始终需要从页面顶层获取服务器数据,而不是独立的逻辑取得状态:
那么,在 RSC 的模式下,我们容易把需要的数据和组件结合起来,例如上面的例子,我们可以很快的封装成组件。
import { Servertime } from './components/server-time'
export default function Home() {
return <Servertime />
}
上面的例子中,ServerTime
组件可以在任何 Server Component 中使用,并且无须传入 props。
更小的包体积
由于 Server Component 只运行在服务端,那么在 Server Component 中使用到的外部库不会再浏览器端加载。这对于很多需要借助三方库去处理数据或者图表更加方便。一般的,这些库体积都会很大,同时这些数据可以仅在服务端处理完成。浏览器端少加载了 JS,既减轻了网络负载也加快了首屏性能。
下面是一个简单的例子。比如代码高亮,一般的我们借助 Prism、Shiki 等三方库去实现。而这类库体积一般都很大,如果需要导入所有语言,那么打包之后的体积可能会增加好几兆。
下文假设我们使用 Shiki 进行高亮代码。
一般的我们会将使用这类库的组件,使用 lazy
或者 dynamic
进行代码分割,防止在首屏加载庞大的 JS 文件降低 LCP 的指标。
const HighLighter = lazy(() =>
import('./components/shiki').then((mod) => ({
default: mod.HighLighter,
})),
)
export default function () {
return (
<div>
<Suspense fallback={'loading code block..'}>
<HighLighter content='const foo = "bar";' lang="ts" />
</Suspense>
</div>
)
}
但是,既然服务端返回的 HTML 中已经渲染好了高亮后的 DOM,浏览器还是需要下载 Shiki 再进行一遍高亮就很没有必要。
而使用 Server Component,这个组件的逻辑都在服务端完成,所以前端渲染此组件没有任何的逻辑,自然也不会去下载 Shiki 了。这样的话 Shiki 也就不会打包进 Client 的 JS undle 里去了。
import { bundledLanguages, getHighlighter } from 'shiki'
import type { FC } from 'react'
import type {
BundledLanguage,
BundledTheme,
CodeToHastOptions,
HighlighterCore,
} from 'shiki'
function codeHighlighter(
highlighter: HighlighterCore,
{
lang,
attrs,
code,
}: {
lang: string
attrs: string
code: string
},
) {
const codeOptions: CodeToHastOptions<BundledLanguage, BundledTheme> = {
lang,
meta: {
__raw: attrs,
},
themes: {
light: 'github-light',
dark: 'github-dark',
},
}
return highlighter.codeToHtml(code, {
...codeOptions,
transformers: [...(codeOptions.transformers || [])],
})
}
export const HighLighter: FC<{
lang: string
content: string
}> = async (props) => {
const { lang: language, content: value } = props
const highlighter = await getHighlighter({
themes: [
import('shiki/themes/github-light.mjs'),
import('shiki/themes/github-dark.mjs'),
],
langs: Object.keys(bundledLanguages),
})
return (
<div
dangerouslySetInnerHTML={{
__html: codeHighlighter(highlighter, {
attrs: '',
code: value,
lang: language || '',
}),
}}
/>
)
}
export default () => {
return <HighLighter content='const foo = "bar"' lang="ts" />
}
效果是显著的。