Oasis's Cloud

一个人的首要责任,就是要有雄心。雄心是一种高尚的激情,它可以采取多种合理的形式。
—— 《一个数学家的辩白》

[mini-next.js] 服务端渲染:SSR 实现

作者:oasis


概述

服务端渲染(Server-Side Rendering, SSR)是 Next.js 的核心特性之一。它允许在服务器端将 React 组件渲染为 HTML 字符串,然后发送给客户端。本文将深入分析并实现 SSR 的核心功能。

核心概念

什么是 SSR?

传统客户端渲染(CSR)

1. 浏览器请求 HTML
2. 服务器返回空的 HTML + JS
3. 浏览器执行 JS,渲染页面

服务端渲染(SSR)

1. 浏览器请求 HTML
2. 服务器渲染 React 组件为 HTML
3. 浏览器直接显示 HTML
4. 浏览器执行 JS,进行 hydration(水合)

SSR 的优势

  1. SEO 友好:搜索引擎可以直接抓取 HTML 内容
  2. 首屏加载快:浏览器立即显示内容,无需等待 JS 执行
  3. 更好的性能:减少客户端计算负担

功能分析

1. React 组件渲染

输入:React 组件 + Props
输出:HTML 字符串

技术:使用 react-dom/serverrenderToString

2. HTML 文档生成

输入:组件渲染的 HTML
输出:完整的 HTML 文档

包含: - HTML 结构(<html>, <head>, <body>) - 元数据(<meta>, <title>) - 样式和脚本引用

3. Props 传递

来源: - 动态路由参数(/posts/:id{ id: "123" }) - 查询参数(?name=value{ name: "value" }

逐步实现

步骤 1:创建渲染器模块结构

import React from 'react';
import { renderToString } from 'react-dom/server';

export function renderPage(Component, props = {}) {
  // 渲染逻辑
}

export function generateHTML(body, title = 'Mini Next.js') {
  // HTML 生成逻辑
}

export function renderFullPage(Component, props = {}, options = {}) {
  // 完整渲染流程
}

设计说明: - 分离关注点:组件渲染、HTML 生成、完整流程 - 可复用:每个函数职责单一

步骤 2:实现组件渲染

export function renderPage(Component, props = {}) {
  try {
    // 渲染 React 组件为 HTML 字符串
    const html = renderToString(React.createElement(Component, props));

    return html;
  } catch (error) {
    console.error('SSR render error:', error);
    throw error;
  }
}

关键点: - 使用 React.createElement 创建元素 - renderToString 将组件树转换为 HTML 字符串 - 错误处理:捕获渲染错误

示例

// 组件
function Home({ name }) {
  return <h1>Hello {name}</h1>;
}

// 渲染
const html = renderPage(Home, { name: 'World' });
// 输出: '<h1>Hello World</h1>'

步骤 3:实现 HTML 文档生成

export function generateHTML(body, title = 'Mini Next.js') {
  return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${title}</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
        'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
        sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      line-height: 1.6;
      color: #333;
    }
    #__next {
      min-height: 100vh;
    }
  </style>
</head>
<body>
  <div id="__next">${body}</div>
  <script>
    // 客户端 hydration 脚本
    // 在真实 Next.js 中,这里会加载编译后的客户端代码
    console.log('Page loaded');
  </script>
</body>
</html>`;
}

关键点: - 完整的 HTML5 文档结构 - 内联样式(简化实现) - #__next 容器(Next.js 约定) - 客户端脚本占位符

设计说明: - 使用模板字符串生成 HTML - 转义 body 内容(React 已处理) - 预留 hydration 脚本位置

步骤 4:实现完整渲染流程

export function renderFullPage(Component, props = {}, options = {}) {
  const body = renderPage(Component, props);
  return generateHTML(body, options.title);
}

流程: 1. 渲染组件为 HTML 2. 生成完整 HTML 文档 3. 返回最终 HTML

完整实现

import React from 'react';
import { renderToString } from 'react-dom/server';

/**
 * 渲染页面组件为 HTML
 */
export function renderPage(Component, props = {}) {
  try {
    // 渲染 React 组件为 HTML 字符串
    const html = renderToString(React.createElement(Component, props));

    return html;
  } catch (error) {
    console.error('SSR render error:', error);
    throw error;
  }
}

/**
 * 生成完整的 HTML 文档
 */
export function generateHTML(body, title = 'Mini Next.js') {
  return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${title}</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
        'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
        sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      line-height: 1.6;
      color: #333;
    }
    #__next {
      min-height: 100vh;
    }
  </style>
</head>
<body>
  <div id="__next">${body}</div>
  <script>
    // 客户端 hydration 脚本
    // 在真实 Next.js 中,这里会加载编译后的客户端代码
    console.log('Page loaded');
  </script>
</body>
</html>`;
}

/**
 * 渲染完整的页面
 */
export function renderFullPage(Component, props = {}, options = {}) {
  const body = renderPage(Component, props);
  return generateHTML(body, options.title);
}

使用示例

在开发服务器中使用

// src/server/dev-server.js
import { renderFullPage } from '../renderer/ssr-renderer.js';

async handlePageRoute(req, res, pathname, url) {
  const route = this.router.matchRoute(pathname);

  // 加载组件
  const Component = await this.moduleLoader.loadModule(route.filePath);

  // 准备 props
  const props = {
    ...route.params,  // 动态路由参数
    query: Object.fromEntries(url.searchParams),  // 查询参数
  };

  // 渲染页面
  const html = renderFullPage(Component, props);

  // 返回响应
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end(html);
}

页面组件示例

// pages/about.js
import React from 'react';

export default function About({ query }) {
  return (
    <div>
      <h1>About Page</h1>
      {query.name && <p>Hello, {query.name}!</p>}
    </div>
  );
}

访问 /about?name=World: - query = { name: 'World' } - 渲染结果包含 <p>Hello, World!</p>

动态路由示例

// pages/posts/[id].js
import React from 'react';

export default function Post({ id }) {
  return (
    <div>
      <h1>Post #{id}</h1>
      <p>This is post with ID: {id}</p>
    </div>
  );
}

访问 /posts/123: - id = "123" - 渲染结果包含 <h1>Post #123</h1>

SSR 流程详解

1. 请求到达

浏览器请求: GET /about
    ↓
服务器接收请求

2. 路由匹配

const route = router.matchRoute('/about');
// { path: '/about', filePath: './pages/about.js', ... }

3. 组件加载

const Component = await moduleLoader.loadModule('./pages/about.js');
// Component = About 函数组件

4. Props 准备

const props = {
  query: { name: 'World' }  // 从 URL 查询参数提取
};

5. 组件渲染

const html = renderToString(
  React.createElement(Component, props)
);
// html = '<div><h1>About Page</h1><p>Hello, World!</p></div>'

6. HTML 文档生成

const fullHTML = generateHTML(html, 'About');
// 完整的 HTML 文档,包含 <html>, <head>, <body> 等

7. 响应返回

res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fullHTML);

与真实 Next.js 的差异

已实现

未实现(简化)

总结

SSR 的核心是在服务器端将 React 组件转换为 HTML

  1. 组件渲染:使用 renderToString 将组件树转换为 HTML
  2. HTML 生成:包装成完整的 HTML 文档
  3. Props 传递:将路由参数和查询参数传递给组件

这个实现虽然简化,但包含了 Next.js SSR 的核心逻辑。在下一篇文章中,我们将实现 App Router,它提供了更强大的嵌套布局功能。


相关文件: - src/renderer/ssr-renderer.js - 完整实现 - examples/basic-app/pages/ - 示例页面