×

Nuxt SSR 优化 SEO

Nuxt SSR优化策略:SEO与首屏性能的平衡解决方案

元智汇电子 元智汇电子 发表于2023-11-11 17:04:35 浏览677 评论0

抢沙发发表评论

问题背景及来源:

当前项目采用Nuxt SSR进行服务端渲染,为满足SEO需求,所有页面资源都进行了服务端直出,导致首屏加载时间增加。对于大量用户,爬虫访问需求占比较小,但却影响了正常用户的访问,产生了SEO与用户体验提升的矛盾。


解决思路:

为了解决这一问题,我们设计并实践了一种自适应SSR方案,旨在同时满足SEO和正常用户的需求。在分享中,我们将探讨该方案的技术细节、设计思路,以及在实施过程中遇到的一些子问题及解决方案。


分享大纲:

  • 问题背景和来源

  • 解决思路

  • 自适应SSR方案介绍

  • 采用自适应SSR优化前后数据

  • Vue SSR客户端水合实践

  • 使用SVG生成骨架屏实践


问题背景及优化前状况:

当前项目中使用Nuxt SSR进行服务端渲染,为了满足SEO需求,我们对非首屏资源进行了请求和服务端直出,然而这一做法导致了首屏加载时间的延长。这是因为非首屏的资源请求和组件渲染都引入了额外的开销。


加载流程优化前的图示:

image.png


目前我们的 Nuxt 项目使用 fetch 进行 SSR 数据预取,fetch 处理了所有关键和非关键请求。


Nuxt 生命周期示意图:




image.png


针对大量用户,爬虫的访问需求对正常用户的体验和SEO效果形成了矛盾。


为了解决这一问题,我们设想通过区分不同场景来实现不同的直出策略:对于SEO场景,全部直出;而其他场景则仅在首屏直出最小化内容,非关键请求则在前端进行异步拉取。


解决方案的核心思路在于通过一种一致的方式来管理数据加载。我们计划引入专门的插件来负责控制数据加载,根据条件有选择性地加载数据,并在首屏渲染后采取一些方法来懒加载其他数据。


具体而言:


在SEO场景下,我们会在fetch阶段执行所有的数据加载逻辑,以确保满足搜索引擎的需求。


对于非SEO场景,我们会在fetch阶段仅执行最小化的数据加载逻辑,待页面完成首屏直出后,通过一些手段来懒加载其他数据。


下方是优化后项目影评页加载流程的示意图。


image.png


自适应 SSR 方案介绍


Gitlab CI Pipeline


image.png


自主开发的 Nuxt 数据获取管道


灵感来源于 Gitlab CI 持续集成的理念和流程。我们将数据请求设计成不同阶段(Stage),每个阶段执行不同的异步任务(Job),所有这些阶段构成了数据请求的管道(Pipeline)。


预定义的阶段有:


  • seoFetch: 针对SEO渲染,包含所有数据请求,通常要求服务端渲染尽可能多的内容。

  • minFetch: 针对首屏渲染的最小化任务集合。

  • mounted: 在首屏加载完成后,在mounted阶段异步执行的任务集合。

  • idle: 仅在空闲时刻执行的任务集合。


每个页面都有一个专属的 Nuxt Fetch Pipeline 实例来管理。配置相应的 job 和 stage 后,该管道会自适应判断请求类型,并有针对性地处理异步数据拉取:


对于SEO场景,只执行seoFetch阶段的任务集合。


对于真实用户访问,会在服务端先执行minFetch阶段的任务集合,然后立即返回,客户端能够看到首屏内容和骨架屏。首屏加载完毕后,会在mounted阶段异步执行mounted阶段的任务集合。其他优先级较低的任务会在idle阶段,即空闲时刻执行。


以下是 Nuxt Fetch Pipeline 的使用示例。

image.png

配置文件 index.pipeline.config.js

image.png


并发控制和任务嵌套


对于 Stage 执行 Job,我们支持并行和串行处理。


当 Stage 配置的类型为 parallel 时,即并行处理,每个 job 会同时开始执行,等待所有的 job 完成后,这个 stage 才算完成。


当 Stage 配置的类型为 serial 时,即串行处理,每个 job 会依次开始执行,前一个 job 完成后,后面的 job 才开始。最后一个 job 完成后,这个 stage 才算完成。


另外,我们引入了任务嵌套的概念,可以将一些可复用的 job 定义为自定义的 stage。然后,在其他的 Stage 里按照以下方式引用,以减少编码的成本。

image.png


这种嵌套结构允许将一系列任务组合在一起,使得配置更为灵活,减少了编码的冗余。



任务执行上下文


为了简化编码并降低改动成本,每个任务的执行上下文类似于 Nuxt fetch,都通过一个 context 参数来访问各种状态。由于在 fetch 阶段组件实例尚未创建,为了保持一致性,我们不能通过 this 访问实例。


目前,支持以下 Nuxt 上下文信息:

  • app

  • route

  • store

  • params

  • query

  • error

  • redirect

通过这些上下文信息,我们能够方便地在任务中获取各种状态,使得任务的编写更为简便。


Stage 的划分思路

image.png

SVG骨架屏的实际应用


在服务端只获取了关键数据的情况下,部分页面可能存在数据缺失的问题,为了改善用户体验,我们使用SVG骨架屏。


SVG骨架屏是一种通过渲染轮廓形状来展示页面结构的方法。当页面的某些部分尚未加载完成时,骨架屏可以提供一种视觉上的反馈,让用户知道内容即将到来。


为了实现这一点,我们使用了SVG(可缩放矢量图形)来生成轮廓形状,其中包括页面中各个元素的简化表示。这种方法在服务端渲染的情况下特别有用,因为它可以在客户端加载之前提供一些页面结构的可视化。


通过采用SVG骨架屏,我们改善了用户在等待页面加载时的体验,使其感知到页面正在加载,同时增强了整体页面的可读性

image.png

Vue Content Loading 使用及原理

举例:

image.png

Vue Content Loading 核心代码

image.png

image.png

image.png

优化SVG动画性能


在使用Vue content loading创建骨架屏后,我们注意到在JavaScript加载和执行过程中,动画可能会出现卡顿。相比之下,CSS动画在大多数情况下可以脱离主线程执行,从而避免卡顿的问题。


选择CSS动画的关键在于,只要我们要动画的属性不触发回流/重绘(请参阅CSS触发器以获取更多信息),我们就可以将这些采样操作移到主线程之外。其中,最常见的属性之一是CSS变换。如果将元素提升为层级,就可以在GPU上执行变换属性的动画,这意味着更好的性能和效率,特别是在移动设备上。在OffMainThreadCompositing中可以找到更多详细信息。developer.mozilla.org/en-US/docs/…。


我们进行了一些测试,并发现浏览器似乎并未对SVG动画进行这方面的优化。因此,我们最终决定修改Vue content loading的实现,改用CSS动画来实现闪烁的加载效果。这一调整不仅提高了性能,还改善了用户在等待页面加载时的流畅性。


image.png

image.png

Vue SSR client side hydration 踩坑实践

举例:

image.png

客户端混合的效果是什么呢?


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没有改变,这导致的结果在我们的场景中是骨架屏一闪而过。

为什么会出现这种情况?

Vue从初始化到最终渲染的整个过程。


image.png

来源:ustbhuangyi.github.io/vue-analysi…


所谓客户端激活是指,Vue在浏览器端接管了由服务端发送的静态HTML,将其转变为由Vue管理的动态DOM的过程。在entry-client.js中,我们使用以下代码对应用程序进行挂载(mount):

image.png


因为服务器已经成功渲染了HTML,我们无需将其丢弃并重新创建所有的DOM元素。相反,我们需要对这些静态的HTML进行"激活",使其成为动态的,能够响应后续的数据变化。


如果你查看服务器渲染的输出结果,你会发现在应用程序的根元素上添加了一个特殊的属性:


image.png

data-server-rendered 是一种特殊属性,它告诉客户端的 Vue,这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。值得注意的是,并没有添加 id="app",而是增加了 data-server-rendered 属性。你需要自行添加 ID 或其他能够选取到应用程序根元素的选择器,否则应用程序将无法正常激活。


需要注意的是,在没有 data-server-rendered 属性的元素上,你还可以通过向 $mount 函数的 hydrating 参数位置传入 true,来强制使用激活模式(hydration):


image.png


在开发模式下,Vue 会检测客户端生成的虚拟 DOM 树是否与从服务器渲染的 DOM 结构匹配。如果无法匹配,它会退出混合模式,丢弃现有的 DOM 并从头开始渲染。但在生产模式下,为了避免性能损耗,这种检测会被跳过。


Vue 不会处理 attrs、class、staticClass、staticStyle、key 等属性。还有一些模块在混合过程中可以跳过创建钩子,因为它们已经在客户端渲染或者不需要。


关于 uid 的解决方案有多种,例如根据组件生成唯一的 UUID,将 props 和 slot 转换为字符串,使用哈希算法等。但有时候这些方法可能过于繁重,因此最终解决方案可能是让用户自己传入 ID。

image.png

优化结果

通过优化 fetch 阶段的数据拉取任务,成功降低了数据拉取时间。同时,减少了服务端渲染的组件数量和相关开销,有效缩短了首字节时间。减小了首屏的体积,从而缩短了下载首屏所需的时间。总体而言,首字节和首屏时间都得到了提前,用户可交互时间也相应提前。


image.png

本地测试显示,当服务端渲染首页仅请求关键的服务器接口时,服务响应时间减少了0.30秒,降低了34%。首页 HTML 文本大小也减少了344 KB,减少了60%。


image.png

首页首屏可见时间中位数从2-3秒降低到了约1.1秒,加载速度提升超过100%。


总结

本文分享了解决 SEO 与提升用户体验之间矛盾的方法,详细介绍了如何借鉴 Gitlab CI 的 pipeline 概念,在服务端渲染中同时关注首屏最小化和 SEO,分享了自适应 SSR 的技术细节和设计思路,以及在实施过程中遇到的一些问题和踩坑经验。希望这些内容能为大家提供启发和帮助。



群贤毕至

访客