prerender-spa-plugin預渲染踩坑


為什么要使用預渲染?

為了應付SEO(國內特別是百度)考慮在網站(vue技術棧系列)做一些優化。大概有幾種方案可以考慮:

服務端做優化:

第一,ssr,vue官方文檔給出的服務器渲染方案,這是一套完整的構建vue服務端渲染應用的指南,具體參考https://cn.vuejs.org/v2/guide/ssr.html

第二,nuxt 簡單易用,參考網站 https://zh.nuxtjs.org/guide/installation

 
前端做優化:
第三,vue-meta-info + prerender-spa-plugin做預渲染,這個是針對單頁面的meta SEO的另一種思路,參考網站 https://zhuanlan.zhihu.com/p/29148760
第四,phantomjs 頁面預渲染,具體參考 phantomjs.org (已經暫停維護了)
甚至我一度考慮過第五種方案來應付百度:做假html節點(節點最終不展示出來)。
 
權衡了一下,做服務端渲染是沒有人力物力了,所以選用了預渲染的方式來處理(第三種),其中遇到幾個大坑,記錄一下。

 

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結構,並將結果輸出到指定目錄,過程類似於爬蟲。

所以CDN配置預渲染失敗原因很簡單:在啟用puppeteer爬蟲時,你的資源在CDN上根本就沒有(其他諸如圖片資源還好說,但是js資源都沒有,咋渲染啊)。
 

我的方案(掘金文章描述的第三種方案:利用webpack的全局變量和正則替換):

網絡上方案是提供了,但是貌似細節都不是很全面,這里本人全面的講述一下。
原理:在webpack打包時使用和本地環境一樣的配置,保證puppeteer爬蟲時成功,然后分成兩步一起來來加上CDN:
  • 第一步,對於生成的html文件,使用正則方式將資源的引用路徑替換為CDN引用;
  • 第二步,對於解析js時才發起的資源請求,給webpack運行時暴露的全局變量__webpack_public_path__設置publicPath,相關文檔,可以用於項目運行時動態加載的js/css修改成cdn域名。
 
第一步處理:
有兩個注意事項:
1. 預渲染中output的publicPath需要和預渲染中處理html的正則配對使用。比如網上的例子基本都使用默認值:空字符''(或者不設置)。一旦設定了非空字符的值,預渲染的html匹配要對應修改。網上的例子為:紅色字體部分要配對使用,
//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'
    })
}));
View Code

 publicPath和postProcess配對使用的,postProcess中的匹配有小改動,目的是為了剔除重復的"/"。其中config[env].assetsPublicPath是本人的CDN路徑變量。

 

第二步處理

為什么要第二步處理?如果vue中的加載 全是同步的加載就沒有必要,如果存在 異步的加載(比如異步路由/異步js),此時完全可能在js中發起另一個js資源的請求,這個請求不再html中,上一步無法處理,就需要動態加上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'
        })
    }));
View Code

 

 3.Vue預渲染之后報:behavior.js:149 [Vue warn]: Cannot find element: #app

原因是:預渲染模擬瀏覽器加載頁面后,爬取頁面節點,這個時候頁面index.html的 節點"<div id="app"></div>"已經被替換成對應的組件了。預渲染的vue2的demo中可以看到app.vue模板的div設置了
<template>
    <div id="app">
        ...
    </div>
</template>
所以,需要我們手動在index.vue中加上這個id="app"
 

4. 微信需要授權的頁面的預渲染問題

這類頁面不好生成預渲染頁面(授權報錯),建議不生成。

 

5. 頁面加載閃現首頁

部分路由是沒有做預渲染的,這部分路由在nginx配置的時候往往默認指向index.html比如類似下面的配置

    location / {
        try_files $uri $uri/index.html /index.html;
        #root   /static/front; #站點目錄 已經配置了全局root
    }

由於對首頁做了預渲染,所以index.html默認有很多內容的。

解決方案有兩種:

  1. 默認根節點隱藏,合適時機再顯式出來:https://blog.csdn.net/Christiano_Lee/article/details/94569119。(感覺思路可行,但是本人沒有實踐,后面實踐后再加上評論)
  2. 新增一個空頁面,路由為'/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中觸發渲染

 

小提示:
  prerender-spa-plugin插件和vue-meta-info插件配合使用效果更佳!
  在預渲染配置過程中很有可能那一步出錯了然后預渲染失敗,讓你很抓狂!!!那么請將預渲染的配置改為:headless false
 
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM