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,頁面至少在加載下面文件後才能被看到。
html
app.css - 樣式
manifest.js - webpack manifest
vendor.js - 第三方庫
app.js - 業務邏輯
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 中的
是有內容的,且 中多了當前路由分包的 js 文件。其餘部分跟 index.html 一樣。雖然有多個 html,但從 /new 跳轉到其他路由時,還是單頁內跳轉的,不會有新的 html 請求。根據上面配置的 nginx 規則,路由對應的返回文件分別是:
/ -> index.html
/new -> new/index.html
/hot -> hot/index.html
/article/:id -> index.htm
其中,/new 和 /hot 路由返回的 html 包含了對應路由的內容,從而實現預渲染。沒有配置預渲染的路由跟原來一樣,還是訪問 /index.html,請求腳本,動態渲染。
預渲染達到了類似服務端渲染的效果。區別在於預渲染髮生在構建時,服務端渲染髮生在服務器處理請求時。
prerender-spa-plugin 原理
那麼 prerender-spa-plugin 是如何做到將運行時的 html 打包到文件中的呢?原理很簡單,就是在 webpack 構建階段的最後,在本地啓動一個 phantomjs,訪問配置了預渲染的路由,再將 phantomjs 中渲染的頁面輸出到 html 文件中,並建立路由對應的目錄。
查看 prerender-spa-plugin源碼
// 打開頁面
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) // 捕獲輸出
}
...
})
預渲染骨架屏
本文實例中更多是變化的數據,時效性要求比較高,不太適合預渲染的場景。如果想用預渲染來減少白屏時間,讓頁面反饋更及時的話,可以預渲染骨架屏。
<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 加載完成後,再拉取數據渲染出正確的內容。