Nuxt 自適應 SSR 方案: SEO 和首屏最小化優化

20190922173229.png

目前項目採用 Nuxt SSR 來完成服務端渲染 ,爲知足 SEO 需求,將非首屏內容也進行了請求和服務端直出,致使首屏時間變長(非首屏的資源請求和組件的渲染都會帶來額外開銷)。對於海量的用戶來講,少許的爬蟲訪問需求反而影響了正經常使用戶的訪問,致使 SEO 和用戶體驗提高存在很大的矛盾。css

爲了解決這個問題,咱們設計和實踐了自適應 SSR 方案,來同時知足這兩種場景的需求。今天會分享這個方案的技術細節、設計思路以及在實施該方案過程當中遇到的一些相關的子問題的實踐踩坑經驗,歡迎你們一塊兒交流。html

分享大綱

  • 問題來源和背景
  • 問題解決思路
  • 自適應 SSR 方案介紹
  • 採用自適應 SSR 優化先後數據
  • Vue SSR client side hydration 踩坑實踐
  • 使用 SVG 生成骨架屏踩坑實踐

問題來源和背景

目前項目採用 Nuxt SSR 來完成服務端渲染,爲知足 SEO 需求,將非首屏資源也進行了請求和服務端直出,致使首屏時間變長(非首屏的資源請求和組件的渲染都會帶來額外開銷)前端

優化前的加載流程圖

20190808160403.png

目前咱們的 Nuxt 項目採用 fetch 來實現 SSR 數據預取,fetch 中會處理全部關鍵和非關鍵請求vue

Nuxt 生命週期圖

20190808160623.png

對於海量的用戶來講,少許的爬蟲訪問需求反而影響了正經常使用戶的訪問,致使 SEO 和用戶體驗提高存在很大的矛盾。git

爲了解決這個問題,咱們但願能區分不一樣的場景進行不一樣的直出,SEO 場景所有直出,其餘場景只直出最小化的首屏,非關鍵請求放在前端異步拉取github

解決思路

計劃經過統一的方式來控制數據加載,將數據加載由專門的插件來控制,插件會根據條件來選擇性的加載數據,同時懶加載一部分數據算法

  • 判斷是 SEO 狀況,fetch 階段執行全部的數據加載邏輯
  • 非 SEO 場景,fetch 階段只執行最小的數據加載邏輯,等到頁面首屏直出後,經過一些方式來懶加載另外一部分數據

優化後的項目影評頁加載流程圖

20190808162208.png

自適應 SSR 方案介紹

Gitlab CI Pipeline

20190808160912.png

自研 Nuxt Fetch Pipeline

借鑑 Gitlab CI 持續集成的概念和流程,將數據請求設計爲不一樣的階段 (Stage ),每一個階段執行不一樣的異步任務(Job),全部的階段組成了數據請求的管線(Pipeline)segmentfault

預置的 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 }) =&gt {
        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 有

  • app
  • route
  • store
  • params
  • query
  • error
  • redirect

Stage 的劃分思路

Stage 適合的 Job 是否並行
seoFetch 所有,SEO 場景追求越多越好 最好並行
minFetch 關鍵的,好比首屏內容、核心流程須要的數據,頁面的主要核心內容(例如影評頁面是影評的正文,短視頻頁面是短視頻信息,帖子頁面是帖子正文)的數據 最好並行
mounted 次關鍵內容的數據,例如側邊欄,第二屏等 根據優先成都考慮是否並行
idle 最次要的內容的數據,例如頁面底部,標籤頁被隱藏的部分 儘可能分批進行,不影響用戶的交互

使用 SVG 生成骨架屏踩坑實踐

因爲服務端只拉取了關鍵數據,部分頁面部分存在沒有數據的狀況,所以須要骨架屏來提高體驗

20190808163542.png

20190808163628.png

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...

測試 Demo 地址

https://jsbin.com/wodenoxaku/...

看起來瀏覽器並無對 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 到最終渲染的整個過程
20190808172826.png

來源:https://ustbhuangyi.github.io...

所謂客戶端激活,指的是 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 屬性的元素上,還能夠向 &dollar;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%

線上數據

file

首頁的首屏可見時間中位數從 2-3s 下降到了 1.1s 左右,加載速度提高 100%+

總結

本文分享瞭如何解決 SEO 和用戶體驗提高之間存在矛盾的問題,介紹了咱們如何借鑑 Gitlab CI 的 pipeline 的概念,在服務端渲染時兼顧首屏最小化和 SEO,分享了自適應 SSR 的技術細節、設計思路以及在實施該方案過程當中遇到的一些相關的子問題的實踐踩坑經驗,但願對你們有所啓發和幫助。


關於我

binggg(Booker Zhao) @騰訊

- 前後就任於迅雷、騰訊等,我的開源項目有 mrn.js 等
- 創辦了迅雷內部組件倉庫 XNPM ,參與幾個迅雷前端開源項目的開發
- 熱衷於優化和提效,是一個奉行「懶惰令人進步」的懶人工程師

社交資料

微信公衆號 binggg_net, 歡迎關注

相關文章
相關標籤/搜索