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

news/2025/11/22 13:55:39/文章来源:https://www.cnblogs.com/yangykaifa/p/19256641

一、前言

上一篇文章,我们从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

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

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

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

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

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

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

那继续逐个来看,基于前端请求路由,我们先看下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 (

Home Page

Data: {data?.message}

Go to About
); } 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 {children};
};
export const useAppContext = () => {return useContext(AppContext);
};
export default function AppProviders({appContext,children,location,isServer,
}) {const Router = isServer ? StaticRouter : BrowserRouter;return ({children});
}

同时支持了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.jsIce.js,你会发现实现原理都很类似。

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

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

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

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

那对你的帮助应该很大!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/973102.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

grep用法linux

grep 是 Linux/Unix 系统中一个非常常用的文本搜索工具,用于在文件中搜索特定的模式(字符串、正则表达式等)。下面我将详细解释 grep 的基本用法和常见参数,帮助你掌握其使用方法。一、基本语法grep [选项] patter…

Matplotlib 电影票房分析挑战

Matplotlib 高级柱状图绘制挑战:电影票房分析 题目要求 你需要使用 Matplotlib 绘制一个详细的三部电影在三天内的票房对比分析图。这个练习将考验你对 Matplotlib 各个细节的掌握程度。 数据 三部电影:《星际穿越》…

selenium: 安装selenium

一,官网: 地址: https://www.selenium.dev/ 代码站: https://github.com/SeleniumHQ/selenium 二,安装: $ pip install selenium 三,安装driver 查看chrome的版本: $ google-chrome --version Google Chrome 14…

基于单片机的故障检测自动保护智能防夹自动门设计及LCD状态显示架构

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

gpt安装 linux

在 Linux 系统中,gpt 是 GRUB2 的一个组件,用于管理启动菜单(grub-menu)的配置。如果你是想在 Linux 系统中安装或配置 GRUB2,那么 gpt 并不是直接的安装命令,而是 GRUB2 的一部分。一、GRUB2 的作用GRUB2 是 Li…

第2周作业

通配符总结:—— 匹配0个或多个字符,但不匹配“.”开头的隐藏文件 {a..z} —— 表示 a~z范围 所有内容 ? —— 用于匹配单个任意字符(不包括空字符) [0-9] —— 匹配 0~9内 任意单个字符 [a-Z] —— 匹配 a~z …

GRANT语句在MySQL中的权限继承策略

在MySQL中,GRANT语句用于授予用户或角色访问数据库对象(如表、视图、存储过程等)的权限。权限继承是MySQL权限系统的一个重要特性,它允许一个用户或角色继承另一个用户或角色的权限。这种继承关系可以是直接的,也…

轨道平面系与轨道姿态系 - 实践

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

Snipaste 桌面截图工具 易上手:桌面 贴 图神器,高效截图不费力

Snipaste 不只是一个截图工具,更是一个让截图"活"起来的神器!它的核心亮点在于:强大截图:按 F1 快速截图,支持各种标注和编辑独特贴图:按 F2 将截图贴回桌面,置顶显示,随时参考多功能转换:把文字、…

【物联网控制体系项目实战】—— 整体架构流程与 WS 构建

【物联网控制体系项目实战】—— 整体架构流程与 WS 构建pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas…

SBD521S-30-ASEMI可直接替代安世1PS79SB10

SBD521S-30-ASEMI可直接替代安世1PS79SB10编辑:ll SBD521S-30-ASEMI可直接替代安世1PS79SB10 型号:SBD521S-30 品牌:ASEMI 封装:SMAF 正向电流:0.2A 反向电压:30V 正向压降:0.55V~0.95V 引线数量:2 芯片个数:…

51单片机(markdown格式阅读) - 实践

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

NCHU_单部电梯调度程序设计分析_23207332

一、前言 三次电梯调度程序设计围绕单一职责原则(SRP)和迭代式开发展开,逐步优化类结构和功能实现:知识点覆盖: 面向对象设计(类、枚举、接口) 集合框架(Queue、List、Map) 正则表达式(输入解析) 状态机管理…

【日记】博客爆炸了(1009 字)

正文这周好忙。还白干了很多活儿。一直没时间写。还差点出大事,引发一场严重的网络安全事故。虽然直接原因和根本原因都不是我,但是我要帮忙去解决。二级分行是管理行,头一次对这个概念这么清晰。但我真的不想为支行…

解决:部署mabayolo模型cd selective_scan pip install . cd ..报错 以及 torch.cuda.is_available()结果False

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

gpt linux

您提到的“gpt linux”可能是指与 GRUB 或 GRUB2 相关的 Linux 系统管理命令,或者是与 GPT(GUID Partition Table)分区表相关的操作。以下是几种常见的情况和对应的 Linux 命令:一、GRUB 系统管理命令(GPT)1. gr…

gpt分区 linux

在 Linux 系统中,gpt(GUID Partition Table)是用于描述磁盘分区的文件系统格式,与传统的 MBR(Master Boot Record)分区方式不同。gpt 是 UEFI 启动模式下常用的分区格式,广泛应用于现代的 Linux 系统中。一、gp…

【调和级数】codeforces 731 F. Video Cards

View Post【调和级数】codeforces 731 F. Video Cards前言 调和级数 调和级数指的是所有正整数的倒数之和。其标准形式是:$$H_n=1+\frac{1}{2}+\frac{1}{3}+...+\frac{1}{n}=\sum_{i=1}^{n}{\frac{1}{i}}$$ 当 \(n\) …

电梯调度迭代之路:三次题目集的总结与反思

一、前言:三次迭代的知识图谱与能力跃迁 1.1 知识点覆盖全景 三次题目集以"单部电梯调度"为核心,构建了从基础实现到面向对象设计的完整知识链: 题目集1(7-5):聚焦基础编程能力,涵盖枚举类型应用(Di…