目前项目采用NuxtSSR来完成服务端渲染,为满足SEO需求,将非首屏内容也进行了请求和服务端直出,导致首屏时间变长(非首屏的资源请求和组件的渲染都会带来额外开销)。对于海量的用户来说,少量的爬虫访问需求反而影响了正常用户的访问,导致SEO和用户体验提升存在很大的矛盾。
为了解决这个问题,我们设计和实践了自适应SSR方案,来同时满足这两种场景的需求。今天会分享这个方案的技术细节、设计思路以及在实施该方案过程中遇到的一些相关的子问题的实践踩坑经验,欢迎大家一起交流。
分享大纲
问题来源和背景问题解决思路自适应SSR方案介绍采用自适应SSR优化前后数据VueSSRclientsidehydration踩坑实践使用SVG生成骨架屏踩坑实践
问题来源和背景
目前项目采用NuxtSSR来完成服务端渲染,为满足SEO需求,将非首屏资源也进行了请求和服务端直出,导致首屏时间变长(非首屏的资源请求和组件的渲染都会带来额外开销)
优化前的加载流程图
目前我们的Nuxt项目采用fetch来实现SSR数据预取,fetch中会处理所有关键和非关键请求
Nuxt生命周期图
对于海量的用户来说,少量的爬虫访问需求反而影响了正常用户的访问,导致SEO和用户体验提升存在很大的矛盾。
为了解决这个问题,我们希望能区分不同的场景进行不同的直出,SEO场景全部直出,其他场景只直出最小化的首屏,非关键请求放在前端异步拉取
解决思路
计划通过统一的方式来控制数据加载,将数据加载由专门的插件来控制,插件会根据条件来选择性的加载数据,同时懒加载一部分数据
判断是SEO情况,fetch阶段执行所有的数据加载逻辑非SEO场景,fetch阶段只执行最小的数据加载逻辑,等到页面首屏直出后,通过一些方式来懒加载另一部分数据
优化后的项目影评页加载流程图
自适应SSR方案介绍
GitlabCIPipeline
自研NuxtFetchPipeline
借鉴GitlabCI持续集成的概念和流程,将数据请求设计为不同的阶段(Stage),每个阶段执行不同的异步任务(Job),所有的阶段组成了数据请求的管线(Pipeline)
预置的Stage
seoFetch:面向SEO渲染需要的job集合,一般要求是全部数据请求都需要,尽可能多的服务端渲染内容minFetch:首屏渲染需要的最小的job集合mounted:首屏加载完之后,在mounted阶段异步执行的job集合idle:空闲时刻才执行的job集合
每一个页面的都有一个NuxtFetchPipeline的实例来控制,NuxtFetchPipeline需要配置相应的job和stage,然后会自适应判断请求的类型,针对性的处理异步数据拉取:
如果是SEO场景,则只会执行seoFetch这个stage的job集合如果是真实用户访问,则会在服务端先执行minFetch这个stage的job集合,然后立即返回,客户端可以看到首屏内容及骨架屏,然后在首屏加载完之后,会在mounted阶段异步执行mountedstage的job集合,另外一些优先级更低的job,则会在idlestage也就是空闲的时候才执行。
NuxtFetchPipeline使用示例
page页面index.vue
importNuxtFetchPipeline,{
pipelineMixin,
adaptiveFetch,
}from'@/utils/nuxt-fetch-pipeline';
importpipelineConfigfrom'';
constnuxtFetchPipeline=newNuxtFetchPipeline(pipelineConfig);
exportdefault{
mixins:[pipelineMixin(nuxtFetchPipeline)],
fetch(context){
returnadaptiveFetch(nuxtFetchPipeline,context);
},
};
配置文件index.pipeline.config.js
exportdefault{
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})=>{
returnstore.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执行上下文和Nuxtfetch类似,而是通过一个context参数来访问一些状态,由于fetch阶段还没有组件实例,为了保持统一,都不可以通过this访问实例
目前支持的nuxtcontext有
approutestoreparamsqueryerrorredirect
Stage的划分思路
Stage
适合的Job
是否并行
seoFetch全部,SEO场景追求越多越好
最好并行
minFetch关键的,比如首屏内容、核心流程需要的数据,页面的主要核心内容(例如影评页面是影评的正文,短视频页面是短视频信息,帖子页面是帖子正文)的数据最好并行
mounted
次关键内容的数据,例如侧边栏,第二屏等
根据优先成都考虑是否并行
idle
最次要的内容的数据,例如页面底部,标签页被隐藏的部分
尽量分批进行,不影响用户的交互使用SVG生成骨架屏踩坑实践
由于服务端只拉取了关键数据,部分页面部分存在没有数据的情况,因此需要骨架屏来提升体验
VueContentLoading使用及原理
例子
[xss_clean]
importVueContentLoadingfrom'vue-content-loading';
exportdefault{
components:{
VueContentLoading,
},
};
[xss_clean]
VueContentLoading核心代码
:style="rect.style" :clip-path="rect.clipPath" x="0" y="0" :width="width" :height="height" /> attributeName="offset" values="-2; 1" :dur="formatedSpeed" repeatCount="indefinite" /> attributeName="offset" values="-1.5; 1.5" :dur="formatedSpeed" repeatCount="indefinite" /> attributeName="offset" values="-1; 2" :dur="formatedSpeed" repeatCount="indefinite" /> [xss_clean] constvalidateColor=color=> /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/.test(color); exportdefault{ 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`00${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; }, }; [xss_clean] SVG动画卡顿 使用了Vuecontentloading做骨架屏之后,发现在js加载并执行的时候动画会卡住,而CSS动画大部分情况下可以脱离主线程执行,可以避免卡顿 CSSanimationsarethebetterchoice.Buthow?Thekeyisthataslongasthepropertieswewanttoanimatedonottriggerreflow/repaint(readCSStriggersformoreinformation),wecanmovethosesamplingoperationsoutofthemainthread.ThemostcommonpropertyistheCSStransform.Ifanelementispromotedasalayer,animatingtransformpropertiescanbedoneintheGPU,meaningbetterperformance/efficiency,especiallyonmobile.FindoutmoredetailsinOffMainThreadCompositing. 测试Demo地址 看起来浏览器并没有对SVG动画做这方面的优化,最终,我们修改了Vuecontentloading的实现,改为了使用CSS动画来实现闪烁的加载效果 [xss_clean] constvalidateColor=color=> /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/.test(color); exportdefault{ 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`00${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}infinitelinear`, transform:this.rtl?'rotateY(180deg)':'none', }; }, }, }; [xss_clean] @keyframesbackgroundAnimation{ 0%{ background-position-x:100%; } 50%{ background-position-x:0; } 100%{ background-position-x:-100%; } } VueSSRclientsidehydration踩坑实践 一个例子 [xss_clean] exportdefault{ data(){ return{ id:Math.random() } } } [xss_clean] clientsidehydration的结果会是如何呢? A.id是client端随机数,text是client端随机数B.id是client端随机数,text是server端随机数C.id是server端随机数,text是client端随机数D.id是server端随机数,text是server端随机数 为什么要问这个问题? Vuecontentloading内部依赖了this._uid来作为svgdefs里的clippath的id,然而this._uid在客户端和服务端并不一样,实际跟上面随机数的例子差不多。 clientsidehydration的结果是C 也就是说id并没有改变,导致的现象在我们这个场景就是骨架屏闪了一下就没了 为什么会出现这个情况? 初始化Vue到最终渲染的整个过程 来源: 所谓客户端激活,指的是Vue在浏览器端接管由服务端发送的静态HTML,使其变为由Vue管理的动态DOM的过程。 在entry-client.js中,我们用下面这行挂载(mount)应用程序: //这里假定App.vuetemplate根元素的`id="app"` app.$mount('#app'); 由于服务器已经渲染好了HTML,我们显然无需将其丢弃再重新创建所有的DOM元素。相反,我们需要"激活"这些静态的HTML,然后使他们成为动态的(能够响应后续的数据变化)。 如果你检查服务器渲染的输出结果,你会注意到应用程序的根元素上添加了一个特殊的属性: 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树(virtualDOMtree),是否与从服务器渲染的DOM结构(DOMstructure)匹配。如果无法匹配,它将退出混合模式,丢弃现有的DOM并从头开始渲染。在生产模式下,此检测会被跳过,以避免性能损耗。 vue对于attrs,class,staticClass,staticStyle,key这些是不处理的 listofmodulesthatcanskipcreatehookduringhydrationbecausetheyarealreadyrenderedontheclientorhasnoneed uid解决方案 根据组件生成唯一UUID props和slot转换为字符串hash算法 太重了,放弃 最终解决方案 干脆让用户自己传ID uid="circlesMediaSkeleton" v-bind="$attrs" :width="186" :height="height" > :key="i + '_r'" x="4" :y="getYPos(i, 4)" rx="2" ry="2" width="24" height="24" /> :key="i + '_r'" x="36" :y="getYPos(i, 6)" rx="3" ry="3" width="200" height="18" /> 优化效果 通过减少fetch阶段的数据拉取的任务,减少了数据拉取时间同时减少了服务端渲染的组件数和开销,缩短了首字节时间首屏大小变小也缩短了下载首屏所需的时间 综合起来,首字节、首屏时间都将提前,可交互时间也会提前 本地数据 类型 服务响应时间首页大小未Gzip首页修改前 0.88s 561KB 首页(最小化fetch请求)0.58s 217KB 在本地测试,服务端渲染首页只请求关键等服务器接口请求时,服务响应时间缩短0.30s,降低34%,首页html文本大小降低344KB,减少60% 线上数据 首页的首屏可见时间中位数从2-3s降低到了1.1s左右,加载速度提升100%+ 总结 本文分享了如何解决SEO和用户体验提升之间存在矛盾的问题,介绍了我们如何借鉴GitlabCI的pipeline的概念,在服务端渲染时兼顾首屏最小化和SEO,分享了自适应SSR的技术细节、设计思路以及在实施该方案过程中遇到的一些相关的子问题的实践踩坑经验,希望对大家有所启发和帮助。 关于我 binggg(BookerZhao)@腾讯 -先后就职于迅雷、腾讯等,个人开源项目有mrn.js等 -创办了迅雷内部组件仓库XNPM,参与几个迅雷前端开源项目的开发 -热衷于优化和提效,是一个奉行“懒惰使人进步”的懒人工程师 社交资料 GitHub:简书:掘金:微博:思否:博客园:开源中国:极术社区:今日头条: 微信公众号binggg_net,欢迎关注