带你手撸一个 Next.js,彻底搞懂 SSR 与 Hydration 错误是怎么产生的

87 阅读2分钟

由于 Next.js 已成为现代前端开发的标准框架,了解它的底层原理是非常有价值的

通过亲手构建一个简化版本,你可以更好地掌握服务端渲染(SSR)的核心概念,并理解导致常见问题(如 hydration 错误)的原因

本文主要以实现 Page Router 的核心概念为主,主要功能包括:

  • pages/{component}.tsx 中新增页面文件后,可自动生成对应的 服务端渲染 HTML客户端 React 组件
  • 通过 getServerSideProps 获取数据,用于动态生成服务端 HTML

 

流程图如下

How_to_build_your_own_Next_js_30fps.gif 本文使用 Bun + TypeScript 来实现


以下是資料夾結構

.
├── node_modules
├── package.json
├── pages
│   ├── index.tsx
│   └── products.tsx
├── README.md
├── server
│   └── index.tsx
├── server.tsx
└── tsconfig.json

 

🤖 服务端 (Server-Side)

  1. 从客户端请求中读取路径名(例如 /product
  2. 根据路径匹配对应的模块(例如 /product/product.jsx
  3. 从模块中提取组件和 getServerSideProps 函数(例如 /product.jsxProduct 组件)

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,
});

 

  1. 执行 getServerSideProps 以获取 initialProps
  2. 预打包组件和 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`;

 

  1. 使用 renderToString 将根组件与模块组件转换为 HTML 字符串
  2. 返回包含生成的 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)

  1. 通过 URL 加载预打包的客户端 hydration 脚本和模块组件(例如 Product.jsx
  2. 使用 React hooks 和客户端组件对静态 HTML 进行 hydration
  3. 将 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 #前端