前言
Hello~大家好,我是秋天的一阵风。
最近,在接手一个老项目时,我发现我们的项目中静态资源文件夹里藏着不少 “历史遗留问题” ——一大堆 重复 的和 过时废弃 的文件,比如一些旧logo的PNG图片、内容一样命名不同的重复SVG文件,还有一些静静躺在角落里的大体积GIF动图。
这些文件明显已经没用了,但它们为啥还呆在这里占地方呢? 我分析了我们公司团队的工作流程,发现了几个可能的原因:
- 重复复制:有时候,团队成员在不同的地方放了相同的图片,可能因为
“找不到”
原来的,或者是“懒得找”
,结果就是同样的图片在不同的地方出现了好几次。
-
版本迭代:随着项目不断更新,一些文件就像旧衣服一样被新版本取代,但旧的文件却没有被及时清理出去,可能是因为我们太忙,或者是忘了它们的存在。
-
图标替换:当我们更新图标或者图片时,新的文件来了,旧的却没有及时被删除。可能是因为我们觉得“总有一天可能会用到”,或者是简单地忘记了。
- 最后一种情况是,在项目最初开始迭代的时候还没有实现echarts图表相关的功能,所以会用一些假图片来占位。
这些文件的存在,虽然看似无害,但实际上它们在悄悄地影响着项目的效率和整洁度。所以思考过后还是决定采取行动,把那些不再需要的文件清理出去。
当然,作为程序员,将一个一个图片文件名字复制去项目代码里搜索,然后手动删除肯定是会让人笑掉大牙的,所以最好的办法就是能用代码来实现,接下来,我们就一起从零开始构建一个Webpack插件。
一、开始之前
在开始编写自定义Webpack插件之前,你需要确保对以下知识熟练掌握:
1. 插件是什么
Webpack插件是一个具有apply
方法的类,该方法在编译过程中被Webpack编译器(compiler)
调用。
apply
方法接收一个编译器(compiler)
对象作为参数,你可以通过这个对象访问Webpack的钩子(hooks)
。
2. 插件的生命周期
在Webpack中,生命周期钩子(也称为事件钩子或插件钩子)是编译过程中的不同阶段,Webpack会在这些阶段触发事件,允许插件介入并执行自定义操作。这些钩子是Webpack插件系统的核心,使得开发者可以在Webpack构建流程的不同阶段插入自己的逻辑。
以下是Webpack中一些重要的生命周期钩子:
-
compile
- 这个钩子在每次开始新的编译时触发。它提供了一个
Compilation
对象,你可以用它来访问和修改编译过程中的各种信息。这个钩子常用于初始化任务,比如设置环境变量或者清理之前的构建结果。
- 这个钩子在每次开始新的编译时触发。它提供了一个
-
seal
seal
钩子在Compilation
对象被封闭(seal)之前触发,这意味着所有的模块和chunk都已经被处理,但是还没有开始生成最终的输出文件。这个钩子适合用于最后的优化和修改Compilation
对象,比如删除不必要的模块或者修改chunk的顺序。
-
afterEmit
afterEmit
钩子在emit阶段之后触发,即所有的文件已经被写入到输出目录。这个阶段是处理最终输出结果的好时机,比如生成额外的资源文件、记录构建信息或者执行清理工作。
-
done
done
钩子在编译完成时触发,无论是成功还是失败。这个钩子可以用来执行清理工作,比如关闭数据库连接,或者发送构建完成的通知。
这些钩子是Webpack插件系统中非常核心的部分,它们允许开发者在构建流程的关键点插入自定义逻辑,从而实现高度定制化的构建过程。
Tips: 更多关于hooks的内容你可以在官网中查看:
3. 关键对象:compilation
我们再重点了解一下afterEmit
钩子以及它的 compilation
对象。
在Webpack 4中,afterEmit
是一个非常重要的生命周期钩子,它在资源生成并输出到输出目录之后被触发。这个钩子允许开发者在Webpack的构建过程结束之后执行自定义逻辑,例如清理旧文件、记录构建信息或者执行其他后续处理。
(1) afterEmit
钩子的参数
afterEmit
钩子接收两个参数:
compilation
:这是一个包含了当前编译上下文详细信息的对象。它提供了对编译过程中生成的资源和模块的访问。通过compilation
对象,你可以获取构建过程中的各种数据,包括编译生成的资源(assets)、模块(modules)和警告(warnings)等。callback
:这是一个必须被调用的回调函数,用于指示异步操作的完成。如果你的钩子处理逻辑是异步的,你需要在操作完成后调用这个回调函数。
(2) compilation.fileDependencies
字段
-
compilation.fileDependencies
是一个数组,它包含了Webpack构建过程中分析出来的所有文件依赖。这些文件依赖是指那些Webpack在构建过程中需要读取或解析的文件,例如模块文件、 loader 执行结果文件等。 -
通过这个字段,插件可以访问到Webpack构建过程中实际使用到的所有文件路径,这对于进行资源优化、清理无用文件等操作非常有用。
二、 定义构造函数参数
- 我们在项目根目录下创建一个plugins文件夹,并在这个文件夹下创建一个文件:
CustomCleanPlugin.js
- 在
vue.config.js
文件中引入该文件,并且传入参数:
// vue.config.js
const CustomCleanPlugin = require('./plugins/CustomCleanPlugin');
const cleanPluginOptions = {
rootDirectory: './src', // 指定要扫描的目录,默认为 './src'
shouldDelete: false, // 是否删除未使用的文件,默认为 false
resultPath: './unused-files.json', // 输出未使用文件的路径
exclusions: [], // 排除文件的路径
}
module.exports = {
// ...
configureWebpack: config => {
if (process.env.NODE_ENV === 'production') {
config.plugins.push(
new CustomCleanPlugin(cleanPluginOptions),
);
}
}
}
三、 搭建插件骨架
// CustomCleanPlugin.js
class CustomCleanPlugin {
constructor(opts) {
this.options = opts;
console.log(this.options)
}
// 插件的入口
apply(compiler) {
// 编译开始时触发
compiler.hooks.compile.tap('CustomCleanPlugin', compilationParams => {
console.log('Compilation has started.');
});
// 编译结束时触发
compiler.hooks.done.tap('CustomCleanPlugin', stats => {
console.log('Compilation has finished.');
});
}
}
module.exports = CustomCleanPlugin;
- 我们定义了一个
CustomCleanPlugin
类,options
变量存储传入的参数对象。 - 还定义了一个
apply
方法,这个是编译的入口。 - 在
compile
和done
这两个生命周期hook
里简单地进行console.log
打印
我们执行·npm run build ·来看看现在的效果:
四、 核心逻辑
1. 获取所有文件
我们首先要拿到当前项目的所有文件,不仅限于图片资源,包括可能存在一些未使用到的组件组件或者文档文件
const glob = require('glob');
const path = require('path');
class CustomCleanPlugin {
constructor(opts) {
this.options = opts;
console.log(this.options)
}
// 插件的入口
apply(compiler) {
// 编译开始时触发
compiler.hooks.compile.tap('CustomCleanPlugin', compilationParams => {
console.log('Compilation has started.');
});
// 编译结束时触发
compiler.hooks.done.tap('CustomCleanPlugin', stats => {
console.log('Compilation has finished.');
});
}
// 获取所有文件
async getAllFiles(directoryPattern) {
return new Promise((resolve, reject) => {
glob(directoryPattern, { nodir: true }, (err, files) => {
if (err) {
reject(err);
} else {
resolve(files.map(file => path.resolve(file)));
}
});
});
}
}
module.exports = CustomCleanPlugin;
2. 获取编译后的文件
compilation.fileDependencies
字段为我们提供了编译后需要依赖的文件路径,我们先对它进行去重,再把node_modules
路径下的文件进行排除。
// 获取已使用的文件
async collectUsedFiles(compilation) {
const usedFilesSet = new Set(compilation.fileDependencies);
const usedFiles = Array.from(usedFilesSet).filter(file => !file.includes('node_modules'));
return usedFiles;
}
3. 在afterEmit hooks里执行文件比对主逻辑
我们对apply方法进行修改:
apply(compiler) {
compiler.hooks.afterEmit.tapAsync('CustomCleanPlugin', async (compilation, callback) => {
try {
await this.findUnusedFiles(compilation, this.options);
callback();
} catch (error) {
callback(error);
}
});
}
const path = require('path');
async findUnusedFiles(compilation, config) {
const {
rootDirectory = './src',
shouldDelete = false,
resultPath = './unused-files.json',
excludedFiles = [], // 直接传入排除文件路径数组
} = config;
const filePattern = `${rootDirectory}/**/*`;
try {
const usedFiles = await this.collectUsedFiles(compilation);
const allFiles = await this.getAllFiles(filePattern);
let unusedFiles = allFiles.filter(file => !usedFiles.includes(file));
if (typeof resultPath === 'string') {
fs.writeFileSync(resultPath, JSON.stringify(unusedFiles, null, 4));
} else if (typeof resultPath === 'function') {
resultPath(unusedFiles);
}
} catch (error) {
throw error;
}
}
我们将两个数组进行比对过滤,得到unusedFiles
数组,并且将结果通过fs
写入目标路径
五、 功能扩展
1. 自动删除
如果传入的参数shouldDelete
为true
,我们可以借助shelljs
进行直接删除
npm install shelljs
const shelljs = require('shelljs');
const fs = require('fs');
// 找出未使用的文件
async findUnusedFiles(compilation, config) {
const {
rootDirectory = './src',
shouldDelete = false,
resultPath = './unused-files.json',
excludedFiles = [], // 直接传入排除文件路径数组
} = config;
const filePattern = `${rootDirectory}/**/*`;
try {
const usedFiles = await this.collectUsedFiles(compilation);
const allFiles = await this.getAllFiles(filePattern);
let unusedFiles = allFiles.filter(file => !usedFiles.includes(file));
if (typeof resultPath === 'string') {
fs.writeFileSync(resultPath, JSON.stringify(unusedFiles, null, 4));
} else if (typeof resultPath === 'function') {
resultPath(unusedFiles);
}
} catch (error) {
throw error;
}
+ if (shouldDelete) {
+ unusedFiles.forEach(file => {
+ shelljs.rm(file);
+ console.log(`Deleted file: ${file}`);
+ });
}
}
}
2. 排除比对路径
如果有些路径下的文件我们不想进行比对,可以再加一个辅助方法filterExcludedFiles进行排除
filterExcludedFiles(excludedFiles, filesList) {
return filesList.filter(file => {
return !file.includes('node_modules') && !excludedFiles.some(excluded => file.includes(excluded));
});
}
// 找出未使用的文件
async findUnusedFiles(compilation, config) {
const {
rootDirectory = './src',
shouldDelete = false,
resultPath = './unused-files.json',
excludedFiles = [] // 直接传入排除文件路径数组
} = config;
const filePattern = `${rootDirectory}/**/*`;
try {
const usedFiles = await this.collectUsedFiles(compilation);
const allFiles = await this.getAllFiles(filePattern);
let unusedFiles = allFiles.filter(file => !usedFiles.includes(file));
+ if (excludedFiles.length > 0) {
+ unusedFiles = this.filterExcludedFiles(excludedFiles, unusedFiles);
+ }
if (typeof resultPath === 'string') {
fs.writeFileSync(resultPath, JSON.stringify(unusedFiles, null, 4));
} else if (typeof resultPath === 'function') {
resultPath(unusedFiles);
}
if (shouldDelete) {
unusedFiles.forEach(file => {
shelljs.rm(file);
console.log(`Deleted file: ${file}`);
});
}
} catch (error) {
throw error;
}
}
最后总结
在执行完npm run build
以后,你可以在项目的根路径得到一个unused-files.json
文件,打开的效果如下:
完整代码:
const fs = require('fs');
const glob = require('glob');
const path = require('path');
const shelljs = require('shelljs');
class CustomCleanPlugin {
constructor(opts) {
this.options = opts;
}
apply(compiler) {
compiler.hooks.afterEmit.tapAsync('CustomCleanPlugin', async (compilation, callback) => {
try {
await this.findUnusedFiles(compilation, this.options);
callback();
} catch (error) {
callback(error);
}
});
}
// 获取已使用的文件
async collectUsedFiles(compilation) {
const usedFilesSet = new Set(compilation.fileDependencies);
const usedFiles = Array.from(usedFilesSet).filter(file => !file.includes('node_modules'));
return usedFiles;
}
// 获取所有文件
async getAllFiles(directoryPattern) {
return new Promise((resolve, reject) => {
glob(directoryPattern, { nodir: true }, (err, files) => {
if (err) {
reject(err);
} else {
resolve(files.map(file => path.resolve(file)));
}
});
});
}
filterExcludedFiles(excludedFiles, filesList) {
return filesList.filter(file => {
return !file.includes('node_modules') && !excludedFiles.some(excluded => file.includes(excluded));
});
}
// 找出未使用的文件
async findUnusedFiles(compilation, config) {
const {
rootDirectory = './src',
shouldDelete = false,
resultPath = './unused-files.json',
excludedFiles = [] // 直接传入排除文件路径数组
} = config;
const filePattern = `${rootDirectory}/**/*`;
try {
const usedFiles = await this.collectUsedFiles(compilation);
const allFiles = await this.getAllFiles(filePattern);
let unusedFiles = allFiles.filter(file => !usedFiles.includes(file));
if (excludedFiles.length > 0) {
unusedFiles = this.filterExcludedFiles(excludedFiles, unusedFiles);
}
if (typeof resultPath === 'string') {
fs.writeFileSync(resultPath, JSON.stringify(unusedFiles, null, 4));
} else if (typeof resultPath === 'function') {
resultPath(unusedFiles);
}
if (shouldDelete) {
unusedFiles.forEach(file => {
shelljs.rm(file);
console.log(`Deleted file: ${file}`);
});
}
} catch (error) {
throw error;
}
}
}
module.exports = CustomCleanPlugin;