共计 7595 个字符,预计需要花费 19 分钟才能阅读完成。
概述
本文次要的内容是通过之前应用的 prerender-spa-plugin 插件的源码浏览,来看下咱们应该如何编写一个 webpack 的插件,同时理解下预渲染插件到底是如何实现的。
这个内容其实曾经在应用 prerender-spa-plugin 外面有所波及了,这一章的内容算是对之前一篇文章的补充和拓展,具体介绍下 Webpack 的插件机制到底是如何运行的,之前写的简略的替换的插件失效的原理到底是什么。
如果大家还没有看之前的如何应用 prerender-spa-plugin 插件对页面进行预渲染这篇文章,能够先去看看,理解下这个插件到底是做什么的,咱们的插件大略是什么样的。
插件源码浅析
prerender-spa-plugin 是开源的,源码在 GitHub 下面能够看到,大家如果有趣味的话,能够本人点击看下。
首先,咱们让咱们来简略回顾下这个插件是如何应用的,这个对于咱们理解其外部结构,有肯定的帮忙。咱们就间接应用它官网文档上提供的一个例子。
const path = require('path') | |
const PrerenderSPAPlugin = require('prerender-spa-plugin') | |
module.exports = { | |
plugins: [ | |
... | |
new PrerenderSPAPlugin({ | |
// Required - The path to the webpack-outputted app to prerender. | |
staticDir: path.join(__dirname, 'dist'), | |
// Required - Routes to render. | |
routes: ['/', '/about', '/some/deep/nested/route'], | |
}) | |
] | |
} |
从下面这个例子来看,咱们能够晓得这个插件须要初始化一个实例,而后传入对应的参数如输入的门路 staticDir
、须要渲染的路由routes
等。
接下来,让咱们来简略介绍下他的源码构造。代码具体分块如下:
function PrerenderSPAPlugin (...args) {...} | |
PrerenderSPAPlugin.prototype.apply = function (compiler) {const afterEmit = (compilation, done) => {...} | |
if (compiler.hooks) {const plugin = { name: 'PrerenderSPAPlugin'} | |
compiler.hooks.afterEmit.tapAsync(plugin, afterEmit) | |
} else {compiler.plugin('after-emit', afterEmit) | |
} | |
} |
整个 prerender-spa-plugin 的插件是由 2 大部分形成:
- 一个 function 函数,次要用于初始化数据获取与解决。在应用这个插件的过程中,咱们须要先进行初始化。这个函数能够用来进行一些数据的解决和解析。
- 一个原型上的 apply 函数,作为一个钩子函数,次要用于解决 Webpack 触发插件执行后,相干逻辑的解决。
上面,咱们就基于 prerender-spa-plugin 插件,来一个一个局部的看下。
初始化 function 函数
首先让咱们来看下初始化的 function 函数。这个函数次要做的是一些初始化参数获取后的解决。具体代码如下:
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.') | |
} | |
} |
因为咱们的插件应用的形式是实例化后增加(即 new 操作符实例化后应用),所以 function 函数的入参次要是将一些须要的参数绑定到 this 对象上,这样实例化后,就能够获取相干的参数。
很多的 SDK 或者说插件相干的工具,因为可能承受多种类型、不同长度的入参,因而会在一开始对参数类型进行判断,确定传入的参数类型到底是哪一种。
从代码中看,目前记录的参数有输入的参数 staticDir
、须要渲染的路由routes
。如果本人定义了renderer
函数,那么也绑定存储下来。同时,这个 V3 版本的代码还对 V2 版本进行了向前兼容。
钩子 apply 函数
说完了初始化的 function,咱们来看下最重要的 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) => {...} | |
if (compiler.hooks) {const plugin = { name: 'PrerenderSPAPlugin'} | |
compiler.hooks.afterEmit.tapAsync(plugin, afterEmit) | |
} else {compiler.plugin('after-emit', afterEmit) | |
} | |
} |
在说 apply 函数之前,咱们先看下 apply 函数接管的参数 compiler
对象和 mkdirp
这个办法,以及生命周期绑定的代码。
complier 对象
整个 apply 办法,接管的参数只有一个 complier
对象,具体的内容咱们能够看 webpack 中对于 complier 对象的形容,具体的源码能够见此处。我上面简略介绍下:
complier
对象是 webpack 提供的一个全局的对象,这个对象下面挂载了一些在插件生命周期中会应用到的性能和属性,比方 options、loader、plugin 等。咱们能够通过这个对象,在构建中获取 webpack 相干的数据。
mkdirp 办法
这个办法就是将执行 mkdir -p
办法的函数转化成一个 Promise 对象。具体能够看代码下面的原文正文。因为比较简单,这里我就不过多介绍了。
生命周期绑定
在最初,钩子函数生命实现后,须要将其关联到最近的生命周期上。这个插件关联的是 afterEmit 这个节点,大家如果想看下整个 webpack 相干构建流程的生命周期,能够参考这个文档。
看完了简略的局部,上面咱们来看下最重点的钩子函数。
钩子函数
接下来,让咱们来看下这个插件中最外围的钩子函数。这个插件的关联的申明周期是 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" | |
); | |
} | |
}); | |
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();}); | |
}; |
在这个办法中,又呈现了一个新的 compilation
对象。这个办法具体的介绍能够看 Webpack compilation 对象,具体的源码能够见此处。上面我简略介绍下:这个对象代表的是一次文件资源的构建。每次有文件变动时,就会创立一个新的对象。这个文件次要蕴含了以后资源构建和变动过程中的一些属性和信息。
另一个 done
参数,代表着以后插件执行完后执行下一步的一个触发器,和咱们常见的 Node 框架中的 next()
一样。
接下来,咱们来简略说下这个函数执行的逻辑:
- 初始化了一个
Prerenderer
的实例。这个实例是用于对页面进行预渲染的一个工具,具体的代码能够见 GitHub。 - 实例初始化后,针对每一个路由,进行了一次预渲染操作。
- 依据拿到的预渲染相干的数据,对有效性进行查看。
- 如果指定了压缩,那么对预渲染数据进行相干的压缩解决。
- 最终将预渲染相干的数据输入到指定门路上。
- 销毁
Prerenderer
实例。
这个就是一个插件执行的残缺流程。
总结
通过 prerender-spa-plugin 这个插件,大家应该可能理解到咱们现行的一个插件到底是如何运行的,咱们编写一个插件须要的核心部件:
- 一个初始化的 function 函数。
- 一个原型链上的 apply 办法。
- 一个钩子函数。
- 一个绑定生命周期的代码。
有了这些货色,咱们的一个 Webpack 的插件就实现了。
心愿通过一个插件源码的示例,可能让大家理解下咱们日常应用的看似很简单的 Webpack 插件,到底是怎么实现的。
附录
- Webpack 官网:如何编写一个插件
- Webpack Complier 钩子
- Webpack Compilation 对象
- Webpack 钩子