本来我想在 《源码篇 | 实现 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 代码,为每个组件单独进行渲染水合。
修改 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">
,用于客户端组件占位。
现在让我们添加一个客户端组件试试吧。
修改 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
指令,所以为了区分客户端组件,我们就“委曲求全”的写在了组件内部。
修改 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
,浏览器交互效果如下:
此时我们就实现了客户端组件。
- 功能实现:客户端组件
- 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/react-rsc-12
- 下载代码:
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。