从renderToString到hydrate,从0~1手写一个SSR框架

一、前言

上一篇文章,我们从ice.js源码学习了SSR的底层框架运行过程。

开源的SSR框架都是怎么实现的?

我们梳理了SSR从服务端渲染 -> 前端可交互的过程主要有如下几个阶段:

  1. 服务端匹配到前端请求;
  2. 基于路由匹配实际需要渲染的React组件(from cjs产物);
  3. 组装App全局上下文和前端路由(react-router-dom);
  4. 服务端执行渲染,产出html string
  5. 前端水合,执行hydrate逻辑;
  6. 用户可交互;

基于这整个过程,你有没有思考过?SSR框架是如何把我们本地的组件(页面 - pages、组件 - components等等)串联成这个渲染链路的?

本文我们基于上述的渲染流程和主流的SSR框架技术实现原理,实现一个mini版本可跑通的SSR框架,从而深入理解SSR的全链路系统原理。

二、0~1分阶段实现SSR框架

2.1、设计先行

作为框架,那必然需要前端构建,传统CSR很简单,基于webpack单入口分析所有模块,打出js、css、html

SSR构建出一个应用,最基本的需要哪些能力呢?首先最大的区别:CSR部署相对静态,而SSR部署相对动态,如:服务端执行渲染、读配置前端水合,都是框架层面的rumtime code,因此需要前端和服务端的运行时产物。

而运行时核心的做的事情和路由 -> 组件有关,而服务端node环境只能识别cjs模块;浏览器环境识别esm模块,因此需要将项目中所有的组件按统一源码,cjsesm不同模块分别打出一份供服务端前端使用。

就像这样:

2.2、项目初始化

我们新建一个项目,并初始化。

复制代码
mkdir ssr-demo
cd ssr-demo
npm init -y

然后分析下需要的依赖。

  • 构建,需要webpack
  • 底层框架,需要reactreact-domreact-router-dom
  • SSR服务,需要express
  • 源码构建编译,需要@babel/core@babel/preset-reactbabel-loader

因此,执行:

复制代码
npm i webpack react react-dom react-router-dom express @babel/core @babel/preset-react babel-loader

然后我们先配置下webpack基础构建能力。

核心是给前端水合的runtime、服务端渲染的runtime打包。

因此拆两个webpack配置文件。

webpack.client.config.js

js 复制代码
const path = require("path");

module.exports = {
  mode: "development",
  entry: "./src/entry/client-entry.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "client-bundle.js",
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: "babel-loader",
        options: {
          presets: [
            [
              "@babel/preset-react",
              {
                runtime: "automatic",
              },
            ],
          ],
        },
      },
    ],
  },
};

webpack.server.config.js

js 复制代码
const path = require("path");

module.exports = {
  mode: "development",
  target: "node",
  entry: "./src/entry/server-entry.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "server-bundle.js",
    libraryTarget: "commonjs2",
  },
  externals: {
    react: "commonjs react",
    "react-dom/server": "commonjs react-dom/server",
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: "babel-loader",
        options: {
          presets: [
            [
              "@babel/preset-react",
              {
                runtime: "automatic",
              },
            ],
          ],
        },
      },
    ],
  },
};

就是两个最基础的配置,只是把代码打包了一下,没有特别复杂的能力。

后面如果要对框架做扩展,我们继续扩展就行。

然后我们再配置下打包和运行。

build就是把两个入口打一下。

start就是把服务跑起来。

package.json

json 复制代码
{
    // ...
    "scripts": {
        "build": "npx webpack --config webpack.client.config.js && npx webpack --config webpack.server.config.js",
        "start": "node server.js"
    }
}

2.3、服务端核心

首先我们基于express跑一个服务,同时匹配路由。

js 复制代码
const express = require("express");
const fs = require("fs");
const path = require("path");
const { render } = require("./dist/server-bundle.js");

const app = express();

// 静态文件(客户端 bundle)
app.use(express.static(path.join(__dirname, "dist")));

app.use(async (req, res) => {
  const { html, data, routePath } = await render(req.path);

  let template = fs.readFileSync(
    path.join(__dirname, "src", "template", "template.html"),
    "utf8"
  );
  template = template.replace("<!--SSR_CONTENT-->", html);
  template = template.replace("<!--INITIAL_DATA-->", JSON.stringify(data));
  template = template.replace("<!--ROUTE_PATH-->", routePath);

  res.send(template);
});

app.listen(3000, () => {
  console.log("SSR server running at http://localhost:3000");
});

这是一个基础框架,匹配到路由后做的事情很简单:

  1. 暴露dist
  2. 传入请求路径,执行render核心函数,解析对应服务端组件;
  3. 基于解析完成的html string运行时App上下文写入模板;
  4. 返回前端;

render核心函数的处理呢?

server-entry.js

js 复制代码
import React from "react";
import ReactDOMServer from "react-dom/server";
import { Route, Routes } from "react-router-dom";
import AppProviders from "../context/AppProviders.jsx";
import routes from "../routes/routes.js";

export async function render(url) {
  let matchedRoute = routes.find((r) => r.path === url);

  let routeData = {};
  let appData = { appName: "SSR Demo" };
  let Component = matchedRoute?.element?.type;
  if (Component && Component.getServerData) {
    routeData = await Component.getServerData();
  }
  const appContext = { appData, routeData, routePath: url };

  const element = (
    <AppProviders appContext={appContext} location={url} isServer>
      <Routes>
        {routes.map((r, idx) => {
          // 只给匹配到的那个路由传数据
          if (r.path === url) {
            return (
              <Route
                key={idx}
                path={r.path}
                element={React.cloneElement(r.element, { data: routeData })}
              />
            );
          }
          // 其它路由照常渲染,data 可以传 undefined 或保持原样
          return <Route key={idx} path={r.path} element={r.element} />;
        })}
      </Routes>
    </AppProviders>
  );

  const html = ReactDOMServer.renderToString(element);

  return { html, data: appContext, routePath: url };
}

服务端渲染核心函数做了这些事情:

  1. 基于前端请求路径匹配路由组件;
  2. 读取组件服务端请求函数,用于在服务端初始化首屏动态数据;
  3. 创建App全局上下文
  4. 创建路由

那继续逐个来看,基于前端请求路由,我们先看下routes文件,看完你就明白了。

js 复制代码
// 用 React Router v6 的形式配置路由
import About from "../components/About.jsx";
import Home from "../components/Home.jsx";
import NotFound from "../components/NotFound.jsx";

export default [
  { path: "/", element: <Home /> },
  { path: "/about", element: <About /> },
  { path: "*", element: <NotFound /> },
];

这里实际就是拿express req url来约定式路由中匹配,找到对应的组件。

ssr框架都支持在组件中暴露页面数据请求函数,用于初始化首屏数据,从props中传入。

因此Home组件会是这样的:

jsx 复制代码
import { Link } from "react-router-dom";

function Home({ data }) {
  return (
    <div>
      <h1>Home Page</h1>
      <p>Data: {data?.message}</p>
      <Link to="/about">Go to About</Link>
    </div>
  );
}

Home.getServerData = async () => {
  const data = { message: "Hello from Home Page API" };
  return data;
};

export default Home;

拿到请求函数在服务端执行下,最后传入路由去就行。

服务端的工作就完成了。

AppProvider组件是干啥的?

通常一些服务端、前端的共用数据、逻辑都会在这里。

比如路由嵌套,因为两端的组件源码是一致的,项目也是同一份,只需要区分Router类型即可。

jsx 复制代码
import { BrowserRouter, StaticRouter } from "react-router-dom";
import { createContext, useContext } from "react";

const AppContext = createContext(null);

export const AppContextProvider = ({ value, children }) => {
  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
};

export const useAppContext = () => {
  return useContext(AppContext);
};

export default function AppProviders({
  appContext,
  children,
  location,
  isServer,
}) {
  const Router = isServer ? StaticRouter : BrowserRouter;

  return (
    <AppContextProvider value={appContext}>
      <Router location={location}>{children}</Router>
    </AppContextProvider>
  );
}

同时支持了isServer参数,这样组件在服务端、前端运行时都可以用。

统一了全局数据。

服务端在生成代码的时候将appContext赋值。

然后将appContext注入到html window中。

ssr 前端运行时再将appContext透传中应用中。

这样业务组件也可以获取到ssr的配置信息。

这样流程就串起来了。

OK,至此,服务端渲染部分讲完了,最后server.js再将DOM、appContext注入到模板中,返回给前端。

js 复制代码
  const { html, data, routePath } = await render(req.path);

  let template = fs.readFileSync(
    path.join(__dirname, "src", "template", "template.html"),
    "utf8"
  );
  template = template.replace("<!--SSR_CONTENT-->", html);
  template = template.replace("<!--INITIAL_DATA-->", JSON.stringify(data));
  template = template.replace("<!--ROUTE_PATH-->", routePath);

  res.send(template);

至此,服务端部分就讲完了。

2.4 前端核心

前端部分比较简单。

回顾一下:前端在ssr中的角色核心是水合hydrate

然后服务端返回的DOM可交互。

CSR中,我们基于react renderRoot来渲染组件。

SSR中,服务端已经返回了当前页面所有的DOM,因此我们基于react hydrateRoot来水合(复用不渲染)组件。

前端运行时代码如下:

js 复制代码
import React from "react";
import { hydrateRoot } from "react-dom/client";
import { Route, Routes } from "react-router-dom";
import AppProviders from "../context/AppProviders.jsx";
import routes from "../routes/routes.js";

function RootApp({ appContext }) {
  return (
    <Routes>
      {routes.map((r, idx) => (
        <Route
          key={idx}
          path={r.path}
          element={React.cloneElement(r.element, {
            data: appContext.routeData,
          })}
        />
      ))}
    </Routes>
  );
}

async function run() {
  const appContext = window.__INITIAL_DATA__;
  const element = (
    <AppProviders appContext={appContext} isServer={false}>
      <RootApp appContext={appContext} />
    </AppProviders>
  );
  hydrateRoot(document.getElementById("root"), element);
}

run();

将这段代码注入到html中,就会将服务端返回的DOM开始水合。

SSR有个非常关键的点,如果前端和服务端的dom不同,则会水合失败,执行渲染流程。

在服务端设计的部分,我们实现了通用的AppProviders,在这里就派上用处了。

js 复制代码
import AppProviders from "../context/AppProviders.jsx";

前端运行时沿用这个组件。

并且将window.__INITIAL_DATA__继续作为上下文透传到前端所有组件中。

这样既保持了组件双端统一性。

也保证了数据统一性(框架数据从后端流到了前端)。

2.5 打包 -> 运行 -> 验证

至此框架的所有代码都编写完了。

我们跑下框架。

复制代码
npm run build
npm run start

先后成功打包了服务端代码和前端代码。

最后把ssr服务跑起来了,运行在3000端口。

我们访问下localhost:3000

请求直接返回了首屏DOM元素。

有动态数据直接渲染。

ssr client运行时脚本执行。

符合预期。

我们再测试下应用是否可以正常用,点击Link执行下路由跳转。

可以看到About组件的动态数据没有渲染,原因很简单。

因为目前的设计是首屏的服务端组件,会在express执行getServerData注入动态数据。

而后续跳转时,组件没有在服务端执行,这时候就需要在前端执行一遍了。

怎么设计呢?

我们在框架层前端runtime加一段逻辑即可。

给非双屏的<Route />包装一层,如果是次屏组件,则请求一次数据再传入就行。

就像这样:

js 复制代码
import React, { useEffect, useState } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { Routes, Route, useLocation } from 'react-router-dom';
import AppProviders from '../context/AppProviders.jsx';
import routes from '../routes/routes.js';

// 页面容器组件:处理首次加载的数据和路由切换时的数据获取
function DataLoader({ route }) {
  const location = useLocation();
  const [data, setData] = useState(() => window.__INITIAL_DATA__?.routeData);

  useEffect(() => {
    let active = true;

    async function fetchData() {
      const Component = route.element.type;
      if (Component.getServerData) {
        const newData = await Component.getServerData();
        if (active) {
          setData(newData);
        }
      }
    }

    // 首屏不请求(数据由 SSR 注入),后续路由切换才请求
    if (location.pathname !== window.__ROUTE_PATH__) {
      fetchData();
    }

    return () => { active = false; };
  }, [location.pathname, route.element.type]);

  const ElementWithData = React.cloneElement(route.element, { data });
  return ElementWithData;
}

function RootApp({ appContext }) {
  return (
    <Routes>
      {routes.map((route, idx) => (
        <Route
          key={idx}
          path={route.path}
          element={<DataLoader route={route} />}
        />
      ))}
    </Routes>
  );
}

export default function run() {
  const appContext = window.__INITIAL_DATA__ || { routeData: {} };

  const element = (
    <AppProviders appContext={appContext} isServer={false}>
      <RootApp appContext={appContext} />
    </AppProviders>
  );

  hydrateRoot(document.getElementById('root'), element);
}

这样一个可用、具备基础功能的SSR就完成了。

三、结尾

至此,从0~1手写一个ssr框架,不就搞定了么?

基于这个思路再去看Next.jsIce.js,你会发现实现原理都很类似。

都是服务端渲染 -> 前端水合,结合双端运行时代码和约定式路由。

为什么现代ssr框架这么热门?

因为react支持了水合,让更完美的渲染方案问世了,即首屏SSR+前端接管

如果你以前对于ssr的理解只停留在后端返回页面,页面跳转不好处理的阶段。

那对你的帮助应该很大!

相关推荐
写点什么呢6 小时前
Word使用记录
word·1024程序员节
开开心心就好7 小时前
内存清理工具点击清理,自动间隔自启
linux·运维·服务器·安全·硬件架构·材料工程·1024程序员节
开开心心就好1 天前
内存清理工具开源免费,自动优化清理项
linux·运维·服务器·python·django·pdf·1024程序员节
张萌杰4 天前
深度学习的基础知识(常见名词解释)
人工智能·深度学习·机器学习·1024程序员节
开开心心就好5 天前
免费无广告卸载工具,轻便安全适配全用户
linux·运维·服务器·网络·安全·启发式算法·1024程序员节
开开心心就好6 天前
图片格式转换工具,右键菜单一键转换简化
linux·运维·服务器·python·django·pdf·1024程序员节
徐子童8 天前
网络协议---TCP协议
网络·网络协议·tcp/ip·面试题·1024程序员节
扫地的小何尚10 天前
NVIDIA RTX PC开源AI工具升级:加速LLM和扩散模型的性能革命
人工智能·python·算法·开源·nvidia·1024程序员节
数据皮皮侠AI11 天前
上市公司股票名称相似度(1990-2025)
大数据·人工智能·笔记·区块链·能源·1024程序员节
开开心心就好11 天前
系统清理工具清理缓存日志,启动卸载管理
linux·运维·服务器·神经网络·cnn·pdf·1024程序员节