什么是插件
webpack 的设计理念是万物皆模块,然后将所有模块打包成一个或者多个 bundle 文件,但是这并不意味着 webpack 自身就能满足所有功能,比如 chunk 提取、代码压缩、css 文件提取等等,所以为了使 webpack 更加了灵活与便于拓展,webpack 设计了插件机制,通过插件可以实现各种拓展功能,满足业务需求
插件的定义
webpack 规定插件必须是一个 javascript 对象,且对象上必须有一个 apply 方法,这个 apply 方法会在 webpack 进行编译的时候进行调用,插件定义如下所示
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
apply() {}
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
到这里我们已经已经知道怎么定义一个 webpack plugin,虽然这个插件能过被执行,但是不会有任何作用,原因是插件内没有做任何处理,那么插件内怎么去介入 webpack 的构建流程,影响最终的构建结果呢?其实 webpack 在执行插件 apply 方法的时候,会传入一个 compiler 对象,这个 compiler 对象上会暴露 webpack 构建阶段不同节点的 hook,然后在这个 hook 上允许注册对应的 callback,如下所示
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
// compiler.hooks 包含一组节点,这些节点,都允许注册对应的callback,webpack执行过程中,会调用这个callback,然后在这个callback
// 调用的时候传入一些参数,然后callback内借助传入的参数修改webpack构建过程中的一些内容,最终影响webpack的构建结果
compiler.hooks.run.tap(pluginName, (compilation) => {
console.log('The webpack build process is starting!');
});
}
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
webpack 构建流程可以简单总结如下图所示 那么 webpack 只要在处理的过程中,在各个阶段,执行我们注册的 callback,那么我们的插件就可以介入 webpack 构建流程,我们从 webpack 源码看下,webpack 是怎么触发我们注册的 hook callback 执行的
const {SyncHook,SyncBailHook,AsyncParallelHook,AsyncSeriesHook} = require("tapable");
this.hooks = Object.freeze({
/** @type {SyncHook<[]>} */
initialize: new SyncHook([]),
/** @type {SyncHook<[CompilationParams]>} */
compile: new SyncHook(["params"]),
/** @type {AsyncParallelHook<[Compilation]>} */
make: new AsyncParallelHook(["compilation"]),
...
});
compile(callback) {
const params = this.newCompilationParams();
// 调用beforeCompile hook,传入参数params, callback
this.hooks.beforeCompile.callAsync(params, err => {
if (err) return callback(err);
// 调用compile hook,传入参数params
this.hooks.compile.call(params);
const compilation = this.newCompilation(params);
// 调用compile hook 传入参数compilation, callback
this.hooks.make.callAsync(compilation, err => {
if (err) return callback(err);
// 调用finishMake hook 传入参数compilation, callback
this.hooks.finishMake.callAsync(compilation, err => {
if (err) return callback(err);
process.nextTick(() => {
compilation.finish(err => {
if (err) return callback(err);
compilation.seal(err => {
if (err) return callback(err);
// 调用afterCompile hook 传入参数compilation, callback
this.hooks.afterCompile.callAsync(compilation, err => {
if (err) return callback(err);
return callback(null, compilation);
});
});
});
});
});
});
});
}
从源码我们可以看到,webpack 在编译的过程中,会在各个节点调用对应的 hook,从而执行对应的 callback,以达到功能拓展的目的
compiler 钩子
目前webpack暴露的事件常见钩子,更多钩子请参见webpack官网
entryOption
在读取配置的 entry 选项后触发
afterPlugins
在设置完初始插件后触发。
compile
在一个新的编译创建后,但在编译开始之前触发。
compilation
在创建 compilation 对象时触发。
make
在编译阶段开始时触发。
afterCompile
在编译阶段完成后触发。
emit
在生成资源到 output 目录之前触发。
afterEmit
在生成资源到 output 目录之后触发。
done
在编译完成时触发。
failed
在编译失败时触发。
我们只需要每个 hook 代表执行的哪个阶段,并且该 hook 属于哪种类型的 hook 即可在插件中通过该 hook 注册 callback,完成对应的逻辑 如下图所示 我想在编译模块之前做些事情
compiler.hooks.beforeRun.tapAsync('MyPlugin', (compiler, callback) => {
/* 处理逻辑 */
callback()
});
比如我想在模块解析之前做些事情
创建插件
webpack 插件由以下组成:
- 一个 JavaScript 命名函数或 JavaScript 类
- 在插件函数的 prototype 上定义一个 apply 方法
- 指定一个绑定到 webpack 自身的事件钩子
- 处理 webpack 内部实例的特定数据
- 功能完成后调用 webpack 提供的回调
// 一个 JavaScript 类
class MyExampleWebpackPlugin {
// 在插件函数的 prototype 上定义一个 `apply` 方法,以 compiler 为参数。
apply(compiler) {
// 指定一个挂载到 webpack 自身的事件钩子。
compiler.hooks.emit.tapAsync(
'MyExampleWebpackPlugin',
(compilation, callback) => {
console.log('这是一个示例插件!');
console.log(
'这里表示了资源的单次构建的 `compilation` 对象:',
compilation
);
// 用 webpack 提供的插件 API 处理构建过程
compilation.addModule(/* ... */);
callback();
}
);
}
}
基本插件架构
插件是由「具有 apply 方法的 prototype 对象」所实例化出来的。这个 apply 方法在安装插件时,会被 webpack compiler 调用一次。apply 方法可以接收一个 webpack compiler 对象的引用,从而可以在回调函数中访问到 compiler 对象。一个插件结构如下:
class HelloWorldPlugin {
apply(compiler) {
compiler.hooks.done.tap(
'Hello World Plugin',
(
stats /* 绑定 done 钩子后,stats 会作为参数传入。 */
) => {
console.log('Hello World!');
}
);
}
}
module.exports = HelloWorldPlugin;
然后,要安装这个插件,只需要在你的 webpack 配置的 plugin 数组中添加一个实例:
// webpack.config.js
var HelloWorldPlugin = require('hello-world');
module.exports = {
// ... 这里是其他配置 ...
plugins: [new HelloWorldPlugin({ options: true })],
};
Compiler 和 Compilation
在插件开发中最重要的两个资源就是 compiler 和 compilation 对象。理解它们的角色是扩展 webpack 引擎重要的第一步
class HelloCompilationPlugin {
apply(compiler) {
// 指定一个挂载到 compilation 的钩子,回调函数的参数为 compilation 。
compiler.hooks.compilation.tap('HelloCompilationPlugin', (compilation) => {
// 现在可以通过 compilation 对象绑定各种钩子
compilation.hooks.optimize.tap('HelloCompilationPlugin', () => {
console.log('资源已经优化完毕。');
});
});
}
}
module.exports = HelloCompilationPlugin;
compiler 和 compilation 以及其他重要对象提供的钩子清单,请查看 plugins API 文档
创建自定义插件
插件分析
基于前面我们对 webpack 插件的介绍,我们分析一下我们目前要做的插件需求,我们需要分析项目中没有使用的资源,这个结果必须是构建完成才可以知晓,我们分析我们梳理的钩子,可选的钩子其实很多,这里我们选择 afterEmit 钩子来处理,输出 asset 到 output 目录之后执行 afterEmit 钩子,那我们初始化的函数就很明朗了
class UnusedFilesWebpackPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
compiler.hooks.afterEmit.tap('UnusedFilesWebpackPlugin', async (compilation) => {
});
}
}
module.exports = UnusedFilesWebpackPlugin;
需求分析
基于我们要分析项目中未使用的资源,我们代码的思路需要实现以下几点
- 设计插件需要的传参
- 查找项目中全部使用的资源路径
- 查找项目已使用的资源路径
- 从全部依赖中过滤出未使用资源路径
- 对未使用的资源进行类别分类
- 输出未使用文件 json
创建项目
新建工程目录文件夹
npm init
安装依赖
添加 picocolor
picocolor 是一个轻量级的命令行颜色输出库,可以用于在控制台中打印有颜色的文本。它非常简单易用,只需要几行代码就可以将控制台输出变得更加美观 使用 picocolor 时,只需在要打印的文本字符串中添加标记即可指定颜色和样式。例如,在字符串中添加“{red}”标记就可以将文本颜色设置为红色。Picocolor 支持的颜色和样式有:黑、红、绿、黄、蓝、洋红、青、白,以及加粗、下划线等
pnpm add picocolors
添加 node,ts 依赖
pnpm add @types/node tslib -D
添加 rollup
Rollup 是一个 JavaScript 模块打包工具,它专注于将多个模块打包成一个或多个较小的文件。与其他常见的打包工具(如 Webpack 和 Parcel)相比,Rollup 的主要特点是其轻量级和高效性能 Rollup 的一些主要特点
- Tree Shaking:Rollup 可以通过静态分析代码并识别未使用的部分,从而在最终的包中删除这些未使用的代码。这种优化技术称为 "Tree Shaking",可以减少最终包的大小,提高性能
- ES6 模块支持:Rollup 原生支持 ES6 模块语法,可以直接处理 ES6 模块的导入和导出,而无需额外的配置。
- 插件系统:Rollup 提供了一个强大的插件系统,使开发者可以通过插件来扩展其功能。这些插件可以用于执行各种任务,如压缩代码、转换文件格式、处理 CSS 等
- 代码拆分(Code Splitting):Rollup 支持将代码拆分为多个文件,通过动态导入(Dynamic Imports)实现按需加载
- 自定义输出格式:Rollup 支持将代码打包为多种不同的输出格式,包括 CommonJS、AMD、UMD 和 ES 模块等
- 高性能打包:由于 Rollup 的设计目标是专注于模块打包,其在性能方面表现出色。相对于其他大型打包工具,Rollup 的构建速度更快,生成的包也更小
pnpm add @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-terser @rollup/plugin-typescript rollup -D
package.json
{
"name": "unused-assets-webpack-plugin",
"version": "0.0.1",
"description": "查找项目中是否存在是否未使用的资源",
"keywords": [
"webpack-plugin",
"unused-assets"
],
"repository": {
"type": "git",
"url": "https://github.com/wgbx/unused-assets-webpack-plugin.git"
},
"homepage": "https://github.com/wgbx/unused-assets-webpack-plugin",
"exports": {
".": {
"require": "./dist/unused-assets-webpack-plugin.cjs",
"import": "./dist/unused-assets-webpack-plugin.mjs"
}
},
"main": "dist/unused-assets-webpack-plugin.cjs",
"module": "dist/unused-assets-webpack-plugin.mjs",
"scripts": {
"build": "rollup --config ./rollup.config.mjs"
},
"author": "微光北下",
"license": "MIT",
"dependencies": {
"picocolors": "^1.0.0"
},
"devDependencies": {
"@rollup/plugin-babel": "^6.0.3",
"@rollup/plugin-commonjs": "^24.1.0",
"@rollup/plugin-node-resolve": "^15.0.2",
"@rollup/plugin-terser": "^0.4.1",
"@rollup/plugin-typescript": "^11.1.2",
"@types/node": "^20.3.3",
"glob": "^7.1.7",
"rollup": "^3.21.5",
"tslib": "^2.6.0"
}
}
rollup.config.mjs
import NodeResolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import babel from '@rollup/plugin-babel'
import terser from '@rollup/plugin-terser'
import typescript from '@rollup/plugin-typescript'
const name = 'unused-assets-webpack-plugin'
export default {
input: './packages/index',
output: [
{
name,
file: 'dist/unused-assets-webpack-plugin.cjs',
format: 'cjs',
plugins: [terser()]
},
{
name,
file: 'dist/unused-assets-webpack-plugin.mjs',
format: 'es',
plugins: [terser()]
}
],
plugins: [
NodeResolve(),
commonjs(),
babel({
babelHelpers: 'bundled',
exclude: 'node_modules/**'
}),
typescript({ outDir: 'dist/' })
],
}
构建代码
const util = require('util');
const glob = util.promisify(require('glob'));
const path = require('path');
const log = require('picocolors')
const { writeFileSync } = require('node:fs')
class UnusedFilesWebpackPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
compiler.hooks.afterEmit.tap('UnusedFilesWebpackPlugin', async (compilation) => {
const userOptions = { ...defaultOptions, ...this.options }
const { output } = userOptions
const unusedAssets = await findUnusedAssets(compilation, userOptions)
const classificationFiles = classificationFile(unusedAssets, userOptions)
setFile(classificationFiles, output)
});
}
}
const defaultOptions = {
path: './src',
output: './unused-files.json',
exclude: []
}
async function findUnusedAssets(compilation, options) {
const { path } = options
const pattern = `${path}/**/*`
const allFiles = await getAllFiles(pattern)
const dependFiles = getDependFiles(compilation)
return allFiles.filter(item => !dependFiles.includes(item));
}
async function getAllFiles(pattern) {
try {
const patternFiles = await glob(pattern, { nodir: true });
return patternFiles.map(item => path.resolve(item))
} catch (error) {
console.log('getAllFiles Error:', error);
}
}
function getDependFiles(compilation) {
const { fileDependencies } = compilation
const dependFiles = []
fileDependencies.forEach(item => {
if (!item.includes('node_modules')) {
dependFiles.push(item)
}
})
return dependFiles
}
function setFile(data, name = './unused-files.json') {
writeFileSync(name, JSON.stringify(data, null, 2), (err) => {
if (err) {
log.red(`${name} 文件写入失败`)
}
})
}
function classificationFile(files, options) {
const { exclude } = options
const classifiedFiles = {};
files.forEach((file) => {
const extname = path.extname(file)
if (!exclude.includes(extname)) {
if (classifiedFiles[extname]) {
classifiedFiles[extname].push(file);
} else {
classifiedFiles[extname] = [file];
}
}
});
return classifiedFiles
}
module.exports = UnusedFilesWebpackPlugin;
在 packages 下创建 index.js
const util = require('util');
const glob = util.promisify(require('glob'));
const path = require('path');
const log = require('picocolors')
const { writeFileSync } = require('node:fs')
class UnusedFilesWebpackPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
compiler.hooks.afterEmit.tap('UnusedFilesWebpackPlugin', async (compilation) => {
const userOptions = { ...defaultOptions, ...this.options }
const { output } = userOptions
const unusedAssets = await findUnusedAssets(compilation, userOptions)
const classificationFiles = classificationFile(unusedAssets, userOptions)
setFile(classificationFiles, output)
});
}
}
const defaultOptions = {
path: './src',
output: './unused-files.json',
exclude: []
}
async function findUnusedAssets(compilation, options) {
const { path } = options
const pattern = `${path}/**/*`
const allFiles = await getAllFiles(pattern)
const dependFiles = getDependFiles(compilation)
return allFiles.filter(item => !dependFiles.includes(item));
}
async function getAllFiles(pattern) {
try {
const patternFiles = await glob(pattern, { nodir: true });
return patternFiles.map(item => path.resolve(item))
} catch (error) {
console.log('getAllFiles Error:', error);
}
}
function getDependFiles(compilation) {
const { fileDependencies } = compilation
const dependFiles = []
fileDependencies.forEach(item => {
if (!item.includes('node_modules')) {
dependFiles.push(item)
}
})
return dependFiles
}
function setFile(data, name = './unused-files.json') {
writeFileSync(name, JSON.stringify(data, null, 2), (err) => {
if (err) {
log.red(`${name} 文件写入失败`)
}
})
}
function classificationFile(files, options) {
const { exclude } = options
const classifiedFiles = {};
files.forEach((file) => {
const extname = path.extname(file)
if (!exclude.includes(extname)) {
if (classifiedFiles[extname]) {
classifiedFiles[extname].push(file);
} else {
classifiedFiles[extname] = [file];
}
}
});
return classifiedFiles
}
module.exports = UnusedFilesWebpackPlugin;
- 利用 afterEmit 钩子使用 glob 获取在参数文件夹下所有使用的文件
- webpack 的 compilation 的 fileDependencies 会暴露出运行阶段所依赖的函数,过滤出不含 node_modules 的文件路径
- 通过过滤筛选出未使用的文件路径
- 基于筛选出的未使用文件,基于文件后缀进行数据分类,使用 node fs 进行文本输出