让prerender-spa-plugin支持cdn域名的几种尝试

4,737 阅读4分钟

react、vue等前端spa框架应用到2c网站的问题之一是较长的白屏时间和不支持seo,prerender是解决这些问题的方案之一。在实践中我也比较推荐这种方式,其开发成本和维护难度都比server side render(SSR)低很多,性价比突出。

通常采用的这个webpack插件:prerender-spa-plugin,其实现原理也很简单:在webpack打包结束并生成文件后(after-emit hook),启动一个server模拟网站的运行,用puppeteer(google官方的headless chrome浏览器)访问指定的页面route,得到相应的html结构,并将结果输出到指定目录,过程类似于爬虫。

但实际应用到生产项目,还是会有一些具体问题需要解决。其中最常见的,就是cdn域名问题——网站静态资源的域名(通常在publicPath或baseUrl处设定)与网站主域不同,在webpack打包过程中,css,js等文件还未上线也即未推送到cdn节点上,导致prerender失败。

方法一 double build

build两次。第一次将build后的css,js等静态资源rsync到cdn服务器。第二次再build时就可以访问到cdn资源了:

{
  "private": true,
  "scripts": {
    "dev": "node build/dev-server.js",
    "prebuild": "node build/prebuild.js && rsync -r dist/static/* example.com:/sites/example/static",
    "build": "npm run prebuild && node build/build.js",
  }
}

这种方式build了两次,成本高而且显得dumb。而且实际情况中为了保证安全,打包机跟生产机未必能够直通。

方法二 build后用脚本修改域名

换一种思路,先将webpack的publicPath配置成与prerender同域,即cdn域名指向prerender-spa-plugin启动的server端口如//127.0.0.1:13010,build后再替换html,js等文件中的域名。

// vue.config.js
publicPath: '//127.0.0.1:13010/'

修改package.json中的npm scripts:

"scripts": {
  "build": "vue-cli-service build && node replaceCDN.js",
}

replaceCDN.js

//字符串替换部分,其他忽略:
  fs.readFile(filename, { flag: 'r+', encoding: 'utf8' }, function (err, data) {
    if (err) {
      console.error(err)
    }
    var replacedContent = data.replace(/\/\/127.0.0.1:13010\//g, cdnPath)
    // write file
    writeFile(filename, replacedContent)
  })

方法三 利用webpack的全局变量和正则替换

__webpack_public_path__是一个webpack暴露的全局变量,可以在运行时设置publicPath,相关文档,可以用于项目运行后动态加载的js/css修改成cdn域名。

新增public-path.js

const isPrerender = window.__PRERENDER_INJECTED__ === 'prerender'
__webpack_public_path__ = isPrerender ? '' : 'http://www.cdn.com'

在main.js的最开始import这个文件

import './public-path'
import Vue from 'vue'
import App from './App'
import router from './router'

Vue.config.productionTip = false

/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  components: { App },
  template: '<App/>'
})

注意__webpack_public_path__只能修改动态加载的css/js域名,打包时生成在html内的域名还需要修改prerender-spa-plugin的配置,在postProcess回调内进行字符串替换:

// webpack.prod.conf.js或vue.config.js
//...
    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'
      })
    })

完整的demo请参考dhgan的github项目。但也有问题,打出来的html引用地址会出现双斜杠//,如http://127.0.0.1:8083//static/js/app.56447ec298fb275735eb.js

方法四 打包机代理cdn域名

我目前采用的方式是用打包机本身来做cdn代理。当然可以使用nginx做这个事儿,但为了工程化方便,我用的是express结合http-proxy-middleware。 这个方法有个前提条件,打包机本身不能有80端口占用。以vue-cli3做脚手架的项目来介绍一下实现思路:

首先配置/etc/hosts,假设cdn域名为www.cdn.com:

127.0.0.1 www.cdn.com

配置 vue.config.js:

const isProd = process.env.NODE_ENV === 'production'
module.exports = {
  publicPath: isProd ? '//www.cdn.com/' : '/',
  configureWebpack: {
    plugins: isProd ? [
      new PrerenderSPAPlugin({
        staticDir: path.join(__dirname, 'dist'),
        server: {
          port: 13010
        },
        routes: [
          '/',
          '/about',
          '/contact'
        ]
      })
    ] : []
  }
 }

修改package.json的npm scripts:

  "scripts": {
    "start": "npm run serve",
    "serve": "vue-cli-service serve --open",
    "build": "node prerender.js \"vue-cli-service build\""
  },

prerender.js:

var { spawn } = require('child_process')
var express = require('express')
var proxyMiddleware = require('http-proxy-middleware')
var app = express()

function makeProxy (renderPort) {
  var options = {
    target: `http://localhost:${renderPort}`,
    changeOrigin: true
  }
  app.use(proxyMiddleware('/', options))
  app.listen(80)
}

makeProxy(13010)
//为了保持子进程的颜色输出
process.env.FORCE_COLOR = true

const [str0, ...rest] = process.argv[2].split(/\s/)
const cmd = spawn(str0, rest, { env: process.env })
cmd.stdout.on('data', function (data) {
  process.stdout.write(data)
})

cmd.stderr.on('data', function (data) {
  process.stderr.write(data)
})

cmd.on('exit', function (code) {
  process.exit(0)
})

上面代码启动了一个proxy server来代理cdn的请求,并将@vue/cli3的build过程通过spawn子进程的方式结合进来。

demo参见dunhuang的vue cli3方案

还提供一个cli2.x的版本 dunhuang的vue cli2.x版本方案

【更新】方法五,使用 prerender-spa-cdn-plugin

最近写了一个npm package。可以通过零配置解决模拟cdn sever的作用。原理是在启动puppeteer时设置一个正向代理服务器,用它来代理去往cdn域名的请求。

npm install prerender-spa-cdn-plugin
const PrerenderSpaCdnPlugin = require('prerender-spa-cdn-plugin')
// ...
new PrerenderSpaCdnPlugin({
  staticDir: path.join(__dirname, 'dist'),
  routes: ['/', '/about'],
  rendererOptions: {
    maxConcurrentRoutes: 1,
    injectProperty: '__PRERENDER_INJECTED',
    inject: {
      rendering: true
    }
  }
})

具体代码和example参见github.com/dunhuang/pr…

总结

可以说几种方法各有利弊,实际采用哪种方法还要结合具体场景和生产打包机的环境状况,最佳实践可能永远是下一种。

(原创文章,转载须注明作者及来源)