為什么要使用預渲染?
為了應付SEO(國內特別是百度)考慮在網站(vue技術棧系列)做一些優化。大概有幾種方案可以考慮:
服務端做優化:
第一,ssr,vue官方文檔給出的服務器渲染方案,這是一套完整的構建vue服務端渲染應用的指南,具體參考https://cn.vuejs.org/v2/guide/ssr.html
第二,nuxt 簡單易用,參考網站 https://zh.nuxtjs.org/guide/installation
1. 下載/安裝失敗
這個問題有網友遇到的比我多,直接引用解決方案:https://blog.csdn.net/wangshu696/article/details/81253124
基本上是使用cnpm/高版本node都能解決掉。
2. 最大的一個坑:CDN支持。
網絡上有解決方案,這篇文章寫的比較清楚:https://juejin.im/post/5cc5af1f6fb9a032447f0299
在github上也有對應的問題,解決方案主要是上面鏈接中的第三種。https://github.com/chrisvfritz/prerender-spa-plugin/issues/114,里面提供的demo也差不多:https://github.com/Dhgan/prerender-cdn-demo【注:這個例子實際有一個問題,預渲染處理html替換是匹配時會多出一個“/”,比如“https://www.cdn.com//test.img”,正則需要改一下,可以看我下面的例子】
重點在於理解預渲染的原理:在webpack打包結束並生成文件后(after-emit hook),啟動一個server模擬網站的運行,用puppeteer(google官方的headless chrome瀏覽器)訪問指定的頁面route,得到相應的html結構,並將結果輸出到指定目錄,過程類似於爬蟲。
我的方案(掘金文章描述的第三種方案:利用webpack的全局變量和正則替換):
- 第一步,對於生成的html文件,使用正則方式將資源的引用路徑替換為CDN引用;
- 第二步,對於解析js時才發起的資源請求,給webpack運行時暴露的全局變量
__webpack_public_path__
設置publicPath,相關文檔,可以用於項目運行時動態加載的js/css修改成cdn域名。
//webpack.common.js
{
output: {
filename: '[name].js',
path: config.outPath,
// 需要注意,預渲染的publicPath要和PrerenderSPAPlugin中的匹配規則對應
publicPath: '' // 設置成默認值或者不設置也可以
}
}
// webpack.prod.js
{
plugins: [
new PrerenderSPAPlugin({
staticDir: config.build.assetsRoot,
routes: [ '/', '/about', '/contact' ],
postProcess (renderedRoute) {
// add CDN
renderedRoute.html = renderedRoute.html.replace(
/(<script[^<>]*src=\")((?!http|https)[^<>\"]*)(\"[^<>]*>[^<>]*<\/script>)/ig,
`$1${config.build.cdnPath}$2$3`
).replace(
/(<link[^<>]*href=\")((?!http|https)[^<>\"]*)(\"[^<>]*>)/ig,
`$1${config.build.cdnPath}$2$3`
)
return renderedRoute
},
renderer: new Renderer({
injectProperty: '__PRERENDER_INJECTED__',
inject: 'prerender'
})
})
]
}
本人的實際運用比上面要復雜一些,publicPath保留了之前項目的值"/",對應的匹配也就要更改。而且添加了對img標簽/內聯圖片以及部分項目特有的處理。
publicPath要以"/"結尾的,相關文檔,所以cdnPath要以“/”結尾

// webpack.common.js { output: { filename: '[name].js', path: config.outPath, // 需要注意,預渲染的publicPath要和PrerenderSPAPlugin中的匹配規則對應 publicPath: '/' } } // webpack.prod.js webpackConfig.plugins.push(new PrerenderSPAPlugin({ // Required - The path to the webpack-outputted app to prerender. staticDir: config.outPath, // indexPath: path.join(config.outPath, 'index.html'), // Required - Routes to render. routes: [ '/', '/course', '/to-class', '/declare', '/agreement', '/user'], postProcess (renderedRoute) { // add CDN // 由於CDN是以"/"結尾的,所以資源開頭的“/”去掉 renderedRoute.html = renderedRoute.html.replace( /(<script[^<>]*src=\")(?!http|https|\/{2})\/([^<>\"]*)(\"[^<>]*>[^<>]*<\/script>)/ig, `$1${config[env].assetsPublicPath}$2$3` ).replace( /(<link[^<>]*href=\")(?!http|https|\/{2})\/([^<>\"]*)(\"[^<>]*>)/ig, `$1${config[env].assetsPublicPath}$2$3` ).replace(/(<img[^<>]*src=\")(?!http|https|data:image|\/{2})\/([^<>\"]*)(\"[^<>]*>)/ig, `$1${config[env].assetsPublicPath}$2$3` ).replace(/(:url\()(?!http|https|data:image|\/{2})\/([^\)]*)(\))/ig,// 樣式內聯,格式必須是":url(/xxx)",其他格式都不行【用來剔除js代碼中類似的字段】 `$1${config[env].assetsPublicPath}$2$3` ).replace(/(<div class="dialog_mask_\w+">)[\s\S]*<\/div>(<\/body>)/ig, `$2`)// 去掉警告彈窗(因為部分調用比較早的ajax會報錯導致多出了彈出框) return renderedRoute }, renderer: new Renderer({ injectProperty: '__PRERENDER_INJECTED__', inject: 'prerender', renderAfterDocumentEvent: 'render-event' }) }));
publicPath和postProcess配對使用的,postProcess中的匹配有小改動,目的是為了剔除重復的"/"。其中config[env].assetsPublicPath是本人的CDN路徑變量。
第二步處理
webpackConfig.plugins.push(new PrerenderSPAPlugin({ // 。。。 renderer: new Renderer({ injectProperty: '__PRERENDER_INJECTED__', inject: 'prerender', renderAfterDocumentEvent: 'render-event' // vue可能需要使用預渲染何時開始的事件 }) }));
如上,注入了__PRERENDER_INJECTED__屬性,值為"prerender"。
然后使用new webpack.DefinePlugin()向運行時注入變量:process.env.CDN_PATH,如:
new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify(process.env.NODE_ENV), CDN_PATH: JSON.stringify(config[env].assetsPublicPath) } }),
然后再工程的根目錄下建立一個public-path.js文件,內容如下
/** * CDN */ /* eslint-disable */ const isPrerender = window.__PRERENDER_INJECTED__ === 'prerender' // 預渲染過程中使用相對路徑來處理模擬瀏覽器爬取節點(否則會因為CDN找不到資源而卡住) // 所以預渲染時使用'/'和publicPath一致,真正運行時值為process.env.CDN_PATH __webpack_public_path__ = isPrerender ? '/' : process.env.CDN_PATH
注意上面紅色字體部分,預渲染時使用的路徑要和配置的publicPath一致。
並在app入口js引用他
import '../public-path';
import Vue from 'vue';
特別注意:使用類似mini-css-extract-plugin這樣的組件將.vue的style樣式提取到外部css,這會導致js中添加的__webpack_public_path__在css中不起作用,比如外鏈css中出現
background:url(/static/img/icon-question.f05e67f.svg) top no-repeat;
js中的CDN變量就失去作用了。需要想額外辦法,解決方案有兩種:
1.要么不在css中直接引用圖片(在模板中插入背景url),這個用着會比較難受。
2.【推薦使用】在webpack打包時直接給所有圖片資源的publicPath配置上CDN路徑,圖片資源在預渲染加載失敗並不會導致整個預渲染失敗,放心大膽使用,比如本人的
{
test: /\.(png|jpe?g|gif|svg|ico)(\?.*)?$/,
loader: 'url-loader',
exclude: [path.resolve(__dirname,'../src/assets/fonts')],
options: {
limit: 100,
name: utils.assetsPath('img/[name].[hash:7].[ext]'),
publicPath: config[env].assetsPublicPath
}
}
其他非js/css的資源(如字體文件/音頻/視頻文件等)類似。
第三步處理
告訴插件什么時候執行預渲染
一般在入口js中寫入
new Vue({ router, el: '#app', render: h => h(App), mounted() { // You'll need this for renderAfterDocumentEvent. document.dispatchEvent(new Event('render-event')) } })
紅色代碼部分,告訴插件vue的App頁面mounted后就進行預渲染。正常情況下沒有問題,異常情況查看踩坑6
額外提示: 多頁面(多html入口)的項目可以調用多次預渲染插件。比如本人的項目除了index.html外,還有一個/h5/index.html為入口的大頁面。這個頁面本人的調用如下

// h5主頁預渲染 webpackConfig.plugins.push(new PrerenderSPAPlugin({ // Required - The path to the webpack-outputted app to prerender. staticDir: config.outPath, // The path your rendered app should be output to. // outputDir: path.join(config.outPath, 'h5'), indexPath: path.join(config.outPath, 'h5/index.html'), // Required - Routes to render. routes: ['/h5', '/h5/about', '/h5/invite', '/h5/purchase/starter'], postProcess (renderedRoute) { // add CDN // 由於CDN是以"/"結尾的,所以資源開頭的“/”去掉 renderedRoute.html = renderedRoute.html.replace( /(<script[^<>]*src=\")(?!http|https|\/{2})\/([^<>\"]*)(\"[^<>]*>[^<>]*<\/script>)/ig, `$1${config[env].assetsPublicPath}$2$3` ).replace( /(<link[^<>]*href=\")(?!http|https|\/{2})\/([^<>\"]*)(\"[^<>]*>)/ig, `$1${config[env].assetsPublicPath}$2$3` ).replace(/(<img[^<>]*src=\")(?!http|https|data:image|\/{2})\/([^<>\"]*)(\"[^<>]*>)/ig, `$1${config[env].assetsPublicPath}$2$3` ).replace(/(:url\()(?!http|https|data:image|\/{2})\/([^\)]*)(\))/ig,// 樣式內聯,格式必須是":url(/xxx)",其他格式都不行【用來剔除js代碼中類似的字段】 `$1${config[env].assetsPublicPath}$2$3` ).replace(/(<div class="dialog_mask_\w+">)[\s\S]*<\/div>(<\/body>)/ig, `$2`)// 去掉警告彈窗(因為部分比較早的ajax會報錯) return renderedRoute }, renderer: new Renderer({ injectProperty: '__PRERENDER_INJECTED__', inject: 'prerender', renderAfterDocumentEvent: 'render-h5-event' }) }));
3.Vue預渲染之后報:behavior.js:149 [Vue warn]: Cannot find element: #app
4. 微信需要授權的頁面的預渲染問題
這類頁面不好生成預渲染頁面(授權報錯),建議不生成。
5. 頁面加載閃現首頁
部分路由是沒有做預渲染的,這部分路由在nginx配置的時候往往默認指向index.html比如類似下面的配置
location / { try_files $uri $uri/index.html /index.html; #root /static/front; #站點目錄 已經配置了全局root }
由於對首頁做了預渲染,所以index.html默認有很多內容的。
解決方案有兩種:
- 默認根節點隱藏,合適時機再顯式出來:https://blog.csdn.net/Christiano_Lee/article/details/94569119。(感覺思路可行,但是本人沒有實踐,后面實踐后再加上評論)
- 新增一個空頁面,路由為'/empty',並為這個路由做預渲染,nginx配置中沒有匹配的路由默認指向加載此頁面。nginx配置改為
location / { try_files $uri $uri/index.html /empty/index.html; # /index.html; #root /static/front; #站點目錄 已經配置了全局root }
6.預渲染觸發的時機
正常情況下,在vue總入口實例化的mounted觸發預渲染是沒有問題的,如下
new Vue({ router, el: '#app', render: h => h(App), mounted() { // You'll need this for renderAfterDocumentEvent. document.dispatchEvent(new Event('render-event')) } })
但是當你對路由組件進行v-if的控制,就出現問題了。如,app.vue長這樣
<template> <div id="app" ref="app" > <router-view v-if="routerShow"></router-view> </div> </template> <script> export default { data() { return { ... routerShow: false } } ... } </script>
routerShow一開始是false。 上面觸發預渲染時,路由里面沒有任何東西,預渲染結果id=“app”的節點是空的。觸發預渲染的時機明顯不對。解決方案有二:
1.需要等到routerShow為true並且路由節點已經掛載后再觸發。
2.v-if改為v-show(這個不一定適合你的情況)
【特別注意】,如果routerShow更改依賴接口返回值,接口很可能報錯,導致預渲染失敗。所以,預渲染要確保和接口無關。
在極端情況:比如所有的路由都是異步加載的,那么觸發時機更為復雜,應該在各自需要預渲染的組件的mounted中觸發渲染