JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第76章—源码篇-实现客户端组件

首次发表于 2024-07-29, 更新于 2024-07-29

前言

本来我想在 《源码篇 | 实现 Server Actions》的基础上,完整实现客户端组件,但奈何实现之路太过坎坷,遇到太多莫名奇妙的问题,所以本篇只能浅浅实现一下,尽管如此,却也能帮助大家理解其背后实现。

现在就让我们开始吧。这次我们基于的是《源码篇 | RSC 实现原理》的实现,此时刚实现 RSC Server 和 SSR Server 的拆分。如果没有实现之前的代码,可以运行:

# 下载指定分支的代码
git clone -b react-rsc-9 git@github.com:mqyqingfeng/next-app-demo.git
# 进入目录并安装依赖项
cd next-app-demo && npm i
# 启动
npm start

实现思路

该怎么实现客户端组件呢?其实实现思路是比较简单的:

编译代码的时候,如果遇到以 'use client'为开头的组件,说明是客户端组件,使用特殊的占位符替代。比如以目前的 JSX 对象实现方式为例的话,可以替换为 这样的节点。表明该位置是客户端组件占位,指向的客户端组件是 <Like>

然后将客户端组件编译为单独的 JS 代码。在客户端加载的时候,遍历所有的客户端组件占位,加载对应的 JS 代码,为每个组件单独进行渲染水合。

Step1:客户端组件编译

修改 client.js,添加代码如下:

import React from 'react'
import { readFile, writeFile } from "fs/promises"
import path from "path"

export async function renderJSXToClientJSX(jsx) {
  if (
    typeof jsx === "string" ||
    typeof jsx === "number" ||
    typeof jsx === "boolean" ||
    jsx == null
  ) {
    return jsx;
  } else if (Array.isArray(jsx)) {
    return Promise.all(jsx.map((child) => renderJSXToClientJSX(child)));
  } else if (jsx != null && typeof jsx === "object") {
    if (jsx.$$typeof === Symbol.for("react.element")) {
      if (typeof jsx.type === "string") {
        return {
          ...jsx,
          props: await renderJSXToClientJSX(jsx.props),
        };
      } else if (typeof jsx.type === "function") {
        const Component = jsx.type;
        const props = jsx.props;
        const isClientComponent = Component.toString().includes("use client")
        if (isClientComponent) {
          return await transformClientComponent(Component, props)
        } else {
          const returnedJsx = await Component(props)
          return renderJSXToClientJSX(returnedJsx)
        }
      } else throw new Error("Not implemented.");
    } else {
      return Object.fromEntries(
        await Promise.all(
          Object.entries(jsx).map(async ([propName, value]) => [
            propName,
            await renderJSXToClientJSX(value),
          ])
        )
      );
    }
  } else throw new Error("Not implemented");
}

async function transformClientComponent(Component, props) {

  const raw = Component.toString()
  const children = await renderJSXToClientJSX(props.children)

  const clientComponent = {
    value: raw,
    props: {
      ...props,
      "data-client": true,
      "data-component": Component.name,
      children,
    },
  }

  await createClientComponentJS(clientComponent)

  return React.createElement(
    "div",
    {
      "data-client": true,
      "data-component": Component.name
    }
  )
}

async function createClientComponentJS(Component) {
  const { props, value } = Component
  const name = props["data-component"]
  const filenameRaw = path.join(process.cwd(), "public", "client", name + ".js")
  const filename = path.normalize(filenameRaw)
  const fileContents = `import React from "react"
      export const props = ${JSON.stringify(props)}
      export const jsx = ${value.replaceAll('import_react.default', 'React')}`
  try {
    await writeFile(filename, fileContents)
  } catch (err) {
    console.log("error in writeComponentToDisk", err)
  }
}

在这段代码中,我们修改了 renderJSXToClientJSX函数,当函数组件代码包含 "use client" 时,视为客户端组件,调用 transformClientComponent 函数进行处理

transformClientComponent 中,我们首先会构建一个名为 clientComponent 的客户端组件对象,然后将其传给 createClientComponentJS 函数用于生成对应的客户端 JS。

以这样一段客户端组件代码为例的话:

  "use client";

import React from "react";
function Like() {
  const [likes, setLikes] = React.useState(100)
  return <button onClick={() => {setLikes(likes + 1)}}>❤️ {likes}</button>;
}
export default Like

最终生成的 JS 代码大致如下:

import React from "react"
export const props = {"data-client":true,"data-component":"Like"}
export const jsx = function Like(){const[likes,setLikes]=React.useState(100);return React.createElement("button",{onClick:()=>{setLikes(likes+1)}},"\u2764\uFE0F ",likes)}

其中 jsx 对应组件的代码,props 对应组件的 props。

因为我们将编译的 JS 代码放在了 public/client目录下,**所以别忘了新建 ****public/client**目录

transformClientComponent 中,最后我们会返回一个 React.createElement( "div", { "data-client": true, "data-component": Component.name})的 JSX 对象,它会被渲染为 <div data-client="client" data-component="Like">,用于客户端组件占位。

Step2:添加客户端组件

现在让我们添加一个客户端组件试试吧。

修改 components.tsx,代码如下:

import Like from "./Like";

async function Post({ slug }) {
  let content = await readFile("./posts/" + slug + ".txt", "utf8");
  return (
    <section>
      <a className="text-blue-600" href={"/" + slug}>{slug}</a>
      <article className="h-40 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">{content}</article>
      <Like />
    </section>
  )
}

新建 Like.tsx,代码如下:

import React from "react";

function Like() {
  "use client";

  const [likes, setLikes] = React.useState(100)
  
  return <button onClick={() => {setLikes(likes + 1)}}>❤️ {likes}</button>;
}

export default Like

代码看起来有些奇怪,其实也是迫不得已才这样写的。

因为我们用的是 tsx 进行的编译,当调用 renderJSXToClientJSX 的时候,获取不到顶部的 use client指令,所以为了区分客户端组件,我们就“委曲求全”的写在了组件内部。

Step3:客户端处理

修改 client.js,添加代码如下:

import React from "react"
import { hydrateRoot, createRoot } from 'react-dom/client';

const clientComponents = document.querySelectorAll("[data-client=true]")

for (const clientComponent of clientComponents) {
  const componentName = clientComponent.getAttribute("data-component")
  const ClientComponent = await import("./client/" + `${componentName}.js`)
  const { jsx, props } = ClientComponent

  const clientComponentJSX = React.createElement(jsx, props)
  clientComponent.setAttribute("data-loading", false)

  const clientComponentRoot = createRoot(clientComponent)
  clientComponentRoot.render(clientComponentJSX)
}

实现的效果就是遍历所有的客户端组件占位,根据其 data-component 属性,加载对应的 JS 文件,然后在客户端进行渲染水合。

修改 server/ssr.ts,将 publick 设为静态目录,顺便修正 2 处细节问题:

// 1. 添加 public 静态目录
app.use(express.static('public'))

app.get("/:route(*)", async (req, res) => {

  // 2. 处理 favicon.ico 文件,防止报错
  if (url.pathname === '/favicon.ico') {
    return
  }

  // 3. 拼接 HTML 这里修改了 react 导入的地址,加了一个 dev 参数
    html += `
      <script type="importmap">
        {
          "imports": {
            "react": "https://esm.sh/react@18.2.0?dev",
            "react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev"
          }
        }
      </script>
      <script type="module" src="/client.js"></script>
    `;
  }
});

此时运行 npm start,刷新页面,public/client下会导出 Like.js,浏览器交互效果如下:

react-rsc-18.gif

此时我们就实现了客户端组件。

  1. 功能实现:客户端组件
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/react-rsc-12
  3. 下载代码:git clone -b react-rsc-12 git@github.com:mqyqingfeng/next-app-demo.git

总结

其实我们实现的客户端组件非常“脆弱”,页面刷新的时候会有,导航的时候就没有了……所以还有很多要处理的地方,但个人能力有限,只能借这个简单的实现帮助大家理解客户端组件的实现。

在 Next.js 中的实现,客户端组件其实是会被预渲染的,而我们是直接替换为一个 <div data-client="client" data-component="Like">DOM 节点。因为 DOM 节点无内容,所以我们直接用了 createRoot,更贴合的实现应该用 hyrateRoot。

此外,Next.js 是在 RSC Payload 中显示要加载的客户端 JS,客户端收到 RSC Payload 后,加载对应的 JS 代码再进行水合,而我们是简单粗暴的遍历节点加载对应的 JS。

Next.js 的客户端组件,你可以简单粗暴的理解为“SSR + 水合 + CSR”,在服务端进行预渲染即 SSR,在客户端进行水合,添加事件,最后在客户端进行更新即 CSR。

© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru