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

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

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

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

分享大綱

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

問題來源和背景

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

優化前的加載流程圖

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

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

Nuxt 生命週期圖

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

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

為了解決這個問題,我們希望能區分不同的場景進行不同的直出,SEO 場景全部直出,其他場景只直出最小化的首屏,非關鍵請求放在前端異步拉取

解決思路

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

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

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

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

自適應 SSR 方案介紹

Gitlab CI Pipeline

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

自研 Nuxt Fetch Pipeline

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

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

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 生成骨架屏踩坑實踐

由於服務端只拉取了關鍵數據,部分頁面部分存在沒有數據的情況,因此需要骨架屏來提升體驗

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

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

Vue Content Loading 使用及原理

例子


<template>
<vue-content-loading>
<circle>
<rect>
<rect>
/<vue-content-loading>
/<template>

Vue Content Loading 核心代碼

<template> 


<rect> :style="rect.style"
:clip-path="rect.clipPath"
x="0"
y="0"
:width="width"
:height="height"
/>
<defs>
<clippath>
<slot>
<rect>
<rect>
<rect>
<rect>
<rect>
<rect>
/<slot>
/<clippath>
<lineargradient>
<stop>
<animate> attributeName="offset"
values="-2; 1"
:dur="formatedSpeed"
repeatCount="indefinite"
/>
/<animate>/<stop>
<stop>
<animate> attributeName="offset"
values="-1.5; 1.5"
:dur="formatedSpeed"
repeatCount="indefinite"
/>
/<animate>/<stop>
<stop>
<animate> attributeName="offset"
values="-1; 2"
:dur="formatedSpeed"
repeatCount="indefinite"
/>
/<animate>/<stop>
/<lineargradient>
/<defs>
/<rect>
/<template>

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/en-US/docs/Web/Performance/CSSJavaScriptanimation_performance

測試 Demo 地址

https://jsbin.com/wodenoxaku/1/edit?html,css,output

看起來瀏覽器並沒有對 SVG 動畫做這方面的優化,最終,我們修改了 Vue content loading 的實現,改為了使用 CSS 動畫來實現閃爍的加載效果

<template>


<defs>
<clippath>
<slot>
<rect>
<rect>
<rect>
<rect>
<rect>
<rect>
/<slot>
/<clippath>
/<defs>


/<template>

<style><br> @keyframes backgroundAnimation {<br> 0% {<br> background-position-x: 100%;<br> }<br> 50% {<br> background-position-x: 0;<br> }<br> 100% {<br> background-position-x: -100%;<br> }<br> }<br>/<style>

Vue SSR client side hydration 踩坑實踐

一個例子

<template>
text: {{ id }}

/<template>

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 到最終渲染的整個過程

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

來源:https://ustbhuangyi.github.io/vue-analysis/data-driven/update.html#%E6%80%BB%E7%BB%93

所謂客戶端激活,指的是 Vue 在瀏覽器端接管由服務端發送的靜態 HTML,使其變為由 Vue 管理的動態 DOM 的過程。

在 entry-client.js 中,我們用下面這行掛載(mount)應用程序:

// 這裡假定 App.vue template 根元素的 `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 樹 (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>
<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"
/>
/<rect>/<rect>/<template>
/<vue-content-loading>

優化效果

  • 通過減少 fetch 階段的數據拉取的任務,減少了數據拉取時間
  • 同時減少了服務端渲染的組件數和開銷,縮短了首字節時間
  • 首屏大小變小也縮短了下載首屏所需的時間

綜合起來,首字節、首屏時間都將提前,可交互時間也會提前

本地數據

類型

服務響應時間 首頁大小 未 Gzip 首頁修改前

0.88s

561 KB

首頁(最小化 fetch 請求) 0.58s

217 KB

在本地測試,服務端渲染首頁只請求關鍵等服務器接口請求時,服務響應時間縮短 0.30s降低 34%,首頁 html 文本大小

降低 344 KB,減少 60%

線上數據

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

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

總結

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

關於我

 binggg(Booker Zhao) @騰訊
- 先後就職於迅雷、騰訊等,個人開源項目有 mrn.js 等
- 創辦了迅雷內部組件倉庫 XNPM ,參與幾個迅雷前端開源項目的開發
- 熱衷於優化和提效,是一個奉行“懶惰使人進步”的懶人工程師

社交資料

  • GitHub: https://github.com/binggg
  • 簡書: https://www.jianshu.com/u/60f22559b79f
  • 掘金: https://juejin.im/user/58d31f130ce4630057edb3ba
  • ️‍️ 微博: https://weibo.com/being99
  • 思否: https://segmentfault.com/u/binggg
  • 博客園: https://www.cnblogs.com/binggg/
  • 開源中國: https://my.oschina.net/u/4217267
  • 極術社區: https://aijishu.com/u/binggg
  • 今日頭條: https://www.toutiao.com/c/user/102306299647
  • CSDN: https://blog.csdn.net/weixin_42541867


分享到:


相關文章: