優化向:單頁應用多路由預渲染指南

迅雷前端發表於2017-10-13

前言

Ajax 技術的出現,讓我們的 Web 應用能夠在不重新整理的狀態下顯示不同頁面的內容,這就是單頁應用。在一個單頁應用中,往往只有一個 html 檔案,然後根據訪問的 url 來匹配對應的路由指令碼,動態地渲染頁面內容。單頁應用在優化了使用者體驗的同時,也給我們帶來了許多問題,例如 SEO 不友好、首屏可見時間過長等。服務端渲染(SSR)和預渲染(Prerender)技術正是為解決這些問題而生的。

閱讀本文,你能夠了解到什麼是預渲染、預渲染與服務端渲染的異同以及預渲染在 Vue.js 專案中的使用

服務端渲染與預渲染

一些概念

  1. 客戶端渲染:使用者訪問 url,請求 html 檔案,前端根據路由動態渲染頁面內容。關鍵鏈路較長,有一定的白屏時間;
  2. 服務端渲染:使用者訪問 url,服務端根據訪問路徑請求所需資料,拼接成 html 字串,返回給前端。前端接收到 html 時已有部分內容;
  3. 預渲染:構建階段生成匹配預渲染路徑的 html 檔案(注意:每個需要預渲染的路由都有一個對應的 html)。構建出來的 html 檔案已有部分內容。

下圖簡單展示了客戶端渲染、服務端渲染和預渲染的請求流程。

本文示例使用 vue-cli 生成,點選這裡檢視示例。dist 目錄是啟用了預渲染的打包目錄,dist2 目錄則是普通客戶端渲染的打包目錄。通過對比目錄中的檔案,你可以對預渲染有個初步的瞭解。若你還是不知道什麼是預渲染,不妨先通讀全文。

共同點

針對單頁應用,服務端渲染和預渲染共同解決的問題:

  1. SEO:單頁應用的網站內容是根據當前路徑動態渲染的,html 檔案中往往沒有內容,網路爬蟲不會等到頁面指令碼執行完再抓取;
  2. 弱網環境:當使用者在一個弱環境中訪問你的站點時,你會想要儘可能快的將內容呈現給他們。甚至是在 js 指令碼被載入和解析前;
  3. 低版本瀏覽器:使用者的瀏覽器可能不支援你使用的 js 特性,預渲染或服務端渲染能夠讓使用者至少能夠看到首屏的內容,而不是一個空白的網頁。

預渲染能與服務端渲染一樣提高 SEO 優化,但前者比後者需要更少的配置,實現成本低。弱網環境下,預渲染能更快地呈現頁面內容,減少頁面可見時間。

不適合的場景

那什麼場景下不適合使用預渲染呢:

  1. 個性化內容:對於路由是 /my-profile 的頁面來說,預渲染就失效了。因為頁面內容依據看它的人而顯得不同;
  2. 經常變化的內容:如果你預渲染一個遊戲排行榜,這個排行榜會隨著新的玩家記錄而更新,預渲染會讓你的頁面顯示不正確直到指令碼載入完成並替換成新的資料。這是一個不好的使用者體驗;
  3. 成千上萬的路由:不建議預渲染非常多的路由,因為這會嚴重拖慢你的構建程式。

Prerender SPA Plugin

prerender-spa-plugin 是一個 webpack 外掛用於在單頁應用中預渲染靜態 html 內容。因此,該外掛限定了你的單頁應用必須使用 webpack 構建,且它是框架無關的,無論你是使用 React 或 Vue 甚至不使用框架,都能用來進行預渲染。本文示例基於 Vue.js 2.0 + vue-router。

下文會從生成專案講起,然後看下沒有配置預渲染前的樣子,再配置預渲染進行構建,對比前後的差別

生成專案

首先生成一個專案並安裝依賴。

vue init webpack vue-prerender-demo
cd vue-prerender-demo && npm install複製程式碼

元件開發過程我們不關注,具體可以檢視示例原始碼。開發完成檢視如下。

路由配置

這是一個新聞應用的頁面,包括了最新、最熱兩個列表頁和一個文章頁。路由配置如下。

new Router({ 
  mode: 'history',
  routes: [
    {
      path: '/',
      component: Home,
      children: [
        {
          path: 'new',
          alias: '/',
          component: () => import('@/components/New')
        },
        {
          path: 'hot',
          component: () => import('@/components/Hot')
        }
      ]
    },
    {
      path: '/article/:id',
      component: Article
    }
  ]
})複製程式碼

預渲染的單頁應用路由需要使用 History 模式而不是 Hash 模式。原因很簡單,Hash 不會帶到伺服器,路由資訊會丟失。vue-router 啟用 History 模式參考這裡

History 模式需要後臺配置支援,最簡單的是通過 nginx 配置 try_files 指令。

location / {
  try_files $uri $uri/ /index.html;
}複製程式碼

沒有配置預渲染前

配置完成後執行構建 npm run build,根據 nginx 配置,現在無論訪問哪個路由都會返回 dist/index.html。

訪問 / 路由。

可以看到,在 Fast 3G 網路下,首屏可見時間是 4.34s,頁面至少在載入下面檔案後才能被看到。

  1. html
  2. app.css - 樣式
  3. manifest.js - webpack manifest
  4. vendor.js - 第三方庫
  5. app.js - 業務邏輯
  6. 0.js - 路由分包檔案

其中 vendor 檔案包含了引用的第三方庫,檔案規模較大。載入檔案多,增加了白屏時間。所以,最有效的優化方案是減少首屏依賴檔案。這裡開始配置預渲染。

預渲染配置

安裝 prerender-spa-plugin,安裝時件略長,因為其依賴了 phantomjs,請耐心等待。

npm install prerender-spa-plugin --save-dev複製程式碼

我們只在生產環境中進行預渲染,修改 build/webpack.prod.conf.js,在配置外掛的地方加入如下程式碼。

var path = require('path')
var PrerenderSpaPlugin = require('prerender-spa-plugin')

{
  // ...
  plugins: [
    // ...
    new PrerenderSpaPlugin(
      // 輸出目錄的絕對路徑
      path.join(__dirname, '../dist'),
      // 預渲染的路由
      [ '/new', '/hot' ]
    )
  ]
}複製程式碼

例項化 PrerenderSpaPlugin 需要至少兩個引數,第一個引數是單頁應用的輸出目錄,第二個引數指定預渲染的路由,這裡執行了兩個路由 /new/hot。執行構建 npm run build

預渲染效果

訪問 /new 路由。

同樣在 Fast 3G 網路下,首屏可見時間縮短至 2.30s。事實上,只要載入 html 和 app.css 檔案,頁面內容就能看到了。

dist
│  index.html
│  
├─hot
│      index.html
│      
├─new
│      index.html
│      
└─static複製程式碼

對比構建完成目錄,可以發現預渲染的目錄多了兩個檔案 new/index.html, hot/index.html

檢視 new/index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>vue-prerender-demo</title>
  <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
  <link href="/static/css/app.23611ac69a9fa48640e3bad8ceeab7bf.css" rel="stylesheet">
  <script type="text/javascript" charset="utf-8" async="" src="/static/js/0.41194d76e86bbf547b16.js"></script>
</head>
<body>
  <div id="app">
    <div>
      <div class="mu-appbar mu-paper-1">
        <div class="left">
          <i class="mu-icon material-icons">home</i>
        </div>
        <div class="mu-appbar-title">
          <span>新聞</span>
        </div>
        <div class="right"></div>
      </div>
      ...
    </div>
  </div>
  <script type="text/javascript" src="/static/js/manifest.4410c20c250c68dac5bc.js"></script>
  <script type="text/javascript" src="/static/js/vendor.d55f477df6e96ccceb5c.js"></script>
  <script type="text/javascript" src="/static/js/app.f199467bd568ee8a197a.js"></script>
</body>
</html>複製程式碼

相比 index.html, new/index.html 中的 <div id="app"></div> 是有內容的,且 <head></head> 中多了當前路由分包的 js 檔案。其餘部分跟 index.html 一樣。雖然有多個 html,但從 /new 跳轉到其他路由時,還是單頁內跳轉的,不會有新的 html 請求。

根據上面配置的 nginx 規則,路由對應的返回檔案分別是:

/ -> index.html
/new -> new/index.html
/hot -> hot/index.html
/article/:id -> index.html複製程式碼

其中,/new/hot 路由返回的 html 包含了對應路由的內容,從而實現預渲染。沒有配置預渲染的路由跟原來一樣,還是訪問 /index.html,請求指令碼,動態渲染。

預渲染達到了類似服務端渲染的效果。區別在於預渲染髮生在構建時,服務端渲染髮生在伺服器處理請求時

prerender-spa-plugin 原理

那麼 prerender-spa-plugin 是如何做到將執行時的 html 打包到檔案中的呢?原理很簡單,就是在 webpack 構建階段的最後,在本地啟動一個 phantomjs,訪問配置了預渲染的路由,再將 phantomjs 中渲染的頁面輸出到 html 檔案中,並建立路由對應的目錄。

檢視 prerender-spa-plugin 原始碼 prerender-spa-plugin/lib/phantom-page-render.js

// 開啟頁面
page.open(url, function (status) {
  ...
  // 沒有設定捕獲鉤子時,在指令碼執行完捕獲
  if (
    !options.captureAfterDocumentEvent &&
    !options.captureAfterElementExists &&
    !options.captureAfterTime
  ) {
    // 拼接 html
    var html = page.evaluate(function () {
      var doctype = new window.XMLSerializer().serializeToString(document.doctype)
      var outerHTML = document.documentElement.outerHTML
      return doctype + outerHTML
    })
    returnResult(html) // 捕獲輸出
  }
  ...
})複製程式碼

最佳實踐

指定捕獲鉤子

預設情況下 html 會在指令碼執行完被捕獲並輸出。你也可以指定一些鉤子,html 將會在特定時機被捕獲。

var path = require('path')
var PrerenderSpaPlugin = require('prerender-spa-plugin')

{
  // ...
  plugins: [
    // ...
    new PrerenderSpaPlugin(
      path.join(__dirname, '../dist'),
      [ '/new', '/hot' ],
      {
        // 監聽到自定事件時捕獲
        // document.dispatchEvent(new Event('custom-post-render-event'))
        captureAfterDocumentEvent: 'custom-post-render-event',

        // 查詢到指定元素時捕獲
        captureAfterElementExists: '#content',

        // 定時捕獲
        captureAfterTime: 5000
      }
    )
  ]
}複製程式碼

預渲染骨架屏

本文例項中更多是變化的資料,時效性要求比較高,不太適合預渲染的場景。如果想用預渲染來減少白屏時間,讓頁面反饋更及時的話,可以預渲染骨架屏。

<template>
  <div>
    <new-list v-if="news.length > 0"></new-list>
    <new-list-skeleton></new-list-skeleton>
  </div>
</template>複製程式碼

請求 news 資料需要一定時間,所以外掛在指令碼執行完捕獲的一般就是骨架屏。如果你想更靈活地指定捕獲時機,可以使用自定義事件鉤子,在元件掛載且請求資料前捕獲。

{
  mounted () {
    document.dispatchEvent(new Event('sketelon-render-event'))
    fetchNews()
  }
}複製程式碼

訪問頁面時,使用者首先看到預渲染的骨架屏(左圖),等待 js 載入完成後,再拉取資料渲染出正確的內容。

代理完整路徑

如果你配置了引用資源連結為帶域名的完整路徑。

// config/index.js

module.exports = {
  build: {
    ...
    assetsPublicPath: '//www.example.com/'
  },
  ...
}複製程式碼

那麼構建時需要將域名代理到本地,否則 prerender-spa-plugin 捕獲的將會是線上的程式碼。

127.0.0.1 www.example.com複製程式碼

預渲染根路由

通常情況下,動態路由如 /users/:id 不會配置預渲染,因為你沒法列舉出所有的 User ID。訪問動態路由時,伺服器會返回根路由 / 的 html,所以根路由也不適合做預渲染。但根路由往往是一個網站的首頁,是訪問量最大的一個路由。通過一些 nginx 可以解決這個問題。

location = / {
  try_files /home/index.html /index.html;
}

location / {
  try_files $uri $uri/ /index.html;
}複製程式碼

使用者訪問 / 路由,實際上是訪問了 /home/index.html,用 router 中配置的 /home 作為首頁。/index.html 可以作為其他沒有匹配到路由的響應。

結語

預渲染是實現成本較低,效果提升明顯的效能優化方案。預渲染有它適合的場景,當你的頁面內容變化不大,又想讓它更快地呈現給使用者時,試試預渲染吧。

掃一掃關注迅雷前端公眾號

相關文章