我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第5篇文章,点击查看活动详情
服务端渲染的发展历史
传统的服务端渲染
传统的服务端渲染有 asp
, jsp(java)
, ejs(nodejs)
等,服务端语言往往通过这些模板引擎将数据 data
和 html
结构在服务端组装,返回一个完整的静态的 html
字符串给客户端,由客户端直接显示。
缺点
- 前后端不分离,前后端代码混在一个工程目录中,维护不方便,。
- 用户体验不佳,每次页面有改动都需要重新加载整个页面。比如,一个列表页面,当用户增加一项时,后台需要重新组装数据
data
和html
结构,返回一个新的页面给前端,这样用户才能看到页面的变化。 - 服务端压力大,不仅要响应静态
html
文件,还要响应数据api
接口。
客户端渲染(CSR)
在现代化的前端项目中,客户端渲染的代表性技术栈是 vue/react/angular
,我们常常使用它们来构建客户端单页或者多页应用程序。
以 SPA
构建程序为例,在浏览器端首先渲染的是一套空的 html
,通过 JavaScript
直接进行页面的渲染和路由跳转等操作,所有的数据通过 ajax
请求从服务端获取。
缺点
- 首屏加载慢,因为第一次会请求一个空的
html
文件,再去加载bundle.js
等打包后的文件。 - 不利于网站
SEO
,因为首次请求回来的是空的html
文件,爬虫无法获取有效内容信息。
现代服务端渲染(同构)
我们现在讲的服务端渲染概念,是指在前端范畴或者说在 Vue/React
等单页面技术范畴内的,基于 Nodejs server
运行环境的服务端渲染方案,这种方案的本质是同构渲染。它的步骤如下:
-
在
Nodejs
中运行相同的前端代码,将用Vue/React
框架写的代码转化为html
结构,然后返回给浏览器渲染,这样爬虫就能爬取到完整的页面信息。 -
客户端获取到服务端返回的页面后,再进行注水(hydrate)化处理,由客户端代码(SPA代码)来接管页面。
为什么要进行注水处理呢?
因为服务端环境毕竟不同于浏览器环境,缺少浏览器环境必要的变量和API
。比如,页面中的点击事件就无法在服务端进行注册,因为在服务端环境中是没有DOM节点的概念的,它只是一堆字符串而已,自然无法使用 document.addEventListener
这样的API
。也就是如果客户端代码接管页面,那么页面里面所有的点击事件将不可用。
什么是同构?
同构简单来讲就是服务端和客户端复用同一套代码。比如,页面html
结构、store
数据存储、router
路由都能共享一套代码。这就是所谓的现代服务端渲染:同构。
为什么选择 SSR?
相比于客户端渲染 CRS (单页面应用),SSR 主要的好处是:
更好的搜索引擎优化 (SEO)
因为后端会一次性的把网站内容返回给前端,所以搜索引擎爬虫会直接读取完整的渲染出来的页面。但如果你的JavaScript 脚本是通过 API 调用获取内容,则爬虫不会等待页面加载完成。这意味着如果你的页面有异步加载的内容且 SEO 很重要,那么你可能需要 SSR。
更快的内容呈现
尤其是网络连接缓慢或设备运行速度缓慢的时候,服务端标记不需要等待所有的 JavaScript 脚本都被下载并执行之后才显示,所以用户可以更快看到完整的渲染好的内容。这带来了更好的用户体验,同时对于内容呈现时间和转化率呈正相关的应用来说尤为关键。
除了上面两个优点外,这里还有一些点来决定是是否选用SSR:
-
开发一致性。浏览器特有的 API 只能在特定的生命周期钩子中使用;一些外部的库在服务端渲染应用中可能需要经过特殊处理。
-
需要更多的构建设定和部署要求。不同于一个完全静态的 SPA 可以部署在任意的静态文件服务器,服务端渲染应用需要一个能够运行
Nodejs
服务器的环境。 -
更多的服务端负载。在
Nodejs
中渲染一个完整的应用会比仅供应静态文件产生更密集的 CPU 运算。所以如果流量很高,请务必准备好与其负载相对应的服务器,并采取明智的缓存策略。
在应用中使用 SSR 之前,你需要问自己的第一个问题是:你是否真的需要它?它通常是由内容呈现时间对应用的重要程度决定的。
例如,如果你正在搭建一个内部管理系统,几百毫秒的初始化加载时间对它来说无关紧要,这种情况下就没有必要使用 SSR。然而,如果内容呈现时间非常关键,SSR 可以助你实现最佳的初始加载性能。
SSR vs 预渲染
如果你仅希望通过 SSR 来改善一些推广页面 (例如 /
、/about
、/contact
等) 的 SEO,那么预渲染也许会更合适。和使用动态编译 HTML 的 web 服务器相比,预渲染可以在构建时为指定的路由生成静态 HTML 文件。
如果你正在使用 webpack,你可以通过 prerender-spa-plugin 来支持预渲染。
开发流程
通过前面的介绍,服务端渲染就是返回一个带有具体内容的 html
字符串给浏览器,那么这个具体的内容是什么呢?
这个具体的内容就是用 Vue
开发的页面内容,但是如果直接把带有 Vue
语法塞进 html 模板浏览器根本无法识别,因此,服务端渲染也需要使用 Vite
进行编译打包转化为浏览器能识别的javascript
语法。
根据同构概念理解,客户端和服务端是公用同一套的页面内容代码的,所以客户端和服务端需要分别打包编译。
首先就是编写通用代码,适用于客户端和服务端。
编写通用代码
由于平台 API 的差异,当运行在不同环境中时,我们写的通用代码将与纯客户端代码不会完全相同。需要注意一下几点:
避免状态单例
在纯客户端应用程序中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此。
但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。
所以,必须要求每个请求都应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染。
因此,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例:
对原有客户端代码代码进行改造:
- 对于入口文件
main.ts
// 原有代码
const app = createApp(App)
app.config.globalProperties.$message = ElMessage
app.use(router)
app.use(store, key)
app.use(ElementPlus)
app.use(i18n)
app.mount('#app')
// 改造后代码
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
const store = createSSRStore()
const router = createSSRRouter()
const i18n = createSSRI18n()
sync(store, router)
app.config.globalProperties.$message = ElMessage
app.use(store, key)
app.use(router)
app.use(ElementPlus)
app.use(i18n)
return { app, router, store }
}
同理,项目中的数据存储store
,路由router
等都需要改造成工厂函数的形式,比如路由:
export function createSSRRouter() {
return createRouter({
// import.meta.env.SSR是vite提供环境变量
// 服务端渲染只能用createMemoryHistory
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes
})
}
组件生命周期钩子函数
由于服务端没有动态更新,所有的生命周期钩子函数中,只有 beforeCreate
和 created
会在服务器端渲染 (SSR) 过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMount
或 mounted
),只会在客户端执行。
此外还需要注意的是,你应该避免在 beforeCreate
和 created
生命周期时产生全局副作用的代码,例如在其中使用 setInterval
设置 timer。在纯客户端的代码中,我们可以设置一个 timer,然后在 beforeDestroy
或 destroyed
生命周期时将其销毁。但是,由于在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来。为了避免这种情况,请将副作用代码移动到 beforeMount
或 mounted
生命周期中。
访问特定平台API
通用代码不可接受特定平台的 API,因此如果你的代码中,直接使用了像 window
或 document
,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此。
对于共享于服务器和客户端,但用于不同平台 API 的任务,建议将平台特定实现包含在通用 API 中,例如,axios是一个 HTTP 客户端,可以向服务器和客户端都暴露相同的 API。
请注意,考虑到如果第三方 library 不是以上面的通用用法编写,则将其集成到服务器渲染的应用程序中,可能会很棘手。可能要通过模拟 (mock) 一些全局变量来使其正常运行,但这只是 hack 的做法。
自定义指令
大多数自定义指令直接操作 DOM,因此会在服务器端渲染 (SSR) 过程中导致错误。有两种方法可以解决这个问题:
-
推荐使用组件作为抽象机制,并运行在「虚拟 DOM 层级(Virtual-DOM level)」(例如,使用渲染函数(render function))。
-
如果你有一个自定义指令,但是不是很容易替换为组件,则可以在创建服务器 renderer 时,使用
directives
选项所提供"服务器端版本(server-side version)"。
构建步骤
编写好通用代码之后,就需要使用构建工具webpack
, Vite
进行打包构建,我们将会采用Vite
进行构建,构建过程如下:
创建客户端入口和服务端入口文件
一个典型的 SSR 应用应该有如下的源文件结构:
- index.html
- server.ts # main application server
- src/
- main.js # 导出环境无关的(通用的)应用代码
- entry-client.ts # 将应用挂载到一个 DOM 元素上
- entry-server.ts # 使用某框架的 SSR API 渲染该应用
index.html
index.html
将需要引用 entry-client.ts
,而不是原来的main.ts
,并包含一个占位标记供给服务端渲染时注入:
<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.ts"></script>
entry-client.ts
import { createApp } from './main'
const { app, router, store } = createApp()
router.isReady().then(() => {
app.mount('#app')
})
entry-server.ts
服务端入口需要导出一个render
函数,这个函数的作用是把我们写的组件内容转化为html字符串。它的调用时机是在浏览器发送请求得到服务端之后,服务端获取请求url
,调用render
函数生成对应页面的html字符串,返回给浏览器。
import { createApp } from './main'
// vue框架提供了该方法把组件转化为字符串
import { renderToString } from '@vue/server-renderer'
export async function render(url: string, manifest: any) {
const { app, router, store } = createApp()
await router.push(url)
await router.isReady()
const appHtml = await renderToString(app)
return appHtml
设置开发服务器
这个是服务端的代码,可以参照vite
官网设置开发服务器。
const fs = require('fs')
const path = require('path')
const express = require('express')
const serveStatic = require('serve-static')
const { createServer: createViteServer } = require('vite')
async function createServer() {
const app = express()
const vite = await createViteServer({
server: { middlewareMode: 'ssr' }
})
app.use(vite.middlewares)
app.use('*', async (req, res) => {
const url = req.originalUrl
// 1. 读取 index.html
let template = fs.readFileSync(path.resolve(__dirname, 'index.html'),
'utf-8')
template = await vite.transformIndexHtml(url, template)
// entry-server.ts 暴露了render方法
let render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
const appHtml = await render(url)
const html = template.replace('<!--ssr-outlet-->', appHtml)
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
})
app.listen(3000, () => {
console.log('node server run at:', isProd ? '生产环境' : '开发环境')
})
}
createServer()
package.json
中的 dev
脚本也应该相应地改变,使用服务器脚本:
"scripts": {
- "dev": "vite"
+ "dev": "node server"
}
打包客户端和服务端代码
为了将 SSR 项目交付生产,我们需要:
- 正常生成一个客户端构建;
- 再生成一个 SSR 构建,使其通过
import()
直接加载,这样便无需再使用 Vite 的ssrLoadModule
;
package.json
中的脚本应该看起来像这样:
{
"scripts": {
"dev": "node server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --outDir dist/server --ssr src/entry-server.js "
}
}
使用 --ssr
标志表明这将会是一个 SSR 构建。同时需要指定 SSR 的入口。
接着,在 server.js
中,通过 process.env.NODE_ENV
条件,需要添加一些用于生产环境的特定逻辑:
- 使用
dist/client/index.html
作为模板,而不是根目录的index.html
,因为前者包含了到客户端构建的正确资源链接。 - 使用
import('./dist/server/entry-server.js')
,而不是await vite.ssrLoadModule('/src/entry-server.js')
(前者是 SSR 构建后的最终结果)。
修改后代码如下:
const fs = require('fs')
const path = require('path')
const express = require('express')
const serveStatic = require('serve-static')
const { createServer: createViteServer } = require('vite')
const isProd = process.env.NODE_ENV === 'production'
async function createServer() {
const app = express()
const vite = await createViteServer({
server: { middlewareMode: 'ssr' }
})
// 在生产环境需要vite与express进行脱钩
if (!isProd) {
// 使用 vite 的 Connect 实例作为中间件,利用这个中间件来起一个静态资源服务器
app.use(vite.middlewares)
} else {
// 在生产环境,利用express框架自带的中间件serve-static,利用这个中间件来起一个静态资源服务器
app.use(
serveStatic(path.resolve(__dirname, 'dist/client'), { index: false })
)
}
app.use('*', async (req, res) => {
const url = req.originalUrl
let template
let render
try {
// 在生产环境需要vite与express进行脱钩
if (!isProd) {
// 1. 读取 index.html
template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8'
)
// 2. 应用 Vite 进行 HTML 转换,这将会注入 Vite HMR 客户端,
template = await vite.transformIndexHtml(url, template)
// 3. 加载服务器入口文件,vite.ssrLoadModule 将自动转换你的 ESM 源码使之可以在 Node.js 中运行。既然是加载文件,肯定是异步的,所以使用await
render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
} else {
template = fs.readFileSync(
path.resolve(__dirname, 'dist/client/index.html'),
'utf-8'
)
// 使用SSR构建后的最终结果
render = require('./dist/server/entry-server.js').render
}
// 4. 渲染应用的 HTML
const appHtml = await render(url, manifest)
// 5. 注入渲染后的应用程序 HTML 到模板中。
const html = template
.replace('<!--ssr-outlet-->', appHtml)
// 6. 返回渲染后的 HTML。
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch (e) {
...
}
})
app.listen(3000, () => {
console.log('node server run at:', isProd ? '生产环境' : '开发环境')
})
}
createServer()
数据预取
在服务器端渲染(SSR)期间,我们本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。
另一个需要关注的问题是在客户端,在挂载 (mount) 到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据,否则客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。
为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或"状态容器(state container)"中。
为此,我们将使用官方状态管理库 Vuex。
那么,我们在哪里放置「dispatch 数据预取 action」的代码?
我们需要通过访问路由,来决定获取哪部分数据 - 这也决定了哪些组件需要渲染。事实上,给定路由所需的数据,也是在该路由上渲染组件时所需的数据。所以在路由组件中放置数据预取逻辑,是很自然的事情。
我们将在路由组件上暴露出一个自定义静态函数 asyncData
。注意,由于此函数会在组件实例化之前调用,所以它无法访问 this
。需要将 store 和路由信息作为参数传递进去:
export default defineComponent({
setup() {
...
},
// asyncData是组件的属性,所以asyncData需要与setup平级,所以必须用setup的写法
// 定义一个asyncData函数用于获取后台接口数据,同时它返回一个promise
asyncData({ store, route }: any) {
return store.dispatch('getRoomList')
}
})
服务器端数据预取
在 entry-server.js
中,我们可以通过路由获得相匹配的组件,如果组件暴露出 asyncData
,我们就调用这个方法。然后我们需要将解析完成的数据,绑定到到window
上。
修改entry-server.ts
:
export async function render(url: string, manifest: any) {
const { app, router, store } = createApp()
await router.push(url)
await router.isReady()
const matchedComponents = router.currentRoute.value.matched.flatMap(record =>
Object.values(record.components)
)
await Promise.all(
matchedComponents.map((Component: any) => {
// 如果组件中定义了asyncData函数,说明这个组件需要去后台获取接口数据
if (Component.asyncData) {
return Component.asyncData({
// 传入store和当前route,store参数用来执行store.dispatch,发起请求
store,
route: router.currentRoute
})
}
return []
})
)
const appHtml = await renderToString(app)
// 此时state里面包含了请求接口获取的数据,然后就把这个数据绑定到window某个属性上,这样当同构的时候,客户端的store.state就能用这个值作为初始化化数据,就不用再去调一次接口了
const state = store.state
return { appHtml, state }
}
修改模板文件index.html
:
<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.ts"></script>
<script>
window.__INITIAL_STATE__ = '<!--vuex-state-->'
</script>
修改server.ts
const { appHtml, state } = await render(url)
const html = template
.replace('<!--ssr-outlet-->', appHtml)
.replace("'<!--vuex-state-->'", JSON.stringify(state))
客户端数据预取
首先思考下为什么会有客户端数据预取?
当我从服务端请求页面内容(/login
)后,客户端代码立即接管页面,当从/login
页面跳转到/home
页面,由于组件的部分生命周期在服务端不能使用,我们没有在onMounted
钩子中执行获取接口的函数,那么此时/home
页面是没有数据的。所以就需要在客户端进行数据预取,既然组件生命周期钩子不能用,还有什么钩子可以用呢?
答案是路由钩子。修改entry-client.ts
:
router.isReady().then(() => {
// beforeResolve表示所有的异步组件全部resolve了
router.beforeResolve((to, from, next) => {
// 找出两个匹配列表的差异组件。
// 如果你刷新当前页面,会发送请求到服务器,服务前拼接好数据和html返回前端,但是有了这个路由钩子,它还会再去请求一遍数据,这就相当于前后台都去请求了一次回去,这没有必要。
// 所以需要做一个判断是否是在刷新页面,也就是to和from是不是一样的,那就不是刷新,如果一样的,就是刷新操作。那么actived为空,不会执行后面的逻辑,也就是客户端不会再次请求数据接口,用服务端带过来的数据就可以了,这就是防止客户端数据二次预取。
const toComponents = router
.resolve(to)
.matched.flatMap(record => Object.values(record.components))
const fromComponents = router
.resolve(from)
.matched.flatMap(record => Object.values(record.components))
const actived = toComponents.filter((c, i) => {
return fromComponents[i] !== c
})
// 客户端预取数据有两种方式:一种是在匹配到路由视图之后就跳转,然后去请求接口,另一种是在请求接口数据返回后再进行跳转,此时页面是包含数据的
if (!actived.length) {
return next()
} else {
// 匹配路由之后直接跳转,然后去请求接口
// next()
}
// 显示loading
const loadingInstance = ElLoading.service({
lock: true,
text: 'Loading',
background: 'rgba(0, 0, 0, 0.7)'
})
Promise.all(
actived.map((Component: any) => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
})
}
return []
})
).then(() => {
// 关闭loading
loadingInstance.close()
// 等数据请求之后再跳转
next()
})
})
app.mount('#app')
})
注意:当客户端页面接管页面后,路由的跳转并不会向服务端发起请求,前端直接就拦截了。
生成预加载指令
当浏览器接收到服务端返回的 html 字符串后开始渲染,此时渲染出来的页面内容是没有样式的,然后就去获取客户端的代码接管页面,当接管页面的时候会有一个闪动,这是因为从服务器获取客户端代码有一个时间延迟。
如果消除这个闪动呢?我们可以在服务端代码中把客户端所需要的资源提前预加载,当客户端接管页面的时候就直接从缓存中获取,而不用去后台获取,这样就减少了这个时间差,闪动的效果就不明显了。
vite build
支持使用 --ssrManifest
标志,这将会在构建输出目录中生成一份 ssr-manifest.json
:
- "build:client": "vite build --outDir dist/client",
+ "build:client": "vite build --outDir dist/client --ssrManifest",
上面的脚本将会为客户端构建生成 dist/client/ssr-manifest.json
,清单包含模块 ID 到它们关联的 chunk 和资源文件的映射。
"src/views/home/homeIndex.vue": [
"/assets/homeIndex.029e5166.js",
"/assets/homeIndex.54140d74.css",
"/assets/banner.d75e2f5e.jpg"
]
为了利用该清单,框架需要提供一种方法来收集在服务器渲染调用期间使用到的组件模块 ID。
@vitejs/plugin-vue
支持该功能,开箱即用,并会自动注册使用的组件模块 ID 到相关的 Vue SSR 上下文:
// entry-server.js
const context: any = {}
const appHtml = await renderToString(app, context)
const state = store.state
// 只在生产环境加上manifest,import.meta.env.PROD是vite自带的环境变量
if (import.meta.env.PROD) {
const preloadLinks = renderLinks(context.modules, manifest)
return { appHtml, state, preloadLinks }
}
return { appHtml, state }
function renderLinks(modules: any, manifest: any) {
let links = ''
// 去重
const set = new Set()
modules.forEach((id: any) => {
const files = manifest[id]
if (files) {
files.forEach((file: any) => {
if (!set.has(file)) {
set.add(file)
}
})
}
})
for (const file of set) {
links += renderPreloadLink(file)
}
return links
}
function renderPreloadLink(file: any) {
if (file.endsWith('.js')) {
return `<link rel="modulepreload" crossorigin href="${file}">\n`
} else if (file.endsWith('.css')) {
return `<link rel="stylesheet" href="${file}" >\n`
} else if (file.endsWith('.woff')) {
return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>\n`
} else if (file.endsWith('.woff2')) {
return ` <link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>\n`
} else if (file.endsWith('.gif')) {
return ` <link rel="preload" href="${file}" as="image" type="image/gif">\n`
} else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) {
return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">\n`
} else if (file.endsWith('.png')) {
return ` <link rel="preload" href="${file}" as="image" type="image/png">\n`
} else {
// TODO
return ''
}
}
当访问主页时,context.modules
包含那些内容模块ID
[
'src/App.vue',
'src/components/layout/HeaderCommon.vue',
'src/views/home/homeIndex.vue',
'src/views/home/components/homeList.vue',
'src/components/layout/FooterCommon.vue'
]
这些都是主页所有组成模块。
根据context.modules
和manifest
,生成需要预加载的link
标签。
然后在index.html
中添加占位符<!--preload-links-->
,把生成的link
标签替换占位符。
修改server.ts
const html = template
// 替换预加载占位符
.replace('<!--preload-links-->', preloadLinks)
.replace('<!--ssr-outlet-->', appHtml)
.replace("'<!--vuex-state-->'", JSON.stringify(state))
到这里,一个完整的现代版服务端渲染SSR框架就搭建完成了。
总结
为了使代码能运行在客户端和服务端,我们对纯客户端进行了修改,通过工厂模式来创建 vue store router
等实例,避免状态单例导致的交叉感染。
分别创建客户端和服务端入口进行打包,并把服务端打包的代码通过 vue 框架提供的renderToString
方法转化为字符串嵌入到index.html
模板中,这样当浏览器发起请求时就能获取完整的带有内容的index.html
文件了。
由于页面是一个包含 html 模板和数据的结合体,在返回给浏览器之前服务端需要先发送请求接口,因此需要在组件中定义获取数据函数asyncData
。
当浏览器渲染从服务端获取的内容后,马上要进行客户端代码的注水处理,以激活页面,这样页面就被客户端代码接管了。
在接管过程中,由于获取客户端代码及资源需要时间,所以页面会有一个闪动的过程,为了缩短获取资源的时间,我们在服务端代码中增加了link proload
预加载指令,这样在渲染服务端代码时,也会去加载设置的预加载内容。这样当客户端去接管的时候,就直接从缓存中获取,这样就节约了时间,避免了页面很明显的闪动。