目前项目采用 Nuxt SSR 来完成服务端渲染 ,为满足 SEO 需求,将非首屏内容也进行了请求和服务端直出,导致首屏时间变长(非首屏的资源请求和组件的渲染都会带来额外开销)。对于海量的用户来说,少量的爬虫访问需求反而影响了正常用户的访问,导致 SEO 和用户体验提升存在很大的矛盾。
为了解决这个问题,我们设计和实践了自适应 SSR 方案,来同时满足这两种场景的需求。今天会分享这个方案的技术细节、设计思路以及在实施该方案过程中遇到的一些相关的子问题的实践踩坑经验,欢迎大家一起交流。
分享大纲
- 问题来源和背景
- 问题解决思路
- 自适应 SSR 方案介绍
- 采用自适应 SSR 优化前后数据
- Vue SSR client side hydration 踩坑实践
- 使用 SVG 生成骨架屏踩坑实践
问题来源和背景
目前项目采用 Nuxt SSR 来完成服务端渲染,为满足 SEO 需求,将非首屏资源也进行了请求和服务端直出,导致首屏时间变长(非首屏的资源请求和组件的渲染都会带来额外开销)
优化前的加载流程图
[外链图片转存失败(img-mSMmtHvZ-1569142634069)(https://raw.githubusercontent.com/binggg/res/develop/images20190808160403.png)]
目前我们的 Nuxt 项目采用 fetch 来实现 SSR 数据预取,fetch 中会处理所有关键和非关键请求
Nuxt 生命周期图
[外链图片转存失败(img-kC5sGuAn-1569142634071)(https://raw.githubusercontent.com/binggg/res/develop/images20190808160623.png)]
对于海量的用户来说,少量的爬虫访问需求反而影响了正常用户的访问,导致 SEO 和用户体验提升存在很大的矛盾。
为了解决这个问题,我们希望能区分不同的场景进行不同的直出,SEO 场景全部直出,其他场景只直出最小化的首屏,非关键请求放在前端异步拉取
解决思路
计划通过统一的方式来控制数据加载,将数据加载由专门的插件来控制,插件会根据条件来选择性的加载数据,同时懒加载一部分数据
- 判断是 SEO 情况,fetch 阶段执行所有的数据加载逻辑
- 非 SEO 场景,fetch 阶段只执行最小的数据加载逻辑,等到页面首屏直出后,通过一些方式来懒加载另一部分数据
优化后的项目影评页加载流程图
[外链图片转存失败(img-8bnpezqf-1569142634073)(https://raw.githubusercontent.com/binggg/res/develop/images20190808162208.png)]
自适应 SSR 方案介绍
Gitlab CI Pipeline
[外链图片转存失败(img-frbUlrRa-1569142634074)(https://raw.githubusercontent.com/binggg/res/develop/images20190808160912.png)]
自研 Nuxt Fetch Pipeline
借鉴 Gitlab CI 持续集成的概念和流程,将数据请求设计为不同的阶段 (Stage ),每个阶段执行不同的异步任务(Job),所有的阶段组成了数据请求的管线(Pipeline)
预置的 Stage
- seoFetch : 面向 SEO 渲染需要的 job 集合,一般要求是全部数据请求都需要,尽可能多的服务端渲染内容
- minFetch:首屏渲染需要的最小的 job 集合
- mounted: 首屏加载完之后,在 mounted 阶段异步执行的 job 集合
- idle: 空闲时刻才执行的 job 集合
每一个页面的都有一个 Nuxt Fetch Pipeline 的实例来控制,Nuxt Fetch Pipeline 需要配置相应的 job 和 stage,然后会自适应判断请求的类型,针对性的处理异步数据拉取:
- 如果是 SEO 场景,则只会执行 seoFetch 这个 stage 的 job 集合
- 如果是真实用户访问,则会在服务端先执行 minFetch 这个 stage 的 job 集合,然后立即返回,客户端可以看到首屏内容及骨架屏,然后在首屏加载完之后,会在 mounted 阶段异步执行 mounted stage 的 job 集合,另外一些优先级更低的 job,则会在 idle stage 也就是空闲的时候才执行。
Nuxt Fetch Pipeline 使用示例
page 页面 index.vue
import NuxtFetchPipeline, {
pipelineMixin,
adaptiveFetch,
} from '@/utils/nuxt-fetch-pipeline';
import pipelineConfig from './index.pipeline.config';
const nuxtFetchPipeline = new NuxtFetchPipeline(pipelineConfig);
export default {
mixins: [pipelineMixin(nuxtFetchPipeline)],
fetch(context) {
return adaptiveFetch(nuxtFetchPipeline, context);
},
};
配置文件 index.pipeline.config.js
export default {
stages: {
// 面向SEO渲染需要的 job 集合,一般要求是全部
seoFetch: {
type: 'parallel',
jobs: [
'task1'
]
},
// 首屏渲染需要的最小的 job 集合
minFetch: {
type: 'parallel',
jobs: [
]
},
// 首屏加载完之后,在 mounted 阶段异步执行的 job 集合
mounted: {
type: 'parallel',
jobs: [
]
},
// 空闲时刻才执行的 job 集合
idle: {
type: 'serial',
jobs: [
]
}
},
pipelines: {
// 任务1
task1: {
task: ({
store, params, query, error, redirect, app, route }) => {
return store.dispatch('action', {
})
}
}
}
}
并发控制
Stage 执行 Job 支持并行和串行 Stage 配置 type 为 parallel 时为并行处理,会同时开始每一个 job 等待所有的 job 完成后,这个 stage 才完成 Stage 配置 type 为 serial 时为串行处理,会依次开始每一个 job,前一个 job 完成后,后面的 job 才开始,最后一个 job 完成后,这个 stage 才完成
Job 嵌套
可以将一些可以复用的 job 定义为自定义的 stage,然后,在其他的 Stage 里按照如下的方式来引用,减少编码的成本
{
seoFetch: {
type: 'serial',
jobs:
[
'getVideo',
{
jobType: 'stage', name: 'postGetVideo' }
]
},
postGetVideo: {
type: 'parallel',
jobs: [
'anyjob',
'anyjob2'
]
}
}
Job 的执行上下文
为了方便编码,以及减少改动成本,每一个 job 执行上下文和 Nuxt fetch 类似,而是通过一个 context 参数来访问一些状态,由于 fetch 阶段还没有组件实例,为了保持统一,都不可以通过 this 访问实例
目前支持的 nuxt context 有
- app
- route
- store
- params
- query
- error
- redirect
Stage 的划分思路
Stage | 适合的 Job | 是否并行 |
---|---|---|
seoFetch | 全部,SEO 场景追求越多越好 | 最好并行 |
minFetch | 关键的,比如首屏内容、核心流程需要的数据,页面的主要核心内容(例如影评页面是影评的正文,短视频页面是短视频信息,帖子页面是帖子正文)的数据 | 最好并行 |
mounted | 次关键内容的数据,例如侧边栏,第二屏等 | 根据优先成都考虑是否并行 |
idle | 最次要的内容的数据,例如页面底部,标签页被隐藏的部分 | 尽量分批进行,不影响用户的交互 |
使用 SVG 生成骨架屏踩坑实践
由于服务端只拉取了关键数据,部分页面部分存在没有数据的情况,因此需要骨架屏来提升体验
[外链图片转存失败(img-rKLvyOO8-1569142634075)(https://raw.githubusercontent.com/binggg/res/develop/images20190808163542.png)]
[外链图片转存失败(img-6AqxAvj6-1569142634076)(https://raw.githubusercontent.com/binggg/res/develop/images20190808163628.png)]
Vue Content Loading 使用及原理
例子
<script>
import VueContentLoading from 'vue-content-loading';
export default {
components: {
VueContentLoading,
},
};
</script>
<template>
<vue-content-loading :width="300" :height="100">
<circle cx="30" cy="30" r="30" />
<rect x="75" y="13" rx="4" ry="4" width="100" height="15" />
<rect x="75" y="37" rx="4" ry="4" width="50" height=<