前面我们有介绍了什么是预渲染、使用场景、然后简单的介绍了预渲染的集成过程,感兴趣的童鞋可以去看一下vue预渲染之prerender-spa-plugin解析(一),这一节我们重点来研究一下prerender-spa-plugin的源码.
附上prerender-spa-plugin的github地址: https://github.com/chrisvfritz/prerender-spa-plugin
我们直接去github拖一份源码:
然后我们用的时候:
webpack.prod.conf.js:
webpackConfig.plugins.push(new PrerenderSPAPlugin({
staticDir: path.join(config.build.assetsRoot),
routes: ['/'],
renderer: new Renderer({
headless: false,
renderAfterDocumentEvent: 'render-event'
})
}))
可以看到,我们在webpack构建结束之前插入了一个叫PrerenderSPAPlugin的插件,PrerenderSPAPlugin是什么呢?
const PrerenderSPAPlugin = require('../prerender')
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer
const path = require('path')
const Prerenderer = require('./prerenderer')
const PuppeteerRenderer = require('./renderer-puppeteer')
const {minify} = require('html-minifier')
function PrerenderSPAPlugin(...args) {
const rendererOptions = {} // Primarily for backwards-compatibility.
this._options = {}
// Normal args object.
if (args.length === 1) {
this._options = args[0] || {}
// Backwards-compatibility with v2
} else {
console.warn("[prerender-spa-plugin] You appear to be using the v2 argument-based configuration options. It's recommended that you migrate to the clearer object-based configuration system.\nCheck the documentation for more information.")
let staticDir, routes
args.forEach(arg => {
if (typeof arg === 'string') staticDir = arg
else if (Array.isArray(arg)) routes = arg
else if (typeof arg === 'object') this._options = arg
})
staticDir ? this._options.staticDir = staticDir : null
routes ? this._options.routes = routes : null
}
// Backwards compatiblity with v2.
if (this._options.captureAfterDocumentEvent) {
console.warn('[prerender-spa-plugin] captureAfterDocumentEvent has been renamed to renderAfterDocumentEvent and should be moved to the renderer options.')
rendererOptions.renderAfterDocumentEvent = this._options.captureAfterDocumentEvent
}
if (this._options.captureAfterElementExists) {
console.warn('[prerender-spa-plugin] captureAfterElementExists has been renamed to renderAfterElementExists and should be moved to the renderer options.')
rendererOptions.renderAfterElementExists = this._options.captureAfterElementExists
}
if (this._options.captureAfterTime) {
console.warn('[prerender-spa-plugin] captureAfterTime has been renamed to renderAfterTime and should be moved to the renderer options.')
rendererOptions.renderAfterTime = this._options.captureAfterTime
}
this._options.server = this._options.server || {}
this._options.renderer = this._options.renderer || new PuppeteerRenderer(Object.assign({}, {headless: true}, rendererOptions))
if (this._options.postProcessHtml) {
console.warn('[prerender-spa-plugin] postProcessHtml should be migrated to postProcess! Consult the documentation for more information.')
}
}
PrerenderSPAPlugin.prototype.apply = function (compiler) {
const compilerFS = compiler.outputFileSystem
// From https://github.com/ahmadnassri/mkdirp-promise/blob/master/lib/index.js
const mkdirp = function (dir, opts) {
return new Promise((resolve, reject) => {
compilerFS.mkdirp(dir, opts, (err, made) => err === null ? resolve(made) : reject(err))
})
}
const afterEmit = (compilation, done) => {
const PrerendererInstance = new Prerenderer(this._options)
PrerendererInstance.initialize()
.then(() => {
return PrerendererInstance.renderRoutes(this._options.routes || [])
})
// Backwards-compatibility with v2 (postprocessHTML should be migrated to postProcess)
.then(renderedRoutes => this._options.postProcessHtml
? renderedRoutes.map(renderedRoute => {
const processed = this._options.postProcessHtml(renderedRoute)
if (typeof processed === 'string') renderedRoute.html = processed
else renderedRoute = processed
return renderedRoute
})
: renderedRoutes
)
// Run postProcess hooks.
.then(renderedRoutes => this._options.postProcess
? Promise.all(renderedRoutes.map(renderedRoute => this._options.postProcess(renderedRoute)))
: renderedRoutes
)
// Check to ensure postProcess hooks returned the renderedRoute object properly.
.then(renderedRoutes => {
const isValid = renderedRoutes.every(r => typeof r === 'object')
if (!isValid) {
throw new Error('[prerender-spa-plugin] Rendered routes are empty, did you forget to return the `context` object in postProcess?')
}
return renderedRoutes
})
// Minify html files if specified in config.
.then(renderedRoutes => {
if (!this._options.minify) return renderedRoutes
renderedRoutes.forEach(route => {
route.html = minify(route.html, this._options.minify)
})
return renderedRoutes
})
// Calculate outputPath if it hasn't been set already.
.then(renderedRoutes => {
renderedRoutes.forEach(rendered => {
if (!rendered.outputPath) {
// rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route, 'index.html')
rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route)
}
})
return renderedRoutes
})
// Create dirs and write prerendered files.
.then(processedRoutes => {
const promises = Promise.all(processedRoutes.map(processedRoute => {
return mkdirp(path.dirname(processedRoute.outputPath))
.then(() => {
return new Promise((resolve, reject) => {
compilerFS.writeFile(processedRoute.outputPath, processedRoute.html.trim(), err => {
if (err) reject(`[prerender-spa-plugin] Unable to write rendered route to file "${processedRoute.outputPath}" \n ${err}.`)
else resolve()
})
})
})
.catch(err => {
if (typeof err === 'string') {
err = `[prerender-spa-plugin] Unable to create directory ${path.dirname(processedRoute.outputPath)} for route ${processedRoute.route}. \n ${err}`
}
throw err
})
}))
return promises
})
.then(r => {
PrerendererInstance.destroy()
done()
})
.catch(err => {
PrerendererInstance.destroy()
const msg = '[prerender-spa-plugin] Unable to prerender all routes!'
console.error(msg)
compilation.errors.push(new Error(msg))
done()
})
}
if (compiler.hooks) {
const plugin = {name: 'PrerenderSPAPlugin'}
compiler.hooks.afterEmit.tapAsync(plugin, afterEmit)
} else {
compiler.plugin('after-emit', afterEmit)
}
}
PrerenderSPAPlugin.PuppeteerRenderer = PuppeteerRenderer
module.exports = PrerenderSPAPlugin
代码有点多,不要慌,我们看重点,作为webpack的插件我们都知道,webpack会回调插件的apply方法,然后把编译器对象传递给插件:
PrerenderSPAPlugin.prototype.apply = function (compiler) {
const compilerFS = compiler.outputFileSystem
// From https://github.com/ahmadnassri/mkdirp-promise/blob/master/lib/index.js
const mkdirp = function (dir, opts) {
return new Promise((resolve, reject) => {
compilerFS.mkdirp(dir, opts, (err, made) => err === null ? resolve(made) : reject(err))
})
}
const afterEmit = (compilation, done) => {
const PrerendererInstance = new Prerenderer(this._options)
PrerendererInstance.initialize()
.then(() => {
return PrerendererInstance.renderRoutes(this._options.routes || [])
})
// Backwards-compatibility with v2 (postprocessHTML should be migrated to postProcess)
.then(renderedRoutes => this._options.postProcessHtml
? renderedRoutes.map(renderedRoute => {
const processed = this._options.postProcessHtml(renderedRoute)
if (typeof processed === 'string') renderedRoute.html = processed
else renderedRoute = processed
return renderedRoute
})
: renderedRoutes
)
// Run postProcess hooks.
.then(renderedRoutes => this._options.postProcess
? Promise.all(renderedRoutes.map(renderedRoute => this._options.postProcess(renderedRoute)))
: renderedRoutes
)
// Check to ensure postProcess hooks returned the renderedRoute object properly.
.then(renderedRoutes => {
const isValid = renderedRoutes.every(r => typeof r === 'object')
if (!isValid) {
throw new Error('[prerender-spa-plugin] Rendered routes are empty, did you forget to return the `context` object in postProcess?')
}
return renderedRoutes
})
// Minify html files if specified in config.
.then(renderedRoutes => {
if (!this._options.minify) return renderedRoutes
renderedRoutes.forEach(route => {
route.html = minify(route.html, this._options.minify)
})
return renderedRoutes
})
// Calculate outputPath if it hasn't been set already.
.then(renderedRoutes => {
renderedRoutes.forEach(rendered => {
if (!rendered.outputPath) {
// rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route, 'index.html')
rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route)
}
})
return renderedRoutes
})
// Create dirs and write prerendered files.
.then(processedRoutes => {
const promises = Promise.all(processedRoutes.map(processedRoute => {
return mkdirp(path.dirname(processedRoute.outputPath))
.then(() => {
return new Promise((resolve, reject) => {
compilerFS.writeFile(processedRoute.outputPath, processedRoute.html.trim(), err => {
if (err) reject(`[prerender-spa-plugin] Unable to write rendered route to file "${processedRoute.outputPath}" \n ${err}.`)
else resolve()
})
})
})
.catch(err => {
if (typeof err === 'string') {
err = `[prerender-spa-plugin] Unable to create directory ${path.dirname(processedRoute.outputPath)} for route ${processedRoute.route}. \n ${err}`
}
throw err
})
}))
return promises
})
.then(r => {
PrerendererInstance.destroy()
done()
})
.catch(err => {
PrerendererInstance.destroy()
const msg = '[prerender-spa-plugin] Unable to prerender all routes!'
console.error(msg)
compilation.errors.push(new Error(msg))
done()
})
}
if (compiler.hooks) {
const plugin = {name: 'PrerenderSPAPlugin'}
compiler.hooks.afterEmit.tapAsync(plugin, afterEmit)
} else {
compiler.plugin('after-emit', afterEmit)
}
}
prerender-spa-plugin做的第一件事就是监听webpack的构建过程:
if (compiler.hooks) {
const plugin = {name: 'PrerenderSPAPlugin'}
compiler.hooks.afterEmit.tapAsync(plugin, afterEmit)
} else {
compiler.plugin('after-emit', afterEmit)
}
当webpack构建完毕后就会发送after-emit事件,然后执行插件的afterEmit方法,我们找到afterEmit方法:
const afterEmit = (compilation, done) => {
const PrerendererInstance = new Prerenderer(this._options)
PrerendererInstance.initialize()
.then(() => {
return PrerendererInstance.renderRoutes(this._options.routes || [])
})
// Backwards-compatibility with v2 (postprocessHTML should be migrated to postProcess)
.then(renderedRoutes => this._options.postProcessHtml
? renderedRoutes.map(renderedRoute => {
const processed = this._options.postProcessHtml(renderedRoute)
if (typeof processed === 'string') renderedRoute.html = processed
else renderedRoute = processed
return renderedRoute
})
: renderedRoutes
)
// Run postProcess hooks.
.then(renderedRoutes => this._options.postProcess
? Promise.all(renderedRoutes.map(renderedRoute => this._options.postProcess(renderedRoute)))
: renderedRoutes
)
// Check to ensure postProcess hooks returned the renderedRoute object properly.
.then(renderedRoutes => {
const isValid = renderedRoutes.every(r => typeof r === 'object')
if (!isValid) {
throw new Error('[prerender-spa-plugin] Rendered routes are empty, did you forget to return the `context` object in postProcess?')
}
return renderedRoutes
})
// Minify html files if specified in config.
.then(renderedRoutes => {
if (!this._options.minify) return renderedRoutes
renderedRoutes.forEach(route => {
route.html = minify(route.html, this._options.minify)
})
return renderedRoutes
})
// Calculate outputPath if it hasn't been set already.
.then(renderedRoutes => {
renderedRoutes.forEach(rendered => {
if (!rendered.outputPath) {
// rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route, 'index.html')
rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route)
}
})
return renderedRoutes
})
// Create dirs and write prerendered files.
.then(processedRoutes => {
const promises = Promise.all(processedRoutes.map(processedRoute => {
return mkdirp(path.dirname(processedRoute.outputPath))
.then(() => {
return new Promise((resolve, reject) => {
compilerFS.writeFile(processedRoute.outputPath, processedRoute.html.trim(), err => {
if (err) reject(`[prerender-spa-plugin] Unable to write rendered route to file "${processedRoute.outputPath}" \n ${err}.`)
else resolve()
})
})
})
.catch(err => {
if (typeof err === 'string') {
err = `[prerender-spa-plugin] Unable to create directory ${path.dirname(processedRoute.outputPath)} for route ${processedRoute.route}. \n ${err}`
}
throw err
})
}))
return promises
})
.then(r => {
PrerendererInstance.destroy()
done()
})
.catch(err => {
PrerendererInstance.destroy()
const msg = '[prerender-spa-plugin] Unable to prerender all routes!'
console.error(msg)
compilation.errors.push(new Error(msg))
done()
})
}
又是一长串代码,不要慌,我们继续往下走~
我们看到创建了一个Prerenderer对象,然后就是执行的Prerenderer对象的一些方法,所以我们找到Prerenderer定义:
const PrerendererInstance = new Prerenderer(this._options)
PrerendererInstance.initialize()
.then(() => {
return PrerendererInstance.renderRoutes(this._options.routes || [])
})
.....
prerenderer.js
const Server = require('./server')
const PortFinder = require('portfinder')
const PACKAGE_NAME = '[Prerenderer]'
const OPTION_SCHEMA = {
staticDir: {
type: String,
required: true
},
indexPath: {
type: String,
required: false
}
}
function validateOptionsSchema (schema, options, parent) {
var errors = []
Object.keys(schema).forEach(key => {
// Required options
if (schema[key].required && !options[key]) {
errors.push(`"${parent || ''}${key}" option is required!`)
return
// Options with default values or potential children.
} else if (!options[key] && (schema[key].default || schema[key].children)) {
options[key] = schema[key].default != null ? schema[key].default : {}
// Non-required empty options.
} else if (!options[key]) return
// Array-type options
if (Array.isArray(schema[key].type) && schema[key].type.indexOf(options[key].constructor) === -1) {
console.log(schema[key].type.indexOf(options[key].constructor))
errors.push(`"${parent || ''}${key}" option must be a ${schema[key].type.map(t => t.name).join(' or ')}!`)
// Single-type options.
} else if (!Array.isArray(schema[key].type) && options[key].constructor !== schema[key].type) {
errors.push(`"${parent || ''}${key}" option must be a ${schema[key].type.name}!`)
return
}
if (schema[key].children) {
errors.push(...validateOptionsSchema(schema[key].children, options[key], key))
return
}
})
errors.forEach(function (error) {
console.error(`${PACKAGE_NAME} ${error}`)
})
return errors
}
class Prerenderer {
constructor (options) {
this._options = options || {}
this._server = new Server(this)
this._renderer = options.renderer
if (this._renderer && this._renderer.preServer) this._renderer.preServer(this)
if (!this._options) throw new Error(`${PACKAGE_NAME} Options must be defined!`)
if (!this._options.renderer) {
throw new Error(`${PACKAGE_NAME} No renderer was passed to prerenderer.
If you are not sure wihch renderer to use, see the documentation at https://github.com/tribex/prerenderer.`)
}
if (!this._options.server) this._options.server = {}
const optionValidationErrors = validateOptionsSchema(OPTION_SCHEMA, this._options)
if (optionValidationErrors.length !== 0) throw new Error(`${PACKAGE_NAME} Options are invalid. Unable to prerender!`)
}
async initialize () {
// Initialization is separate from construction because science? (Ideally to initialize the server and renderer separately.)
this._options.server.port = this._options.server.port || await PortFinder.getPortPromise() || 13010
await this._server.initialize()
await this._renderer.initialize()
return Promise.resolve()
}
destroy () {
this._renderer.destroy()
this._server.destroy()
}
getServer () {
return this._server
}
getRenderer () {
return this._renderer
}
getOptions () {
return this._options
}
modifyServer (server, stage) {
if (this._renderer.modifyServer) this._renderer.modifyServer(this, server, stage)
}
renderRoutes (routes) {
return this._renderer.renderRoutes(routes, this)
// Handle non-ASCII or invalid URL characters in routes by normalizing them back to unicode.
// Some browser environments may change unicode or special characters in routes to percent encodings.
// We need to convert them back for saving in the filesystem.
.then(renderedRoutes => {
renderedRoutes.forEach(rendered => {
rendered.route = decodeURIComponent(rendered.route)
})
return renderedRoutes
})
}
}
module.exports = Prerenderer
好在代码不是很多哈,我们看一下构造函数:
constructor (options) {
this._options = options || {}
//创建一个node服务器管理类
this._server = new Server(this)
this._renderer = options.renderer
if (this._renderer && this._renderer.preServer) this._renderer.preServer(this)
if (!this._options) throw new Error(`${PACKAGE_NAME} Options must be defined!`)
if (!this._options.renderer) {
throw new Error(`${PACKAGE_NAME} No renderer was passed to prerenderer.
If you are not sure wihch renderer to use, see the documentation at https://github.com/tribex/prerenderer.`)
}
if (!this._options.server) this._options.server = {}
const optionValidationErrors = validateOptionsSchema(OPTION_SCHEMA, this._options)
if (optionValidationErrors.length !== 0) throw new Error(`${PACKAGE_NAME} Options are invalid. Unable to prerender!`)
}
忽略一些初始化变量的赋值,最重要的就是创建一个Server(node服务器):
this._server = new Server(this)
然后我们再次回到之前的入口文件:
const afterEmit = (compilation, done) => {
const PrerendererInstance = new Prerenderer(this._options)
PrerendererInstance.initialize()
.then(() => {
return PrerendererInstance.renderRoutes(this._options.routes || [])
})
执行了Prerenderer的initialize方法,所以我们找到Prerenderer的initialize方法:
async initialize () {
// Initialization is separate from construction because science? (Ideally to initialize the server and renderer separately.)
this._options.server.port = this._options.server.port || await PortFinder.getPortPromise() || 13010
await this._server.initialize()
await this._renderer.initialize()
return Promise.resolve()
}
分别直接了this._server跟this._renderer的initialize方法,我们一个一个看哈~
首先看一下:
constructor (options) {
this._options = options || {}
//创建一个node服务器管理类
this._server = new Server(this)
....
}
await this._server.initialize()
Server是什么呢? 它的initialize方法又干了什么呢?
server.js:
const express = require('express')
const proxy = require('http-proxy-middleware')
const path = require('path')
class Server {
constructor (Prerenderer) {
this._prerenderer = Prerenderer
this._options = Prerenderer.getOptions()
this._expressServer = express()
this._nativeServer = null
}
initialize () {
const server = this._expressServer
this._prerenderer.modifyServer(this, 'pre-static')
server.get('*.*', express.static(this._options.staticDir, {
dotfiles: 'allow'
}))
this._prerenderer.modifyServer(this, 'post-static')
this._prerenderer.modifyServer(this, 'pre-fallback')
if (this._options.server && this._options.server.proxy) {
for (let proxyPath of Object.keys(this._options.server.proxy)) {
server.use(proxyPath, proxy(this._options.server.proxy[proxyPath]))
}
}
server.get('*', (req, res) => {
res.sendFile(this._options.indexPath ? this._options.indexPath : path.join(this._options.staticDir, req.path))
})
this._prerenderer.modifyServer(this, 'post-fallback')
return new Promise((resolve, reject) => {
this._nativeServer = server.listen(this._options.server.port, () => {
resolve()
})
})
}
destroy () {
this._nativeServer.close()
}
}
module.exports = Server
很简单,就是利用express创建了一个node服务器,然后监听url返回对应的资源~
server.get('*', (req, res) => {
res.sendFile(this._options.indexPath ? this._options.indexPath : path.join(this._options.staticDir, req.path))
})
那么监听的又是哪里的文件呢? 我继续贴一下上一节中的流程图:
监听的就是我们直接用webpack打包出来没有经过预渲染的资源文件.
server类我们算是看完了,然后我们继续分析:
await this._renderer.initialize()
this._renderer又是什么呢? this._renderer就是我们在webpack.prod.conf.js文件中传递进去的PuppeteerRenderer对象:
.....
const PrerenderSPAPlugin = require('../prerender')
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer
.....
webpackConfig.plugins.push(new PrerenderSPAPlugin({
staticDir: path.join(config.build.assetsRoot),
routes: ['/'],
renderer: new Renderer({
headless: false,
renderAfterDocumentEvent: 'render-event'
})
}))
module.exports = webpackConfig
new Renderer({
headless: false,
renderAfterDocumentEvent: 'render-event'
})
我们找到Renderer的源码~
renderer-puppeteer/es6/renderer.js:
const promiseLimit = require('promise-limit')
const puppeteer = require('puppeteer')
const waitForRender = function (options) {
options = options || {}
return new Promise((resolve, reject) => {
// Render when an event fires on the document.
if (options.renderAfterDocumentEvent) {
if (window['__PRERENDER_STATUS'] && window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED) resolve()
document.addEventListener(options.renderAfterDocumentEvent, () => resolve())
// Render after a certain number of milliseconds.
} else if (options.renderAfterTime) {
setTimeout(() => resolve(), options.renderAfterTime)
// Default: Render immediately after page content loads.
} else {
resolve()
}
})
}
class PuppeteerRenderer {
constructor(rendererOptions) {
this._puppeteer = null
this._rendererOptions = rendererOptions || {}
if (this._rendererOptions.maxConcurrentRoutes == null) this._rendererOptions.maxConcurrentRoutes = 0
if (this._rendererOptions.inject && !this._rendererOptions.injectProperty) {
this._rendererOptions.injectProperty = '__PRERENDER_INJECTED'
}
}
async initialize() {
try {
// Workaround for Linux SUID Sandbox issues.
if (process.platform === 'linux') {
if (!this._rendererOptions.args) this._rendererOptions.args = []
if (this._rendererOptions.args.indexOf('--no-sandbox') === -1) {
this._rendererOptions.args.push('--no-sandbox')
this._rendererOptions.args.push('--disable-setuid-sandbox')
}
}
this._puppeteer = await puppeteer.launch(this._rendererOptions)
} catch (e) {
console.error(e)
console.error('[Prerenderer - PuppeteerRenderer] Unable to start Puppeteer')
// Re-throw the error so it can be handled further up the chain. Good idea or not?
throw e
}
return this._puppeteer
}
async handleRequestInterception(page, baseURL) {
await page.setRequestInterception(true)
page.on('request', req => {
// Skip third party requests if needed.
if (this._rendererOptions.skipThirdPartyRequests) {
if (!req.url().startsWith(baseURL)) {
req.abort()
return
}
}
req.continue()
})
}
async renderRoutes(routes, Prerenderer) {
const rootOptions = Prerenderer.getOptions()
const options = this._rendererOptions
const limiter = promiseLimit(this._rendererOptions.maxConcurrentRoutes)
const pagePromises = Promise.all(
routes.map(
(route, index) => limiter(
async () => {
const page = await this._puppeteer.newPage()
if (options.consoleHandler) {
page.on('console', message => options.consoleHandler(route, message))
}
if (options.inject) {
await page.evaluateOnNewDocument(`(function () { window['${options.injectProperty}'] = ${JSON.stringify(options.inject)}; })();`)
}
const baseURL = `http://localhost:${rootOptions.server.port}`
// Allow setting viewport widths and such.
if (options.viewport) await page.setViewport(options.viewport)
await this.handleRequestInterception(page, baseURL)
// Hack just in-case the document event fires before our main listener is added.
if (options.renderAfterDocumentEvent) {
page.evaluateOnNewDocument(function (options) {
window['__PRERENDER_STATUS'] = {}
document.addEventListener(options.renderAfterDocumentEvent, () => {
window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED = true
})
}, this._rendererOptions)
}
const navigationOptions = (options.navigationOptions) ? Object.assign({waituntil: 'networkidle0'}, options.navigationOptions) : {waituntil: 'networkidle0'};
await page.goto(`${baseURL}${route}`, navigationOptions);
// Wait for some specific element exists
const {renderAfterElementExists} = this._rendererOptions
if (renderAfterElementExists && typeof renderAfterElementExists === 'string') {
await page.waitForSelector(renderAfterElementExists)
}
// Once this completes, it's safe to capture the page contents.
await page.evaluate(waitForRender, this._rendererOptions)
const result = {
originalRoute: route,
route: await page.evaluate('window.location.pathname'),
html: await page.content()
}
await page.close()
return result
}
)
)
)
return pagePromises
}
destroy() {
this._puppeteer.close()
}
}
module.exports = PuppeteerRenderer
代码有点多,我们直接看重点方法initialize:
async initialize() {
try {
// Workaround for Linux SUID Sandbox issues.
if (process.platform === 'linux') {
if (!this._rendererOptions.args) this._rendererOptions.args = []
if (this._rendererOptions.args.indexOf('--no-sandbox') === -1) {
this._rendererOptions.args.push('--no-sandbox')
this._rendererOptions.args.push('--disable-setuid-sandbox')
}
}
this._puppeteer = await puppeteer.launch(this._rendererOptions)
} catch (e) {
console.error(e)
console.error('[Prerenderer - PuppeteerRenderer] Unable to start Puppeteer')
// Re-throw the error so it can be handled further up the chain. Good idea or not?
throw e
}
return this._puppeteer
}
可以看到,最主要的就是执行了一行:
this._puppeteer = await puppeteer.launch(this._rendererOptions)
puppeteer是啥呢?
Puppeteer(中文翻译”木偶”) 是 Google Chrome 团队官方的无界面(Headless)Chrome 工具,它是一个 Node 库,提供了一个高级的 API 来控制 DevTools协议上的无头版 Chrome .
简单来说就是可以操作你本地的谷歌浏览器~~感兴趣的小伙伴可以自己去搜一搜哈,Puppeteer工具还是很强大的.
好啦,我们继续回到我们的入口文件:
PrerendererInstance.initialize()
.then(() => {
return PrerendererInstance.renderRoutes(this._options.routes || [])
})
可以看到,执行完initialize方法后(启动了一个server,并且初始化了Puppeteer工具),执行了:
return PrerendererInstance.renderRoutes(this._options.routes || [])
async renderRoutes(routes, Prerenderer) {
const rootOptions = Prerenderer.getOptions()
const options = this._rendererOptions
const limiter = promiseLimit(this._rendererOptions.maxConcurrentRoutes)
const pagePromises = Promise.all(
routes.map(
(route, index) => limiter(
async () => {
const page = await this._puppeteer.newPage()
if (options.consoleHandler) {
page.on('console', message => options.consoleHandler(route, message))
}
if (options.inject) {
await page.evaluateOnNewDocument(`(function () { window['${options.injectProperty}'] = ${JSON.stringify(options.inject)}; })();`)
}
const baseURL = `http://localhost:${rootOptions.server.port}`
// Allow setting viewport widths and such.
if (options.viewport) await page.setViewport(options.viewport)
await this.handleRequestInterception(page, baseURL)
// Hack just in-case the document event fires before our main listener is added.
if (options.renderAfterDocumentEvent) {
page.evaluateOnNewDocument(function (options) {
window['__PRERENDER_STATUS'] = {}
document.addEventListener(options.renderAfterDocumentEvent, () => {
window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED = true
})
}, this._rendererOptions)
}
const navigationOptions = (options.navigationOptions) ? Object.assign({waituntil: 'networkidle0'}, options.navigationOptions) : {waituntil: 'networkidle0'};
await page.goto(`${baseURL}${route}`, navigationOptions);
// Wait for some specific element exists
const {renderAfterElementExists} = this._rendererOptions
if (renderAfterElementExists && typeof renderAfterElementExists === 'string') {
await page.waitForSelector(renderAfterElementExists)
}
// Once this completes, it's safe to capture the page contents.
await page.evaluate(waitForRender, this._rendererOptions)
const result = {
originalRoute: route,
route: await page.evaluate('window.location.pathname'),
html: await page.content()
}
await page.close()
return result
}
)
)
)
return pagePromises
}
又是一长串注释,小伙伴不要被吓到哈,首先是遍历我们在webpack.prod.conf.js配置文件中传入的routes数组:
webpackConfig.plugins.push(new PrerenderSPAPlugin({
staticDir: path.join(config.build.assetsRoot),
routes: ['/'],
renderer: new Renderer({
headless: false,
renderAfterDocumentEvent: 'render-event'
})
}))
可以看到,我们在webpack的配置文件中传入了 routes: [’/’]:
async renderRoutes(routes, Prerenderer) {
const rootOptions = Prerenderer.getOptions()
const options = this._rendererOptions
const limiter = promiseLimit(this._rendererOptions.maxConcurrentRoutes)
const pagePromises = Promise.all(
routes.map(
(route, index) => limiter(
async () => {
//开启一个新页面
const page = await this._puppeteer.newPage()
//是否需要console回调
if (options.consoleHandler) {
page.on('console', message => options.consoleHandler(route, message))
}
//是否需要给window对象中注入内容
if (options.inject) {
await page.evaluateOnNewDocument(`(function () { window['${options.injectProperty}'] = ${JSON.stringify(options.inject)}; })();`)
}
//获取需要预渲染页面的地址
const baseURL = `http://localhost:${rootOptions.server.port}`
// 设置打开后窗体的大小(可以指定屏幕的大小{width: 375,height:1440})
if (options.viewport) await page.setViewport(options.viewport)
//拦截页面中的请求
await this.handleRequestInterception(page, baseURL)
// Hack just in-case the document event fires before our main listener is added.
//监听预加载结束的通知
if (options.renderAfterDocumentEvent) {
page.evaluateOnNewDocument(function (options) {
window['__PRERENDER_STATUS'] = {}
document.addEventListener(options.renderAfterDocumentEvent, () => {
window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED = true
})
}, this._rendererOptions)
}
const navigationOptions = (options.navigationOptions) ? Object.assign({waituntil: 'networkidle0'}, options.navigationOptions) : {waituntil: 'networkidle0'};
//打开指定页面
await page.goto(`${baseURL}${route}`, navigationOptions);
// Wait for some specific element exists
const {renderAfterElementExists} = this._rendererOptions
if (renderAfterElementExists && typeof renderAfterElementExists === 'string') {
await page.waitForSelector(renderAfterElementExists)
}
// 等待预加载结束
await page.evaluate(waitForRender, this._rendererOptions)
const result = {
originalRoute: route,
route: await page.evaluate('window.location.pathname'),
html: await page.content()
}
await page.close()
//返回加载过后的结果
return result
}
)
)
)
return pagePromises
}
遍历完所有页面,并打开所有页面,然后拿到所有页面的返回结果(静态html内容),主要流程算是已经走完了.
我们回到入口文件:
PrerendererInstance.initialize()
.then(() => {
return PrerendererInstance.renderRoutes(this._options.routes || [])
})
// Backwards-compatibility with v2 (postprocessHTML should be migrated to postProcess)
.then(renderedRoutes => this._options.postProcessHtml
? renderedRoutes.map(renderedRoute => {
const processed = this._options.postProcessHtml(renderedRoute)
if (typeof processed === 'string') renderedRoute.html = processed
else renderedRoute = processed
return renderedRoute
})
: renderedRoutes
)
// Run postProcess hooks.
.then(renderedRoutes => this._options.postProcess
? Promise.all(renderedRoutes.map(renderedRoute => this._options.postProcess(renderedRoute)))
: renderedRoutes
)
// Check to ensure postProcess hooks returned the renderedRoute object properly.
.then(renderedRoutes => {
const isValid = renderedRoutes.every(r => typeof r === 'object')
if (!isValid) {
throw new Error('[prerender-spa-plugin] Rendered routes are empty, did you forget to return the `context` object in postProcess?')
}
return renderedRoutes
})
// Minify html files if specified in config.
.then(renderedRoutes => {
if (!this._options.minify) return renderedRoutes
renderedRoutes.forEach(route => {
route.html = minify(route.html, this._options.minify)
})
return renderedRoutes
})
// Calculate outputPath if it hasn't been set already.
.then(renderedRoutes => {
renderedRoutes.forEach(rendered => {
if (!rendered.outputPath) {
// rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route, 'index.html')
rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route)
}
})
return renderedRoutes
})
// Create dirs and write prerendered files.
.then(processedRoutes => {
const promises = Promise.all(processedRoutes.map(processedRoute => {
return mkdirp(path.dirname(processedRoute.outputPath))
.then(() => {
return new Promise((resolve, reject) => {
compilerFS.writeFile(processedRoute.outputPath, processedRoute.html.trim(), err => {
if (err) reject(`[prerender-spa-plugin] Unable to write rendered route to file "${processedRoute.outputPath}" \n ${err}.`)
else resolve()
})
})
})
.catch(err => {
if (typeof err === 'string') {
err = `[prerender-spa-plugin] Unable to create directory ${path.dirname(processedRoute.outputPath)} for route ${processedRoute.route}. \n ${err}`
}
throw err
})
}))
return promises
})
.then(r => {
PrerendererInstance.destroy()
done()
})
.catch(err => {
PrerendererInstance.destroy()
const msg = '[prerender-spa-plugin] Unable to prerender all routes!'
console.error(msg)
compilation.errors.push(new Error(msg))
done()
})
可以看到我们已经跑完了:
PrerendererInstance.initialize()
.then(() => {
return PrerendererInstance.renderRoutes(this._options.routes || [])
})
接着就是对结果的处理工作了,包括文件压缩、重组文件等等…
PrerenderSPAPlugin.prototype.apply = function (compiler) {
const compilerFS = compiler.outputFileSystem
// From https://github.com/ahmadnassri/mkdirp-promise/blob/master/lib/index.js
const mkdirp = function (dir, opts) {
return new Promise((resolve, reject) => {
compilerFS.mkdirp(dir, opts, (err, made) => err === null ? resolve(made) : reject(err))
})
}
const afterEmit = (compilation, done) => {
const PrerendererInstance = new Prerenderer(this._options)
PrerendererInstance.initialize()
.then(() => {
return PrerendererInstance.renderRoutes(this._options.routes || [])
})
// 可以传递postProcessHtml参数对返回的html做处理
.then(renderedRoutes => this._options.postProcessHtml
? renderedRoutes.map(renderedRoute => {
const processed = this._options.postProcessHtml(renderedRoute)
if (typeof processed === 'string') renderedRoute.html = processed
else renderedRoute = processed
return renderedRoute
})
: renderedRoutes
)
//可以传递postProcess函数进一步对结果进行处理
.then(renderedRoutes => this._options.postProcess
? Promise.all(renderedRoutes.map(renderedRoute => this._options.postProcess(renderedRoute)))
: renderedRoutes
)
//验证返回的结果
.then(renderedRoutes => {
const isValid = renderedRoutes.every(r => typeof r === 'object')
if (!isValid) {
throw new Error('[prerender-spa-plugin] Rendered routes are empty, did you forget to return the `context` object in postProcess?')
}
return renderedRoutes
})
// 是否需要压缩打包过后的html文件
.then(renderedRoutes => {
if (!this._options.minify) return renderedRoutes
renderedRoutes.forEach(route => {
route.html = minify(route.html, this._options.minify)
})
return renderedRoutes
})
// 文件重组
.then(renderedRoutes => {
renderedRoutes.forEach(rendered => {
if (!rendered.outputPath) {
// rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route, 'index.html')
rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route)
}
})
return renderedRoutes
})
// 重组并输出
.then(processedRoutes => {
const promises = Promise.all(processedRoutes.map(processedRoute => {
return mkdirp(path.dirname(processedRoute.outputPath))
.then(() => {
return new Promise((resolve, reject) => {
compilerFS.writeFile(processedRoute.outputPath, processedRoute.html.trim(), err => {
if (err) reject(`[prerender-spa-plugin] Unable to write rendered route to file "${processedRoute.outputPath}" \n ${err}.`)
else resolve()
})
})
})
.catch(err => {
if (typeof err === 'string') {
err = `[prerender-spa-plugin] Unable to create directory ${path.dirname(processedRoute.outputPath)} for route ${processedRoute.route}. \n ${err}`
}
throw err
})
}))
return promises
})
//预加载结束
.then(r => {
PrerendererInstance.destroy()
done()
})
.catch(err => {
PrerendererInstance.destroy()
const msg = '[prerender-spa-plugin] Unable to prerender all routes!'
console.error(msg)
compilation.errors.push(new Error(msg))
done()
})
}
好啦,预加载的全部内容都结束了~~
总结一下:
1、利用puppeteer预加载页面,页面的加载出来样式就是某一种设备的效果,页面兼容性会有问题.
2、绕了一圈,感觉这是在模仿jsp页面呀(哈哈哈)
3、比如页面有一个轮播图,加载出来的页面是没法滚动的,因为代码都在js文件中.
…
做一些静态的、简单的比如“公司介绍页面”这样的用用预渲染还不错,其它页面就算了,意义不大,你们觉得呢? 哈哈~ 欢迎一起交流~
这节先到这里了,下一节我们照着老套路从头到尾的撸一遍源码.
先到这里啦,欢迎志同道合的小伙伴入群,一起交流一起学习~~ 加油骚年!!
qq群链接: