目前項目採用 Nuxt SSR 來完成服務端渲染 ,爲知足 SEO 需求,將非首屏內容也進行了請求和服務端直出,致使首屏時間變長(非首屏的資源請求和組件的渲染都會帶來額外開銷)。對於海量的用戶來講,少許的爬蟲訪問需求反而影響了正經常使用戶的訪問,致使 SEO 和用戶體驗提高存在很大的矛盾。css
爲了解決這個問題,咱們設計和實踐了自適應 SSR 方案,來同時知足這兩種場景的需求。今天會分享這個方案的技術細節、設計思路以及在實施該方案過程當中遇到的一些相關的子問題的實踐踩坑經驗,歡迎你們一塊兒交流。html
目前項目採用 Nuxt SSR 來完成服務端渲染,爲知足 SEO 需求,將非首屏資源也進行了請求和服務端直出,致使首屏時間變長(非首屏的資源請求和組件的渲染都會帶來額外開銷)前端
目前咱們的 Nuxt 項目採用 fetch 來實現 SSR 數據預取,fetch 中會處理全部關鍵和非關鍵請求vue
對於海量的用戶來講,少許的爬蟲訪問需求反而影響了正經常使用戶的訪問,致使 SEO 和用戶體驗提高存在很大的矛盾。git
爲了解決這個問題,咱們但願能區分不一樣的場景進行不一樣的直出,SEO 場景所有直出,其餘場景只直出最小化的首屏,非關鍵請求放在前端異步拉取github
計劃經過統一的方式來控制數據加載,將數據加載由專門的插件來控制,插件會根據條件來選擇性的加載數據,同時懶加載一部分數據算法
借鑑 Gitlab CI 持續集成的概念和流程,將數據請求設計爲不一樣的階段 (Stage ),每一個階段執行不一樣的異步任務(Job),全部的階段組成了數據請求的管線(Pipeline)segmentfault
每個頁面的都有一個 Nuxt Fetch Pipeline 的實例來控制,Nuxt Fetch Pipeline 須要配置相應的 job 和 stage,而後會自適應判斷請求的類型,針對性的處理異步數據拉取:瀏覽器
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 定義爲自定義的 stage,而後,在其餘的 Stage 裏按照以下的方式來引用,減小編碼的成本
{ seoFetch: { type: 'serial', jobs: [ 'getVideo', { jobType: 'stage', name: 'postGetVideo' } ] }, postGetVideo: { type: 'parallel', jobs: [ 'anyjob', 'anyjob2' ] } }
爲了方便編碼,以及減小改動成本,每個 job 執行上下文和 Nuxt fetch 相似,而是經過一個 context 參數來訪問一些狀態,因爲 fetch 階段尚未組件實例,爲了保持統一,都不能夠經過 this 訪問實例
目前支持的 nuxt context 有
Stage | 適合的 Job | 是否並行 |
---|---|---|
seoFetch | 所有,SEO 場景追求越多越好 | 最好並行 |
minFetch | 關鍵的,好比首屏內容、核心流程須要的數據,頁面的主要核心內容(例如影評頁面是影評的正文,短視頻頁面是短視頻信息,帖子頁面是帖子正文)的數據 | 最好並行 |
mounted | 次關鍵內容的數據,例如側邊欄,第二屏等 | 根據優先成都考慮是否並行 |
idle | 最次要的內容的數據,例如頁面底部,標籤頁被隱藏的部分 | 儘可能分批進行,不影響用戶的交互 |
因爲服務端只拉取了關鍵數據,部分頁面部分存在沒有數據的狀況,所以須要骨架屏來提高體驗
<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>
<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>
使用了 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>
<template> <div :id="id"> text: {{ id }}</div> </template> <script> export default { data () { return { id: Math.random() } } } </script>
client side hydration 的結果會是如何呢?
Vue content loading 內部依賴了 this._uid 來做爲 svg defs 裏的 clippath 的 id,然而 this._uid 在客戶端和服務端並不同,實際跟上面隨機數的例子差很少。
client side hydration 的結果是 C
也就是說 id 並無改變,致使的現象在咱們這個場景就是骨架屏閃了一下就沒了
初始化 Vue 到最終渲染的整個過程
來源: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 屬性的元素上,還能夠向 $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
過重了,放棄
乾脆讓用戶本身傳 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>
綜合起來,首字節、首屏時間都將提早,可交互時間也會提早
類型 | 服務響應時間 | 首頁大小 未 Gzip |
---|---|---|
首頁修改前 | 0.88s | 561 KB |
首頁(最小化 fetch 請求) | 0.58s | 217 KB |
在本地測試,服務端渲染首頁只請求關鍵等服務器接口請求時,服務響應時間縮短 0.30s,下降 34%,首頁 html 文本大小下降 344 KB,減小 60%
首頁的首屏可見時間中位數從 2-3s 下降到了 1.1s 左右,加載速度提高 100%+
本文分享瞭如何解決 SEO 和用戶體驗提高之間存在矛盾的問題,介紹了咱們如何借鑑 Gitlab CI 的 pipeline 的概念,在服務端渲染時兼顧首屏最小化和 SEO,分享了自適應 SSR 的技術細節、設計思路以及在實施該方案過程當中遇到的一些相關的子問題的實踐踩坑經驗,但願對你們有所啓發和幫助。
binggg(Booker Zhao) @騰訊 - 前後就任於迅雷、騰訊等,我的開源項目有 mrn.js 等 - 創辦了迅雷內部組件倉庫 XNPM ,參與幾個迅雷前端開源項目的開發 - 熱衷於優化和提效,是一個奉行「懶惰令人進步」的懶人工程師
微信公衆號 binggg_net
, 歡迎關注