目前项目采取 Nuxt SSR 来完成做事端渲染 ,为知足 SEO 需求,将非首屏内容也进行了要乞降做事端直出,导致首屏韶光变长(非首屏的资源要乞降组件的渲染都会带来额外开销)。对付海量的用户来说,少量的爬虫访问需求反而影响了正常用户的访问,导致 SEO 和用户体验提升存在很大的抵牾。
为理解决这个问题,我们设计和实践了自适应 SSR 方案,来同时知足这两种场景的需求。本日会分享这个方案的技能细节、设计思路以及在履行该方案过程中碰着的一些干系的子问题的实践踩坑履历,欢迎大家一起互换。

分享大纲

问题来源和背景
目前项目采取 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 有
approutestoreparamsqueryerrorredirectStage 的划分思路
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/102306299647CSDN: https://blog.csdn.net/weixin_42541867
微信"大众年夜众号 binggg_net, 欢迎关注
