1 背景
如今的一些主流框架(Vue、React)打包的项目都是单页web应用,在页面加载时需要执行JS来生成DOM元素,这会消耗很大的时间,因此会出现首屏加载速度慢的问题。现阶段的解决方案有服务端渲染(SSR)、静态网站生成(SSG)、预渲染(prerender)
| 几种方案的对比 | |||
|---|---|---|---|
| 方案 | SSR | SSG | prerender |
| 特点 | 在收到请求时,实时在服务端提前处理完接口请求,生成HTML文件传给前端 | 服务端提前编译好静态HTML,在收到请求时返回HTML文件 | 在webpack打包时,启动一个无头浏览器,加载应用程序的路由,并将结果保存到一个静态HTML文件中 |
| 问题 | 在服务端实时处理JS文件生成HTML,对服务端造成性能压力 | 提前打包为HTML文件,丢失动态能力 | 只能处理静态资源,无法提前处理动态获取的资源,webpack打包时间增加 |
SSR对服务器要求高,穷学生没资源,SSG限制又比较大,因此预渲染是我比较喜欢的一个方案。
2 预渲染
预渲染的实现流程在之前的一篇文章 SEO 优化——预渲染(prerender) - 掘金 (juejin.cn)里提到过,这里针对一个常见的预渲染的包prerender-spa-plugin进行源码的解析。
文档以及源码地址可以看prerender-spa-plugin - npm (npmjs.com),选取es6版本的源码进行解读,整体的代码不算长,只有100多行,分为两部分,一个是构造函数PrerenderSPAPlugin,一个是方法apply。
2.1 预备知识
基础较好的可以跳过这部分
- 这份代码用到的包
@prerenderer、html-minifier。@prerenderer是一个提供预渲染功能的第三方库,上文提到的启动一个无头浏览器就是在这个包里面实现的;html-minifier是一个压缩HTML的库,去掉空格、注释、引号等不必要的字符。
const path = require('path')
const Prerenderer = require('@prerenderer/prerenderer') //Prerenderer 是一个第三方库,它提供了预渲染的功能
const PuppeteerRenderer = require('@prerenderer/renderer-puppeteer')
const { minify } = require('html-minifier') //html压缩工具,去掉空格、注释、引号。用于
- 代码中多次用到Promise.then(), .then() 方法返回一个新的 Promise 对象,它的参数是上一个 Promise 的返回值,这样就可以通过 .then() 方法的链式调用
- Webpack提供了许多钩子(hooks)来扩展webpack的功能。
after-emit钩子是 webpack 在编译完成并且输出文件到输出目录之后触发的钩子。在这个钩子中可以对编译后的文件进行进一步处理。
2.2 构造函数PrerenderSPAPlugin
构造函数的作用是处理传入参数(兼容V2以及V3版本两种写法,两种写法可以对照文档去看),添加到this._options。传入参数采用扩展运算符语法,相当于主动把传入的若干变量组合成数组arg。
主要的流程就是获取配置,然后保存到this._options,V3版本的写法中,所有参数都写在一个对象中,但是V2版本的staticDir是作为一个字符串单独写在外面,routes作为一个数组单独写在外面。因此将这两项加入到this._options,后面的captureAfterDocumentEvent、captureAfterElementExists、captureAfterTime,也是类似,如果包含则输出警告并将其改为 renderer 的配置。详细的源码注释如下
function PrerenderSPAPlugin (...args) {
const rendererOptions = {} // 处理V2版本写法里renderer的配置
this._options = {} // _options 用于保存插件的配置
// Normal args object. 判断是否使用的是旧版配置方式,新版的方式args只有一个,直接保存在this._options,新版本全部用一个对象包含起来
// 统一新旧两种版本的参数
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
}
// 判断插件配置是否包含 captureAfterDocumentEvent、captureAfterElementExists、captureAfterTime,如果包含则输出警告并将其改为 renderer 的配置
// 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
}
// 如果有上面三个不规范的选项,就再把它们加到_options.renderer
this._options.server = this._options.server || {}
this._options.renderer = this._options.renderer || new PuppeteerRenderer(Object.assign({}, { headless: true }, rendererOptions))
// 如果插件配置包含 postProcessHtml,输出警告要求改为 postProcess
if (this._options.postProcessHtml) {
console.warn('[prerender-spa-plugin] postProcessHtml should be migrated to postProcess! Consult the documentation for more information.')
}
}
2.3 PrerenderSPAPlugin.prototype.apply
该方法将在 webpack 构建过程中被调用,主要作用是渲染出对应路由的HTML、对其进行适当转换、存储到文件夹中。该方法的传入参数compiler 是 Webpack 实例,可以通过这个编译器实例来访问 Webpack 的钩子函数、配置信息。
执行顺序大致为
new一个Prerenderer对象并初始化 --> 渲染对应的路由,生成HTML文件 --> 执行postProcessHtml转换HTML文件 -->
执行postProcess处理静态文件 --> 检查postProcess执行结果是否正确 --> 压缩HTML文件 -->
设置输出文件夹位置 --> 创建输出文件目录 --> 销毁Prerenderer对象
一个小区别:postProcess 可以对所有的生成的文件进行处理,包括 HTML、JS 和 CSS 文件,适用于对文件进行统一处理的场景,而 postProcessHTML 只能对 HTML 文件进行处理。
下面是详细的源码注释:
// 绑定一个apply方法,该方法将在 webpack 构建过程中被调用
PrerenderSPAPlugin.prototype.apply = function (PrerenderSPAPlugin.prototype.apply) {
// 输出文件系统对象,定义了 webpack 打包后如何输出文件。
const compilerFS = compiler.outputFileSystem
// 定义Promise版本的mkdirp方法,用于创建目录,打包后的新建目录
const mkdirp = function (dir, opts) {
return new Promise((resolve, reject) => {
compilerFS.mkdirp(dir, opts, (err, made) => err === null ? resolve(made) : reject(err))
})
}
// 在 Webpack打包完成后生成预渲染 HTML 文件
const afterEmit = (compilation, done) => {
const PrerendererInstance = new Prerenderer(this._options)
// 设置 Prerenderer 实例的配置项,包括渲染引擎、渲染选项、插件等。
PrerendererInstance.initialize()
.then(() => {
// 渲染对应的路由,生成HTML文件,PrerendererInstance.renderRoutes 将返回一个 Promise 数组,每个 Promise 都包含一个对象,该对象包含渲染的 HTML 和路由信息
// 过程中使用了无头浏览器来访问路由并生成预渲染页面
return PrerendererInstance.renderRoutes(this._options.routes || [])
})
// postProcessHtml是一个钩子,用于预渲染之后手动转换每个页面的HTML,比如设置HTML页面的标题以及metadata,用法详见 https://www.npmjs.com/package/prerender-spa-plugin
.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.
// 配置允许您在将prerender spa插件写入文件之前调整其输出。每个渲染的路由调用一次,并以以下形式传递一个上下文对象:
.then(renderedRoutes => this._options.postProcess
? Promise.all(renderedRoutes.map(renderedRoute => this._options.postProcess(renderedRoute)))
: renderedRoutes
)
// 检查 postProcess 执行结果是否正确
// 后续的操作需要使用渲染结果的对象属性,如: renderedRoute.outputPath 和 renderedRoute.html 等。如果 postProcess 函数的执行结果不是对象,那么后续操作可能会因为对象属性的不存在而出错,甚至可能导致整个插件的运行失败。
.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. 压缩 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')
}
})
return renderedRoutes
})
// 创建目录,存储HTML文件
.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
})
// 完成后销毁Prerenderer实例
.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()
})
}
// 在after-emit这个钩子(hook)时执行afterEmit函数
// 检测当前使用的 webpack 版本是否支持钩子机制
// 如果支持,则使用 compiler.hooks.afterEmit.tapAsync() 注册插件处理函数;如果不支持,则使用旧版的 compiler.plugin('after-emit', ...) 注册插件处理函数
if (compiler.hooks) {
const plugin = { name: 'PrerenderSPAPlugin' }
compiler.hooks.afterEmit.tapAsync(plugin, afterEmit)
} else {
compiler.plugin('after-emit', afterEmit)
}
}
// 增加一个属性PuppeteerRenderer,用于V3的配置选项里的render用 render new PuppeteerRenderer()
PrerenderSPAPlugin.PuppeteerRenderer = PuppeteerRenderer
module.exports = PrerenderSPAPlugin
2.4 小结
prerender-spa-plugin是一个用于webpack插件的JavaScript模块,它将单页应用程序(SPA)预呈现为静态HTML文件。
该插件导出一个构造函数PrerenderSPAPlugin,该函数可用于创建插件的新实例。该函数接受可变数量的参数,并返回一个带有apply方法的对象。应该使用webpack编译器实例调用apply方法,以便向webpack注册插件。
当应用插件时,它会使用插件选项初始化Prerenderer实例,包括设置为PuppeterRenderer实例的渲染器选项。然后,插件使用预渲染器实例来预渲染指定的路由,并将预渲染的文件写入磁盘。 该插件还支持前一版本插件的几个遗留选项,并处理这些选项以确保向后兼容性。此外,如果minify选项设置为true,则插件可以使用HTML minifier包缩小生成的HTML文件。