概述
本文主要的內容是通過之前使用的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外掛,到底是怎麼實現的。