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…
总结
可以说几种方法各有利弊,实际采用哪种方法还要结合具体场景和生产打包机的环境状况,最佳实践可能永远是下一种。
(原创文章,转载须注明作者及来源)