首发于 Web开发
Nuxt 自适应 SSR 方案: SEO 和首屏最小化优化

Nuxt 自适应 SSR 方案: SEO 和首屏最小化优化

之前的项目采用 Nuxt SSR 来完成服务端渲染 ,为满足 SEO 需求,将非首屏内容也进行了请求和服务端直出,导致首屏时间变长(非首屏的资源请求和组件的渲染都会带来额外开销)。对于海量的用户来说,少量的爬虫访问需求反而影响了正常用户的访问,导致 SEO 和用户体验提升存在很大的矛盾。

为了解决这个问题,我们设计和实践了自适应 SSR 方案,来同时满足这两种场景的需求。今天会分享这个方案的技术细节、设计思路以及在实施该方案过程中遇到的一些相关的子问题的实践踩坑经验,欢迎大家一起交流。

分享大纲

问题来源和背景

目前项目采用 Nuxt SSR 来完成服务端渲染,为满足 SEO 需求,将非首屏资源也进行了请求和服务端直出,导致首屏时间变长(非首屏的资源请求和组件的渲染都会带来额外开销)

优化前的加载流程图



目前我们的 Nuxt 项目采用 fetch 来实现 SSR 数据预取,fetch 中会处理所有关键和非关键请求

Nuxt 生命周期图



对于海量的用户来说,少量的爬虫访问需求反而影响了正常用户的访问,导致 SEO 和用户体验提升存在很大的矛盾。

为了解决这个问题,我们希望能区分不同的场景进行不同的直出,SEO 场景全部直出,其他场景只直出最小化的首屏,非关键请求放在前端异步拉取

解决思路

计划通过统一的方式来控制数据加载,将数据加载由专门的插件来控制,插件会根据条件来选择性的加载数据,同时懒加载一部分数据

优化后的项目影评页加载流程图



自适应 SSR 方案介绍

Gitlab CI Pipeline



自研 Nuxt Fetch Pipeline

借鉴 Gitlab CI 持续集成的概念和流程,将数据请求设计为不同的阶段 (Stage ),每个阶段执行不同的异步任务(Job),所有的阶段组成了数据请求的管线(Pipeline)

预置的 Stage

每一个页面的都有一个 Nuxt Fetch Pipeline 的实例来控制,Nuxt Fetch Pipeline 需要配置相应的 job 和 stage,然后会自适应判断请求的类型,针对性的处理异步数据拉取:

Nuxt Fetch Pipeline 使用示例

page 页面 index.vue

配置文件 index.pipeline.config.js

并发控制

Stage 执行 Job 支持并行和串行 Stage 配置 type 为 parallel 时为并行处理,会同时开始每一个 job 等待所有的 job 完成后,这个 stage 才完成 Stage 配置 type 为 serial 时为串行处理,会依次开始每一个 job,前一个 job 完成后,后面的 job 才开始,最后一个 job 完成后,这个 stage 才完成

Job 嵌套

可以将一些可以复用的 job 定义为自定义的 stage,然后,在其他的 Stage 里按照如下的方式来引用,减少编码的成本

Job 的执行上下文

为了方便编码,以及减少改动成本,每一个 job 执行上下文和 Nuxt fetch 类似,而是通过一个 context 参数来访问一些状态,由于 fetch 阶段还没有组件实例,为了保持统一,都不可以通过 this 访问实例

目前支持的 nuxt context 有

Stage 的划分思路

Stage适合的 Job是否并行seoFetch全部,SEO 场景追求越多越好最好并行minFetch关键的,比如首屏内容、核心流程需要的数据,页面的主要核心内容(例如影评页面是影评的正文,短视频页面是短视频信息,帖子页面是帖子正文)的数据最好并行mounted次关键内容的数据,例如侧边栏,第二屏等根据优先成都考虑是否并行idle最次要的内容的数据,例如页面底部,标签页被隐藏的部分尽量分批进行,不影响用户的交互

使用 SVG 生成骨架屏踩坑实践

由于服务端只拉取了关键数据,部分页面部分存在没有数据的情况,因此需要骨架屏来提升体验





Vue Content Loading 使用及原理

例子

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. developer.mozilla.org/e

测试 Demo 地址

jsbin.com/wodenoxaku/1/

看起来浏览器并没有对 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 踩坑实践

一个例子

client side hydration 的结果会是如何呢?

为什么要问这个问题 ?

Vue content loading 内部依赖了 this._uid 来作为 svg defs 里的 clippath 的 id,然而 this._uid 在客户端和服务端并不一样,实际跟上面随机数的例子差不多。

client side hydration 的结果是 C

也就是说 id 并没有改变,导致的现象在我们这个场景就是骨架屏闪了一下就没了

为什么会出现这个情况?

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


来源: ustbhuangyi.github.io/v

所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。

在 entry-client.js 中,我们用下面这行挂载(mount)应用程序:

由于服务器已经渲染好了 HTML,我们显然无需将其丢弃再重新创建所有的 DOM 元素。相反,我们需要"激活"这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)。

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

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

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

在开发模式下,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

太重了,放弃

最终解决方案

干脆让用户自己传 ID

优化效果

综合起来,首字节、首屏时间都将提前,可交互时间也会提前

本地数据

类型服务响应时间首页大小 未 Gzip首页修改前0.88s561 KB首页(最小化 fetch 请求)0.58s217 KB

在本地测试,服务端渲染首页只请求关键等服务器接口请求时,服务响应时间缩短 0.30s降低 34%,首页 html 文本大小降低 344 KB,减少 60%

线上数据



首页的首屏可见时间中位数从 2-3s 降低到了 1.1s 左右,加载速度提升 100%+

总结

本文分享了如何解决 SEO 和用户体验提升之间存在矛盾的问题,介绍了我们如何借鉴 Gitlab CI 的 pipeline 的概念,在服务端渲染时兼顾首屏最小化和 SEO,分享了自适应 SSR 的技术细节、设计思路以及在实施该方案过程中遇到的一些相关的子问题的实践踩坑经验,希望对大家有所启发和帮助。




关于我

社交资料

微信公众号 binggg_net, 欢迎关注

代做工资流水公司嘉兴入职银行流水模板南通收入证明制作衡阳背调流水开具宁德查询车贷银行流水苏州代做工资代付流水肇庆银行流水修改代办扬州银行流水PS公司沧州代办流水账单长春打银行流水修改济宁企业对私流水查询太原打车贷流水唐山代开企业贷流水深圳银行流水电子版模板南京打印贷款流水郑州查询个人银行流水江门对公流水打印株洲企业贷流水样本盐城对公银行流水公司兰州签证流水模板淮安查询银行流水电子版上饶银行流水账单报价福州制作流水单银川打印企业银行流水肇庆签证银行流水 图片淮安银行流水电子版查询唐山查流水单绵阳薪资银行流水样本保定银行流水单图片长春入职流水查询无锡在职证明公司香港通过《维护国家安全条例》两大学生合买彩票中奖一人不认账让美丽中国“从细节出发”19岁小伙救下5人后溺亡 多方发声卫健委通报少年有偿捐血浆16次猝死汪小菲曝离婚始末何赛飞追着代拍打雅江山火三名扑火人员牺牲系谣言男子被猫抓伤后确诊“猫抓病”周杰伦一审败诉网易中国拥有亿元资产的家庭达13.3万户315晚会后胖东来又人满为患了高校汽车撞人致3死16伤 司机系学生张家界的山上“长”满了韩国人?张立群任西安交通大学校长手机成瘾是影响睡眠质量重要因素网友洛杉矶偶遇贾玲“重生之我在北大当嫡校长”单亲妈妈陷入热恋 14岁儿子报警倪萍分享减重40斤方法杨倩无缘巴黎奥运考生莫言也上北大硕士复试名单了许家印被限制高消费奥巴马现身唐宁街 黑色着装引猜测专访95后高颜值猪保姆男孩8年未见母亲被告知被遗忘七年后宇文玥被薅头发捞上岸郑州一火锅店爆改成麻辣烫店西双版纳热带植物园回应蜉蝣大爆发沉迷短剧的人就像掉进了杀猪盘当地回应沈阳致3死车祸车主疑毒驾开除党籍5年后 原水城县长再被查凯特王妃现身!外出购物视频曝光初中生遭15人围殴自卫刺伤3人判无罪事业单位女子向同事水杯投不明物质男子被流浪猫绊倒 投喂者赔24万外国人感慨凌晨的中国很安全路边卖淀粉肠阿姨主动出示声明书胖东来员工每周单休无小长假王树国卸任西安交大校长 师生送别小米汽车超级工厂正式揭幕黑马情侣提车了妈妈回应孩子在校撞护栏坠楼校方回应护栏损坏小学生课间坠楼房客欠租失踪 房东直发愁专家建议不必谈骨泥色变老人退休金被冒领16年 金额超20万西藏招商引资投资者子女可当地高考特朗普无法缴纳4.54亿美元罚金浙江一高校内汽车冲撞行人 多人受伤

代做工资流水公司 XML地图 TXT地图 虚拟主机 SEO 网站制作 网站优化