由于 Next.js 已成为现代前端开发的标准框架,了解它的底层原理是非常有价值的
通过亲手构建一个简化版本,你可以更好地掌握服务端渲染(SSR)的核心概念,并理解导致常见问题(如 hydration 错误)的原因
本文主要以实现 Page Router 的核心概念为主,主要功能包括:
- 在
pages/{component}.tsx中新增页面文件后,可自动生成对应的 服务端渲染 HTML 与 客户端 React 组件 - 通过
getServerSideProps获取数据,用于动态生成服务端 HTML
流程图如下
本文使用 Bun + TypeScript 来实现
以下是資料夾結構
.
├── node_modules
├── package.json
├── pages
│ ├── index.tsx
│ └── products.tsx
├── README.md
├── server
│ └── index.tsx
├── server.tsx
└── tsconfig.json
🤖 服务端 (Server-Side)
- 从客户端请求中读取路径名(例如
/product) - 根据路径匹配对应的模块(例如
/product→/product.jsx) - 从模块中提取组件和
getServerSideProps函数(例如/product.jsx→Product组件)
server.tsx
Bun.serve({
async fetch(req) {
const moduleName = pathname.replace('/', '') || 'index'; // (1)
const App = require(`./pages/${moduleName}.tsx`); // (2)
const Component = App.default; // (3)
const getServerSideProps = App.getServerSideProps; // (3)
},
port: process.env.PORT || 8080,
});
- 执行
getServerSideProps以获取initialProps - 预打包组件和 hydration 脚本(例如已打包的客户端
Product组件)
server.tsx
const ComponentProps = await getServerSideProps(); // (4)
// 生成客户端脚本文件,用于后续在客户端进行水合
const clientScriptContent = `
import { hydrateRoot } from 'react-dom/client';
import React from 'react';
import App from './pages/${moduleName}.tsx';
const domNode = document.getElementById('root');
if (domNode) {
hydrateRoot(domNode, React.createElement(App, window.initialProps || {}));
}
`;
// 将客户端脚本写入本地临时文件
const tempClientFile = `./temp-client-${moduleName}.tsx`;
await Bun.write(tempClientFile, clientScriptContent);
// 打包客户端脚本
// 打包时会同时引入组件文件,并将水合逻辑与组件文件一并打包
await Bun.build({ // (5)
entrypoints: [tempClientFile],
target: 'browser',
outdir: './dist',
naming: {
entry: `client-${moduleName}.js`,
},
});
// 删除临时文件
try {
await Bun.file(tempClientFile).unlink();
} catch (error) {
// 忽略删除错误
}
const clientScriptUrl = `/client-${moduleName}.js`;
- 使用
renderToString将根组件与模块组件转换为 HTML 字符串 - 返回包含生成的 HTML 和客户端 hydration 脚本 URL 的响应
server/index.tsx
import React from 'react';
import type { ReactNode } from 'react';
type IndexProps = {
initialProps: any;
children: ReactNode;
clientScriptUrl?: string;
};
export default function Index({
initialProps,
children,
clientScriptUrl,
}: IndexProps) {
return (
<html>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
window.initialProps = ${JSON.stringify(initialProps)};
`,
}}
></script>
{clientScriptUrl && (
<script type="module" src={clientScriptUrl}></script>
)}
</head>
<body>
<h1>Next.js Lite</h1>
<div id="root">{children}</div>
</body>
</html>
);
}
server.tsx
const htmlFromReact = renderToString( // (6)
<Index
initialProps={ComponentProps.props}
children={<Component {...ComponentProps.props} />}
clientScriptUrl={clientScriptUrl}
/>
);
return new Response(htmlFromReact, { // (7)
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
🧑💻 客户端 (Client-Side)
- 通过 URL 加载预打包的客户端 hydration 脚本和模块组件(例如
Product.jsx) - 使用 React hooks 和客户端组件对静态 HTML 进行 hydration
- 将 initialProps 应用于组件,确保 Hydration 一致性,避免服务端渲染的 HTML 与客户端渲染的 JSX 不匹配,从而导致组件无法正确完成水合
client-${moduleName}.js
import { hydrateRoot } from 'react-dom/client';
import React from 'react';
import App from './pages/${moduleName}.tsx';
const domNode = document.getElementById('root');
if (domNode) {
hydrateRoot(domNode, React.createElement(App, window.initialProps || {}));
}
如果感兴趣,可以查看这个 GitHub 仓库的源码:
🔗 github.com/MechaChen/n…
#NextJs #前端