• 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+">)[sS]*</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+">)[sS]*</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"
    【注意】只有在预渲染打包才可以添加这个id。正常情况不要添加,否则现网打包出来的页面不会渲染模板节点(因为两个id相同导致节点冲突)
     

    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插件配合使用效果更佳!
      在预渲染配置过程中很有可能那一步出错了然后预渲染失败,让你很抓狂!!!那么请将预渲染的配置改为:headlessfalse
     
     
  • 相关阅读:
    python操作MYSQL时防止SQL注入
    防止SQL注入的一些解决方法
    Gitbook 学习链接
    MySQL_编码utf8_bin和utf8_general_ci的区别
    使用linux脚本shell检查大数据各节点服务是否运行正常
    shell脚本监测elasticsearch集群节点
    Filebeat+Kafka+Logstash+ElasticSearch+Kibana搭建日志收集系统
    python中集合用法大全
    python常用内置函数
    跨模块全局变量的使用问题
  • 原文地址:https://www.cnblogs.com/chuaWeb/p/prerender-plugin.html
Copyright © 2020-2023  润新知