vue预渲染之prerender-spa-plugin解析(二)

前面我们有介绍了什么是预渲染、使用场景、然后简单的介绍了预渲染的集成过程,感兴趣的童鞋可以去看一下vue预渲染之prerender-spa-plugin解析(一),这一节我们重点来研究一下prerender-spa-plugin的源码.

附上prerender-spa-plugin的github地址: https://github.com/chrisvfritz/prerender-spa-plugin

我们直接去github拖一份源码:

vue预渲染之prerender-spa-plugin解析(二)_第1张图片

然后我们用的时候:
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群链接:
在这里插入图片描述

你可能感兴趣的:(html5学习笔记)