Webpack通過Loader完成模塊的轉換工作,讓“一切皆模塊”成為可能。Plugin機制則讓其更加靈活,可以在Webpack生命周期中調用鈎子完成各種任務,包括修改輸出資源、輸出目錄等等。
本章我們一起來學習如何編寫Webpack插件。
基本構建流程
在編寫插件之前,還需要了解一下Webpack的構建流程,以便在合適的時機插入合適的插件邏輯。Webpack的基本構建流程如下:
- 校驗配置文件
- 生成Compiler對象
- 初始化默認插件
- run階段:如果運行在watch模式則執行watch方法,否則執行run方法
- compilation階段:創建Compilation對象回調compilation相關鈎子
- emit階段:文件內容准備完成,准備生成文件,這是最后一次修改最終文件的機會
- afterEmit階段:文件已經寫入磁盤完成
- done階段:完成編譯
插件示例
一個典型的Webpack插件代碼如下:
// 插件代碼
class MyWebpackPlugin {
constructor(options) {
}
apply(compiler) {
// 插入鈎子函數
compiler.hooks.emit.tap('MyWebpackPlugin', (compilation) => {});
}
}
module.exports = MyWebpackPlugin;
接下來需要在webpack.config.js中引入這個插件。
module.exports = {
plugins:[
// 傳入插件實例
new MyWebpackPlugin({
param:'paramValue'
}),
]
};
Webpack在啟動時會實例化插件對象,在初始化compiler對象之后會調用插件實例的apply方法,傳入compiler對象,插件實例在apply方法中會注冊感興趣的鈎子,Webpack在執行過程中會根據構建階段回調相應的鈎子。
Compiler && Compilation對象
在編寫Webpack插件過程中,最常用也是最主要的兩個對象就是Webpack提供的Compiler和Compilation,Plugin通過訪問Compiler和Compilation對象來完成工作。
- Compiler 對象包含了當前運行Webpack的配置,包括entry、output、loaders等配置,這個對象在啟動Webpack時被實例化,而且是全局唯一的。Plugin可以通過該對象獲取到Webpack的配置信息進行處理。
- Compilation對象可以理解編譯對象,包含了模塊、依賴、文件等信息。在開發模式下運行Webpack時,每修改一次文件都會產生一個新的Compilation對象,Plugin可以訪問到本次編譯過程中的模塊、依賴、文件內容等信息。
常見鈎子
Webpack會根據執行流程來回調對應的鈎子,下面我們來看看都有哪些常見鈎子,這些鈎子支持的tap操作是什么。
| 鈎子 | 說明 | 參數 | 類型 |
|---|---|---|---|
| afterPlugins | 啟動一次新的編譯 | compiler | 同步 |
| compile | 創建compilation對象之前 | compilationParams | 同步 |
| compilation | compilation對象創建完成 | compilation | 同步 |
| emit | 資源生成完成,輸出之前 | compilation | 異步 |
| afterEmit | 資源輸出到目錄完成 | compilation | 異步 |
| done | 完成編譯 | stats | 同步 |
Tapable
Tapable是Webpack的一個核心工具,Webpack中許多對象擴展自Tapable類。Tapable類暴露了tap、tapAsync和tapPromise方法,可以根據鈎子的同步/異步方式來選擇一個函數注入邏輯。
- tap 同步鈎子
- tapAsync 異步鈎子,通過callback回調告訴Webpack異步執行完畢
- tapPromise 異步鈎子,返回一個Promise告訴Webpack異步執行完畢
tap
tap是一個同步鈎子,同步鈎子在使用時不可以包含異步調用,因為函數返回時異步邏輯有可能未執行完畢導致問題。
下面一個在compile階段插入同步鈎子的示例。
compiler.hooks.compile.tap('MyWebpackPlugin', params => {
console.log('我是同步鈎子')
});
tapAsync
tapAsync是一個異步鈎子,我們可以通過callback告知Webpack異步邏輯執行完畢。
下面是一個在emit階段的示例,在1秒后打印文件列表。
compiler.hooks.emit.tapAsync('MyWebpackPlugin', (compilation, callback) => {
setTimeout(()=>{
console.log('文件列表', Object.keys(compilation.assets).join(','));
callback();
}, 1000);
});
tapPromise
tapPromise也是也是異步鈎子,和tapAsync的區別在於tapPromise是通過返回Promise來告知Webpack異步邏輯執行完畢。
下面是一個將生成結果上傳到CDN的示例。
compiler.hooks.afterEmit.tapPromise('MyWebpackPlugin', (compilation) => {
return new Promise((resolve, reject) => {
const filelist = Object.keys(compilation.assets);
uploadToCDN(filelist, (err) => {
if(err) {
reject(err);
return;
}
resolve();
});
});
});
apply方法中插入鈎子的一般形式如下:
compileer.hooks.階段.tap函數('插件名稱', (階段回調參數) => {
});
常用API
讀取輸出資源、模塊及依賴
在emit階段,我們可以讀取最終需要輸出的資源、chunk、模塊和對應的依賴,如果有需要還可以更改輸出資源。
apply(compiler) {
compiler.hooks.emit.tapAsync('MyWebpackPlugin', (compilation, callback) => {
// compilation.chunks存放了代碼塊列表
compilation.chunks.forEach(chunk => {
// chunk包含多個模塊,通過chunk.modulesIterable可以遍歷模塊列表
for(const module of chunk.modulesIterable) {
// module包含多個依賴,通過module.dependencies進行遍歷
module.dependencies.forEach(dependency => {
console.log(dependency);
});
}
});
callback();
});
}
修改輸出資源
通過操作compilation.assets對象,我們可以添加、刪除、更改最終輸出的資源。
apply(compiler) {
compiler.hooks.emit.tapAsync('MyWebpackPlugin', (compilation) => {
// 修改或添加資源
compilation.assets['main.js'] = {
source() {
return 'modified content';
},
size() {
return this.source().length;
}
};
// 刪除資源
delete compilation.assets['main.js'];
});
}
assets對象需要定義source和size方法,source方法返回資源的內容,支持字符串和Node.js的Buffer,size返回文件的大小字節數。
插件編寫實例
接下來我們開始編寫自定義插件,所有插件使用的示例項目如下(需要安裝webpack和webpack-cli):
|----src
|----main.js
|----plugins
|----my-webpack-plugin.js
|----package.json
|----webpack.config.js
相關文件的內容如下:
// src/main.js
console.log('Hello World');
// package.json
{
"scripts":{
"build":"webpack"
}
}
const path = require('path');
const MyWebpackPlugin = require('my-webpack-plugin');
// webpack.config.js
module.exports = {
entry:'./src/main',
output:{
path: path.resolve(__dirname, 'build'),
filename:'[name].js',
},
plugins:[
new MyWebpackPlugin()
]
};
生成清單文件
通過在emit階段操作compilation.assets實現。
class MyWebpackPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('MyWebpackPlugin', (compilation, callback) => {
const manifest = {};
for (const name of Object.keys(compilation.assets)) {
manifest[name] = compilation.assets[name].size();
// 將生成文件的文件名和大小寫入manifest對象
}
compilation.assets['manifest.json'] = {
source() {
return JSON.stringify(manifest);
},
size() {
return this.source().length;
}
};
callback();
});
}
}
module.exports = MyWebpackPlugin;
構建完成后會在build目錄添加manifest.json,內容如下:
{"main.js":956}
構建結果上傳到CDN
在實際開發中,資源文件構建完成后一般會同步到CDN,最終前端界面使用的是CDN服務器上的靜態資源。
下面我們編寫一個Webpack插件,文件構建完成后上傳CDN。
筆者使用的是七牛CDN,各位讀者根據需要選擇適合自己的CDN服務商。
我們的插件依賴qiniu,因此需要額外安裝qiniu模塊
npm install qiniu --save-dev
七牛的Node.js SDK文檔地址如下:
https://developer.qiniu.com/kodo/sdk/1289/nodejs
開始編寫插件代碼:
const qiniu = require('qiniu');
const path = require('path');
class MyWebpackPlugin {
// 七牛SDK mac對象
mac = null;
constructor(options) {
// 讀取傳入選項
this.options = options || {};
// 檢查選項中的參數
this.checkQiniuConfig();
// 初始化七牛mac對象
this.mac = new qiniu.auth.digest.Mac(
this.options.qiniu.accessKey,
this.options.qiniu.secretKey
);
}
checkQiniuConfig() {
// 配置未傳qiniu,讀取環境變量中的配置
if (!this.options.qiniu) {
this.options.qiniu = {
accessKey: process.env.QINIU_ACCESS_KEY,
secretKey: process.env.QINIU_SECRET_KEY,
bucket: process.env.QINIU_BUCKET,
keyPrefix: process.env.QINIU_KEY_PREFIX || ''
};
}
const qiniu = this.options.qiniu;
if (!qiniu.accessKey || !qiniu.secretKey || !qiniu.bucket) {
throw new Error('invalid qiniu config');
}
}
apply(compiler) {
compiler.hooks.afterEmit.tapPromise('MyWebpackPlugin', (compilation) => {
return new Promise((resolve, reject) => {
// 總上傳數量
const uploadCount = Object.keys(compilation.assets).length;
// 已上傳數量
let currentUploadedCount = 0;
// 七牛SDK相關參數
const putPolicy = new qiniu.rs.PutPolicy({ scope: this.options.qiniu.bucket });
const uploadToken = putPolicy.uploadToken(this.mac);
const config = new qiniu.conf.Config();
config.zone = qiniu.zone.Zone_z1;
const formUploader = new qiniu.form_up.FormUploader()
const putExtra = new qiniu.form_up.PutExtra();
// 因為是批量上傳,需要在最后將錯誤對象回調
let globalError = null;
// 遍歷編譯資源文件
for (const filename of Object.keys(compilation.assets)) {
// 開始上傳
formUploader.putFile(
uploadToken,
this.options.qiniu.keyPrefix + filename,
path.resolve(compilation.outputOptions.path, filename),
putExtra,
(err) => {
console.log(`uploade ${filename} result: ${err ? `Error:${err.message}` : 'Success'}`)
currentUploadedCount++;
if (err) {
globalError = err;
}
if (currentUploadedCount === uploadCount) {
globalError ? reject(globalError) : resolve();
}
});
}
})
});
}
}
module.exports = MyWebpackPlugin;
Webpack中需要傳遞給該插件傳遞相關配置:
module.exports = {
entry: './src/index',
target: 'node',
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].js',
publicPath: 'CDN域名'
},
plugins: [
new CleanWebpackPlugin(),
new QiniuWebpackPlugin({
qiniu: {
accessKey: '七牛AccessKey',
secretKey: '七牛SecretKey',
bucket: 'static',
keyPrefix: 'webpack-inaction/demo1/'
}
})
]
};
編譯完成后資源會自動上傳到七牛CDN,這樣前端只用交付index.html即可。
小結
至此,Webpack相關常用知識和進階知識都介紹完畢,需要各位讀者在工作中去多加探索,Webpack配合Node.js生態,一定會涌現出更多優秀的新語言和新工具!
