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 呈现当前的时间。我们很快就写出了这样的代码。
由于水合时,浏览器的时间和服务器渲染时不同,导致数据不一致。就得到了水合错误。
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
去确保数据的恒定。
这种分离式写法,如果在大量状态的情况下,将会变的非常难以管理,并且服务器数据的获取必须都集中在当前页面中,而不是在单个组件中,这让开发体验也会更加复杂。
例如,当你需要获取更多服务器数据时并且组件依赖服务器数据时,你需要把服务器数据从页面顶层传入到每个组件中,如果组件层级很深,你就不得不用 Context 或者状态库去传递了,即便组件逻辑和页面并没有强关联。这种方式限制了组件的复用,因为这类组件始终需要从页面顶层获取服务器数据,而不是独立的逻辑取得状态:
那么,在 RSC 的模式下,我们容易把需要的数据和组件结合起来,例如上面的例子,我们可以很快的封装成组件。
上面的例子中,ServerTime
组件可以在任何 Server Component 中使用,并且无须传入 props。
由于 Server Component 只运行在服务端,那么在 Server Component 中使用到的外部库不会再浏览器端加载。这对于很多需要借助三方库去处理数据或者图表更加方便。一般的,这些库体积都会很大,同时这些数据可以仅在服务端处理完成。浏览器端少加载了 JS,既减轻了网络负载也加快了首屏性能。
下面是一个简单的例子。比如代码高亮,一般的我们借助 Prism、Shiki 等三方库去实现。而这类库体积一般都很大,如果需要导入所有语言,那么打包之后的体积可能会增加好几兆。
下文假设我们使用 Shiki 进行高亮代码。
一般的我们会将使用这类库的组件,使用 lazy
或者 dynamic
进行代码分割,防止在首屏加载庞大的 JS 文件降低 LCP 的指标。
但是,既然服务端返回的 HTML 中已经渲染好了高亮后的 DOM,浏览器还是需要下载 Shiki 再进行一遍高亮就很没有必要。
而使用 Server Component,这个组件的逻辑都在服务端完成,所以前端渲染此组件没有任何的逻辑,自然也不会去下载 Shiki 了。这样的话 Shiki 也就不会打包进 Client 的 JS undle 里去了。
效果是显著的。
import { useEffect, useState } from 'react'
export default function Home() {
return <div>{Date.now()}</div>
}
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(),
}
}
import { Servertime } from './components/server-time'
export default function Home() {
return <Servertime />
}
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>
)
}
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" />
}