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

前言

本文着重于性能优化,对概念类的东西会一概而过。

同构渲染

与传统的服务端渲染,使用模板引擎生成 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
<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
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()
}
}

区分登录和非登录情况

待更…

ssr 和 spa 的切换

待更…

部署策略

待更…

node 集群

待更…

nginx 集群

待更…

防止 cc 攻击

待更…

总结

待更…

Your browser is out-of-date!

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

×