自定义Webpack插件:五步清除项目中的“僵尸”文件!

495 阅读7分钟

前言

Hello~大家好,我是秋天的一阵风。

最近,在接手一个老项目时,我发现我们的项目中静态资源文件夹里藏着不少 “历史遗留问题” ——一大堆 重复 的和 过时废弃 的文件,比如一些旧logo的PNG图片、内容一样命名不同的重复SVG文件,还有一些静静躺在角落里的大体积GIF动图。

这些文件明显已经没用了,但它们为啥还呆在这里占地方呢? 我分析了我们公司团队的工作流程,发现了几个可能的原因:

  1. 重复复制:有时候,团队成员在不同的地方放了相同的图片,可能因为“找不到”原来的,或者是“懒得找”,结果就是同样的图片在不同的地方出现了好几次。
image.png
  1. 版本迭代:随着项目不断更新,一些文件就像旧衣服一样被新版本取代,但旧的文件却没有被及时清理出去,可能是因为我们太忙,或者是忘了它们的存在。

  2. 图标替换:当我们更新图标或者图片时,新的文件来了,旧的却没有及时被删除。可能是因为我们觉得“总有一天可能会用到”,或者是简单地忘记了。

image.png
  1. 最后一种情况是,在项目最初开始迭代的时候还没有实现echarts图表相关的功能,所以会用一些假图片来占位。
image.png

这些文件的存在,虽然看似无害,但实际上它们在悄悄地影响着项目的效率和整洁度。所以思考过后还是决定采取行动,把那些不再需要的文件清理出去。

当然,作为程序员,将一个一个图片文件名字复制去项目代码里搜索,然后手动删除肯定是会让人笑掉大牙的,所以最好的办法就是能用代码来实现,接下来,我们就一起从零开始构建一个Webpack插件。

一、开始之前

在开始编写自定义Webpack插件之前,你需要确保对以下知识熟练掌握:

1. 插件是什么

Webpack插件是一个具有apply方法的类,该方法在编译过程中被Webpack编译器(compiler)调用。

apply方法接收一个编译器(compiler)对象作为参数,你可以通过这个对象访问Webpack的钩子(hooks)

2. 插件的生命周期

在Webpack中,生命周期钩子(也称为事件钩子或插件钩子)是编译过程中的不同阶段,Webpack会在这些阶段触发事件,允许插件介入并执行自定义操作。这些钩子是Webpack插件系统的核心,使得开发者可以在Webpack构建流程的不同阶段插入自己的逻辑。

以下是Webpack中一些重要的生命周期钩子:

  1. compile

    • 这个钩子在每次开始新的编译时触发。它提供了一个Compilation对象,你可以用它来访问和修改编译过程中的各种信息。这个钩子常用于初始化任务,比如设置环境变量或者清理之前的构建结果。
  2. seal

    • seal钩子在Compilation对象被封闭(seal)之前触发,这意味着所有的模块和chunk都已经被处理,但是还没有开始生成最终的输出文件。这个钩子适合用于最后的优化和修改Compilation对象,比如删除不必要的模块或者修改chunk的顺序。
  3. afterEmit

    • afterEmit钩子在emit阶段之后触发,即所有的文件已经被写入到输出目录。这个阶段是处理最终输出结果的好时机,比如生成额外的资源文件、记录构建信息或者执行清理工作。
  4. done

    • done钩子在编译完成时触发,无论是成功还是失败。这个钩子可以用来执行清理工作,比如关闭数据库连接,或者发送构建完成的通知。

这些钩子是Webpack插件系统中非常核心的部分,它们允许开发者在构建流程的关键点插入自定义逻辑,从而实现高度定制化的构建过程。

Tips: 更多关于hooks的内容你可以在官网中查看:

Compilation Hooks - webpack 4 Documentation

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构建过程中实际使用到的所有文件路径,这对于进行资源优化、清理无用文件等操作非常有用。

二、 定义构造函数参数

  1. 我们在项目根目录下创建一个plugins文件夹,并在这个文件夹下创建一个文件:CustomCleanPlugin.js
  2. 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;

  1. 我们定义了一个CustomCleanPlugin类,options变量存储传入的参数对象。
  2. 还定义了一个apply方法,这个是编译的入口。
  3. compiledone这两个生命周期hook里简单地进行console.log打印

我们执行·npm run build ·来看看现在的效果:

image.png image.png

四、 核心逻辑

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. 自动删除

如果传入的参数shouldDeletetrue,我们可以借助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文件,打开的效果如下:

image.png

完整代码:


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;