一、概述
在现代web开发中,我们往往采用spa的开发方式。即服务端只返回一个根节点,剩下的DOM由浏览器根据js生成。大多数对SEO不敏感的中后台如此。但是一旦涉及到项目面向C端用户,例如官网,spa的方式就有点捉襟见肘。
spa的弊端
首先是白屏的问题,初次渲染时由于会加载较多的JavaScript文件,首屏渲染时间可能较长,影响用户的初次体验。其次是SEO,由于内容是由客户端渲染的,搜索引擎爬虫通常无法抓取动态内容,除非使用特殊的SSR/预渲染解决方案来补充。再者SPA通常会进行大量的前端渲染和数据处理,尤其是复杂应用,容易造成前端性能瓶颈。
ssr的优势
于是许多的希望提升用户体验与网站搜索排名的网站采用了SSR,即服务端的渲染方式。页面的渲染由服务器直接返回完整的HTML,加速了首屏的加载,可以被搜索引擎爬取,利于搜索引擎优化(SEO)
本文将结合React + Node,搭建一个简易的SSR框架,本文作为个人学习笔记,如有错误的地方,烦请指出~
二、思路整理
服务器渲染结合spa和ssr两者的优势,首次进入页面由服务器端渲染,后续在该页面的交互就是客户端接管。
流程:
1、用户输入url访问页面,服务器接收到请求并根据path匹配到对应的组件。
2、服务器获取所需的数据后,将数据传递给组件进行渲染
3、服务端将组件渲染为html字符串,与数据一起输送给浏览器(注水),浏览器拿到数据注入组件(脱水)。
4、浏览器开始进行渲染和节点对比,完成组件内事件绑定和交互
思考:为什么不在服务端进行事件绑定
服务器端渲染生成的是纯静态 HTML,无法处理用户的动态交互。事件处理通常依赖于浏览器环境中的 DOM、事件模型等,而这些在服务器端是不可用的。
思考:为什么服务端要将数据传输给浏览器?
为了实现客户端的无缝接管,服务端通常会在 HTML 中注入一段 JSON 格式的“数据状态”,通常嵌入在 <script>
标签中,便于客户端 JavaScript 读取。当 HTML 页面加载到浏览器后,客户端的 JavaScript 会读取这份注入的数据,并使用它来初始化客户端的状态管理(如 Redux 或 Zustand),这样客户端代码就能继续在已有数据的基础上执行,而不必重新请求数据。
这里有一个很重要的概念:同构
同构的目的在于让代码在服务器和客户端都能顺利运行,以实现高效的渲染和一致的用户体验。大致由三个关键点
- 渲染同构
渲染同构是指服务器端和客户端使用相同的渲染逻辑和组件,以便在任意端渲染的页面都保持一致
- 路由同构
指服务器和客户端使用同样的路由配置,以确保访问同一路径时返回的页面内容保持一致
- 数据同构
数据同构是指服务器和客户端之间保持一致的数据状态
三、架构搭建
最基本的结构:服务端返回html代码,并在浏览器进行渲染
安装express npm install express
// src/server/index.js
const express = require("express");
const childProcess = require("child_process");
const app = express();
app.get("*", (req, res) => {
res.send(`
<html
<body>
<div>server-ssr-render</div>
</body>
</html>
`);
});
app.listen(3333, () => {
console.log("ssr-server listen on 3333");
});
childProcess.exec("start http://127.0.0.1:3333");
打开浏览器控制台,发现html代码直接输出到了浏览器,实现了简单的服务端渲染。
为了使项目更符合真实的ssr架构,我们需要进行以下设计:
1、使用ts代替js
2、利用 Webpack 打包并运行项目
安装webpack以及babel-loader
、ts-loader
分别构建js和ts代码
npm install @babel/preset-env babel-loader ts-loader webpack webpack-merge webpack-cli @types/express --save-dev
新增通用的 Webpack 配置
// webpack.base.js
const path = require("path");
module.exports = {
module: {
rules: [
{
test: /.js$/,
loader: "babel-loader",
exclude: /node_modules/,
options: {
presets: ["@babel/preset-env"],
},
},
{
test: /.(ts|tsx)?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
alias: {
"@": path.resolve(process.cwd(), "./src"),
},
},
};
// webpack.server.js
const path = require("path");
const { merge } = require("webpack-merge");
const baseConfig = require("./webpack.base");
module.exports = merge(baseConfig, {
mode: "development",
entry: "./src/server/index.tsx",
target: "node",
output: {
filename: "bundle.js",
path: path.resolve(process.cwd(), "server_build"),
},
});
初始化ts代码配置
// tsconfig.json
{
"compilerOptions": {
"module": "CommonJS",
"types": ["node"],
"jsx": "react-jsx",
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"baseUrl": "./",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"]
}
更改服务端代码
// src/server/index.tsx
import express from "express";
import childProcess from "child_process";
const app = express();
app.get("*", (req, res) => {
res.send(`
<html
<body>
<div>server-ssr-render</div>
</body>
</html>
`);
});
app.listen(3333, () => {
console.log("ssr-server listen on 3333");
});
childProcess.exec("start http://127.0.0.1:3333");
安装react,后续将通过react的hook组件编写组件
npm install react react-dom --save
npm install @types/react @types/react-dom --save-dev
// src/pages/Home/index.tsx
const Home = () => {
return (
<div>
<h1>Home Page</h1>
<button onClick={() => alert("hello-ssr")}>btn</button>
</div>
);
};
export default Home;
添加脚本命令进行打包与执行,其中利用nodemon运行打包后的代码
// package.json
"scripts": {
"start": "npx nodemon --watch src server_build/bundle.js",
"build:server": "npx webpack build --config ./webpack.server.js --watch",
}
1、渲染同构
渲染同构是指服务器端和客户端使用相同的渲染逻辑和组件,以便在任意端渲染的页面都保持一致
1.1 renderToString
基于上面的思路,现在已经实现服务端返回html结构,简单实现了服务端渲染。但是在真实的项目中,我们不可能将所有的代码都写成字符串形式,需要用到组件化的开发方式。那么如何将组件转变为html呢
这里用到了 React 提供的renderToString
方法,它可以将 React 组件转换成 HTML 字符串
将上方的代码进行简单改造
import express from "express";
import childProcess from "child_process";
import Home from "@/pages/Home";
import { renderToString } from "react-dom/server";
const app = express();
const content = renderToString(<Home />); // 将组件渲染为HTML
app.get("*", (req, res) => {
res.send(`
<html
<body>
${content}
</body>
</html>
`);
});
app.listen(3333, () => {
console.log("ssr-server listen on 3333");
});
childProcess.exec("start http://127.0.0.1:3333");
可以看到,组件渲染成功了。但是这个时候点击按钮是没有反应的。因为服务端是绑定不了事件的
1.2 ReactDom.hydrateRoot
既然要实现事件绑定等客户端才能实现的交互,那么就需要将组件也在客户端调用一次,由浏览器处理好交互。
解决方案就是另写一份代码专门运行在客户端,并且最好能够最大程度地复用服务端中的代码
ReactDOM.hydrateRoot
是 React 18 引入的新方法,用于在客户端复用服务端渲染的 HTML,并将事件绑定到服务端生成的静态标记上,这一过程称为注水、水合
创建一个专用于客户端渲染的入口
// src/client/index.tsx
import { hydrateRoot } from "react-dom/client";
import Home from "@/pages/Home";
hydrateRoot(document.getElementById("root") as Document | Element, <Home />);
注意:ReactDom.hydrateRoot
需要指定一个绑定的真实 dom,记得给 server 入口页面加一个id
// src/server/index.tsx
<div id="root">${content}</div>
将客户端代码进行打包成js文件,并在服务端组件中引入
// webpack.client.js
const path = require("path");
const { merge } = require("webpack-merge");
const baseConfig = require("./webpack.base");
module.exports = merge(baseConfig, {
mode: "development",
entry: "./src/client/index.tsx",
output: {
filename: "index.js",
path: path.resolve(process.cwd(), "client_build"),
},
});
"scripts": {
"build:client": "npx webpack build --config ./webpack.client.js --watch",
},
// src/server/index.tsx
app.use(express.static(path.resolve(process.cwd(), "client_build")));
app.get("*", (req, res) => {
res.send(`
<html
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
此时再次构建并运行项目,就可以看到事件绑定成功了
2、路由同构
路由同构是指服务器端和客户端使用相同的路由配置,以确保无论在服务器还是客户端渲染,访问同一路径时返回的页面内容保持一致。
安装一下路由相关的依赖npm install react-router-dom --save
,并配置一个新的路由页面About
// src/router.tsx
import Home from "@/pages/Home";
import About from "@/pages/About";
interface Router {
path: string;
element: JSX.Element;
}
const router: Array<Router> = [
{
path: "/",
element: <Home />,
},
{
path: "/about",
element: <About />,
},
];
export default router;
// src/pages/About/index.tsx
import { FC } from "react";
const About: FC = (data) => {
return (
<div>About Page</div>
);
};
export default About;
重构客户端和服务端的路由
// src/server/index.tsx
import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import path from "path";
import router from "@/routes";
import { Route, Routes } from "react-router-dom";
import { StaticRouter } from "react-router-dom/server";
const app = express();
app.use(express.static(path.resolve(process.cwd(), "client_build")));
app.get("*", (req, res) => {
// 服务端渲染HTML
const content = renderToString(
<StaticRouter location={req.path}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</StaticRouter>
);
res.send(`
<html
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(3333, () => {
console.log("ssr-server listen on 3333");
});
childProcess.exec("start http://127.0.0.1:3333");
// src/client/index.tsx
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import router from "@/routes";
const Client = (): JSX.Element => {
return (
<BrowserRouter>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</BrowserRouter>
);
};
hydrateRoot(document.getElementById("root") as Document | Element, <Client />);
构建并运行项目,发现就可以访问/about
页面了,并且也是由服务端进行的渲染
注意:当使用 React 内置的路由跳转的时候,会进行客户端路由的跳转,采用客户端渲染。而使用a标签或者新开一个页面时,走的是服务端渲染
3、数据同构
数据同构是指服务器和客户端之间保持一致的数据状态
我们试着在About页面中编写一个接口看看效果
npm install axios body-parser --save
创建接口
//src/server/index.tsx
const bodyParser = require("body-parser");
// 请求body解析
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.post("/api/getAboutData", (req, res) => {
res.send({
data: req.body,
status_code: 0,
});
});
调用
//src/pages/About/index.tsx
import { FC, useState, useEffect } from "react";
import axios from "axios";
const About: FC = (data) => {
const [content, setContent] = useState("");
useEffect(() => {
axios
.post("/api/getAboutData", {
content: "这是一个demo页面",
})
.then((res: any) => {
setContent(res.data?.data?.content);
});
}, []);
return <div>{content}</div>;
};
export default About;
但是返回的html字符串却不包含数据,因为接口是在客户端发起的
需要的效果是服务端根据路径匹配到页面组件中所有的请求,发送请求获取数据并拼接成HTML字符串返回给前端
我们可以将数据请求的方法关联到路由中,然后在服务端查找到对应的方法调用获取数据
这里使用了zustand来进行全局数据管理
npm install zustand
//src/store.ts
import { create } from "zustand";
interface State {
data: any;
}
interface Actions {
setData: (data: any) => void;
}
export const useStore = create<State & Actions>((set) => {
return {
data: "",
setData: (data: string) => set({ data }),
};
});
关联网络请求
//src/pages/About/index.tsx
import { useStore } from "@/store";
import axios from "axios";
const About = () => {
const content = useStore.getState().data;
return (
<div>
<h1>About Page</h1>
{JSON.stringify(content)}
</div>
);
};
About.getInitData = () => {
return axios
.post("http://127.0.0.1:3333/api/getAboutData", {
content: "网络请求的数据:123321",
})
.then((res: any) => {
return {
content: res.data?.data?.content,
};
});
};
export default About;
//src/routes.tsx
interface Router {
path: string;
element: JSX.Element;
loadData?: () => Promise<any>;
}
const router: Array<Router> = [
{
path: "/",
element: <Home />,
},
{
path: "/about",
element: <About />,
loadData: About.getInitData, //网络请求
},
];
在服务端匹配到路由的网络请求,获取数据并存储到store中
//src/server/index.tsx
app.get("*", async (req, res) => {
// 服务端渲染HTML
const content = renderToString(
<StaticRouter location={req.path}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</StaticRouter>
);
const routeMap = new Map<string, () => Promise<any>>();
router.forEach((item) => {
if (item.path && item.loadData) {
routeMap.set(item.path, item.loadData);
}
});
const matchedRoutes = matchRoutes(router, req.path);
const promises: Array<Promise<any>> = [];
matchedRoutes?.forEach((item) => {
if (routeMap.has(item.pathname)) {
promises.push(routeMap.get(item.pathname)!());
}
});
// 获取数据
Promise.all(promises).then((data) => {
console.log("data", data);
// 数据注入Store
useStore.getState().setData(data);
});
res.send(`
<html
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
重新构建项目,并启动
这时候你会发现确实网络数据由服务端渲染进了html中,但是页面闪烁了一下数据又消失了,为什么呢
因为客户端的store与服务端的store数据不一致。当客户端js执行时,由于其store是空的,结果就是把服务端的网咯请求数据给覆盖了。
所以,需要同时将服务端的store,传递给客户端,实现数据同构
// src/server/index.tsx
// 获取完数据,讲数据通过script传递到客户端的window中
Promise.all(promises).then((data) => {
useStore.getState().setData(data);
res.send(`
<html
<body>
<div id="root">${content}</div>
<script>
window.__INITIAL_DATA__ = {
state: ${JSON.stringify(useStore.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>
`);
});
});
客户端接收数据,并初始化store
//src/client/index.tsx
// 获取服务端注入的数据
const initialData = window.__INITIAL_DATA__?.state || {};
// 初始化 Zustand 状态
useStore.setState(initialData);
hydrateRoot(document.getElementById("root") as Document | Element, <Client />);
服务端和客户端共享同一个store,解决了闪烁的问题
到此,项目的架构基本实现了渲染同构、路由同构、数据同构。