[spa应用预渲染] prerender-spa-plugin 踩坑记

·  阅读 1547
[spa应用预渲染] prerender-spa-plugin 踩坑记

最近公司的项目不算很忙,我闲置了下来,刚好有位网友求助说公司项目要做优化项,主要提升SEO和首屏渲染速度,公司的技术栈是Vue,因项目场景的原因,该项目不能使用SSR来解决,所以她再三思考,决定使用 1 这个插件,但是里面用到了一个叫 2 的依赖包,在自己电脑上可以使用淘宝镜像安装,但是在公司电脑上却怎么也安装不了,我大致看了下发现我也没用过这个东西,来了兴趣,大致了解了情况后,开始踩坑,最后解决了这个问题,想到可能有别人也会遇到这个问题,分享下我的解决过程

先讲下服务端渲染和预渲染的区别吧

服务端渲染(SSR)和预渲染(Prerendering)的区别

SSR: 服务端(node.js)通过一些包(如 vue-server-renderer)把html页面转换成字符串之后吐给前端。Google 和 Bing 可以很好对同步 web 应用程序进行索引,所以此处称有利于seo。但是一些事件和一些DOMAPI是无法在服务端用的,所以需要把客户端打包好的js代码动态注入到html中,此处为异步获取js逻辑。所以此处seo是抓取不到任何有用的信息的。那么问题来了,什么时候用ajax获取数据?什么时候通过服务端获取数据?当利于seo有优化时,比如开发的新闻网站,购物网站。需要被搜索引擎索引到,就需要从服务端获取数据,然后生成html,再吐给浏览器。而一些无关紧要的数据通过ajax获取就行。

Prerendering:预渲染并不需要服务端支持。它只能生成你在templete写好的dom结构,也就是只能生成静态的html文件。一般是每个路由对应一个静态的html文件,在服务器获取时就已经生成好了,而不需要diff再动态生成dom。假如你想通过ajax获取到的数据来优化seo,可以吗?可以,可以设置一个预渲染时常,假如你用prerender-spa-plugin插件,设置renderAfterTime:5000。可触发渲染的时间,用于获取数据(ajax)后再保存渲染结果。

然后是预渲染的业务场景

营销页面(例如/,/about,/contact等)的 SEO,你可能需要预渲染。无需使用 web 服务器实时动态编译 HTML,而是使用预渲染方式,在构建时 (build time) 简单地生成针对特定路由的静态 HTML 文件。优点是设置预渲染更简单,并可以将你的前端作为一个完全静态的站点。预渲染不适用经常变化的数据,比如说股票代码网站,天气预报网站。因为此时的数据是动态的,而预渲染时已经生成好了dom节点。此时如果要兼容seo就需要使用SSR了。 预渲染也不适用大量的路由页面,比如成千上百个路由,因为此时打包后预渲染将会非常慢。而SSR并没有这些问题。 预渲染最好的应用场景是需要seo的活动页面。预渲染也可以配置骨架屏,当ajax获取到数据之后把骨架屏替换掉,可以减少白屏时间。使用prerender-spa-plugin插件配合vue-meta-info 可以轻松地配置预渲染页面,它已经被 Vue/React 应用程序广泛测试,基本上是开箱即用。而SSR技术成本较高,配置繁琐,虽然有Nuxt.js但并不能做到开箱即用。而且一些第三方包在服务端渲染时也会有兼容性问题。

新建一个vue的demo,按照官方的提示进行按照依赖,果不其然报错了 其实原因还是很简单,被墙了,根据npm的提示我在根目录新建一个 .npmrc 文件配置了变量 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 但是其实并没有解决问题,配置该变量只是跳过了下载无头浏览器的步骤让你可以顺利安装依赖而已,我们再来看下源码,先进入它上面报错的相关文件,node_modules\puppeteer\install.js 发现是调用了BrowserFetcher 对象的download方法 然后我们看下那个变量为什么可以跳过下载,果然简单粗暴啊 报错信息在64行,那我们接着追溯,找到了node_modules\puppeteer\lib\BrowserFetcher.js
在它的顶部我们发现了一些类似下载链接的东西,我们看到有个地址 storage.googleapis.com

ping 一下看看

丢包率还是挺高的,证明这个地址是真的不靠谱,无头浏览器的包在140m左右,这样的网络环境,肯定不适合下载大的包,原因找到了,怎么解决呢,我百度了下,找到一个镜像npm.taobao.org/mirrors/chr… 进去一看,这不就是我们想要的吗 可是百度了一圈,大家的解决方案都是先跳过无头浏览器的下载,然后手动下载解压到Puppeteer依赖包的根目录下,这样的方式在多人协作开发的情况下还是有些不方便的,尤其是项目组来新人的时候,怎么办? 没办法,自己动手丰衣足食,首先在package.json 的scripts里面添加一个运行项,随便起一个名字,我的是叫 "install-chromium": "node ./download-chromium.js",后面这个是用node执行脚本的意思,接下来新建 download-chromium.js 在根目录 然后开始写我们的下载脚本

download-chromium.js

const os = require('os');
const fs = require('fs');
const path = require('path');
const extract = require('extract-zip');
const util = require('util');
const URL = require('url');
const progressStream = require('progress-stream')
const fetch = require("node-fetch");
const ProgressBar = require('./progress-bar');

// 阻止证书认证
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = 0

// 这里的下载url的地址实际位置进行动态拼接
const DEFAULT_DOWNLOAD_HOST = 'https://npm.taobao.org/mirrors';
const download_path = '/chromium-browser-snapshots'
const version = require('./node_modules/puppeteer/package.json').puppeteer.chromium_revision
const downloadURLs = {
    linux: `${DEFAULT_DOWNLOAD_HOST}${download_path}/Linux_x64/${version}/chrome-linux.zip`,
    mac: `${DEFAULT_DOWNLOAD_HOST}${download_path}/Mac/${version}/chrome-mac.zip`,
    win32: `${DEFAULT_DOWNLOAD_HOST}${download_path}/Win/${version}/chrome-win32.zip`,
    win64: `${DEFAULT_DOWNLOAD_HOST}${download_path}/Win_x64/${version}/chrome-win.zip`,
};

// 判断操作系统
const platform = os.platform();
let _platform = ''
if (platform === 'darwin')
    _platform = 'mac';
else if (platform === 'linux')
    _platform = 'linux';
else if (platform === 'win32')
    _platform = os.arch() === 'x64' ? 'win64' : 'win32';
    
// 这里是一些控制台颜色的配置,不要也可,我只是为了好玩
var styles = {
    'bold'          :'\x1B[1m%s\x1B[22m',
    'italic'        :'\x1B[3m%s\x1B[23m',
    'underline'     :'\x1B[4m%s\x1B[24m',
    'inverse'       :'\x1B[7m%s\x1B[27m',
    'strikethrough' :'\x1B[9m%s\x1B[29m',
    'white'         :'\x1B[37m%s\x1B[39m',
    'grey'          :'\x1B[90m%s\x1B[39m',
    'black'         :'\x1B[30m%s\x1B[39m',
    'blue'          :'\x1B[34m%s\x1B[39m',
    'cyan'          :'\x1B[36m%s\x1B[39m',
    'green'         :'\x1B[32m%s\x1B[39m',
    'magenta'       :'\x1B[35m%s\x1B[39m',
    'red'           :'\x1B[31m%s\x1B[39m',
    'yellow'        :'\x1B[33m%s\x1B[39m',
    'whiteBG'       :'\x1B[47m%s\x1B[49m',
    'greyBG'        :'\x1B[49;5;8m%s\x1B[49m',
    'blackBG'       :'\x1B[40m%s\x1B[49m',
    'blueBG'        :'\x1B[44m%s\x1B[49m',
    'cyanBG'        :'\x1B[46m%s\x1B[49m',
    'greenBG'       :'\x1B[42m%s\x1B[49m',
    'magentaBG'     :'\x1B[45m%s\x1B[49m',
    'redBG'         :'\x1B[41m%s\x1B[49m',
    'yellowBG'      :'\x1B[43m%s\x1B[49m'
};    

const pb = new ProgressBar('下载进度', 0);

// 下载函数
function download(u, p) {
    //下载 的文件 地址
    let fileURL = u;
    //下载保存的文件路径
    let fileSavePath = path.join(__dirname, path.basename(fileURL));
    //缓存文件路径
    let tmpFileSavePath = fileSavePath + ".tmp";

    const fileStream = fs.createWriteStream(tmpFileSavePath).on('error', function (e) {
        console.error('error==>', e)
    }).on('ready', function () {
        console.log(styles.yellow,"开始下载:", fileURL);
    }).on('finish', function () {
        //下载完成后重命名文件
        fs.renameSync(tmpFileSavePath, fileSavePath);

        // node_modules\puppeteer\.local-chromium\win64-686378\chrome-win 在使用淘宝镜像下载成功过一次后知道了无头浏览器的位置,所以我这里进行拼接路径,将文件下载后解压到这个位置并在解压后将下载文件删除
        const mkdirPath = path.join(__dirname, `./node_modules/puppeteer/.local-chromium/${_platform}-${version}`)
        fs.mkdir(mkdirPath, { recursive: true }, (err) => {
            if (err) throw err;
            console.log(styles.yellow,"\n chromium 下载成功,开始安装...");
            const zipPath = path.join(__dirname, fileName)
            extractZip(zipPath, mkdirPath).then(res => {
                console.log(styles.green,"chromium 解压成功!开始删除下载文件");
                fs.unlinkSync(zipPath)
                console.log(styles.green,'删除成功!')
            })
        });
    });
    return fetch(u, {
        method: 'GET',
        headers: { 'Content-Type': 'application/octet-stream' },
    }).then(res => {
        //获取请求头中的文件大小数据
        let fsize = res.headers.get("content-length");

        let str = progressStream({
            length: fsize,
            time: 100 /* ms */
        });
        // 下载进度 
        str.on('progress', function (progressData) {
            pb.render({
                completed: progressData.percentage,
                total: 100,
                spend: progressData.speed,
            })
        });
        res.body.pipe(str).pipe(fileStream);
    }).catch(err => console.error(err))
}

/**
 * @param {string} zipPath
 * @param {string} folderPath
 * @return {!Promise<?Error>}
 */
function extractZip(zipPath, folderPath) {
    return new Promise((fulfill, reject) => extract(zipPath, { dir: folderPath }, err => {
        if (err)
            reject(err);
        else
            fulfill();
    }));
}

console.log(`当前是${_platform}系统`);
var url = downloadURLs[_platform];
const fileName = url.split("/").reverse()[0]
download(url, fileName)
复制代码

然后我为了让下载能够看得到进度条,又写了个小玩意

progress-bar.js

// 这里用到一个很实用的 npm 模块,用以在同一行打印文本
var slog = require('single-line-log').stdout;
 
// 封装的 ProgressBar 工具
function ProgressBar(description, bar_length){
  // 两个基本参数(属性)
  this.description = description || 'Progress';       // 命令行开头的文字信息
  this.length = bar_length || 25;                     // 进度条的长度(单位:字符),默认设为 25
 
  // 刷新进度条图案、文字的方法
  this.render = function (opts){
    var percent = (opts.completed / opts.total).toFixed(4);    // 计算进度(子任务的 完成数 除以 总数)
    var cell_num = Math.floor(percent * this.length);             // 计算需要多少个 █ 符号来拼凑图案
 
    // 拼接黑色条
    var cell = '';
    for (var i=0;i<cell_num;i++) {
      cell += '█';
    }
 
    // 拼接灰色条
    var empty = '';
    for (var i=0;i<this.length-cell_num;i++) {
      empty += '░';
    }
    
    let spend = `${(opts.spend/1024).toFixed(0)} kb\s`
    // 拼接最终文本
    var cmdText = this.description + ': ' + (100*percent).toFixed(2) + '% ' + cell + empty + ' ' + spend;
    
    // 在单行输出文本
    slog(cmdText);
  };
}
module.exports = ProgressBar;
复制代码

跑下我们刚刚写的脚本看看 确认下有没有下载到指定的位置

依赖配置好了,接下来就简单了,在根目录新建vue.config.js,并配置如下

vue.config.js

const PrerenderSPAPlugin = require('prerender-spa-plugin');
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer;
const path = require('path');

module.exports = {
    configureWebpack: config => {
        const TARGET = process.env.npm_lifecycle_event;
        
        const plugins=[]
        const prerenderSPAPlugin = new PrerenderSPAPlugin({
            // 生成文件的路径,也可以与webpakc打包的一致。
            // 下面这句话非常重要!!!
            // 这个目录只能有一级,如果目录层次大于一级,在生成的时候不会有任何错误提示,在预渲染的时候只会卡着不动。
            staticDir: path.join(__dirname, 'dist'),
            // 对应自己的路由文件,比如a有参数,就需要写成 /a/param1。
            routes: ['/', '/product', '/about'],
            // 这个很重要,如果没有配置这段,也不会进行预编译
            renderer: new Renderer({
                inject: {
                    foo: 'bar'
                },
                headless: false,
                // 在 main.js 中 document.dispatchEvent(new Event('render-event')),两者的事件名称要对应上。
                renderAfterDocumentEvent: 'render-event'
            })
        })
        if (process.env.NODE_ENV === 'production') {
            plugins.push(prerenderSPAPlugin)
        };    
        return {
            plugins,
        };
    }
}

复制代码

在src\main.js中添加事件

main.js

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
  mounted(){
    // 这是新加的事件
    document.dispatchEvent(new Event('render-event'))
  }
}).$mount('#app')
复制代码

因为我们配置的是只有production 模式下才能生效,那我们build下看看
可以看到build成功后,dist文件夹里面添加了两个目录,是我们刚刚配置好的路由,点击进去分别是一个html静态页面,因为是demo,这两个页面我并没有做,路由也没有配置,所以它是按照App.vue 也就是我们的根组件去渲染的,至此,大功告成。

Footnotes

  1. 一个webpack预渲染插件,www.npmjs.com/package/pre…

  2. Puppeteer 是 Google Chrome 出品的一个无头浏览器,提供高级 API,通过 DevTools Protocol 来控制 Chrome 或 Chromium。www.npmjs.com/package/pup…

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改