一文理解服务端渲染SSR的原理,附实战基于vite和webpack打造React和Vue的SSR开发环境
SSR和CSR
首先,我们先要了解什么是
SSR
和CSR
,SSR
是服务端渲染,CSR
是客户端渲染,服务端渲染是指HTTP
服务器直接根据用户的请求,获取数据,生成完整的HTML
页面返回给客户端(浏览器)展现。相对地,HTTP
服务器根据用户请求返回对应的JSON
数据,在HTML
页面中通过JavaScript
来生成最终用户看到的页面,叫做客户端渲染。比如,现在非常流行的Vue
和React
框架就是客户端渲染的一个应用。在非常早期的Web
技术中,大家还是使用JSP
这种古老的模板语法来编写前端的页面,然后直接将JSP
文件放到服务端,在服务端填入数据并渲染出完整的页面内容,可以说那个时代的做法是天然的服务端渲染。但是随着Web2.0
的到来。Web
应用变得复杂了起来,相比于服务端渲染要发送完整页面,客户端渲染每次只需要请求页面上需要更新的数据,网络传输数据量小,能够显著减轻服务器压力。而且客户端渲染真正实现了前后端职责分离,后端只需关注数据,逻辑由前端的JS
去完成,这种开发模式更加灵活,效率也更高。因此,客户端渲染渐渐成为了主流。
但客户端渲染也存在着一定的问题,例如首屏加载比较慢、对
SEO
不太友好,因此SSR
即服务端渲染技术应运而生,它在保留CSR
技术栈的同时,也能解决CSR
的各种问题。在某些特定的情况下,采用服务端渲染要比客户端渲染有优势,所以服务端渲染又逐渐回归人们的视野,成为了特定场景下的一种选择。当然,SSR 中只能生成页面的内容和结构,并不能完成事件绑定,因此需要在浏览器中执行 CSR 的 JS 脚本,完成事件绑定,让页面拥有交互的能力,这个过程被称作hydrate
(翻译为注水
或者激活
)。同时,像这样服务端渲染 + 客户端 hydrate 的应用也被称为同构应用
。
这里附上CSR
和SSR
的简易流程图
看到这里相信大家应该对
SSR
和CSR
的区别有所了解了,接下来我们就自己动手搭建React
和Vue
的SSR
环境,我们分别使用webpack
和vite
来搭建。
实战环节
使用vite打造React的SSR环境
搭建项目骨架
首先我们先使用
vite
初始化一个React的项目,我们这里包管理工具使用pnpm
,如果没有下载pnpm
,先在命令行执行npm install pnpm -g
,然后我们执行pnpm create vite
,输入我们的项目名,然后选择React
和ts
进行开发,进入项目根目录执行pnpm i
安装依赖。
然后我们进入tsconfig.json
里,把esModuleInterop
的值改为true
,让我们在项目里可以支持import path from 'path'
这种写法':
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
// 可以支持import path from 'path'的写法
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
删除项目自带的src/main.ts
,然后在 src 目录下新建entry-client.tsx
和entry-server.tsx
两个入口文件:
entry-client.ts
// 客户端入口文件
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.hydrateRoot(
document.getElementById('root')!,
<React.StrictMode>
<App data={data} />
</React.StrictMode>
)
entry-server.ts
// 导出 SSR 组件入口
import App from './App'
import './index.css'
function ServerEntry(props: any) {
return <App data={props.data} />
}
export { ServerEntry }
我们以 Express
框架为例来实现 Node
后端服务,后续的 SSR
逻辑会接入到这个服务中。当然我们需要安装以下的依赖:
pnpm i express -S
pnpm i @types/express -D
接着新建src/ssr-server/index.ts
:
// 后端服务
import express from 'express';
async function createServer() {
const app = express();
app.listen(3000, () => {
console.log('Node 服务器已启动~')
console.log('http://localhost:3000');
});
}
createServer();
SSR运行时的实现
SSR
作为一种特殊的后端服务,我们可以将其封装成一个中间件的形式,如以下的代码所示:
src/ssr-server/index.ts
import express, { RequestHandler, Express } from 'express'
import { ViteDevServer } from 'vite'
const isProd = process.env.NODE_ENV === 'production'
const cwd = process.cwd()
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
let vite: ViteDevServer | null = null
if (!isProd) {
vite = await (
await import('vite')
).createServer({
root: process.cwd(),
server: {
middlewareMode: 'ssr',
},
})
// 注册 Vite Middlewares
// 主要用来处理客户端资源
app.use(vite.middlewares)
}
return async (req, res, next) => {
// SSR 的逻辑
// 1. 加载服务端入口模块
// 2. 数据预取
// 3. 「核心」渲染组件 -> 字符串
// 4. 拼接 HTML,返回客户端
}
}
async function createServer() {
const app = express()
// 加入 Vite SSR 中间件
app.use(await createSsrMiddleware(app))
app.listen(3000, () => {
console.log('Node 服务器已启动~')
console.log('http://localhost:3000')
})
}
createServer()
Ok,那接下来我们来实现中间件内SSR的逻辑实现,首先实现第一步即加载服务端入口模块
:
我们将它抽离到src/server/utils.ts
里:
src/server/utils.ts
import { ViteDevServer } from 'vite'
import path from 'path'
export const isProd = process.env.NODE_ENV === 'production'
export const cwd = process.cwd()
async function loadSsrEntryModule(vite: ViteDevServer | null) {
// 生产模式下直接 require 打包后的产物
if (isProd) {
const entryPath = path.join(cwd, 'dist/server/entry-server.js');
return require(entryPath);
}
// 开发环境下通过 no-bundle 方式加载
else {
const entryPath = path.join(cwd, 'src/entry-server.tsx');
return vite!.ssrLoadModule(entryPath);
}
}
我们补充一下中间件的逻辑:
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
// 省略前面的代码
return async (req, res, next) => {
const url = req.originalUrl;
// 1. 服务端入口加载
const { ServerEntry } = await loadSsrEntryModule(vite);
// ...
}
}
接下来我们来实现服务端的数据预取操作,你可以在entry-server.tsx
中添加一个简单的获取数据的函数:
export async function fetchData() {
return { user: 'pujie' }
}
然后在 SSR 中间件中完成数据预取的操作:
src/ssr-server/index.ts
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
// 省略前面的代码
return async (req, res, next) => {
const url = req.originalUrl;
// 1. 服务端入口加载
const { ServerEntry, fetchData } = await loadSsrEntryModule(vite);
// 2. 预取数据
const data = await fetchData();
}
}
接着我们进入到核心的组件渲染阶段:
src/ssr-server/index.ts
import { renderToString } from 'react-dom/server';
import React from 'react';
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
// 省略前面的代码
return async (req, res, next) => {
const url = req.originalUrl;
// 1. 服务端入口加载
const { ServerEntry, fetchData } = await loadSsrEntryModule(vite);
// 2. 预取数据
const data = await fetchData();
// 3. 组件渲染 -> 字符串
const appHtml = renderToString(React.createElement(ServerEntry, { data }));
}
}
我们在第一步之后我们拿到了入口组件,现在可以调用React的renderToString
API 将组件渲染为字符串,组件的具体内容便就此生成了。
OK,目前我们已经拿到了组件的 HTML 以及预取的数据,接下来我们在根目录下的 HTML 中提供相应的插槽,方便内容的替换:
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"><!-- SSR_APP --></div>
<script type="module" src="/src/entry-client.tsx"></script>
<!-- SSR_DATA -->
</body>
</html>
OK然后我们拿到html文件后,我们要将插槽占位的内容换成对应的内容,然后将html返回给客户端:
src/ssr-server/index.ts
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
// 省略之前的代码
return async (req, res, next) => {
const url = req.originalUrl;
// 省略前面的步骤
// 4. 拼接完整 HTML 字符串,返回客户端
const templatePath = resolveTemplatePath();
let template = await fs.readFileSync(templatePath, 'utf-8');
// 开发模式下需要注入 HMR、环境变量相关的代码,因此需要调用 vite.transformIndexHtml
if (!isProd && vite) {
template = await vite.transformIndexHtml(url, template);
}
const html = template
.replace('<!-- SSR_APP -->', appHtml)
// 注入数据标签,用于客户端 客户端进行注水接管浏览器事件
.replace(
'<!-- SSR_DATA -->',
`<script>window.__SSR_DATA__=${JSON.stringify(data)}</script>`
);
res.status(200).setHeader('Content-Type', 'text/html').end(html);
}
}
补充工具函数:
src/ssr-server/utils.ts
export function resolveTemplatePath() {
return isProd
? path.join(cwd, 'dist/client/index.html')
: path.join(cwd, 'index.html')
}
在拼接 HTML 的逻辑中,除了添加页面的具体内容,同时我们也注入了一个挂载全局数据的script
标签,这是用来干什么的呢?
在 SSR
的基本概念中我们就提到过,为了激活页面的交互功能,我们需要执行 CSR
的 JavaScript
代码来进行 hydrate
操作,而客户端 hydrate
的时候需要和服务端同步预取后的数据
,保证页面渲染的结果和服务端渲染一致,因此,我们刚刚注入的数据 script 标签便派上用场了。由于全局的 window
上挂载服务端预取的数据,我们可以在entry-client.tsx
也就是客户端渲染入口中拿到这份数据,并进行 hydrate
,如果我们要做全局状态管理也是这个思路。
entry-client.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { fetchData } from './entry-server'
import './index.css'
// 跳过ts类型检测
// @ts-ignore
const data = window.__SSR_DATA__ ?? fetchData() // 如果服务端获取数据失败我们要做客户端降级,在客户端获取数据
ReactDOM.hydrateRoot(
document.getElementById('root')!,
<React.StrictMode>
<App data={data} />
</React.StrictMode>
)
我们在这里还需要处理生产环境下静态资源的处理,这里我们可以通过serve-static
中间件来完成这个服务:
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
return async (req, res, next) => {
try {
const url = req.originalUrl;
if (!matchPageUrl(url)) {
// 走静态资源的处理
return await next();
}
// SSR 的逻辑省略
} catch(e: any) {
vite?.ssrFixStacktrace(e);
console.error(e);
res.status(500).end(e.message);
}
}
}
async function createServer() {
const app = express();
// 加入 Vite SSR 中间件
app.use(await createSsrMiddleware(app));
// 注册中间件,生产环境端处理客户端资源
if (isProd) {
app.use(serve(path.join(cwd, 'dist/client')))
}
// 省略其它代码
}
我们还需要补充matchPageUrl
这个工具函数
src/ssr-server/utils.ts
export function matchPageUrl(url: string) {
if (url === '/') {
return true
}
}
这样一来,我们就解决了生产环境下静态资源失效的问题。不过,一般情况下,我们会将静态资源部上传到 CDN 上,并且将 Vite
的 base
配置为域名前缀,这样我们可以通过 CDN 直接访问到静态资源,而不需要加上服务端的处理。
自定义head
在 SSR
的过程中,我们虽然可以在决定组件的内容,即<div id="root"></div>
这个容器 div 中的内容,但对于 HTML 中head
的内容我们无法根据组件的内部状态来决定,比如对于一个直播间的页面,我们需要在服务端渲染出 title 标签,title 的内容是不同主播的直播间名称,不能在代码中写死,这种情况怎么办?React其实有一个库叫react-helmet
可以帮我们完成这个需求,我们下载这个库:
Ok,我们编写一下App.tsx
的内容:
import logo from './assets/react.svg'
import './App.css'
import { Helmet } from 'react-helmet'
export interface AppProps {
data: any
}
function App(props: AppProps) {
const { data } = props
// @ts-ignore
return (
<div className="App">
<Helmet>
<title>{data.user}的页面</title>
<link rel="canonical" href="http://mysite.com/example" />
</Helmet>
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>Hello Vite + React!</p>
<p>
Edit <code>App.tsx</code> and save to test HMR updates.
</p>
<p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
{' | '}
<a
className="App-link"
href="https://vitejs.dev/guide/features.html"
target="_blank"
rel="noopener noreferrer"
>
Vite Docs
</a>
</p>
</header>
</div>
)
}
export default App
OK,最后我们在package.json
里添加一些脚本:
"scripts": {
// 开发阶段启动 SSR 的后端服务
"dev": "nodemon --watch src/ssr-server/index.ts --exec esno src/ssr-server/index.ts",
// 打包客户端产物和 SSR 产物
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client --ssrManifest --manifest",
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
// 生产环境预览 SSR 效果
"preview": "set NODE_ENV=production && esno src/ssr-server/index.ts"
},
nodemon
大家应该比较熟悉,esno
是用来执行ts
文件的,类似ts-node
,OK,我们安装一下依赖:
pnpm i esno nodemon -D
然后我们试一下开发环境怎么样,执行pnpm dev
,可以看到如下页面:
OK,我们再试一下生产环境,执行pnpm build
然后执行pnpm preview
,看到的页面应该和开发环境一样的,那我们React
简易的SSR
环境就搭建完成了
使用webpack打造Vue的SSR环境
其实Vue的SSR环境搭建的过程是和React差不多的,只是将资源换成webpack处理而已,首先我们先搭建项目环境:
搭建项目骨架
pnpm init -y
pnpm install webpack webpack-cli -D
建立文件目录如下:
├─ package.json
├─ pnpm-lock.yaml
├─ server.js
├─ src
│ ├─ App.vue
│ ├─ entry-client.js
│ ├─ entry-server.js
├─ webpack.base.js
├─ webpack.client.js
└─ webpack.server.js
由于Vue的SSR搭建和React的思路差不多,所以不把步骤拆的那么细了,不过我会给代码添加注释
我们需要分别为服务端和客户端准备入口文件:
entry-server.js
import { createSSRApp } from 'vue'
import App from './App.vue'
export default () => {
return createSSRApp(App)
}
entry-client.js
import { createSSRApp } from 'vue'
import App from './App.vue'
createSSRApp(App).mount('#app')
两者区别在于:客户端版本会立即调用 mount
将组件挂载到页面上;而服务端版本只是 export
一个创建应用的工厂函数。
编写不同环境的webpack配置文件
我们需要分别为客户端、服务端版本编写 Webpack 配置文件和一个基础的配置文件,也就是服务端和客户端都会用到的配置,即上述目录中的三个**webpack.*.js
文件**。
webpack.base.js
用于客户端和服务端的公共配置:
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
module.exports = {
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
output: {
filename: '[name].[contenthash].js',
path: path.join(__dirname, 'dist'),
},
resolve: {
extensions: ['.vue', '.js'],
},
module: {
rules: [{ test: /.vue$/, use: 'vue-loader' }],
},
plugins: [new VueLoaderPlugin()],
}
webpack.client.js
用于定义构建客户端资源的配置:
// 合并webpack配置的插件
const Merge = require('webpack-merge')
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { WebpackManifestPlugin } = require('webpack-manifest-plugin')
const base = require('./webpack.base')
module.exports = Merge.merge(base, {
entry: {
// 入口指向 `entry-client.js` 文件
client: path.join(__dirname, './src/entry-client'),
},
output: {
publicPath: '/',
},
module: {
rules: [{ test: /\.css$/, use: ['style-loader', 'css-loader'] }],
},
plugins: [
// 这里使用 webpack-manifest-plugin 记录产物分布情况
// 方面后续在 `server.js` 中使用
new WebpackManifestPlugin({ fileName: 'manifest-client.json' }),
// 自动生成 HTML 文件内容
new HtmlWebpackPlugin({
templateContent: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Webpack App</title>
</head>
<body>
<div id="app" />
</body>
</html>
`,
}),
],
})
webpack.server.js
用于定义构建服务端资源的配置:
const Merge = require('webpack-merge')
const path = require('path')
const { WebpackManifestPlugin } = require('webpack-manifest-plugin')
const base = require('./webpack.base')
module.exports = Merge.merge(base, {
entry: {
server: path.join(__dirname, 'src/entry-server'),
},
target: 'node',
output: {
// 打包后的结果会在 node 环境使用
// 因此此处将模块化语句转译为 commonjs 形式
libraryTarget: 'commonjs2',
},
module: {
rules: [
{
test: /\.css$/,
use: [
// 注意,这里用 `vue-style-loader` 而不是 `style-loader`
// 因为 `vue-style-loader` 对 SSR 模式更友好
'vue-style-loader',
{
loader: 'css-loader',
options: {
esModule: false,
},
},
],
},
],
},
plugins: [
// 这里使用 webpack-manifest-plugin 记录产物分布情况
// 方面后续在 `server.js` 中使用
new WebpackManifestPlugin({ fileName: 'manifest-server.json' }),
],
})
然后,我们只需要调用适当命令即可分别生成客户端、服务端版本代码:
# 客户端版本:
npx webpack --config ./webpack.client.js
# 服务端版本:
npx webpack --config ./webpack.server.js
编写SSR的服务端的服务
server.js
const express = require('express')
const path = require('path')
const { renderToString } = require('@vue/server-renderer')
// 通过 manifest 文件,找到正确的产物路径
const clientManifest = require('./dist/manifest-client.json')
const serverManifest = require('./dist/manifest-server.json')
const serverBundle = path.join(__dirname, './dist', serverManifest['server.js'])
// 这里就对标到 `entry-server.js` 导出的工厂函数
const createApp = require(serverBundle).default
const server = express()
server.get('/', async (req, res) => {
const app = createApp()
const html = await renderToString(app)
const clientBundle = clientManifest['client.js']
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Vue SSR </title>
</head>
<body>
<!-- 注入组件运行结果 -->
<div id="app">${html}</div>
<!-- 注入客户端代码产物路径 -->
<!-- 实现 Hydrate 效果 -->
<script src="${clientBundle}"></script>
</body>
</html>
`)
})
server.use(express.static('./dist'))
server.listen(3000, () => {
console.log('服务在3000端口运行')
})
OK,我们编写App.vue
文件:
<template>
<div :class="['main', cls]">
<h3>{{ message }}</h3>
<button @click="handleClick">Toggle</button>
</div>
</template>
<script>
import { ref, computed } from '@vue/reactivity'
export default {
setup() {
const isActivity = ref(false)
const cls = computed(() => (isActivity.value ? 'activate' : 'deactivate'))
const handleClick = () => {
isActivity.value = !isActivity.value
}
return {
isActivity,
message: 'Hello World',
cls,
handleClick,
}
},
}
</script>
<style>
h3 {
color: #42b983;
}
.main {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
padding: 20px 12px;
transition: background 0.3s linear;
}
.activate {
background: #000;
}
.deactivate {
background: #fff;
}
</style>
然后我们在package.json
里添加脚本如下:
"scripts": {
"build:client": "webpack --config ./webpack.client.js",
"build:server": "webpack --config ./webpack.server.js",
"start": "nodemon server.js"
},
Ok,然后我们执行pnpm build:client
和pnpm build:server
,然后执行pnpm start
,项目就跑起来了,我们可以看到如下页面,点击Toggle可以切换背景:
OK,相信读了文章的同学们应该对SSR应该有了新的认识,有问题可以一起讨论哇,咱们下回再见咯。
React的SSR环境搭建代码地址
Vue的SSR环境搭建代码地址
转载自:https://juejin.cn/post/7168381796550197279