React Server Component 为何是 2024 年内容驱动网站一次质的飞跃 🚀

209 阅读8分钟

image.png

作者:Rodrigo Pombo

译者:legend80s@JavaScript与编程艺术

本文将从实际例子出发由历史到现在由浅入深带我们在一篇文章中走过 React 组件的三大阶段:运行时组件 - 编译时组件 - 服务端组件

让我们对 React Server Component 有更好的了解,更具象的理解 RSC 为什么一定会出现。

注意本文是在 Next.js 框架中讲述,但并无大碍,不熟悉该框架也能看懂本文演进的思路

第一版:2024-10-15 10:23:48

在内容驱动的网站中,经常需要在内容渲染之前做一些加工。例如,用 Markdown 编写的博客可能需要对代码块进行语法高亮。

让我们用一个小例子来说明这个问题。

我们有一个带有链接的 Markdown 文件,我们希望这些链接在悬停时显示链接对应的图片:

image.png

本文将逐一给出该问题的三种解决方案,让大家知道为什么 RSC(React Server Component)是内容驱动网站的最佳模式。

但首先,让我们快速回顾一下内容是如何从 Markdown 转换为我们最终部署到 CDN 的 JS 的。

当我们运行 next build 时 Markdown 文件发生了哪些变化

假设我们有一个 Next.js 应用,使用了页面路由器、@next/mdx插件和静态导出

让我们看看当我们运行next build时,pages/index.jsx页面会发生什么:

// pages/index.jsx
import Content from "./content.md"

function MyLink({ href, children }) {
  return <a href={href}>{children}</a>
}

export default function Page() {
  return <Content components={{ a: MyLink }} />
}
# Hello

This is [Code Hike](https://codehike.org)

import Content from "./content.md"将使MDX加载器处理content.md文件。

MDX加载器将分几个步骤处理content.md

第一步:➡️ Markdown Abstract Syntax Tree

第一步是将源字符串转换为Markdown抽象语法树(mdast - Markdown Abstract Syntax Tree)。

// Markdown Abstract Syntax Tree
{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "children": [{ "type": "text", "value": "Hello" }]
    },
    {
      "type": "paragraph",
      "children": [
        { "type": "text", "value": "This is " },
        {
          "type": "link",
          "url": "https://codehike.org",
          "children": [{ "type": "text", "value": "Code Hike" }]
        }
      ]
    }
  ]
}

第二步:Remark 插件 ➡️ HTML Abstract Syntax Tree

如果有任何remark插件,它们将逐一应用于 mdast。

这就是你可以插入任何想要应用于Markdown的转换的地方。

应用完所有remark插件后,mdast被转换为另一个AST:hast

它被称为HTML抽象语法树 HTML Abstract Syntax Tree,但它不会被用来生成HTML,而是被用来生成JSX,这两者很相似。

// HTML Abstract Syntax Tree
{
  "type": "root",
  "children": [
    {
      "type": "element",
      "tagName": "h1",
      "children": [{ "type": "text", "value": "Hello" }]
    },
    {
      "type": "element",
      "tagName": "p",
      "children": [
        { "type": "text", "value": "This is " },
        {
          "type": "element",
          "tagName": "a",
          "properties": { "href": "https://codehike.org" },
          "children": [{ "type": "text", "value": "Code Hike" }]
        }
      ]
    }
  ]
}

第三步:Rehype 插件

如果有rehype插件,它们将逐一应用于hast。

在这个阶段,常见的操作是为代码块添加语法高亮:rehype插件将找到所有pre元素,并用具备样式的span替换其内容。

第四步:➡️ ECMAScript Abstract Syntax Tree

然后hast被转换为另一个AST:esast(ECMAScript Abstract Syntax Tree ECMAScript抽象语法树)。

然后esast被转换为JSX文件。这个JSX文件是mdx加载器的输出,它将控制权交回打包器。

// Compiled Markdown
export default function MDXContent(props = {}) {
  const _components = {
    a: "a",
    h1: "h1",
    p: "p",
    ...props.components,
  }
  return (
    <>
      <_components.h1>Hello</_components.h1>
      <_components.p>
        {"This is "}
        <_components.a href="https://codehike.org">Code Hike</_components.a>
      </_components.p>
    </>
  )
}

打包器现在理解了import Content from "./content.md"导入了什么。所以它可以完成处理pages/index.jsx文件,并将编译后的content.md文件一起打包。

它还会编译JSX并压缩代码,但为了清晰起见,我们忽略这一点。

// out/pages/index.js
import React from "react"

function Content(props = {}) {
  const _components = {
    a: "a",
    h1: "h1",
    p: "p",
    ...props.components,
  }
  return (
    <>
      <_components.h1>Hello</_components.h1>
      <_components.p>
        {"This is "}
        <_components.a href="https://codehike.org">Code Hike</_components.a>
      </_components.p>
    </>
  )
}

function MyLink({ href, children }) {
  return <a href={href}>{children}</a>
}

export default function Page() {
  return <Content components={{ a: MyLink }} />
}

现在让我们回到我们的问题:我们希望在悬停卡中显示链接URL的图片。

方法一:客户端方案 📱

如果你对构建过程一无所知,你的第一个想法可能是在渲染链接时在客户端获取图片。那么让我们从那里开始。

假设我们已经有了一个async function scrape(url),给定一个URL,它获取HTML,找到开放图片标签,并返回content属性,这是我们想要的图片的URL。

我们还有一个function LinkWithCard({ href, children, image }),它渲染一个带有悬停卡的链接,显示图片。

一个在客户端解决这个问题的组件看起来像这样:

// pages/index.jsx

import { useEffect, useState } from "react"
import Content from "./content.mdx"
import { scrape } from "./scraper"
import { LinkWithCard } from "./card"

function MyLink({ href, children }) {
  const [image, setImage] = useState(null)
  useEffect(() => {
    scrape(href).then((data) => {
      setImage(data.image)
    })
  }, [href])
  return (
    <LinkWithCard href={href} image={image}>
      {children}
    </LinkWithCard>
  )
}

export default function Page() {
  return <Content components={{ a: MyLink }} />
}

客户端组件在组件加载(useEffect)时才发请求获取图片地址

这是一个简单的方法,可以完成工作,但它有一些主要的缺点:

  • 每个用户都将为页面上的每个链接进行获取(过多的请求
  • 我们正在向客户端发送抓取图片的代码(过大的 bundle size

对于不同的场景,这种方法甚至可能无法实现。例如,如果我们想要显示链接URL的截图。

办法二:构建时插件方法 🏗️

一个更有效的解决方案是将抓取部分移动到构建时,使用rehype插件:

// next.config.mjs

import { visit } from "unist-util-visit"
import { scrape } from "./scraper"

function rehypeLinkImage() {
  return async (tree) => {
    const links = []
    visit(tree, (node) => {
      if (node.tagName === "a") {
        links.push(node)
      }
    })
    const promises = links.map(async (node) => {
      const url = node.properties.href
      const { image } = await scrape(url)
      node.properties["dataImage"] = image
    })
    await Promise.all(promises)
  }
}

这个插件为HTML语法树中的每个<a>标签添加一个data-image属性(如果你跟不上代码,不用担心,代码难以理解是我稍后要讲的一个重要点之一)。

然后我们可以在组件中使用这个属性,并将其传递给<LinkWithCard>组件:

// pages/index.jsx

import Content from "./content.mdx"
import { LinkWithCard } from "./card"

function MyLink({ href, children, ...props }) {
  const image = props["data-image"]
  return (
    <LinkWithCard href={href} image={image}>
      {children}
    </LinkWithCard>
  )
}

export default function Page() {
  return <Content components={{ a: MyLink }} />
}

我们成功解决了客户端方法的缺陷。但这种方法从严格意义上来说是否真的更好?

对比前两种方法 🔍

构建时插件方法优点 👍:

  • ✅ 在构建时获取,节省客户端的大量工作
  • ✅ 不向客户端发送抓取图片代码

即用户体验(UX)最佳。

但不要忽略了客户端方法也有优点 👍:

  • ✅ 所有行为都包含在一个组件中,例如,如果我们想要在悬停卡中添加描述,我们可以在一个地方完成
  • ✅ 我们可以在其他地方使用该组件,不仅仅是 Markdown 文件。
  • ✅ 我们不需要学习如何编写rehype插件

即开发体验(DX)最佳

这是开发者体验和用户体验之间的权衡。

一般在这种情况下,用户体验最重要。但如果我们能消除这种权衡呢?能否让 UXDX 体验兼备?

方法三:React Server Component 🏭

第三种选择是使用 React Server Components(Next.js 13 之前不支持):

// app/page.jsx

import { LinkWithCard } from "./card"
import { scrape } from "./scraper"

async function MyLink({ href, children }) {
  const { image } = await scrape(href)
  return (
    <LinkWithCard href={href} image={image}>
      {children}
    </LinkWithCard>
  )
}

export default function Page() {
  return <Content components={{ a: MyLink }} />
}

当我们运行next build时,我们有一个额外的步骤:

// bundled js

import React from "react"
import { LinkWithCard } from "./card"
import { scrape } from "./scraper"

function Content(props = {}) {
  const _components = {
    a: "a",
    h1: "h1",
    p: "p",
    ...props.components,
  }
  return (
    <>
      <_components.h1>Hello</_components.h1>
      <_components.p>
        {"This is "}
        <_components.a href="https://codehike.org">Code Hike</_components.a>
      </_components.p>
    </>
  )
}

async function MyLink({ href, children }) {
  const { image } = await scrape(href)
  return (
    <LinkWithCard href={href} image={image}>
      {children}
    </LinkWithCard>
  )
}

export default function Page() {
  return <Content components={{ a: MyLink }} />
}

由于function Page()是一个服务器组件,它将在构建时运行并被其结果替换(不是100%正确,但这是一个好的心理模型)。

function Page()的输出是:

<>
  <h1>Hello</h1>
  <p>
    {"This is "}
    <MyLink href="https://codehike.org">
      Code Hike
    </MyLink>
  </p>
</>

function MyLink()也是一个服务器组件,所以它也将在构建时被解析。

运行<MyLink href="https://codehike.org">Code Hike</MyLink>意味着我们在构建时运行scrape("https://codehike.org")并用以下内容替换元素:

<LinkWithCard
  href="https://codehike.org"
  image="https://codehike.org/codehike.png"
>
  Code Hike
</LinkWithCard>

由于我们不再使用function scrape()import { scrape } from "./scraper"将从捆绑包中移除

// out/app/page.js

import { LinkWithCard } from "./card"

export default function Page() {
  return (
    <>
      <h1>Hello</h1>
      <p>
        {"This is "}
        <LinkWithCard
          href="https://codehike.org"
          image="https://codehike.org/codehike.png"
        >
          Code Hike
        </LinkWithCard>
      </p>
    </>
  )
}

只是为了清楚,因为React Server Components这个名字可能会引起混淆:这发生在构建时,我们可以将构建的静态输出部署到CDN。

与其他两种方法相比,我们拥有所有优势:

  • ✅ 在构建时获取,节省用户进行冗余工作
  • ✅ 不向客户端发送抓取图片的代码
  • ✅ 所有行为都包含在一个组件中,例如,如果我们想要在悬停卡中添加开放图谱描述,我们可以在一个地方完成
  • ✅ 我们可以使用该组件在其他地方使用,不仅仅是Markdown
  • ✅ 我们不需要学习如何编写rehype插件

image.png

图片来源:dev.to/builderio/a…

这种方法结合了两者的优点:最佳的用户体验 & 最佳的开发者体验 🙌。

总结 🎯

Mind the Gap”演讲提出很多处理请求的方案,不同的方案有不同的权衡。随着 React Server Components 的引入,组件现在能够越过网络,这些权衡已经消失。

越过(cross)这个词很巧妙大家仔细体会 😄。

在内容日显重要的时代而又能做到不损失开发者体验,这将丰富🎨和造就百家齐放的内容驱动网站。

更多阅读 📚

如果你需要更多关于 React Server Components 为内容网站带来的新可能性的例子,这里有一些我一直在跟踪的内容:

原文:Build-time Components

如果觉得本文对你有帮助,希望能够给我点赞支持一下哦,也欢迎关注公众号『JavaScript与编程艺术』💫。