由于我司的 C 端商城项目是基于 Vue2 + Nuxt.js 框架实现服务端渲染的海外电商家具平台,维护了这个项目很久,但是一直没去了解服务端渲染的相关知识,趁着现在刚好有时间深入了解 SSR 的内容,网上的相关资料感觉比较乱,因此自己总结了一些关于服务端渲染的知识,在这里与大家分享!
一、SSR、CSR
1. 什么是服务端渲染
服务端渲染
(Server-Side Rendering,SSR)是一种将页面的渲染过程从客户端移动到服务器端的技术。服务端渲染首先是在服务器端生成完整的 HTML 页面,然后再将其发送给客户端;服务器端执行一部分或全部的页面渲染工作,包括数据获取、模板渲染等,最终生成带有动态内容的完整 HTML 页面返回给客户端;客户端接收到的页面已经包含了初始化的内容,用户可以更快地看到页面的完整内容和交互功能。
2. 什么是客户端渲染
客户端渲染
(Client-Side Rendering,CSR):在用户访问页面时,会先下载 HTML、CSS 和 JavaScript 文件,然后通过 JavaScript 在客户端完成页面的渲染。
你可以用如下的方法辨别一个页面是否是 CSR:打开 chrome 控制台 - 网络面板,查看第一条请求,就能看到当前页面向服务器请求的 html 资源;如果是 CSR(如下图所示),这个 html 的 body 中是没有实际内容的。
那么页面内容是如何渲染出来的呢?仔细看上面的 html,会发现存在一个 script 标签,打包器正是把整个应用都打包进了这个 js 文件里面。
当浏览器请求页面的时候,服务器先会返回一个空的 html 和打包好的 js 代码;等到 js 代码下载完毕,浏览器再执行 js 代码,页面就被渲染出来了。因为页面的渲染是在浏览器中而非服务器端进行的,所以被称为客户端渲染。
3. 客户端渲染的优缺点
客户端渲染
会把整个网站打包进 js 里,当 js 下载完毕后,相当于网站的页面资源都被下载好了。这样在跳转新页面的时候,不需要向服务器再次请求资源(js 会直接操作 dom 进行页面渲染),从而让整个网站的使用体验上更加流畅。
但是这种做法也带来了一些问题:在请求第一个页面的时候需要下载 js,而下载 js 直至页面渲染出来这段时间,页面会因为没有任何内容而出现白屏。在 js 体积较大或者渲染过程较为复杂的情况下,白屏问题会非常明显。
另外,由于使用了 CSR 的网站,会先下载一个空的 html,然后才通过 js 进行渲染;这个空的 html 会导致某些搜索引擎无法通过爬虫正确获取网站信息,从而影响网站的搜索引擎排名(SEO)。
4. 服务端渲染的优缺点
相对于客户端渲染,服务端渲染有以下几个主要优势:
- 首屏加载速度更快:由于服务器端已经在渲染过程中生成了完整的 HTML 页面,可以直接发送给客户端,用户无需等待 JavaScript 文件下载和执行,可以更快地看到页面内容。
- 更好的 SEO:搜索引擎爬虫可以直接抓取到完整的 HTML 页面内容,能够更好地索引和理解页面的信息,对搜索引擎优化(SEO)更友好。
- 更好的用户体验:用户在等待页面加载完成时不会看到空白页面或加载中的状态,可以更快地与页面进行交互,提升用户体验。
需要注意的是,服务端渲染也有一些局限性:
- 由于服务端渲染会在每次请求时都重新生成完整的 HTML 页面,页面的状态不会像客户端渲染那样被保留,可能需要额外的开发工作来处理页面状态的恢复和持久化。
- 同构资源的处理:劣势在于程序需要具有通用性。结合 Vue 的钩子来说,能在 SSR 中调用的生命周期只有 beforeCreate 和 created,这就导致在使用三方 API 时必须保证运行不报错;在三方库的引用时需要特殊处理使其支持服务端和客户端都可运行。
- 部署构建配置资源的支持:劣势在于运行环境单一,程序需处于 node.js server 运行环境。基于 node 的服务端渲染,难得不是渲染而是高可用的 node 服务才是麻烦的地方。
- 服务器更多的缓存准备:劣势在于高流量场景需采用缓存策略,应用代码需在双端运行解析,cpu 性能消耗更大,负载均衡和多场景缓存处理比 SPA 做更多准备。
二、同构渲染
1. 什么是同构渲染
CSR
和 SSR
的优劣势是互补的,所以只要把它们二者结合起来,就能实现理想的渲染方法,也就是同构渲染。同构的理念十分简单,最开始的步骤和 SSR 相同,将生成的 html 字符串返回给浏览器即可;但同时可以将 CSR 生成的 JS 也一并发送给用户;这样浏览器在接收到 SSR 生成的 html 后,页面还会再执行一次 CSR 的流程。
一般是指服务端和客户端同构,意思是服务端和客户端运行同一套代码程序,构建双端(server 和 client)逻辑,最大限度的重用代码,不用维护两套代码。SSR
的核心就是同构,没有同构的 SSR 是没有意义的。
当然同构渲染也是有一些缺点的:
- 浏览器特定的代码只能在某些生命周期钩子函数中使用
- 一些外部的库可能要经过特殊的处理才能在服务端渲染中使用
- 不能在服务端渲染期间操作DOM
- 某些代码需要区分运行环境
2. 一个同构案例
服务器端渲染html字符串: 在客户端渲染里我们会使用 createApp
来创建一个 Vue 应用实例,但在同构渲染中则需要替换成 createSSRApp
。如果仍然使用原本的 createApp
,会导致首屏页面先在服务器端渲染一次,浏览器端又重复渲染一次。
当使用了 createSSRApp
,Vue 就会在浏览器端渲染前先进行一次检查,如果结果和服务器端渲染的结果一致,就会停止首屏的客户端渲染过程,从而避免了重复渲染的问题。
代码如下:
js
import { renderToString } from 'vue/server-renderer'
import { createSSRApp } from 'vue'
// 一个计数的vue组件
function createApp() {
// 通过createSSRApp创建一个vue实例
return createSSRApp({
data: () => ({ count: 1 }),
template: `<button @click="count++">{{ count }}</button>`,
});
}
const app = createApp();
// 通过renderToString将vue实例渲染成字符串
renderToString(app).then((html) => {
// 将字符串插入到html模板中
const htmlStr = `
<!DOCTYPE html>
<html>
<head>
<title>Vue SSR Example</title>
</head>
<body>
<div id="app">${html}</div>
</body>
</html>
`;
console.log(htmlStr);
});
通过服务器发送html字符串: 启动服务器,然后在浏览器访问 http://localhost:3000
js
import express from 'express'
import { renderToString } from 'vue/server-renderer'
import { createSSRApp } from 'vue'
// 一个计数的vue组件
function createApp() {
return createSSRApp({
data: () => ({ count: 1 }),
template: `<button @click="count++">{{ count }}</button>`,
});
}
// 创建一个express实例
const server = express();
// 通过express.get方法创建一个路由, 作用是当浏览器访问'/'时, 对该请求进行处理
server.get('/', (req, res) => {
// 通过createSSRApp创建一个vue实例
const app = createApp();
// 通过renderToString将vue实例渲染成字符串
renderToString(app).then((html) => {
// 将字符串插入到html模板中
const htmlStr = `
<!DOCTYPE html>
<html>
<head>
<title>Vue SSR Example</title>
</head>
<body>
<div id="app">${html}</div>
</body>
</html>
`;
// 通过res.send将字符串返回给浏览器
res.send(htmlStr);
});
})
// 监听3000端口
server.listen(3000, () => {
console.log('ready http://localhost:3000')
})
激活客户端渲染: 如果你访问过上面的地址,就会发现页面上的按钮是点不动的,这是因为通过 renderToString 渲染出来的页面是完全静态的,这时候就要进行客户端激活。
激活的方法其实就是执行一遍客户端渲染,在 Vue 里面就是执行 app.mount
。我们可以创建一个 js,在里面写入客户端激活的代码,然后通过 script
标签把这个文件插入到 html
模板中,这样浏览器就会请求这个 js 文件了。
如下所示,首先写一段客户端激活的代码,放到名为client-entry.js
的文件里:
js
import { createSSRApp } from 'vue'
// 通过createSSRApp创建一个vue实例
function createApp() {
return createSSRApp({
data: () => ({ count: 1 }),
template: `<button @click="count++">{{ count }}</button>`,
});
}
createApp().mount('#app');
可以看到,这里的 createApp
函数和服务器端的 counter 组件是完全相同的(在实际开发中,createApp 代表的就是你的整个应用),所以客户端激活实际上就是把客户端渲染再执行一遍,唯一区别就是要使用createSSRApp
这个 api 防止重复渲染。
改造后的如下 html 模板如下:
js
const htmlStr = `
<!DOCTYPE html>
<html>
<head>
<title>Vue SSR Example</title>
// 将client-entry.js文件路径写入script
<script type="module" src="/client-entry.js"></script>
</head>
<body>
<div id="app">${html}</div>
</body>
</html>
`;
这样我们的按钮就可以点击了,而且查看控制台,请求的 HTML 资源也是有内容的,不再是 CSR 那种空白的 html 了:
3. 实现脱水(Dehydrate)和注水(Hydrate)
同构应用还有一个比较重要的点,就是如何实现服务器端的数据的预取,并让其随着 html 一起传递到浏览器端。
例如我们有一个列表页,列表数据是从其他服务器获取的,为了让用户第一时间就看到页面内容,最好的方法当然是在服务器就拿到数据,然后随着 html 一起传递给浏览器。浏览器拿到 html 和传过来的数据,直接对页面进行初始化,而不需要再在客户端请求这个接口。
为了实现这个功能,整个过程分为两部分:
- 服务器端获取到数据后,把数据随着 html 一起传给客户端的过程,一般叫做脱水
- 客户端拿到 html 和数据,利用这个数据来初始化组件,这个过程叫做注水
注水其实就是前面提到过的客户端激活,区别只是前面的没有数据,而这次我们会试着加上数据。
实现服务器端脱水: 为了让服务器获取到我们要请求的接口,我们可以在 Vue 组件中挂载一个自定义函数,然后在服务器端调用这个函数即可(需要注意的是,服务器环境不能直接使用fetch,应该用axios或者node-fetch替代)。如下:
js
// 组件中的代码
import { createSSRApp } from 'vue'
function createApp() {
return createSSRApp({
data: () => ({ count: 1 }),
template: `<button @click="count++">{{ count }}</button>`,
// 自定义一个名为asyncData的函数
asyncData: async () => {
// 在处理远程数据并return出去
const data = await getSomeData()
return data;
}
});
}
// 服务器端的代码
const app = createApp();
// 保存初始化数据
let initData = null;
// 判断是否有我们自定义的asyncData方法,如果有就用该函数初始化数据
if (app._component.asyncData) {
initData = await app._component.asyncData();
}
拿到数据后该如何传递到浏览器呢?其实有一个很简单的方法:我们可以把数据格式化成字符串,然后用如下的方式,直接将这个字符串放到 html 模板的一个 script 标签中:
js
const htmlStr = `
<!DOCTYPE html>
<html>
<head>
...
// 将数据格式化成json字符串,放到script标签中
<script>window.__INITIAL_DATA__ = ${JSON.stringify(initData)}</script>
</head>
...
</html>
`;
当 html 被传到浏览器端的时候,这个 script 标签就会被浏览器执行,于是我们的数据就被放到了 window.__INITIAL_DATA__
里面,此时客户端就可以从这个对象里面拿到数据了。
实现客户端注水: 先判断 window.__INITIAL_DATA__
是否有值,如果有的话直接将其赋值给页面 state;否则就让客户自己再请求一次接口,代码如下:
js
function createApp() {
return createSSRApp({
data: () => ({ count: 1 }),
template: `<button @click="count++">{{ count }}</button>`,
// 自定义一个名为asyncData的函数
asyncData: async () => {
// 在处理远程数据并return出去
const data = await getSomeData()
return data;
},
async mounted() {
// 如果已经有数据了,直接从window中获取
if (window.__INITIAL_DATA__) {
// 有服务端数据时,使用服务端渲染时的数据
this.count = window.__INITIAL_DATA__;
window.__INITIAL_DATA__ = undefined;
return;
} else {
// 如果没有数据,就请求数据
this.count = await getSomeData();
}
}
});
}
这样我们就实现了一套完整的注水和脱水流程。
4. 同构需要注意的几点
避免状态单例: 服务器端返回给客户端的每个请求都应该是全新的、独立的应用程序实例,因此不应当有单例对象------也就是避免直接将对象或变量创建在全局作用域,否则它将在所有请求之间共享,在不同请求之间造成状态污染。
避免访问特定平台api: 服务器端是 node 环境,而客户端是浏览器环境,如果你在 node 端直接使用了像 window 、 document 或者 fetch(在 node 端应该用 axios 或 node-fetch),这种仅浏览器可用的全局变量或api,则会在 Node.js 中执行时抛出错误。
需要注意的是,在 Vue 组件中,服务器端渲染时只会执行 beforeCreate 和 created 生命周期,在这两个生命周期之外执行浏览器 api 是安全的,所以推荐将操作 dom 或访问 window 之类的浏览器行为,一并写在 onMounted 生命周期中,这样就能避免在 node 端访问到浏览器 api。
避免在服务器端生命周期内执行全局副作用代码: Vue 服务器端渲染会执行 beforeCreate 和 created 生命周期,应该避免在这两个生命周期里产生全局副作用的代码。
例如使用 setInterval 设置定时器。在纯客户端的代码中,我们可以设置一个定时器,然后在 beforeDestroy 或 destroyed 生命周期时将其销毁。但是,由于在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来,最终造成服务器内存溢出。
5. 创建生产中的同构应用
上面的讲解只是一个最基础的同构渲染,但距离一个能在开发中实际使用的框架还差得很远。如果要创建实际生产中的同构应用,至少还要解决下面几个问题:
- 集成前端工具链,如 vite、eslint、ts 等
- 集成前端路由,如 vue-router
- 集成全局状态管理库,如 pinia
- 处理
#app
节点之外的元素。如 Vue 的 teleport - 处理预加载资源
三、Nuxt.js 框架
Nuxt
是基于 Vue ssr 之上,集成了 Vue-Router,Vuex,Webpack 等框架、组件的一个服务端渲染框架,其实 Nuxt 就是一个升级版的 Vue ssr,为我们预设了服务端渲染的应用所需要的各种配置,但是相应的,Nuxt 的入侵性是特别高的,我们需要理解 Nuxt 的思路,才能发挥它的优势。
这里不再对 nuxt 展开讲解,需要进一步了解该框架的可以参考以下文章: