一、webpack 核心概念
1. Entry(入口)
指示 webpack 应该使用哪个模块,来作为构建其内部依赖图的开始。
2. Output(输出)
告诉 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件。
3. Module(模块)
在 Webpack 里一切皆模块,一个模块对应着一个文件。
4. Chunk(代码块)
一个 Chunk 由多个模块组合而成,用于代码合并与分割。
5. Loader
webpack loader 将所有类型的文件,转换为应用程序的依赖图(和最终的 bundle)可以直接引用的模块。
6. Plugin
插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量。插件接口功能极其强大,可以用来处理各种各样的任务。
二、webpack 构建流程
- 校验配置文件
- 生成Compiler对象
- 初始化默认插件
- run阶段:如果运行在watch模式则执行watch方法,否则执行run方法
- compilation阶段:创建Compilation对象回调compilation相关钩子
- emit阶段:文件内容准备完成,准备生成文件,这是最后一次修改最终文件的机会
- afterEmit阶段:文件已经写入磁盘完成
- done阶段:完成编译
三、插件简易示例
1. Webpack 常用构建阶段的钩子
Webpack 提供钩子有很多,这里简单介绍几个,完整具体可参考文档《Compiler Hooks》:
钩子 | 说明 | 参数 | 类型 |
---|---|---|---|
entryOption | 在 webpack 选项中的 entry 配置项 处理过之后 | context,entry | 同步 |
afterPlugins | 设置完初始插件之后 | compiler | 同步 |
compile | 创建compilation对象之前 | compilationParams | 同步 |
compilation | 编译对象创建之后,生成文件之前 | compilation | 同步 |
emit | 资源生成完成,输出之前 | compilation | 异步 |
afterEmit | 资源输出到目录完成 | compilation | 异步 |
done | 完成编译 | stats | 同步 |
2. Webpack 提供的三种触发钩子方法 (在 compiler.hooks 下指定事件钩子函数,便会触发钩子时,执行回调函数):
tap
:以同步方式触发钩子;tapAsync
:以异步方式触发钩子;tapPromise
:以异步方式触发钩子,返回 Promise;
Tapable
Tapable是Webpack的一个核心工具,Webpack中许多对象扩展自Tapable类。Tapable类暴露了tap、tapAsync和tapPromise方法,可以根据钩子的同步/异步方式来选择一个函数注入逻辑。
- tap 同步钩子
- tapAsync 异步钩子,通过callback回调告诉Webpack异步执行完毕
- tapPromise 异步钩子,返回一个Promise告诉Webpack异步执行完毕
tap
tap是一个同步钩子,同步钩子在使用时不可以包含异步调用,因为函数返回时异步逻辑有可能未执行完毕导致问题。
下面一个在compile阶段插入同步钩子的示例。
代码语言:javascript复制compiler.hooks.compile.tap('MyWebpackPlugin', params => {
console.log('我是同步钩子')
});
tapAsync
tapAsync是一个异步钩子,我们可以通过callback告知Webpack异步逻辑执行完毕。
下面是一个在emit阶段的示例,在1秒后打印文件列表。
代码语言:javascript复制compiler.hooks.emit.tapAsync('MyWebpackPlugin', (compilation, callback) => {
setTimeout(()=>{
console.log('文件列表', Object.keys(compilation.assets).join(','));
callback();
}, 1000);
});
tapPromise
tapPromise也是也是异步钩子,和tapAsync的区别在于tapPromise是通过返回Promise来告知Webpack异步逻辑执行完毕。
下面是一个将生成结果上传到CDN的示例。
代码语言:javascript复制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方法中插入钩子的一般形式如下:
代码语言:javascript复制compileer.hooks.阶段.tap函数('插件名称', (阶段回调参数) => {
});
3. 一个典型的Webpack插件示例
代码语言:javascript复制// 插件代码
class MyWebpackPlugin {
constructor(options) {
}
apply(compiler) {
// 插入钩子函数
compiler.hooks.emit.tap('MyWebpackPlugin', (compilation) => {});
}
}
module.exports = MyWebpackPlugin;
接下来需要在webpack.config.js中引入这个插件。
代码语言:javascript复制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可以访问到本次编译过程中的模块、依赖、文件内容等信息。
四、常用API
读取输出资源、模块及依赖
在emit阶段,我们可以读取最终需要输出的资源、chunk、模块和对应的依赖,如果有需要还可以更改输出资源。
代码语言:javascript复制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对象,我们可以添加、删除、更改最终输出的资源。
代码语言:javascript复制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):
代码语言:javascript复制|----src
|----main.js
|----plugins
|----my-webpack-plugin.js
|----package.json
|----webpack.config.js
相关文件的内容如下:
代码语言:javascript复制// src/main.js
console.log('Hello World');
代码语言:javascript复制// package.json
{
"scripts":{
"build":"webpack"
}
}
代码语言:javascript复制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实现。
代码语言:javascript复制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,内容如下:
代码语言:javascript复制{"main.js":956}
构建结果上传到七牛CDN
在实际开发中,资源文件构建完成后一般会同步到CDN,最终前端界面使用的是CDN服务器上的静态资源。
下面我们编写一个Webpack插件,文件构建完成后上传CDN。
代码语言:javascript复制我们的插件依赖qiniu,因此需要额外安装qiniu模块
npm install qiniu --save-dev
七牛的Node.js SDK文档地址如下:
代码语言:javascript复制https://developer.qiniu.com/kodo/sdk/1289/nodejs
开始编写插件代码:
代码语言:javascript复制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中需要传递给该插件传递相关配置:
代码语言:javascript复制Copymodule.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即可。
五、插件案例
1. 背景介绍
在项目打包遇到问题:“当项目托管到 CDN 平台,希望实现项目中的 index.js 不被缓存”。因为我们需要修改 index.js 中的内容,不想用户被缓存。
解决方案:需要在 index.html 生成之前,修改 js 文件的路径,并添加时间戳。
2 Webpack 插件组成
- 一个具名 JavaScript 函数;
- 在它的原型上定义 apply 方法;
- 指定一个触发到 Webpack 本身的事件钩子;
- 操作 Webpack 内部的实例特定数据;
- 在实现功能后调用 Webpack 提供的 callback。
3. Webpack 插件基本架构
插件由一个构造函数实例化出来。构造函数定义 apply
方法,在安装插件时,apply
方法会被 Webpack compiler
调用一次。apply
方法可以接收一个 Webpack compiler
对象的引用,从而可以在回调函数中访问到 compiler
对象。
class HelloWorldPlugin {
apply(compiler) {
compiler.hooks.done.tap('Hello World Plugin', (stats) => {
console.log('hello world');
})
}
}
4. HtmlWebpackPlugin 介绍
4.1 插件两个主要作用:
- 为 HTML 文件引入外部资源(如
script
/link
)动态添加每次编译后的 hash,防止引用文件的缓存问题; - 动态创建 HTML 入口文件,如单页应用的
index.html
文件。
4.2 插件原理介绍:
- 读取 Webpack 中
entry
配置的相关入口chunk
和extract-text-webpack-plugin
插件抽取的 CSS 样式; - 将样式插入到插件提供的
template
或templateContent
配置指定的模版文件中; - 插入方式是:通过
link
标签引入样式,通过script
标签引入脚本文件。
5. 实现流程
5.1 实现的原理
通过HtmlWebpackPlugin生成 HTML 文件前,将模版文件预留位置替换成脚本,脚本中执行自动添加时间戳来引用脚本文件。
5.2 初始化插件文件
代码语言:javascript复制// SetScriptTimestampPlugin.js
class SetScriptTimestampPlugin {
apply(compiler) {
compiler.hooks.done.tap('SetScriptTimestampPlugin',
(compilation, callback) => {
console.log('SetScriptTimestampPlugin!');
});
}
}
module.exports = SetScriptTimestampPlugin;
5.3 选择插件触发时机
插件应该是要在 HTML 输出之前,动态添加script
标签,所以我们选择钩入compilation
阶段,
由于compilation
是SyncHook
同步钩子,所以采用tap
触发方式,
代码修改如下:
代码语言:javascript复制// SetScriptTimestampPlugin.js
class SetScriptTimestampPlugin {
apply(compiler) {
- compiler.hooks.done.tap('SetScriptTimestampPlugin',
compiler.hooks.compilation.tap('SetScriptTimestampPlugin',
(compilation, callback) => {
console.log('SetScriptTimestampPlugin!');
});
}
}
module.exports = SetScriptTimestampPlugin;
5.4 添加插件替换入口
在模版文件 template.html 中添加 <!--SetScriptTimestampPlugin inset script-->
作为标识替换入口:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Webpack 插件开发入门</title>
</head>
<body>
<!-- other code -->
<!--SetScriptTimestampPlugin inset script-->
</body>
</html>
5.5 编写插件逻辑