> For the complete documentation index, see [llms.txt](https://onenesss-organization.gitbook.io/jj-xiaoce/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://onenesss-organization.gitbook.io/jj-xiaoce/next.js-kai-fa-zhi-nan/78.-yuan-ma-pian-shi-xian-ke-hu-duan-zu-jian.md).

# 78. 源码篇 ❘ 实现客户端组件

### 前言

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

现在就让我们开始吧。这次我们基于的是[《源码篇 | RSC 实现原理》](https://juejin.cn/book/7307859898316881957/section/7309115864737611827#heading-7)的实现，此时刚实现 RSC Server 和 SSR Server 的拆分。如果没有实现之前的代码，可以运行：

```javascript
# 下载指定分支的代码
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`，添加代码如下：

```javascript
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。

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

```javascript
  "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 代码大致如下：

```javascript
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`，代码如下：

```jsx
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`，代码如下：

```javascript
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`，添加代码如下：

```javascript
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 处细节问题：

```javascript
// 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](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d2df536a5de2415693878dd1f9cd12c5~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1113\&h=701\&s=116800\&e=gif\&f=35\&b=252728)

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

> 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。
