服务端渲染(SSR)的原理及实践
在2年前校招找前端工作的时候,我背了一堆面试题,其中有一些关于SSR的面试题,内容主要是SSR的优点和解决的问题等概念性问题,不过没有实现SSR的方案。 虽然市面上已经有开箱即用的开源SSR方案(Next.js、Nuxt.js),其原理还是有必要去探究清楚的。本文旨在介绍SSR的概念和原理,并提供使用webpack+react和Vite+Vue3搭建SSR项目的完整实战指南。在上述两个项目的代码实现过程中,我参考了一些文章的方案,但发现这些方案并不尽完善,因此我自己也做了一些探索和优化。在本文中,我也会分享这些值得注意的点。
SSR 是什么
首先,介绍下SSR的概念和特性。
SSR(Server-Side Rendering,服务端渲染)是一种将服务器端生成的HTML内容直接返回给浏览器的技术,通过在服务器端渲染页面,可以提高页面的首屏加载速度和SEO(搜索引擎优化)效果。相比于传统的客户端渲染,SSR可以减少浏览器的工作量,提升用户体验。
与客户端渲染CSR相比,其渲染HTML字符串的过程放到了服务器,所以返回客户端的是一个带有当前页面完整HTML结构的字符串。一般浏览器会有查看页面源代码的功能,可以利用这个功能看出于SSR和CSR的区别。由于SSR模式下向URL发起HTTP请求返回的HTML字符串信息更完整,更容易被爬虫抓取,所以SSR对于SEO(搜索引擎优化)更有利。
一般来说,使用服务端渲染主要是为了更快的首屏加载,因为在SSR中,服务器会在返回HTML字符串时一并返回页面的内容(包括首屏的XHR网络请求),而在CSR中,浏览器需要先下载HTML文件,然后再下载JavaScript文件并执行,最后才能够渲染页面,这个过程显然需要更长的时间。
SSR 用什么实现
目前比较流行的SSR解决方案主要有以下几种:
Next.js:是基于React的一个SSR框架,提供了丰富的功能和插件,比如动态路由、静态生成、数据预取等,可以快速构建高性能、SEO友好的Web应用。值得一提的是,新版本的
Nuxt.js支持React 18的新特性Server Component和流式SSR。Nuxt.js:是基于Vue.js的一个SSR框架,提供了类似于Next.js的功能和插件,可以让开发者快速构建Vue.js应用的SSR版本。
注
由于我不会Angular,所以关于这个框架的SSR方案就不提了
当然,除了开箱即用的方案,我们可以自己搭一个。不管是Vue和React,都有和SSR相关的API,然后在选一款服务端技术(express、koa等)和构建工具(webpack、vite等)就可以开始搭建了。理解清楚SSR流程之后,搭建思路也是比较清晰的。不过,如果是工作项目,能开箱即用还是开箱即用,不要给自己找麻烦(除非你是架构组)。
SSR 的主流程
从客户端和服务端工作的角度来看,SSR的主流程包括以下几个步骤:
客户端向服务器发送
HTTP请求服务器获取页面数据:服务器接收到
HTTP请求后,根据请求的URL地址获取相应的页面数据。服务器渲染页面:服务器使用获取到的页面数据,将页面模版和数据进行组合,生成
HTML字符串,并将HTML字符串返回给客户端。客户端接收
HTML字符串:客户端接收到服务器返回的HTML字符串,并进行解析渲染。此时渲染的页面不具备交互性。客户端注水(
hydrate):客户端解析HTML字符串后,激活页面上的JavaScript代码,并进行事件绑定等操作,在此之后的流程和CSR一般无二。
流程不算难,有两个值得注意的概念,也是实现SSR的难点:注水(hydration)和脱水(dehydration):
注水(
hydration)是指在SSR中,服务器将页面模版和数据组合生成HTML字符串后,将字符串发送给客户端,客户端接收到字符串后,将其中的JavaScript代码进行解析和执行,实现页面的激活和数据渲染的过程。脱水(
dehydration)是指在SSR中,客户端将注水后的页面进行缓存或保存后,当下一次请求相同的页面时,客户端会直接将之前保存的HTML字符串返回给浏览器。
在客户端拿到完整的HTML字符串时,本地是不存在所谓React或者Vue的实例对象的,此时我们实际是无法进行交互操作的。不过在这串HTML字符串中有<script>标签会去请求js代码并执行,初始化后我们的页面就可以进行交互了。
当我们的页面有数据要加载时,我们希望在首屏时把这些数据一起带过来,而不是在客户端进行请求。所以这些数据会在服务端预取并写入待返回的HTML字符串,这就是所谓脱水。
下面以我自己搭的两个项目架子为例来实践下SSR,我会直接把完整代码贴出来,代码有比较详尽的注释,不过我会对需要注意的点进行讲解。
实战:React+Webpack+express 实现 SSR
项目的代码基本是按照一本掘金小册上面给的示例去搭的,不过项目结构有所差别。
注
小册地址:SSR 实战:官网开发指南 - 祯民 - 掘金小册
这本小册在一些实现方案比如SSR、CMS、动画等给我提供了一些思路,总体还是挺不错的。不过监控部分讲的巨水,作者是字节员工,借着这个机会顺带推销自家的火山引擎,似乎也是情有可原。客观来说,如果监控是结合Sentry来讲,那将是绝杀,可惜换不得。
完整项目地址:https://github.com/kirazZ1/ssr-demo-react
项目基本结构搭建
项目用到的依赖不少,这里是项目最终的package.json:
{
"name": "webpack-react-ssr-demo",
"version": "1.0.0",
"description": "a demo of react ssr , builded by webpack",
"scripts": {
"start": "npx nodemon ./server_build/bundle.js",
"build:client": "npx webpack build --config ./build/webpack.client.js --watch",
"build:server": "npx webpack build --config ./build/webpack.server.js --watch",
"server:dev": "pnpm run build:server & pnpm run build:client & pnpm run start",
"clean": "rimraf client_build & rimraf server_build"
},
"keywords": ["webpack", "ssr", "react"],
"author": "kira",
"license": "ISC",
"dependencies": {
"@reduxjs/toolkit": "^1.9.3",
"axios": "^1.3.4",
"body-parser": "^1.20.2",
"express": "^4.18.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-redux": "^8.0.5",
"react-router-dom": "^6.9.0",
"redux-thunk": "^2.4.2"
},
"devDependencies": {
"@swc/core": "^1.3.41",
"@types/express": "^4.17.17",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/react-helmet": "^6.1.6",
"nodemon": "^2.0.21",
"redux": "^4.2.1",
"rimraf": "^4.4.0",
"swc-loader": "^0.2.3",
"ts-loader": "^9.4.2",
"typescript": "^5.0.2",
"webpack": "^5.76.2",
"webpack-cli": "^5.0.1",
"webpack-merge": "^5.8.0",
"webpackbar": "^5.0.2"
}
}这里面包括了webpack要使用到的一些依赖、我们在项目中使用到的技术栈如:React、React Router、Redux和express等,以及一些工具如rimraf。一些需要用到的启动脚本命令也先贴在这里了。
可以在空白文件夹里新建package.json,把上面的内容贴进去,然后执行pnpm i(pnpm需要自己安装)。
项目采用ts进行开发,这里是tsconfig.json的配置,已经把别名啥的配好了:
{
"compilerOptions": {
"module": "CommonJS",
"types": ["node"],
"jsx": "react-jsx",
"target": "ES6",
"lib": ["dom", "dom.iterable", "esnext"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"baseUrl": "./",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"]
}然后新建build文件夹,写webpack的配置文件,我这边分了三个文件:一个公用配置webpack.base.js,一个客户端打包配置webpack.client.js和一个服务端打包配置webpack.server.js。在这里使用了更快的swc-loader取代babel-loader进行代码的转译,需要其他loader可以自行添加,下面是详细的配置内容:
webpack.base.js:
// /build/webpack.base.js
const WebpackBar = require("webpackbar");
const path = require("path");
module.exports = {
module: {
rules: [
{
test: /.js$/,
loader: "swc-loader",
exclude: /node_modules/,
},
{
test: /.(ts|tsx)?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
alias: {
"@": path.resolve(process.cwd(), "./src"),
},
},
plugins: [new WebpackBar()],
};webpack.client.js:
// /build/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"),
},
});webpack.server.js:
// /build/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.ts",
target: "node",
output: {
filename: "bundle.js",
path: path.resolve(process.cwd(), "server_build"),
},
});写点页面
新建src/pages文件夹,用来放页面。
在src/pages中新建一个页面:
// /src/pages/Home/index.tsx
import { useState } from "react";
const Home = () => {
const [count, setCount] = useState(0);
return (
<div>
<h1>Hello SSR</h1>
<div>
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
</div>
</div>
);
};
export default Home;编写服务端和客户端渲染的根实例
由于服务端渲染和客户端渲染的过程是不一样的,所以这里把逻辑进行分离。在src文件夹下建两个文件夹client和server进行维护:

src/client/index.tsx的文件内容:
import { hydrateRoot } from "react-dom/client";
import Home from "@/pages/Home";
const App: React.FC = () => {
return <Home />;
};
hydrateRoot(document.getElementById("root") as Document | Element, <App />);src/server/index.tsx的文件内容:
import { renderToString } from "react-dom/server";
import Home from "@/pages/Home";
const App: React.FC = () => {
return <Home />;
};
export const render = () => {
return renderToString(<App />);
};这里renderToString方法是React服务端渲染的核心,其返回值为渲染组件的HTML字符串。
编写服务端
在目录新建src/server.ts,内容大致是这样:
import express from "express";
import path from "path";
import { render } from "./server/index";
const app = express();
//使用 express.static 可以非常方便地将静态文件的访问交给 Express 处理,避免手动处理静态文件请求,提高开发效率。
app.use(express.static(path.resolve(process.cwd(), "client_build")));
app.get("*", (req, res) => {
const content = render();
res.send(`
<html>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(4000, () => {
console.log("ssr-server listen on 4000");
});这样以后,运行pnpm run server:dev,项目就跑起来了。访问localhost:4000,可以看到如下页面:

当然,内容也是可交互的,因为我们在返回的HTML字符串中插入了<script>标签进行注水。
整合 React Router
首先在src文件夹下新建一个router用于路由表维护:

src/router/index.tsx:
import Home from "@/pages/Home";
import Example from "@/pages/Example";
interface IRouter {
path: string;
element: JSX.Element;
loadData?: (store: any) => any;
}
const router: Array<IRouter> = [
{
path: "/",
element: <Home />,
},
{
path: "/example",
element: <Example />,
},
];
export default router;顺带建个Example页面,代码如下:
const Example = () => {
return (
<div>
<h1>This is an example</h1>
</div>
);
};
export default Example;将/src/server/index.tsx修改如下:
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { Route, Routes } from "react-router-dom";
import router from "@/router";
const App: React.FC<{ path: string }> = ({ path }) => {
return (
<StaticRouter location={path}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</StaticRouter>
);
};
export const render = (path: string) => {
return renderToString(<App path={path} />);
};上面增加了StaticRouter组件,官网的描述是这样的:
<StaticRouter>is used to render a React Router web app in node. Provide the current location via thelocationprop.
我们可以在这个组件中传入location的方式去静态渲染某个路由指向的页面,当然,路径可以由服务端那边调用render时传进来。
客户端代码也要整合React Router,将/src/client/index.tsx修改如下:
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import router from "@/router";
const App: React.FC = () => {
return (
<BrowserRouter>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</BrowserRouter>
);
};
hydrateRoot(document.getElementById("root") as Document | Element, <App />);然后修改服务端代码src/server.ts,在调用render的地方把路径传进来就行:
import express from "express";
import path from "path";
import { render } from "./server/index";
const app = express();
app.use(express.static(path.resolve(process.cwd(), "client_build")));
app.get("*", (req, res) => {
const content = render(req.path); // 传了路径
res.send(`
<html>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(4000, () => {
console.log("ssr-server listen on 4000");
});保存代码刷新页面,可以看到如下的页面:

查看网页源代码,也可以发现页面内容被完整的返回了过来。
首屏数据加载方案(数据的脱水与注水)
在CSR项目中,我们初次进入页面加载页面数据可以直接用useEffect来实现。事实上在服务端,它不会被调用,也就是说在这种情况下的SSR,返回的HTML字符串并没有远程请求页面数据的内容,页面数据请求的操作被放到了客户端一侧,这显然不是我们想要的结果。
关于这个问题,比较通用的解决思路是使用状态管理工具来存储数据。注意状态管理工具是可以在服务端使用的,所以我们只需要想办法去调用对应页面获取首屏数据的请求,并把请求到的数据放进去状态管理的store中,最后将store数据注入HTML字符串中。客户端在加载时会根据返回的HTML对store进行初始化。这样便实现了由服务端进行首屏数据请求的前提下双端的同构。这个解决问题的思路无论是对于React,还是对于Vue,都是一样的。
首先我们在服务端写一个接口模拟后台请求:
import express from "express";
import path from "path";
import { render } from "./server/index";
const bodyParser = require("body-parser");
const app = express();
app.use(express.static(path.resolve(process.cwd(), "client_build")));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.post("/api/example", (req, res) => {
res.send({
data: "远程数据",
code: 0,
});
});
app.get("*", (req, res) => {
const content = render(req.path);
res.send(`
<html>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(4000, () => {
console.log("ssr-server listen on 4000");
});用Postman测试下:

接下来要把redux给整合上,在src文件夹新建store目录:
// src/store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import thunk from "redux-thunk";
import { demoReducer } from "@/pages/Demo/store";
const clientStore = configureStore({
reducer: {},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(thunk),
});
const serverStore = configureStore({
reducer: {},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(thunk),
});
export { clientStore, serverStore };两个store分别对应给客户端和服务端,在客户端和服务端的代码中分别注入:
src/client/index.tsx:
// src/client/index.tsx
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import router from "@/router";
import { Provider } from "react-redux";
import { clientStore } from "@/store";
const App: React.FC = () => {
return (
<Provider store={clientStore}>
<BrowserRouter>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</BrowserRouter>
</Provider>
);
};
hydrateRoot(document.getElementById("root") as Document | Element, <App />);src/server/index.tsx:
//src/server/index.tsx
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { Route, Routes } from "react-router-dom";
import router from "@/router";
import { serverStore } from "@/store";
import { Provider } from "react-redux";
const App: React.FC<{ path: string }> = ({ path }) => {
return (
<Provider store={serverStore}>
<StaticRouter location={path}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</StaticRouter>
</Provider>
);
};
export const render = (path: string) => {
return renderToString(<App path={path} />);
};我们将刚才创建的Example页面作为脱水和注水的实验页面,修改下页面内容(具体干了啥看看代码就知道了):
import React, { useState } from "react";
const Example: React.FC<{
content?: string;
getData?: (data: string) => void;
}> = ({ content, getData }) => {
const [value, setValue] = useState("");
return (
<div>
<h1>This is an {content}</h1>
<input onChange={(e) => setValue(e.target.value)} />
<button onClick={() => getData && getData(value)}>Fetch</button>
</div>
);
};
export default Example;在Example文件夹下面新建store/index.ts:

src/pages/Example/store/index.tsx:
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
// 这里需要判断是浏览器环境还是服务端环境,服务端环境需要完整路径
const rootUrl = typeof window !== "undefined" ? '' : 'http://localhost:4000'
const getData = createAsyncThunk(
"example/getData",
async (initData: string) => {
const res = await axios.post(rootUrl + "/api/example", {
content: initData,
});
return res.data?.data?.content;
}
);
const exampleReducer = createSlice({
name: "example",
initialState:
typeof window !== "undefined"
? (window as any)?.context?.state?.example
: {
content: "默认数据",
},
reducers: {},
extraReducers(build) {
build
.addCase(getData.pending, (state, action) => {
state.content = "请求中...";
})
.addCase(getData.fulfilled, (state, action) => {
state.content = action.payload;
})
.addCase(getData.rejected, (state, action) => {
state.content = "请求失败";
});
},
});
export { exampleReducer, getData };上面的initialState的代码其实就是数据注水的过程,在等下的server.ts中生成的HTML我们可以看到与之对应的代码。
修改/src/store/index.ts,把新建的reducer注入到store里面:
import { exampleReducer } from "@/pages/Example/store";
import { configureStore } from "@reduxjs/toolkit";
import thunk from "redux-thunk";
const clientStore = configureStore({
reducer: { example: exampleReducer.reducer },
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(thunk),
});
const serverStore = configureStore({
reducer: { example: exampleReducer.reducer },
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(thunk),
});
export { clientStore, serverStore };这时候,Example的页面代码也需要修改:
import React, { useState } from "react";
import { connect } from "react-redux";
import { getData } from "./store";
const Example: React.FC<{
content?: string;
getData?: (data: string) => void;
}> = ({ content, getData }) => {
const [value, setValue] = useState("");
return (
<div>
<h1>This is an {content}</h1>
<input onChange={(e) => setValue(e.target.value)} />
<button onClick={() => getData && getData(value)}>Fetch</button>
</div>
);
};
const mapStateToProps = (state: any) => {
// 将对应reducer的内容透传回dom
return {
content: state?.example?.content,
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
getData: (data: string) => {
dispatch(getData(data));
},
};
};
const storeDemo: any = connect(mapStateToProps, mapDispatchToProps)(Example);
storeDemo.getInitProps = (store: any, data?: string) => {
return store.dispatch(getData(data || "首屏加载数据"));
};
export default storeDemo;这里组件把首屏数据获取的方法挂载到了组件逻辑外部,可以把这个组件请求数据的方法也放到路由表里,方便服务端拿到:
import Home from "@/pages/Home";
import Example from "@/pages/Example";
interface IRouter {
path: string;
element: JSX.Element;
loadData?: (store: any) => any;
}
const router: Array<IRouter> = [
{
path: "/",
element: <Home />,
},
{
path: "/example",
element: <Example />,
loadData: Example.getInitProps
},
];
export default router;下面先给出这时候server.ts的代码,说明见注释:
import router from "@/router/index";
import express from "express";
import path from "path";
import { matchRoutes, RouteObject } from "react-router-dom";
import { render } from "./server/index";
import { serverStore } from "./store";
const bodyParser = require("body-parser");
const app = express();
app.use(express.static(path.resolve(process.cwd(), "client_build")));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.post("/api/example", (req, res) => {
res.send({
data: req.body,
code: 0,
});
});
app.get("*", (req, res) => {
// 根据路由表初始化Map(如果存在loadData就放入Map里面,方便后面取用)
// loadData获取到的远程数据会放入serverStore里面
const routeMap = new Map<string, () => Promise<any>>();
router.forEach((item) => {
if (item.path && item.loadData) {
routeMap.set(item.path, item.loadData(serverStore));
}
});
// 匹配当前路由的routes
const matchedRoutes = matchRoutes(router as RouteObject[], req.path);
const promises: Array<() => Promise<any>> = [];
matchedRoutes?.forEach((item) => {
if (routeMap.has(item.pathname)) {
promises.push(routeMap.get(item.pathname) as () => Promise<any>);
}
});
// 脱水操作 - 确保把数据放入store之后在进行render
Promise.all(promises).then(() => {
const content = render(req.path);
res.send(`
<html>
<body>
<div id="root">${content}</div>
<script>
window.context = {
state: ${JSON.stringify(serverStore.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>
`);
});
});
app.listen(4000, () => {
console.log("ssr-server listen on 4000");
});这个项目的SSR流程到这里就算走完了,看看效果:

可以看到数据的脱水和注水都成功了,页面也能正常 进行交互:

实战:Vue+Vite+express 实现 SSR
首先说明一下,这边使用的Vue是Vue3.x版本,老版本的Vue的 SSR实在不想去研究了。值得一提的是,整合了Vite之后,开发体验似乎很好?
注
本文参考的项目:vite-plugin-vue/playground/ssr-vue at main · vitejs/vite-plugin-vue · GitHub
完整项目地址:https://github.com/kirazZ1/ssr-demo-vue
除此之外,这里还有一些有参考价值的文档:
基本项目搭建
这边依然建议使用pnpm作为包管理工具。首先可以通过pnpm create vite拉一个Vue3的项目架子:

为了方便安装依赖,我也先把最终的package.json贴出来:
{
"name": "ssr-demo-vue",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "pnpm run hot-build:client & npx nodemon ./src/server.js",
"build": "npm run build:client && npm run build:server",
"build:noExternal": "npm run build:client && npm run build:server:noExternal",
"hot-build:client": "vite build --ssrManifest --outDir dist/client --watch",
"build:client": "vite build --ssrManifest --outDir dist/client",
"build:server": "vite build --ssr src/server/entry-server.js --outDir dist/server",
"build:server:noExternal": "vite build --config vite.config.noexternal.js --ssr src/entry-server.js --outDir dist/server"
},
"dependencies": {
"vue": "^3.2.47",
"vue-router": "^4.1.6",
"axios": "^1.3.4",
"body-parser": "^1.20.2",
"pinia": "^2.0.33",
"serve-static": "^1.15.0",
"compression": "^1.7.4",
"express": "^4.18.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.1.0",
"nodemon": "^2.0.21",
"vite": "^4.2.0"
}
}用这部分内容覆盖原来的package.json并执行pnpm i即可。
客户端和服务端渲染代码编写
新建两个文件夹来维护两个渲染入口代码:

修改src/main.js:
// src/main.js
import { createSSRApp } from 'vue'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
return { app }
}注意这里使用了createSSRApp创建应用,按照官网的说法,这是为了在激活模式下挂载应用。关于激活模式的表述大致如下:
为了使客户端的应用可交互,Vue 需要执行一个激活步骤。在激活过程中,Vue 会创建一个与服务端完全相同的应用实例,然后将每个组件与它应该控制的 DOM 节点相匹配,并添加 DOM 事件监听器。
然后在src/client中创建entry-client.js文件,作为CSR的入口:
// src/client/entry-client.js
import "../style.css";
import { createApp } from "../main";
const { app } = createApp();
app.mount("#app");在src/server中创建entry-server.js文件,作为SSR的入口:
// src/client/entry-server.js
import { createApp } from "../main";
import { renderToString } from "vue/server-renderer";
export async function render() {
const { app } = createApp();
const html = await renderToString(app);
return [html];
}这里暴露了一个render方法,供express调用。下面来写基本的express的代码,放在src/server.js中:
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";
import { createServer as createViteServer } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const resolve = (p) => path.resolve(__dirname, p);
async function createServer(
_,
isProd = process.env.NODE_ENV === "production"
) {
const app = express();
const indexProd = isProd
? fs.readFileSync(resolve("dist/client/index.html"), "utf-8")
: "";
let vite;
if (!isProd) {
// 以中间件模式创建 Vite 应用,这将禁用 Vite 自身的 HTML 服务逻辑
// 并让上级服务器接管控制
vite = await createViteServer({
server: {
middlewareMode: true,
watch: {
usePolling: true,
interval: 100,
},
},
appType: "custom",
});
// 使用 vite 的 Connect 实例作为中间件
// 如果你使用了自己的 express 路由(express.Router()),你应该使用 router.use
app.use(vite.middlewares);
} else {
app.use((await import("compression")).default());
app.use(await import("serve-static")).default(resolve("dist/client"), {
index: false,
});
}
app.use("*", async (req, res) => {
const url = req.originalUrl
try {
let template, render;
if (!isProd) {
// 1. 读取 index.html
template = fs.readFileSync(resolve("../index.html"), "utf-8");
// 2. 应用 Vite HTML 转换。这将会注入 Vite HMR 客户端,
// 同时也会从 Vite 插件应用 HTML 转换。
// 例如:@vitejs/plugin-react 中的 global preambles
template = await vite.transformIndexHtml(url, template);
// 3. 加载服务器入口。vite.ssrLoadModule 将自动转换
// 你的 ESM 源码使之可以在 Node.js 中运行!无需打包
// 并提供类似 HMR 的根据情况随时失效。
render = (await vite.ssrLoadModule("/src/server/entry-server.js")).render;
} else {
template = indexProd;
render = (await import("./dist/server/entry-server.js")).render;
}
// 4. 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
// 函数调用了适当的 SSR 框架 API。
// 例如 ReactDOMServer.renderToString()
const [appHtml] = await render();
// 5. 注入渲染后的应用程序 HTML 到模板中。
const html = template.replace(`<!--app-html-->`, appHtml)
// 6. 返回渲染后的 HTML。
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
// 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回
// 你的实际源码中。
isProd || vite.ssrFixStacktrace(e);
console.error(`[error]`, e.stack);
res.status(500).end(e.stack);
}
});
return { app };
}
createServer().then(({ app }) => {
app.listen(3000, () => {
console.log("[server] http://localhost:3000");
});
});这边的代码对开发模式下和生产模式下的渲染逻辑做了区分,如果是开发模式下,需要借助vite进行一些处理如:使用transformIndexHtml来读取index.html、使用ssrLoadModule来加载模块等;如果是生产模式则直接对打包产物进行使用,这里需要使用到compression和serve-static两个express中间件。
最后修改根html的代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/client/entry-client.js"></script>
</body>
</html>运行pnpm run start,访问localhost:3000可以看到页面效果:

基本的服务端渲染算是完成了,接下来我们要把路由功能也就是vue-router给整合上。
整合vue-router
在src下新建router文件夹用来存放路由表,新建pages用来放页面:

之后我们在pages文件夹里新建两个页面用于测试:
src/page/Home/index.vue:
<script setup>
import HelloWorld from "../../components/HelloWorld.vue";
</script>
<template>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="../../assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div>
<HelloWorld msg="Vite + Vue" />
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>src/page/Example/index.vue:
<template>
<h1>This is an example</h1>
<button type="button" @click="count++">count is {{ count }}</button>
</template>
<script setup>
import { ref } from "vue";
const count = ref(0);
</script>修改src/App.vue:
// src/App.vue
<template>
<router-view></router-view>
</template>然后在刚刚建的router文件夹里写入页面路由:
import {
createWebHistory,
createRouter as _createRouter,
createMemoryHistory,
} from "vue-router";
const routes = [
{
path: "/",
alias: "/Home",
component: () => import("../pages/Home/index.vue"),
},
{
path: "/Example",
alias: "/Example",
component: () => import("../pages/Example/index.vue"),
},
];
export function createRouter() {
return _createRouter({
// history: import.meta.env.SSR ? createMemoryHistory("/ssr") : createWebHistory("/ssr"),
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes,
});
}上面的代码有一个需要注意的点,SSR是在服务端渲染页面的,而服务端是不支持history API的,所以路由模式需要根据运行环境做相应的调整。createMemoryHistory的作用如下:
createMemoryHistory是Vue Router 4.x中新增的API,它用于创建一个内存式的路由历史记录,而不需要依赖于浏览器URL的变化。这个函数返回一个history对象,它具有与浏览器历史记录API类似的方法,如push、replace、go、back等。通过
createMemoryHistory创建的内存式路由历史记录,可以应用于一些特殊场景,例如:
- 在服务端渲染(
SSR)应用程序中,可以使用内存式路由历史记录来处理页面的跳转和历史记录管理。- 在测试环境中,可以使用内存式路由历史记录来进行单元测试和集成测试,而不需要关心浏览器URL的变化。
之后,main.js的代码要做相应的修改:
import { createSSRApp } from 'vue'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const router = createRouter()
app.use(router)
return { app, router }
}entry-server.js的代码需要补充根据传入的url进行路由跳转的逻辑:
import { createApp } from "../main";
import { renderToString } from "vue/server-renderer";
export async function render(url) {
const { app, router } = createApp();
await router.push(url);
await router.isReady();
const html = await renderToString(app);
return [html];
}在server.js中把请求中的url传入render函数如下:
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";
import { createServer as createViteServer } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const resolve = (p) => path.resolve(__dirname, p);
async function createServer(
_,
isProd = process.env.NODE_ENV === "production"
) {
const app = express();
const indexProd = isProd
? fs.readFileSync(resolve("dist/client/index.html"), "utf-8")
: "";
let vite;
if (!isProd) {
// 以中间件模式创建 Vite 应用,这将禁用 Vite 自身的 HTML 服务逻辑
// 并让上级服务器接管控制
vite = await createViteServer({
server: {
middlewareMode: true,
watch: {
usePolling: true,
interval: 100,
},
},
appType: "custom",
});
// 使用 vite 的 Connect 实例作为中间件
// 如果你使用了自己的 express 路由(express.Router()),你应该使用 router.use
app.use(vite.middlewares);
} else {
app.use((await import("compression")).default());
app.use(await import("serve-static")).default(resolve("dist/client"), {
index: false,
});
}
app.use("*", async (req, res) => {
const url = req.originalUrl
try {
let template, render;
if (!isProd) {
// 1. 读取 index.html
template = fs.readFileSync(resolve("../index.html"), "utf-8");
// 2. 应用 Vite HTML 转换。这将会注入 Vite HMR 客户端,
// 同时也会从 Vite 插件应用 HTML 转换。
// 例如:@vitejs/plugin-react 中的 global preambles
template = await vite.transformIndexHtml(url, template);
// 3. 加载服务器入口。vite.ssrLoadModule 将自动转换
// 你的 ESM 源码使之可以在 Node.js 中运行!无需打包
// 并提供类似 HMR 的根据情况随时失效。
render = (await vite.ssrLoadModule("/src/server/entry-server.js")).render;
} else {
template = indexProd;
render = (await import("./dist/server/entry-server.js")).render;
}
// 4. 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
// 函数调用了适当的 SSR 框架 API。
// 例如 ReactDOMServer.renderToString()
const [appHtml] = await render(url);
// 5. 注入渲染后的应用程序 HTML 到模板中。
const html = template.replace(`<!--app-html-->`, appHtml)
// 6. 返回渲染后的 HTML。
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
// 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回
// 你的实际源码中。
isProd || vite.ssrFixStacktrace(e);
console.error(`[error]`, e.stack);
res.status(500).end(e.stack);
}
});
return { app };
}
createServer().then(({ app }) => {
app.listen(3000, () => {
console.log("[server] http://localhost:3000");
});
});这时候,路由算整合完成了,我们看看效果:
localhost:3000/Home:

localhost:3000/Example:

可以看到服务端渲染是成功的,也能进行交互。
但是这时控制台会报错,报错信息大致是这样的:
Hydration completed but contains mismatches.
这是因为,服务端进行了路由跳转之后渲染的页面和客户端渲染结果不符合,等待路由加载完毕后在进行挂载可以避免这个问题。
在
Vue Router初始化时,会初始化路由系统并异步加载路由配置和组件,这个过程可能需要一段时间。在路由系统还未准备完毕时,如果尝试直接访问当前路由的信息,可能会出现一些错误。为了避免这种情况,Vue Router提供了isReady来判断路由系统是否已经准备完毕。
我们需要对客户端入口entry-client.js做一下修改:
import "../style.css";
import { createApp } from "../main";
const { app, router } = createApp();
router.isReady().then(() => {
app.mount("#app");
})刷新页面,可以发现报错消失了。
首屏数据加载方案(数据的脱水与注水)
在上一个项目中我们已经聊过了问题的成因和解决方案,就不重复说了。Vue这边我选择的状态管理工具是Pinia,当然如果用Vuex也没关系,思路都是一样的。下面我们基于前面的代码整合pinia并实现首屏数据的脱水与注水。
我们先把pinia中间件注册进vue实例,修改main.js:
// src/main.js
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createPinia } from 'pinia'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
const router = createRouter()
app.use(router)
return { app, router, pinia }
}在src创建文件夹store:

之后在src/store/index.js建一个store:
import { defineStore } from "pinia";
import axios from "axios";
const rootUrl = typeof window !== "undefined" ? "" : "http://localhost:3000";
// 你可以对 `defineStore()` 的返回值进行任意命名,但最好使用 store 的名字,同时以 `use` 开头且以 `Store` 结尾。(比如 `useUserStore`,`useCartStore`,`useProductStore`)
// 第一个参数是你的应用中 Store 的唯一 ID。
export const useMainStore = defineStore("main", {
// 其他配置...
state: () => ({
data: "default Data",
}),
actions: {},
});在Example页面进行使用:
<template>
<h1>This is an example</h1>
<h2>{{store.data}}</h2>
<button type="button" @click="count++">count is {{ count }}</button>
</template>
<script setup>
import { ref } from "vue";
import { useMainStore } from '../../store/index'
const count = ref(0);
const store = useMainStore();
</script>这时候可以看到页面显示如下:

然后现在我们在express写个接口,模拟下首屏数据获取:
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";
import { createServer as createViteServer } from "vite";
import bodyParser from "body-parser";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const resolve = (p) => path.resolve(__dirname, p);
async function createServer(_, isProd = process.env.NODE_ENV === "production") {
const app = express();
const indexProd = isProd
? fs.readFileSync(resolve("dist/client/index.html"), "utf-8")
: "";
let vite;
if (!isProd) {
// 以中间件模式创建 Vite 应用,这将禁用 Vite 自身的 HTML 服务逻辑
// 并让上级服务器接管控制
vite = await createViteServer({
server: {
middlewareMode: true,
watch: {
usePolling: true,
interval: 100,
},
},
appType: "custom",
});
// 使用 vite 的 Connect 实例作为中间件
// 如果你使用了自己的 express 路由(express.Router()),你应该使用 router.use
app.use(vite.middlewares);
} else {
app.use((await import("compression")).default());
app.use(await import("serve-static")).default(resolve("dist/client"), {
index: false,
});
}
// 请求body解析
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.post("/api/getData", (req, res) => {
res.send({
...req.body,
code: 0,
});
});
app.use("*", async (req, res) => {
const url = req.originalUrl;
try {
let template, render;
if (!isProd) {
// 1. 读取 index.html
template = fs.readFileSync(resolve("../index.html"), "utf-8");
// 2. 应用 Vite HTML 转换。这将会注入 Vite HMR 客户端,
// 同时也会从 Vite 插件应用 HTML 转换。
// 例如:@vitejs/plugin-react 中的 global preambles
template = await vite.transformIndexHtml(url, template);
// 3. 加载服务器入口。vite.ssrLoadModule 将自动转换
// 你的 ESM 源码使之可以在 Node.js 中运行!无需打包
// 并提供类似 HMR 的根据情况随时失效。
render = (await vite.ssrLoadModule("/src/server/entry-server.js"))
.render;
} else {
template = indexProd;
render = (await import("./dist/server/entry-server.js")).render;
}
// 4. 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
// 函数调用了适当的 SSR 框架 API。
// 例如 ReactDOMServer.renderToString()
const [appHtml] = await render(url);
// 5. 注入渲染后的应用程序 HTML 到模板中。
const html = template.replace(`<!--app-html-->`, appHtml);
// 6. 返回渲染后的 HTML。
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
// 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回
// 你的实际源码中。
isProd || vite.ssrFixStacktrace(e);
console.error(`[error]`, e.stack);
res.status(500).end(e.stack);
}
});
return { app };
}
createServer().then(({ app }) => {
app.listen(3000, () => {
console.log("[server] http://localhost:3000");
});
});用Postman测试下:

然后我们写点请求接口的代码:
src/store/index.js:
import { defineStore } from "pinia";
import axios from "axios";
const rootUrl = typeof window !== "undefined" ? "" : "http://localhost:3000";
// 你可以对 `defineStore()` 的返回值进行任意命名,但最好使用 store 的名字,同时以 `use` 开头且以 `Store` 结尾。(比如 `useUserStore`,`useCartStore`,`useProductStore`)
// 第一个参数是你的应用中 Store 的唯一 ID。
export const useMainStore = defineStore("main", {
// 其他配置...
state: () => ({
data: "default Data",
}),
actions: {
async fetchData(data) {
const res = await axios.post(rootUrl + "/api/getData", {
data: data
});
this.data = res.data.data
},
},
});src/pages/Example/index.vue:
<template>
<h1>This is an example</h1>
<h2>{{store.data}}</h2>
<button type="button" @click="clickBtn">count is {{ count }}</button>
</template>
<script setup>
import { ref,onServerPrefetch } from "vue";
import { useMainStore } from '../../store/index'
const count = ref(0);
const store = useMainStore();
const clickBtn = () => {
count.value++
store.fetchData("交互请求数据" + count.value);
}
store.fetchData("首屏数据");
</script>然后刷新页面,可以看到,页面正常的发送了数据请求:

不过我们会发现服务端渲染返回的数据和显示的数据有些差别,因为实际的网络请求发生在客户端一侧,所以我们要做一些改造。
我们使用vue3的onServerPrefetch钩子来发送请求,注意这个钩子只会在服务端执行,不会在客户端执行,这时候的Example页面是这样的:
<template>
<h1>This is an example</h1>
<h2>{{store.data}}</h2>
<button type="button" @click="clickBtn">count is {{ count }}</button>
</template>
<script setup>
import { ref,onServerPrefetch } from "vue";
import { useMainStore } from '../../store/index'
const count = ref(0);
const store = useMainStore();
const clickBtn = () => {
count.value++
store.fetchData("交互请求数据" + count.value);
}
onServerPrefetch(async () => {
// component is rendered as part of the initial request
// pre-fetch data on server as it is faster than on the client
await store.fetchData("首屏数据");
})
</script>这样以后,服务端渲染的HTML字符串将会包含首屏数据信息,然后修改下服务端渲染入口的逻辑,让pinia里的数据暴露给express,方便我们后面的同构:
// src/server/entry-server.js
import { createApp } from "../main";
import { renderToString } from "vue/server-renderer";
export async function render(url) {
const { app, router, pinia } = createApp();
await router.push(url);
await router.isReady();
const html = await renderToString(app);
return [html, pinia.state.value];
}在express的代码中,我们可以通过render拿到pinia的数据,只需要在渲染的HTML中插入同构的代码就行了:
// src/server.js
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";
import { createServer as createViteServer } from "vite";
import bodyParser from "body-parser";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const resolve = (p) => path.resolve(__dirname, p);
async function createServer(_, isProd = process.env.NODE_ENV === "production") {
const app = express();
const indexProd = isProd
? fs.readFileSync(resolve("dist/client/index.html"), "utf-8")
: "";
let vite;
if (!isProd) {
// 以中间件模式创建 Vite 应用,这将禁用 Vite 自身的 HTML 服务逻辑
// 并让上级服务器接管控制
vite = await createViteServer({
server: {
middlewareMode: true,
watch: {
usePolling: true,
interval: 100,
},
},
appType: "custom",
});
// 使用 vite 的 Connect 实例作为中间件
// 如果你使用了自己的 express 路由(express.Router()),你应该使用 router.use
app.use(vite.middlewares);
} else {
app.use((await import("compression")).default());
app.use(await import("serve-static")).default(resolve("dist/client"), {
index: false,
});
}
// 请求body解析
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.post("/api/getData", (req, res) => {
res.send({
...req.body,
code: 0,
});
});
app.use("*", async (req, res) => {
const url = req.originalUrl;
try {
let template, render;
if (!isProd) {
// 1. 读取 index.html
template = fs.readFileSync(resolve("../index.html"), "utf-8");
// 2. 应用 Vite HTML 转换。这将会注入 Vite HMR 客户端,
// 同时也会从 Vite 插件应用 HTML 转换。
// 例如:@vitejs/plugin-react 中的 global preambles
template = await vite.transformIndexHtml(url, template);
// 3. 加载服务器入口。vite.ssrLoadModule 将自动转换
// 你的 ESM 源码使之可以在 Node.js 中运行!无需打包
// 并提供类似 HMR 的根据情况随时失效。
render = (await vite.ssrLoadModule("/src/server/entry-server.js"))
.render;
} else {
template = indexProd;
render = (await import("./dist/server/entry-server.js")).render;
}
// 4. 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
// 函数调用了适当的 SSR 框架 API。
// 例如 ReactDOMServer.renderToString()
const [appHtml, state] = await render(url);
// 5. 注入渲染后的应用程序 HTML 到模板中。
const html = template.replace(`<!--app-html-->`, appHtml).replace(
`<!--pinia-state-->`,
`
<script>
window.context = {
pinia_state: ${JSON.stringify(state)}
}
</script>
`
);
// 6. 返回渲染后的 HTML。
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
// 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回
// 你的实际源码中。
isProd || vite.ssrFixStacktrace(e);
console.error(`[error]`, e.stack);
res.status(500).end(e.stack);
}
});
return { app };
}
createServer().then(({ app }) => {
app.listen(3000, () => {
console.log("[server] http://localhost:3000");
});
});index.html也应该预留一个插槽<!--pinia-state-->:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/client/entry-client.js"></script>
<!--pinia-state-->
</body>
</html>客户端在初始化时,应该去判断window.context.pinia_state是否有数据,如果有就对store数据进行初始化:
// src/client/entry-client.js
import "../style.css";
import { createApp } from "../main";
const { app, router, pinia } = createApp();
router.isReady().then(() => {
if (window && window.context?.pinia_state)
pinia.state.value = window.context?.pinia_state;
app.mount("#app");
})这样以后,整个SSR的基本流程就完成了,可以看看效果:


写在最后
通过这两个示例项目,我们可以更清楚的了解SSR的工作流程。当然,对于不同的框架而言,SSR还不止于此,例如:React 18 的流式SSR等。在这里我就不做更深入的探讨了(因为我不会)。
