目前我们采用 Nuxt SSR 完成服务端渲染,以满足 SEO 需求,但将非首屏内容也进行了请求和服务端直出,导致首屏时间延长。为解决这一问题,我们设计并实践了一种自适应 SSR 策略,旨在同时满足 SEO 和用户体验需求。本文将分享该方案的技术细节、设计思路,以及在实施过程中遇到的相关子问题及解决经验。
分享大纲:
1. 问题来源与背景
Nuxt SSR 的当前挑战
长首屏时间对用户体验和SEO的影响
2. 问题解决思路
自适应 SSR 的设计理念
区分不同场景的直出需求
3. 自适应 SSR 策略介绍
统一控制数据加载
插件控制加载条件,实现选择性数据加载
最小化首屏加载,延迟加载非关键数据
4. 采用自适应 SSR 优化前后数据
优化前加载流程对比
优化后的性能改进
5. Vue SSR 客户端注水踩坑实践
处理客户端 hydration 的经验教训
6. 使用 SVG 生成骨架屏踩坑实践
实践中遇到的问题及解决方法
一、问题来源与背景
目前,我们选择使用Nuxt SSR进行服务端渲染,以满足SEO的需求,使得除首屏外的资源也在请求和服务端直出,导致首屏加载时间变长(非首屏资源请求和组件渲染都带来额外开销)。
加载优化前的流程图:
当前,我们的Nuxt项目使用fetch来实现SSR数据预取,fetch阶段处理所有关键和非关键请求,如Nuxt生命周期图所示。
对于大量用户而言,爬虫的访问需求虽然较少,却对正常用户的访问产生负面影响,导致SEO和用户体验的提升存在明显矛盾。
为解决这一问题,我们计划区分不同场景进行差异化直出,即在SEO场景下全部进行直出,在其他场景下仅直出极小化的首屏内容,非关键请求则在前端通过异步方式拉取。
二、问题解决思路
我们计划通过一致的方法来管理数据加载,将数据加载的掌控权交由专门的插件,该插件将根据特定条件有选择性地进行数据加载,并同时实现对一部分数据的懒加载。
具体实施方案如下:
在判断为SEO情况下,在fetch阶段执行所有数据加载逻辑,以满足SEO需求。
非SEO场景下,在fetch阶段仅执行最小化的数据加载逻辑,等待页面首屏直出后,通过特定方式实现对另一部分数据的懒加载。
为了清晰呈现项目影评页加载的优化后流程,我们将通过流程图展示具体的改进。
三、自适应 SSR 方案概述
Gitlab CI Pipeline 启发
我们构建了一套自研的 Nuxt Fetch Pipeline,灵感来源于Gitlab CI持续集成的概念和流程。在这个方案中,将数据请求划分为不同阶段(Stage),每个阶段执行不同的异步任务(Job),这些阶段共同组成了数据请求的Pipeline。
预定义的阶段:
seoFetch: 针对SEO渲染的任务集合,通常需要全部数据请求,以服务端渲染尽可能多的内容。
minFetch: 针对首屏渲染的最小任务集合。
mounted: 页面首屏加载完成后,在mounted阶段异步执行的任务集合。
idle: 在空闲时刻执行的任务集合。
每个页面都有一个 Nuxt Fetch Pipeline 的实例来进行控制。该Pipeline需要配置相应的任务和阶段,然后会自适应判断请求的类型,有针对性地处理异步数据拉取:
如果是SEO场景,仅会执行seoFetch阶段的任务集合。
如果是真实用户访问,则会在服务端先执行minFetch阶段的任务集合,然后立即返回,客户端可见首屏内容及骨架屏。随后,在首屏加载完毕后,会在mounted阶段异步执行mounted阶段的任务集合。一些优先级较低的任务则会在idle阶段,即空闲时执行。
Nuxt Fetch Pipeline 使用示例
页面 index.vue
配置文件 index.pipeline.config.js
并发控制
在该自适应 SSR 方案中,Stage 执行 Job 支持并行和串行两种模式。当 Stage 的配置中 type 为 parallel 时,表示为并行处理,即会同时开始每一个 job,等待所有的 job 完成后,该 stage 才会完成。而当 type 为 serial 时,表示为串行处理,依次开始每一个 job,前一个 job 完成后,后面的 job 才开始,最后一个 job 完成后,该 stage 才完成。
Job 嵌套
为了提高代码的复用性,可以将一些可复用的 job 定义为自定义的 stage。然后,在其他的 stage 中按照以下方式引用,以降低编码成本。
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应用
由于服务端只拉取了关键数据,某些页面存在部分缺乏数据的情况,因此我们采用骨架屏来提升用户体验。
Vue Content Loading的使用与原理
例子
Vue Content Loading核心代码
五、解决SVG动画卡顿问题
在使用Vue Content Loading制作骨架屏时,我们发现在JavaScript加载并执行时,动画会出现卡顿的情况。相比之下,CSS动画大多数情况下可以脱离主线程执行,从而避免卡顿。
CSS动画是更好的选择。关键在于只要我们要动画的属性不会触发重排/重绘(详细信息请参阅CSS触发器),我们就可以将这些取样操作移出主线程。最常见的属性之一是CSS transform。如果将元素提升为层,可以在GPU中执行变换属性的动画,这意味着更好的性能和效率,尤其是在移动设备上。有关更多详细信息,请参阅OffMainThreadCompositing
我们进行了一些测试,发现浏览器似乎并没有对SVG动画进行此类优化。因此,我们最终修改了Vue Content Loading的实现,改为使用CSS动画来实现闪烁的加载效果。
改进后的代码:
六、Vue SSR客户端混合(client-side hydration)问题实践
考虑以下Vue组件的情况:
在进行客户端混合时,最终的结果是什么呢?
A. id是客户端随机数,text是客户端随机数。
B. id是客户端随机数,text是服务器端随机数。
C. id是服务器端随机数,text是客户端随机数。
D. id是服务器端随机数,text是服务器端随机数。
问题背景和原因:
在使用Vue Content Loading时,内部依赖了this._uid作为SVG <defs>中<clipPath>的id。然而,this._uid在客户端和服务端并不一样,类似于上面随机数的例子。
结果解释:
在客户端混合时,最终的结果是C,即id是服务器端随机数,text是客户端随机数。这导致的问题是骨架屏在客户端混合后会闪烁一下然后消失。
为什么会出现这个情况?
这涉及到Vue应用程序从初始化到最终渲染的整个过程。在entry-client.js中,通过app.$mount('#app')挂载应用程序。由于服务器已经渲染好了HTML,我们不需要丢弃并重新创建所有的DOM元素,而是需要激活这些静态的HTML,使其成为动态的,能够响应后续的数据变化。
在服务器端渲染的输出结果中,应用程序的根元素上会添加一个特殊的属性data-server-rendered="true",这让客户端的Vue知道这部分HTML是由Vue在服务端渲染的,并应该以激活模式进行挂载。
在开发模式下,Vue会检查客户端生成的虚拟DOM树是否与从服务器渲染的DOM结构匹配。如果无法匹配,它将退出混合模式,丢弃现有的DOM并从头开始渲染。在生产模式下,此检测会被跳过,以避免性能损耗。
值得注意的是,Vue对于attrs、class、staticClass、staticStyle、key等属性是不处理的。同时,有一些模块在混合过程中可以跳过创建钩子,因为它们已经在客户端渲染或者不需要处理。
解决uid的方案:
根据组件生成唯一UUID: 一种解决方案是通过组件自身的一些特征生成唯一的UUID。这可以通过将props和slot转换为字符串,并应用哈希算法来实现。然而,这可能会导致额外的复杂性和性能开销,因此可能不是最佳选择。
Hash算法: 另一种尝试是使用Hash算法来生成唯一标识。但由于算法的复杂性和可能的性能问题,最终可能被认为是过于繁琐,不够优雅。
最终解决方案: 一种更简单的解决方案是让用户自己传递ID。通过在vue-content-loading组件上设置uid属性,用户可以自定义唯一标识符。这种方法既简单又直接,避免了复杂的计算和潜在的性能问题。
最终,通过让用户自己传递ID,可以更轻松地实现唯一性,同时保持代码的清晰和简洁。用户可以根据自己的需要定义和管理唯一标识符,使整体方案更加灵活和易用。
通过优化数据拉取阶段,成功减少了数据拉取时间。同时,我们还降低了服务端渲染的组件数量和相关开销,有效缩短了首字节时间。这项优化也导致首屏的大小减小,从而进一步缩短了下载首屏所需的时间。
总体而言,首字节和首屏时间都得到了提前,可交互时间也会随之提前。
以下是在本地测试中的一些性能指标对比:
在服务端渲染首页只请求关键服务器接口时,服务响应时间缩短了0.30s,降低了34%。同时,首页HTML文本大小降低了344 KB,减少了60%。
这些优化的效果在提升用户体验和性能方面都取得了显著的成果。
线上数据显示,首页的首屏可见时间中位数从2-3秒降低到了大约1.1秒,加载速度提升了100%以上。
综合总结,本文分享了解决SEO和用户体验提升之间矛盾问题的方法。我们介绍了如何借鉴Gitlab CI的pipeline概念,在服务端渲染时同时考虑了首屏最小化和SEO的需求。文章还分享了自适应SSR的技术细节、设计思路以及在实施该方案过程中遇到的一些相关子问题的实践经验。希望这些内容对读者有所启发和帮助。