
现在项目接纳 Nuxt SSR 来完成效劳端渲染 ,为满意 seo 需求,将非首屏内容也举行了哀求和效劳端直出,导致首屏时间变长(非首屏的资源哀求和组件的渲染都会带来额外开销)。对于海量的用户来说,少量的爬虫访问需求反而影响了正常用户的访问,导致 SEO 和用户体验提拔存在很大的抵牾。
为了办理这个题目,我们计划和实践了自顺应 SSR 方案,来同时满意这两种场景的需求。本日会分享这个方案的技能细节、计划思绪和在实行该方案过程中碰到的一些相干的子题目的实践踩坑履历,欢送各人一起交换。
分享大纲
- 题目泉源和配景
- 题目办理思绪
- 自顺应 SSR 方案先容
- 接纳自顺应 SSR 优化前后数据
- Vue SSR client side hydration 踩坑实践
- 利用 SVG 天生骨架屏踩坑实践
题目泉源和配景
现在项目接纳 Nuxt SSR 来完成效劳端渲染,为满意 SEO 需求,将非首屏资源也举行了哀求和效劳端直出,导致首屏时间变长(非首屏的资源哀求和组件的渲染都会带来额外开销)
优化前的加载流程图

现在我们的 Nuxt 项目接纳 fetch 来实现 SSR 数据预取,fetch 中会处置惩罚全部关键和非关键哀求
Nuxt 生命周期图

对于海量的用户来说,少量的爬虫访问需求反而影响了正常用户的访问,导致 SEO 和用户体验提拔存在很大的抵牾。
为了办理这个题目,我们盼望能区分差别的场景举行差别的直出,SEO 场景全部直出,其他场景只直出最小化的首屏,非关键哀求放在前端异步拉取
办理思绪
方案通过同一的方式来控制数据加载,将数据加载由专门的插件来控制,插件会根据条件来选择性的加载数据,同时懒加载一部门数据
- 判定是 SEO 环境,fetch 阶段履行全部的数据加载逻辑
- 非 SEO 场景,fetch 阶段只履行最小的数据加载逻辑,比及页面首屏直出后,通过一些方式来懒加载另一部门数据
优化后的项目影评页加载流程图

自顺应 SSR 方案先容
Gitlab CI Pipeline

自研 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 天生骨架屏踩坑实践
由于效劳端只拉取了关键数据,部门页面部门存在没稀有据的环境,因此必要骨架屏来提拔体验


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="10" />
</vue-content-loading>
</template>
Vue Content Loading 焦点代码
<template>
<svg :viewBox="viewbox" :style="svg" preserveAspectRatio="xMidYMid meet">
<rect
:style="rect.style"
:clip-path="rect.clipPath"
x="0"
y="0"
:width="width"
:height="height"
/>
<defs>
<clipPath :id="clipPathId">
<slot>
<rect x="0" y="0" rx="5" ry="5" width="70" height="70" />
<rect x="80" y="17" rx="4" ry="4" width="300" height="13" />
<rect x="80" y="40" rx="3" ry="3" width="250" height="10" />
<rect x="0" y="80" rx="3" ry="3" width="350" height="10" />
<rect x="0" y="100" rx="3" ry="3" width="400" height="10" />
<rect x="0" y="120" rx="3" ry="3" width="360" height="10" />
</slot>
</clipPath>
<linearGradient :id="gradientId">
<stop offset="0%" :stop-color="primary">
<animate
attributeName="offset"
values="-2; 1"
:dur="formatedSpeed"
repeatCount="indefinite"
/>
</stop>
<stop offset="50%" :stop-color="secondary">
<animate
attributeName="offset"
values="-1.5; 1.5"
:dur="formatedSpeed"
repeatCount="indefinite"
/>
</stop>
<stop offset="100%" :stop-color="primary">
<animate
attributeName="offset"
values="-1; 2"
:dur="formatedSpeed"
repeatCount="indefinite"
/>
</stop>
</linearGradient>
</defs>
</svg>
</template>
<script>
const validateColor = color =>
/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/.test(color);
export default {
name: 'VueContentLoading',
props: {
rtl: {
default: false,
type: Boolean,
},
speed: {
default: 2,
type: Number,
},
width: {
default: 400,
type: Number,
},
height: {
default: 130,
type: Number,
},
primary: {
type: String,
default: '#f0f0f0',
validator: validateColor,
},
secondary: {
type: String,
default: '#e0e0e0',
validator: validateColor,
},
},
computed: {
viewbox() {
return `0 0 ${this.width} ${this.height}`;
},
formatedSpeed() {
return `${this.speed}s`;
},
gradientId() {
return `gradient-${this.uid}`;
},
clipPathId() {
return `clipPath-${this.uid}`;
},
svg() {
if (this.rtl) {
return {
transform: 'rotateY(180deg)',
};
}
},
rect() {
return {
style: {
fill: 'url(#' + this.gradientId + ')',
},
clipPath: 'url(#' + this.clipPathId + ')',
};
},
},
data: () => ({
uid: null,
}),
created() {
this.uid = this._uid;
},
};
</script>
SVG 动画卡顿
利用了 Vue content loading 做骨架屏以后,发如今 js 加载并履行的时间动画会卡住,而 CSS 动画大部门环境下可以离开主线程履行,可以制止卡顿
CSS animations are the better choice. But how? The key is that as long as the properties we want to animate do not trigger reflow/repaint (read CSS triggers for more information), we can move those sampling operations out of the main thread. The most common property is the CSS transform. If an element is promoted as a layer, animating transform properties can be done in the GPU, meaning better performance/efficiency, especially on mobile. Find out more details in OffMainThreadCompositing. https://developer.mozilla.org/en-US/docs/Web/Performance/CSSJavaScriptanimation_performance
测试 Demo 地点
https://jsbin.com/wodenoxaku/1/edit?html,css,output
看起来欣赏器并没有对 SVG 动画做这方面的优化,终究,我们修改了 Vue content loading 的实现,改为了利用 CSS 动画来实现闪耀的加载结果
<template>
<div :style="style">
<svg :viewBox="viewbox" preserveAspectRatio="xMidYMid meet">
<defs :key="uid">
<clipPath :id="clipPathId" :key="clipPathId">
<slot>
<rect x="0" y="0" rx="5" ry="5" width="70" height="70" />
<rect x="80" y="17" rx="4" ry="4" width="300" height="13" />
<rect x="80" y="40" rx="3" ry="3" width="250" height="10" />
<rect x="0" y="80" rx="3" ry="3" width="350" height="10" />
<rect x="0" y="100" rx="3" ry="3" width="400" height="10" />
<rect x="0" y="120" rx="3" ry="3" width="360" height="10" />
</slot>
</clipPath>
</defs>
</svg>
</div>
</template>
<script>
const validateColor = color =>
/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/.test(color);
export default {
name: 'VueContentLoading',
props: {
rtl: {
default: false,
type: Boolean,
},
speed: {
default: 2,
type: Number,
},
width: {
default: 400,
type: Number,
},
height: {
default: 130,
type: Number,
},
primary: {
type: String,
default: '#F0F0F0',
validator: validateColor,
},
secondary: {
type: String,
default: '#E0E0E0',
validator: validateColor,
},
uid: {
type: String,
required: true,
},
},
computed: {
viewbox() {
return `0 0 ${this.width} ${this.height}`;
},
formatedSpeed() {
return `${this.speed}s`;
},
clipPathId() {
return `clipPath-${this.uid || this._uid}`;
},
style() {
return {
width: `${this.width}px`,
height: `${this.height}px`,
backgroundSize: '200%',
backgroundImage: `linear-gradient(-90deg, ${this.primary} 0, ${this.secondary} 20%, ${this.primary} 50%, ${this.secondary} 75%, ${this.primary})`,
clipPath: 'url(#' + this.clipPathId + ')',
animation: `backgroundAnimation ${this.formatedSpeed} infinite linear`,
transform: this.rtl ? 'rotateY(180deg)' : 'none',
};
},
},
};
</script>
<style lang="scss">
@keyframes backgroundAnimation {
0% {
background-position-x: 100%;
}
50% {
background-position-x: 0;
}
100% {
background-position-x: -100%;
}
}
</style>
Vue SSR client side hydration 踩坑实践
一个例子
<template>
<div :id="id"> text: {{ id }}</div>
</template>
<script>
export default {
data () {
return {
id: Math.random()
}
}
}
</script>
client side hydration 的效果会是怎样呢?
- A. id 是 client 端随机数, text 是 client 端随机数
- B. id 是 client 端随机数, text 是 server 端随机数
- C. id 是 server 端随机数, text 是 client 端随机数
- D. id 是 server 端随机数, text 是 server 端随机数
为什么要问这个题目 ?
Vue content loading 内部依靠了 this._uid 来作为 svg defs 里的 clippath 的 id,然而 this._uid 在客户端和效劳端并不一样,现实跟上面随机数的例子差未几。
client side hydration 的效果是 C
也就是说 id 并没有改变,导致的征象在我们这个场景就是骨架屏闪了一下就没了
为什么会出现这个环境?
初始化 Vue 到终究渲染的整个过程

泉源:https://ustbhuangyi.github.io/vue-analysis/data-driven/update.html#%E6%80%BB%E7%BB%93
所谓客户端激活,指的是 Vue 在欣赏器端接收由效劳端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。
在 entry-client.js 中,我们用下面这行挂载(mount)运用英魂步伐:
// 这里假定 App.vue template 根元素的 `id="app"`
app.$mount('#app');
由于效劳器已渲染好了 HTML,我们显然无需将其抛弃再重新创建全部的 DOM 元素。相反,我们必要"激活"这些静态的 HTML,然后使他们成为动态的(可以或许相应后续的数据变革)。
假如你查抄效劳器渲染的输出效果,你会注重到运用英魂步伐的根元素上添加了一个特别的属性:
<div id="app" data-server-rendered="true"></div>
data-server-rendered 特别属性,让客户端 Vue 知道这部门 HTML 是由 Vue 在效劳端渲染的,而且应当以激活模式举行挂载。注重,这里并没有添加 id="app",而是添加 data-server-rendered 属性:你必要自行添加 ID 或其他可以或许选取到运用英魂步伐根元素的选择器,否则运用英魂步伐将无法正常激活。
注重,在没有 data-server-rendered 属性的元素上,还可以向 $mount 函数的 hydrating 参数位置传入 true,来逼迫利用激活模式(hydration):
// 逼迫利用运用英魂步伐的激活模式
app.$mount('#app', true);
在开辟模式下,Vue 将推断客户端天生的假造 DOM 树 (virtual DOM tree),是否与从效劳器渲染的 DOM 构造 (DOM structure) 匹配。假如无法匹配,它将退出混淆模式,抛弃现有的 DOM 并重新开始渲染。在生产模式下,此检测会被跳过,以制止性能消耗。
vue 对于 attrs,class,staticClass,staticStyle,key 这些是不处置惩罚的
list of modules that can skip create hook during hydration because they are already rendered on the client or has no need
uid 办理方案
根据组件天生唯一 UUID
- props 和 slot 转换为字符串
- hash 算法
太重了,放弃
终究办理方案
干脆让用户本身传 ID
<vue-content-loading
uid="circlesMediaSkeleton"
v-bind="$attrs"
:width="186"
:height="height"
>
<template v-for="i in rows">
<rect
:key="i + '_r'"
x="4"
:y="getYPos(i, 4)"
rx="2"
ry="2"
width="24"
height="24"
/>
<rect
:key="i + '_r'"
x="36"
:y="getYPos(i, 6)"
rx="3"
ry="3"
width="200"
height="18"
/>
</template>
</vue-content-loading>
优化结果
- 通过淘汰 fetch 阶段的数据拉取的使命,淘汰了数据拉取时间
- 同时淘汰了效劳端渲染的组件数和开销,收缩了首字节时间
- 首屏巨细变小也收缩了下载首屏所需的时间
综合起来,首字节、首屏时间都将提前,可交互时间也会提前
当地数据
范例
效劳相应时间 首页巨细 未 Gzip 首页修改前
0.88s
561 KB
首页(最小化 fetch 哀求) 0.58s
217 KB
在当地测试,效劳端渲染首页只哀求关键等效劳器接口哀求时,效劳相应时间收缩 0.30s,低落 34%,首页 html 文本巨细低落 344 KB,淘汰 60%
线上数据

首页的首屏可见时间中位数从 2-3s 低落到了 1.1s 摆布,加载速率提拔 100%+
总结
本文分享了怎样办理 SEO 和用户体验提拔之间存在抵牾的题目,先容了我们怎样鉴戒 Gitlab CI 的 pipeline 的概念,在效劳端渲染时分身首屏最小化和 SEO,分享了自顺应 SSR 的技能细节、计划思绪和在实行该方案过程中碰到的一些相干的子题目的实践踩坑履历,盼望对各人有所开导和资助。
关于我
binggg(Booker Zhao) @腾讯
- 先后就职于迅雷、腾讯等,个人开源项目有 mrn.js 等
- 开办了迅雷内部组件堆栈 XNPM ,到场几个迅雷前端开源项目标开辟
- 热衷于优化和提效,是一个奉行“懒惰使人进步”的懒人工程师
交际资料
- GitHub: https://github.com/binggg
- 简书: https://www.jianshu.com/u/60f22559b79f
- 掘金: https://juejin.im/user/58d31f130ce4630057edb3ba
- 微博: https://weibo.com/being99
- 思否: https://segmentfault.com/u/binggg
- 博客园: https://www.cnblogs.com/binggg/
- 开源中国: https://my.oschina.net/u/4217267
- 极术社区: https://aijishu.com/u/binggg
- 本日头条: https://www.toutiao.com/c/user/102306299647
- CSDN: https://blog.csdn.net/weixin_42541867
微信公众号 binggg_net, 欢送关注
说点什么...