从renderToString到hydrate,从0~1手写一个SSR框架
一、前言
上一篇文章,我们从ice.js源码学习了SSR的底层框架运行过程。
开源的SSR框架都是怎么实现的?
我们梳理了SSR从服务端渲染 -> 前端可交互的过程主要有如下几个阶段:
- 服务端匹配到前端请求;
- 基于路由匹配实际需要渲染的
React组件(from cjs产物); - 组装
App全局上下文和前端路由(react-router-dom); - 服务端执行渲染,产出
html string; - 前端水合,执行hydrate逻辑;
- 用户可交互;
基于这整个过程,你有没有思考过?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模块,因此需要将项目中所有的组件按统一源码,cjs、esm不同模块分别打出一份供服务端、前端使用。
就像这样:

2.2、项目初始化
我们新建一个项目,并初始化。
mkdir ssr-demo
cd ssr-demo
npm init -y
然后分析下需要的依赖。
- 构建,需要
webpack - 底层框架,需要
react、react-dom、react-router-dom - SSR服务,需要
express - 源码构建编译,需要
@babel/core、@babel/preset-react、babel-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
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
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
{// ..."scripts": {"build": "npx webpack --config webpack.client.config.js && npx webpack --config webpack.server.config.js","start": "node server.js"}
}
2.3、服务端核心
首先我们基于express跑一个服务,同时匹配路由。
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");
});
这是一个基础框架,匹配到路由后做的事情很简单:
- 暴露
dist; - 传入请求路径,执行
render核心函数,解析对应服务端组件; - 基于解析完成的
html string、运行时App上下文写入模板; - 返回前端;
那render核心函数的处理呢?
server-entry.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 (<Routekey={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 };
}
服务端渲染核心函数做了这些事情:
- 基于前端请求路径匹配路由组件;
- 读取组件服务端请求函数,用于在服务端初始化首屏动态数据;
- 创建
App全局上下文; - 创建
路由;
那继续逐个来看,基于前端请求路由,我们先看下routes文件,看完你就明白了。
// 用 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组件会是这样的:
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类型即可。
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注入到模板中,返回给前端。
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来水合(复用不渲染)组件。
前端运行时代码如下:
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) => (<Routekey={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,在这里就派上用处了。
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 />包装一层,如果是次屏组件,则请求一次数据再传入就行。
就像这样:
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) => (<Routekey={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.js、Ice.js,你会发现实现原理都很类似。
都是服务端渲染 -> 前端水合,结合双端运行时代码和约定式路由。
为什么现代ssr框架这么热门?
因为react支持了水合,让更完美的渲染方案问世了,即首屏SSR+前端接管。
如果你以前对于ssr的理解只停留在后端返回页面,页面跳转不好处理的阶段。
那对你的帮助应该很大!
