Vue转React学习笔记(2):实现简易的react版SSR

一、原理概述

1. 历史进程

web前端发展二三十年以来,前端渲染方案经历了“前后端耦合”—“ 前后端分离 + CSR”—“混合渲染逐渐流行”三个阶段。

a. 前后端耦合阶段

这一阶段是纯粹的服务端渲染,当时前端尚未形成独立的概念,只需要承担切图的工作,由后端通过JSP/php模板拼接html字符串,由浏览器解析html。这种方式前后端耦合度极高,开发效率低,页面交互体验差(每次操作都要刷新整个页面,重新请求HTML)。

b. 前后端分离 + CSR主导

随着SPA框架(React、Vue)的兴起,前端进入“前后端分离”时代。后端仅提供接口,前端通过AJAX请求获取数据,再通过JS动态生成页面内容、操作DOM。这是目前前端最主流的实现方案。这种方式解决了前后端耦合的问题,页面交互流畅(无刷新),但也带来了致命痛点

  • 首屏加载慢(需要先加载空HTML、JSbundle,再请求数据、渲染页面)
  • SEO极差(搜索引擎爬虫只能抓取到空HTML,无法识别动态生成的内容)。

c. 混合渲染(SSR/ISR/SSG)

为了解决CSR的首屏和SEO问题,混合渲染方案应运而生。核心思路是“取长补短”:首屏由服务端渲染生成完整HTML,保证加载速度和SEO;后续交互由客户端接管,保留SPA的流畅体验。SSR(服务端渲染)就是其中最基础、最常用的一种,也是本文的所指的“服务端渲染”。

2. 原理与应用场景

a. SSR项目的访问流程

ssr详细流程如下图,基本流程大致可以概括为服务端预处理+客户端水合两个阶段。

b. 概念辨析

首先区分一下“服务端”渲染中的“服务端”含义:

  • Nodejs服务端:SSR架构中通常会内置Nodejs服务器,承担预处理工作
  • 业务后端:通常由Java/Go/Python等语言开发,承担接口请求、数据库连接、业务处理

个人觉得服务端渲染这个词有点名实不符,它并不是真的让后端工程师来完成渲染工作,更不是所谓的全栈开发。它准确地说应该叫“中间层”渲染,因为这些工作都是前端开发者完成的

其次区分一下“渲染”这个词的不同含义

  • 浏览器渲染的“渲染”:指的是浏览器获取HTML,CSS,JS文件后构建为渲染树,最终绘制屏幕内容的过程。
  • 服务端渲染的“渲染”:指的是将React/Vue组件转化为HTML代码。

现代SSR和传统服务端渲染的不同在于ssr方案是建立在现代前端工程(框架、构建工具等生态)的基础上的,它只是在服务器端做了一层预处理,并没有因此抛弃CSR时代构建的技术体系。

c. SSR、CSR的区别及适用场景

  • 首屏加载速度:SSR > CSR(SSR直接返回完整HTML,CSR需加载JS后再渲染);
  • SEO友好度:SSR > CSR(SSR的HTML带内容,CSR的HTML为空壳);
  • 开发成本与架构复杂度:SSR > CSR(SSR需要配置服务端、处理前后端同构、数据预取等问题);
  • 服务器压力:SSR > CSR(SSR需要服务器执行前端代码、渲染HTML,CSR服务器仅提供接口)

适用场景:

- 适合用SSR:面向公网、对首屏速度和SEO要求高的项目,比如官网、博客、电商首页、资讯平台等;

- 不适合用SSR:后台管理系统、内部系统等(无需SEO,用户多为登录用户,首屏速度要求不高),这类项目用CSR更简洁高效。

借用知乎上的一张图总结一下,这张图可以说是非常精髓了

二、技术生态

1. 主流框架

  • React生态:Next.js(最主流)。Next.js内置了SSR、ISR、SSG等多种渲染方案,自动配置Webpack、Babel,支持路由同构、数据预取,开发者只需专注业务代码即可。目前大部分React SSR项目,都会选择Next.js。
  • Vue生态:Nuxt.js。和Next.js对应,Nuxt.js是Vue的SSR框架,功能和Next.js类似,支持SSR、SSG,简化Vue的服务端渲染开发。
  • 其他:Remix(React生态,注重嵌套路由和数据加载)、SvelteKit(Svelte框架的SSR方案)等,适用场景相对小众。此外国内大厂可能会有一些内部的自研SSR框架。

2. SSR,ISR,SSG

  • SSR(Server-Side Rendering,服务端渲染):每次用户请求,服务器都会重新渲染HTML返回最新内容。
  • SSG(Static Site Generation,静态站点生成):构建时(npm run build),就提前渲染好所有页面的HTML,部署后直接返回静态HTML,无需服务器实时渲染。
  • ISR(Incremental Static Regeneration,增量静态再生):SSG的增强版。构建时生成静态HTML,部署后,服务器会在指定时间间隔(比如1小时)重新渲染页面,更新静态HTML。
  • 流式渲染(Streaming SSR):SSR的优化版,核心是“分块渲染、逐步返回”,打破传统SSR“一次性渲染完整HTML再返回”的逻辑。具体来说,服务端无需等待所有组件、所有数据都准备完成,而是先返回页面框架(比如导航栏、页脚等公共部分),再逐步返回后续的内容块(比如列表数据、详情内容),浏览器接收一块就渲染一块,减少用户感知的白屏时间。

三、手动实现React版SSR

篇幅所限代码并不是完整的,不过有什么问题通过ai-coding应该都可以解决的哦~

1. 准备工作

a. 项目结构搭建

我们采用react(react-dom)+webpack+babel来实现最基础的SSR方案,首先来看看项目的结构。

  • serverclient中的代码分别运行于Node环境(服务端预处理)和浏览器环境(客户端水合)
  • component以及shared中的React组件与js代码会在服务端、客户端先后执行两次
  • 采用webpack+babel来进行打包构建,注意server代码和client代码都需要打包
src/
├── server/          # 服务端代码(Node.js + Express)
│   └── index.js     # 服务端入口,处理请求、渲染HTML
├── client/          # 客户端代码(React交互逻辑)
│   └── index.js     # 客户端入口,实现注水
├── component/       # React组件(共享)
│   ├── Home.jsx     # 首页组件
│   ├── Detail.jsx   # 详情页组件
│   └── NotFound.jsx # 404组件
└── shared/          # 共享代码(路由)
    ├── route.js     # 路由配置(前后端同用)
    └── App.jsx      # 根组件(包含路由)
    └── api.js  
webpack.config.js     # Webpack配置(服务端+客户端)
package.json         # 依赖配置

b. 依赖安装与配置

其中:webpack-node-externals 是一个用于 Webpack 的库,它允许你在打包过程中排除 node_modules 文件夹中的模块,可以减小文件的打包体积。

{
  "dependencies": {
    "babel-loader": "^10.0.0",
    "express": "^5.2.1",
    "nodemon": "^3.1.11",
    "react": "^19.2.3",
    "react-dom": "^19.2.3",
    "react-router-dom": "^7.13.0",
    "webpack-node-externals": "^3.0.0"
  },
  "devDependencies": {
    "@babel/core": "^7.28.6",
    "@babel/preset-env": "^7.28.6",
    "@babel/preset-react": "^7.28.5",
    "@babel/register": "^7.28.6",
    "webpack": "^5.104.1",
    "webpack-cli": "^6.0.1"
  }
}

我们需要先build打包输出最后的bundle文件,然后启动这个文件,相当于本地开启了一个服务器

"scripts": {
    "build": "webpack",
    "start": "node dist/server.bundle.js",
    "test": "echo \"Error: no test specified\" && exit 1"
},

webpack需要打包客户端代码和服务端代码

import path from "path";
import nodeExternals from "webpack-node-externals";

const serverConfig = {
  name: "server",
  target: "node",
  mode: "development",
  entry: "./src/server/index.js",
  output: {
    path: path.resolve(import.meta.dirname, "dist"),
    filename: "server.bundle.js",
    library: {
      type: "module",
    },
  },
  experiments: {
    outputModule: true,
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: "babel-loader",
      },
    ],
  },
  externals: [
    nodeExternals({
      modulesDir: path.resolve(import.meta.dirname, "node_modules"),
      importType: "module",
    }),
    { buffer: false, fs: false, net: false, path: false, tls: false },
  ],
  resolve: {
    extensions: [".js", ".jsx"],
  },
};

const clientConfig = {
  name: "client",
  target: "web",
  mode: "development",
  entry: "./src/client/index.js",
  output: {
    path: path.resolve(import.meta.dirname, "dist"),
    filename: "client.bundle.js",
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: "babel-loader",
      },
    ],
  },
  resolve: {
    extensions: [".js", ".jsx"],
  },
};

export default [serverConfig, clientConfig];

2. 基础服务端渲染

import express from "express";
const app = express();
import path from "path";
import { renderToString } from "react-dom/server";
import React from "react";
import App from "../shared/App.jsx";

// 静态资源托管:让浏览器能访问到dist目录下的文件
app.use("/", express.static(path.resolve(process.cwd(), "dist")));

// 如果express版本有问题,可以试试路径这样写成正则表达式的形式
app.get(/(.*)/, async (req, res) => {
  // 引入renderToString将react组件转化为字符串
  const appHtml = renderToString(
    <App />
  );

  res.send(`
    <!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="root">${appHtml}</div>
  </body>
</html>
    `);
});

app.listen(3000, () => {
  console.log("server is running on port 3000");
});

这一阶段我们完成了服务端渲染基础的纯静态html文件,但此时的html并没有绑定事件不可交互,需要下一步客户端水合过程。

3. 客户端注水

import React from "react";
import { hydrateRoot } from "react-dom/client";
import App from "../shared/App.jsx";
import React from "react";

// 注水,将服务端渲染的html注入事件,使页面具有交互性
const root = document.getElementById("root");
hydrateRoot(root, <App />);

HTML部分也要做出修改,添加引入client代码。

<body>
  <div id="root">${appHtml}</div>
  <!--  引入client代码进行水合操作 -->
  <script type="module" src="/client.bundle.js"></script>
</body>

4. 路由同构

react-router提供了StaticRouterBrowserRouter分别用于服务端环境和客户端环境。

import React from "react";
import Home from "../component/Home.jsx";
import Detail from "../component/Detail.jsx";
import Auth from "../component/Auth.jsx";
import NotFound from "../component/NotFound.jsx";
const routes = [
  {
    path: "/",
    element: <Home />,
  },
  {
    path: "/detail",
    element: <Detail />,
  },
  {
    path: "/auth",
    element: <Auth />,
  },
  {
    path: "*",
    element: <NotFound />,
  },
];
export default routes;
// src/shared/App.jsx
import React from "react";
import { Routes, Route } from "react-router-dom";
import routes from "./route";

const App = () => {
  return (
    <div>
      <nav>
        <a href="/">Home</a> | <a href="/detail">Detail</a>
      </nav>
      <Routes>
        {routes.map((route) => (
          <Route key={route.path} {...route} />
        ))}
      </Routes>
    </div>
  );
};

app.get(*,(req,res)=>{
  const appHtml = renderToString(
    <StaticRouter location={req.path}>
      <App />
    </StaticRouter>,
  );
})

客户端部分也相应地添加路由,这里 BrowserRouter拦截<a>标签的默认跳转行为,不发送 HTTP 请求,而是通过 JS 修改 URL + 渲染对应组件,这样发挥了我们客户端路由的传统SPA优势,实现 无刷新的流畅交互

hydrateRoot(
  root,
  <BrowserRouter>
    <App initialData={initialData} />
  </BrowserRouter>,
);

这样页面就可以实现路由切换的效果了

5. 数据预获取

首先我们在api.js中编写调接口的请求函数,这个逻辑在客户端和服务端都要写一遍,服务端会执行,客户端做兼容和一致性的确认。

export const fetchPosts = async () => {
  const mockData=...
  // 模拟网络延迟
  await new Promise((resolve) => setTimeout(resolve, 500));
  // 模拟返回数据
  return mockData;
};

挂载到需要的组件上

Home.getInitialProps = async () => {
  try {
    const initialPosts = await fetchPosts();
    return { initialPosts };
  } catch (error) {
    console.error("Failed to fetch posts on server:", error);
    return { initialPosts: [] };
  }
};

接下来这部分是nodejs服务与真实后端服务之间的通信,如果有初始请求,那么我们会发起请求向真实业务后端拿数据。

const matchedRoute = routes.find((route) => {
    if (route.path === "*") return true;
    return route.path === req.path;
});

let initialData = {};

// 如果组件有 getInitialProps 方法,调用它获取初始数据
try {
  initialData = await matchedRoute?.element?.type?.getInitialProps?.();
} catch (error) {
  console.error("Failed to get initial props:", error);
}
  

然后我们把数据经过stringify处理后挂载到window上,传递给客户端

<body>
  <div id="root">${appHtml}</div>
  <script>
    // 将初始数据传递给客户端
    window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};
  </script>
  <script type="module" src="/client.bundle.js"></script>
</body>

客户端部分,先从window上获取初始数据,然后往组件传递

// 从 window 对象中获取服务端传递的初始数据
const initialData = window.__INITIAL_DATA__ || {};

// 注水,将服务端渲染的html注入事件,使页面具有交互性
const root = document.getElementById("root");

hydrateRoot(
  root,
  <BrowserRouter>
    <App initialData={initialData} />
  </BrowserRouter>,
);

// src/shared/App.jsx
import React from "react";
import { Routes, Route, Link } from "react-router-dom";
import routes from "./route";
import Home from "../component/Home.jsx";
import Detail from "../component/Detail.jsx";
import NotFound from "../component/NotFound.jsx";
const App = ({ initialData = {} }) => {
  return (
    <div>
      <nav>
        <Link to="/" style={{ marginRight: "20px" }}>
          Home
        </Link>
        <Link to="/detail">Detail</Link>
      </nav>
      <Routes>
        <Route
          path="/"
          element={<Home initialPosts={initialData.initialPosts} />}
        />
        <Route path="/detail" element={<Detail />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </div>
  );
};

export default App;

这里我们在useEffect中判断服务端渲染是否拿到了数据,如果没拿到的话执行一次

export default function Home({ initialPosts }) {
  const [posts, setPosts] = useState(initialPosts || []);
  const [loading, setLoading] = useState(!initialPosts);

  // 客户端数据获取
  useEffect(() => {
    if (!initialPosts || initialPosts.length === 0) {
      loadPosts();
    }
  }, [initialPosts]);

  const loadPosts = async () => {
    setLoading(true);
    try {
      const data = await fetchPosts();
      setPosts(data);
    } catch (error) {
      console.error("Failed to fetch posts:", error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div style={{ backgroundColor: "aqua", padding: "20px" }}>
      <h1>Home</h1>
      <div style={{ marginTop: "30px" }}>
        <h2>Posts</h2>
        {loading ? (
      <p>Loading posts...</p>
    ) : (
      <ul>
        {posts.map((post) => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.content}</p>
        </li>
      ))}
      </ul>
    )}
      </div>
    </div>
  );
}

最开始看SSR的时候也觉得晕头转向,流程非常绕,不过确实是加深了我对前端体系的认知,特别是网络交互和路由这一块,发现自己之前很多知识都学的非常浅薄。

四、面试八股的考点

分享几点比较零碎的个人想法供参考,其实SSR面试问到的概率并不会很高也未必是加分项,所以大家结合自己的掌握情况灵活应对,记住面试不是为了展示自己会越多技术越好,而是体现你自己的思考。

1. 项目涉及服务端渲染

一定要有充分的理由说明你的技术选型,可以从性能、SEO、用户访问量等角度出发,尽量不要做个增删改查的后台管理系统还用上nextjs,这样可能会让面试官觉得你缺乏宏观的思考。

2. 性能优化方案

除了答常规的网络、资源压缩、打包优化等相关的东西之外,可以简要回答一下架构升级层面,也就是采用SSR方案,这是对首屏性能优化程度最大,也是技术成本最高的方案,这样你的答案可能比其他同学更全面一些。

3. 输入url后发生了什么

这是非常高频的问题,通常我们在回答的时候都会讲到,浏览器发送http请求,从服务器获取HTML文件后开始解析,遇到script标签的时候执行JS文件,但我发现这个回答严格来讲是不全面的,因为这只是主流的CSR场景,而在SSR场景则会有一些微小的区别:

  • 如果是CSR场景,浏览器获取的是HTML文件,并且是空壳无内容的;而SSR场景下,浏览器获取的是动态拼接的HTML字符串,并且是带内容的。
  • CSR场景,浏览器是先解析HTML,遇到script标签执行JS;而SSR场景下,在服务器发送html之前,JS代码(也就是你写的Vue代码和React代码)已经预先在服务端跑一遍了。
  • SSR场景下JS在服务端执行的时候做了以下预处理:
  • ① 解析请求路径,匹配路由;

    ② 请求页面所需的初始数据,绑定到window上;

    ③ 将组件和数据渲染为完整的HTML字符串;然后将HTML字符串返回给浏览器

五、一些感想

我个人认为整个现代SSR的理念总结来讲就是:在沿用前端SPA框架(Vue、React)优势不变的前提下,让前端JavaScript回归管理用户交互(事件)的作用上来。因为本身JavaScript的设计就是为了给网页注入动态效果的,构建静态的dom节点并不一定需要JS,而Vue和React等前端框架兴起的主要愿景就是前后端完全分离,前端JS包揽一切(从数据请求、dom操作到事件绑定),而后端只管json传输,因此一定程度上牺牲了首屏体验。从这个角度看,CSR、SSR的演进其实就好像水多了加面,面多了加水,是一种不断权衡、螺旋上升的过程。

全部评论

相关推荐

KKorz:是这样的,还会定期默写抽查
点赞 评论 收藏
分享
评论
1
1
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务