浅谈 vue 前端同构框架 nuxt 及其性能优化

前言

使用 nuxt.js 做项目也接近快一年了,从立项到内测、公测、再到正式上线,还有后面的不断维护,也陆陆续续的踩了很多坑,其中最大的问题就是 node 的渲染性能问题了。模板转换是 cpu 密集型的操作,node 又是单线程的,并发一高,cpu 就会飙到 100% 。为了提升 nuxt.js 的渲染性能,也陆陆续续的查找了很多资料,发现网上针对 nuxt.js 的性能优化的文章比较少,比较杂。所以我写下这篇文章记录下自己对 nuxt.js 做性能优化的时候采取的一些方法,算是篇总结吧,也希望能给从谷歌搜到这的朋友一些帮助。本文着重于性能优化,对概念类的东西会一概而过。

同构渲染

与传统的服务端渲染,使用模板引擎生成 html 不同。前端同构渲染在服务器端和客户端运行的是同一套代码,只有首屏是服务端直出 html ,而点击路由切换则是一个单页应用(spa)。同构渲染即能做到首屏直出,又能体验到无刷新的用户体验。使用模板引擎生成 html 可以做到首屏直出 html ,但做不到 spa 应用无刷新的用户体验,不过模板引擎不限制你的服务端语言,而同构渲染因为要在客户端和服务端跑着同一套代码,所以只能使用 node.js 做渲染服务器。

Nuxt

nuxt.js 是 vue 的一个服务端渲染的框架,把 webpack 、vue loader,vuex, router 系列配置整合到了一起,是一个比较完整的 vue 服务端渲染的方案。

生命周期

ssr 在没有做缓存的情况下,客户端的每次 request 都会到 node 服务器中,触发后端渲染。渲染服务器引入 renderer 和相应的 vue 应用,根据 route 找到相应的组件和数据,拉组件再拉数据(可能是异步的),加载组件生产 DOM,然后再使用 renderToString 吐给 response。

下图是来自 nuxt 官方文档的生命周期:

nuxt 生命周期

性能优化

有得必有失,vue ssr 需要在服务器根据 vue 文件生成虚拟 dom 再序列化成 html,是 cpu 密集型的操作。并且为了隔离请求不同的请求,它会为每一个请求创建一个上下文 context,这样一来,vue ssr 和传统的模板引擎相比,其性能起码差了几十倍。要想 vue ssr 经得起上线的考验,高并发的情况下能正常工作,必须要采取一系列的性能优化手段,并采取明智的部署策略。

缓存

但凡是提到性能优化,第一个想到的必然是缓存。node.js 是单线程的,它的高性能是相对于异步 io 操作频繁的。针对 io 密集型而非 cpu 密集型,因此需要在渲染过程中采取合理的缓存策略。ssr 的缓存可分为 组件级别的缓存、数据级别的缓存、页面级别的缓存。当命中缓存的时候,只需将缓存中的 html 吐给 response ,不用再进行一系列的渲染活动,极大的节省 cpu 资源。

1. 组件的缓存

nuxt 的组件级别的缓存,使用的是 Component Cache module 模块,将 @nuxtjs/component-cache 从 npm 中添加到依赖中,在配置文件 nuxt.config.js 做出如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
{
modules: [
'@nuxtjs/component-cache',
[
'@nuxtjs/component-cache',
{
max: 10000,
maxAge: 1000 * 60 * 60
}
]
]
}

在需要缓存的组件中使用 serverCacheKey 函数来标识

1
2
3
4
5
6
7
export default {
name: 'ReplyItem',
serverCacheKey: props => props.postId,
props: {
postId: String
}
}

serverCacheKey 函数所返回的 key 必须有足够的信息去标识该组件,返回常量将导致组件始终被缓存,这对纯静态组件是有好处的。同时,缓存的组件必须要有唯一的 name 属性。

但值得注意的是,使用组件级别缓存的时候,不要缓存单一实例的组件。应该缓存的是那些重复使用的展示型组件,如 v-for 下的重复组件,在我所写的项目中,我使用组件级别的缓存也主要是这一类,如帖子列表、新闻列表、评论列表等。

如果 render 在渲染组件的过程中,命中缓存,则直接使用缓存结果,所以一些情况不能使用组件级别的缓存:

  • 可能拥有依赖 global 数据的子组件。
  • 具有在渲染 context 中产生副作用的子组件。

2. 数据的缓存

在 node 服务器向后端请求数据的时间,也会影响到渲染的时间,所以数据层,最好也要有缓存。如果从后端 api 拉取数据的时间需要 3 秒,那这 3 秒会直接反应在首屏渲染时间上。对于数据层的缓存,应该对那些不涉及用户信息和实时性要求不高的接口进行缓存。对于数据层的缓存,使用的是 lru-cache 这个模块。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import axios from 'axios'
import LRU from 'lru-cache'
import md5 from 'md5'

const options = {
// 最大缓个数
max: 1000,
// 缓存周期
maxAge: 1000 * 60 * 5 // 5 分钟的缓存
}

const cache = new LRU(options)

// 需要缓存数据的接口
const CACHE_API = ['v1/api/xxxx', 'v1/api/xxxx', 'v1/api/xxxx', 'v1/api/xxxx']

export function get(url, params) {
const key = md5(url + JSON.stringify(params))
// 只在服务端进行缓存
if (process.server && cache.has(key)) {
// 命中缓存
return Promise.resolve(cache.get(key))
}

return axios
.get(url, { params })
.then(res => {
// 只在服务端进行缓存
if (process.server && CACHE_API.includes(url)) {
cache.set(key, res.data)
}
return Promise.resolve(res.data)
})
.catch(err => throw err)
}

3. 页面的缓存

不是每一请求都需要触发后端渲染的,当页面不涉及用户数据,就可以对整个页面进行缓存。url 命中缓存的时候,直接将缓存吐给 response ,不再触发一系列的渲染活动。在 nuxt 中,使用页面级别的缓存,使用的是服务器渲染中间件 serverMiddleware 。在这一层的缓存中可以使用 redis 进行缓存,在 nginx 层的时候就可以直接调用 redis 吐数据,缓存过期后再重新出发 node 渲染并重新缓存。当然,也可以缓存在内存中。

示例代码:

根目录下新建一个 serverMiddleware/pageCache.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const LRU = require('lru-cache')

const options = {
max: 1000,
maxAge: 1000 * 60 * 5
}

// 需要进行页面级别缓存的路由
const CACHE_URL = ['/xxx', '/xxx', '/xxx']

const cache = new LRU(options)

export default function (req, res, next) {
const url = req._parsedOriginalUrl
const pathname = !!url.pathname ? url.pathname : ''

if (CACHE_URL.includes(pathname)) {
const existsHtml = cache.get(pathname)
if (existsHtml) {
// 不要忘了设置 Content-Type 不然浏览器有时候可能不会渲染而是触发下载文件
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
return res.end(existsHtml.html, 'utf-8')
} else {
res.original_end = res.end
res.end = function (data) {
if (res.statusCode === 200) {
cache.set(pathname, { html: data })
}
res.original_end(data, 'utf-8')
}
}
}
next()
}

页面缓存渲染中间件写好之后,在配置文件 nuxt.config.js 中使用

1
2
3
4
5
module.exports = {
// ...
serverMiddleware: ['~/serverMiddleware/pageCache.js']
// ...
}

不必要的渲染开销

服务端渲染最主要的作用是 seo,但并不是所有的页面都需要进行 seo。整站式的 ssr 意味着将消耗巨大服务器 cpu 资源,如果只从后端渲染需要 seo 的页面,将极大的节省 cpu 资源,空余出来的 cpu 资源则作用于更大的并发量。例如:掘金 就仅仅是在文章的详情页做 ssr 。

那么在 nuxt.js 中,如何根据不同的路由去觉得是进行服务端渲染或是客户端渲染呢?这一点在 nuxt.js 的官方文档中并未提到这一点,但我在 nuxt.js 的源码中找到关于 url 控制是服务度渲染还是客户端渲染的代码:

nuxt.js/packages/vue-renderer/src/renderer.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async renderRoute(url, renderContext = {}, _retried) {

// ...

if (renderContext.spa === undefined) {
// TODO: Remove reading from renderContext.res in Nuxt3
renderContext.spa = !this.SSR || req.spa || (renderContext.res && renderContext.res.spa);
}

// ...

return renderContext.spa
? this.renderSPA(renderContext)
: this.renderSSR(renderContext)
}

其中决定不同的路由是服务度渲染还是客户端渲染,取决于 renderContext.spa 的值,而 renderContext.spa 的值按先后顺序取决于 options.render.ssrreq.sparenderContext.res.spa 。 所以我们只需要在特定的路由将 res.spa 置为 true 即可。

新建渲染服务器中间件 serverMiddleware/spaPage.js:

1
2
3
4
5
6
7
8
9
10
11
12
export default function (req, res, next) {
const { _parsedOriginalUrl } = req
const pathname = _parsedOriginalUrl.pathname
? _parsedOriginalUrl.pathname
: ''

res.spa = true
if (pathname.includes('/post') || pathname === '/') {
res.spa = false
}
next()
}

只在文章详情页和首页进行 ssr ,在配置文件 nuxt.config.js 中使用

1
2
3
4
5
module.exports = {
// ...
serverMiddleware: ['~/serverMiddleware/spaPage.js']
// ...
}

首屏最小化

为了进一步的节省服务器性能,我们可以分析需要服务端渲染的页面布局。进行合理的页面结构的拆分,首屏所需的数据和页面结构在服务端获取并渲染,非首屏所需的数据和结构在客户端拉取并渲染。需要在服务端拉取的数据可以使用 asyncData 方法来异步获取数据,不需要在服务端拉取的数据在 mounted 这个钩子获取。分割结构可以使用 no-ssrclient-only(nuxt 版本不小于 2.9) 标签包裹不需要在服务端进行渲染的结构。

使用 no-ssr 包裹不需要服务端渲染的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div class="post-detail">
<main>
<post-head />
<post-body />
<post-foot />
</main>

<aside>
<!-- 不需要服务端渲染的内容 -->
<no-ssr>
<f-block />
<f-block />
<f-block />
</no-ssr>
</aside>
</div>
</template>

服务端渲染所需的数据在 asyncData 中获取,客户端渲染所需的数据在 mounted 钩子中获取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export default {
// 需要在服务端拉取的数据
async asyncData({ app, params }) {

// 发送请求
const threadResultPromise = app.$api.thread.getThreadDetail(parseInt(params.tid, 10))
const replyListResultPromise = app.$api.thread.getThreadReplyList({ threadId: parseInt(params.tid, 10) })

return Promise.all([
threadResultPromise,
replyListResultPromise
] => {
return {
// ...
}
})
},

// 在客户端获取的数据
mounted() {
this.getSpecialColumnInfo()
this.setContentTypeName()
}
}

区分登录和非登录情况

搜索引擎的爬虫,访问你的服务器的时候并不会携带用户相关的信息。所以我们可以针对这一特性来做进一步的优化,进一步的减少 node 服务器的渲染压力。这一步可以参考掘金社区,只在非登录的情况下做服务端渲染,用户登录的情况下就是客户端渲染。在 nuxt.js 中,可以编写一个渲染服务器中间件,来对登录用户和非登录用户进行不同的处理。如采用 cookie 来对用户是否登录进行判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const cookieparser = require('cookieparser')

export default function (res, req, next) {
try {
const parsed = req.headers.cookie
? cookieparser.parse(req.headers.cookie)
: ''

// cookie 中存在用户信息
if (parsed && parsed.userInfo) {
// 置为 spa 模式
res.spa = true
next()
return
}
} catch (err) {
throw err
}
next()
}

ssr 和 spa 的切换

nuxt.js 的配置文件中,导出的一个 mode 属性,它的默认值为 universal 同构应用程序,提供的另外一个属性 spa 单页。想要在 ssr 和 spa 之间切换只需要更改 mode 的值,再重新编译即可:

1
2
3
4
5
6
7
module.exports = {
// 服务端渲染 前端同构
mode: 'universal'

// 客户端渲染 单页应用
// mode: 'spa'
}

部署策略

服务端渲染,cpu 密集型的操作,高并发下很容易造成 cpu 满载,在我负责的服务端渲染的项目中,我采用的 pm2 来部署项目。pm2 是一个 node 进程管理工具,它可以对 node 应用进行监控,自动重启,负载均衡等。pm2 可以启动多个实例来用于负载均衡,多个 node 实例可以实现前端应用不停机更新。

除了每个服务器启动 node 集群实现每个进程之间之外,还要多个服务器之间实现负载均衡,使用 nginx 实现多个服务器间的负载均衡。我们公司在部署的时候,就采用了 3 台 8 核的服务器进行负载均衡,每台服务器又开启 8 个 node 进程进行负载均衡。

node 集群

node 是基于 v8 引擎实现的在操作系统中运行 JavaScript 的工具,JavaScript 的单线程在多核的 cpu 上运行无法发挥多核 cpu 的性能,只有一个 cpu 核心在运行,其他的核在闲置,效率很低。在我负责的项目中,使用的是 pm2 实现 node 的集群,pm2 可以把你的应用以 集群(cluster)模式来运行(仅限 node 程序),部署到服务器 cpu 的所有核心上。

使用 pm2 来启动 nuxt 服务器可以在根目录下新建一个 pm2 启动描述文件:pm2.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"apps": [
{
"name": "feng-pc-render-server",
"max_memory_restart": "1200M",
"script": "server/index.js",
"env": {
"NODE_ENV": "production",
"PORT": 3000,
"HOST": "0.0.0.0"
},
"instances": 0,
"exec_mode": "cluster",
"autorestart": true
}
]
}

其中 max_memory_restart 自动重启的最大内存,当进程内存超过该值时重启该进程,可以在一定程度上解决内存泄露,但这明显不是明智的选择,exec_mode 表示启动的模式,值为 cluster 时已集群的模式启动。instances 字段表示需要启动的 node 进程,当值为 0 或 max 时,pm2 已当前服务器所能启动的最多实例来启动项目,仅在 cluster 模式下生效。

iShot2020-06-1817.51.19.png

当使用 pm2 的集群模式启动后,使用 top 命令或 htop 查看服务器运行信息,可以看到服务器的 8 个核心,均有负载。

iShot2020-06-1818.06.09.png

此外,如果 cpu 核心少的话,也可以启动多个端口使用 nginx 进行负载均衡,但这样也起不了多少效果。

nginx 集群

在我所负责的服务端渲染项目中,是使用 nginx 对 3 台 8 核的服务器进行负载均衡。当用户访问服务端时,服务端通过 nginx 负载到其中资源利用率较低的一台,再反向代理到负载均衡的 node 集群,然后随机将用户的请求发给比较闲的node服务。

在这个过程中也碰到了很多坑,如怎么在 3 台 服务器间进行状态同步,保持登录。由于我们公司的网络配置层是由运维去管理的,如何配置 nginx 负载均衡在这里不进行赘述,有兴趣的朋友可以自行搜索。

防止 cc 攻击

我们公司的网站也是国内流量比较大的社区类站点,项目在上线几个月后,遭遇了一次大规模的 cc 攻击,针对这种情况,我们也做了大量的处理:配置 ip 黑名单,对单个 ip 限制最大并发数、写 lua 脚本拦截 cc 攻击,最后还上了高防服务器。

总结

将 vue 渲染成 html 是 cpu 密集型的操作,node 又是单线程的,所以性能不是很好,想要提高并发,就得做缓存。
在 node 渲染服务器中做三层的缓存:页面级别的缓存、组件级别的缓存、api 级别的缓存。由于搜索引擎的爬虫不会携带用户信息,还可以区分用户登录和非登录的情况,针对非登录用户做服务端渲染 ssr ,对登录用户做客户端渲染 spa。也不是所有页面都需要服务端渲染,可以仅针对特定的路由做服务端渲染,还要控制首屏的大小,非必要的组件使用懒加载的方式在客户端渲染,再加上多层的集群处理。

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×