写一个干掉代码中的console.log 的插件

NOTE

仅用作原理学习和面试吹牛 B,真实项目请使用现成的轮子

想要亲自动手写一个 Webpack 的插件,那么不得不聊一下 Webpack 的构建过程。看看 plugin 是在什么阶段生效的,或者说 plugin 能生效的阶段都有哪些

Webpack 的构建过程

Webpack 的构建过程可以分为以下几个主要步骤:(下方的内容会结合 create-react-app 这个脚手架去聊,以下简称为 CRA)

初始化阶段 (Initialization)

启动

Webpack 通过 CLI 或 API 启动,并读取配置文件(如 webpack.config.js)。在 webpack.config.js 会存在判断当前环境的代码:

const isEnvDevelopment = webpackEnv === "development";
const isEnvProduction = webpackEnv === "production";

并且在文件内容中,很多配置的地方,都会找到一些三元表达式:

也就是说,同一个 plugin,根据不同的环境,可能存在不同的配置,这是为什么呢?

这其实就和 Webpack 的优化有关了。通过区分环境(开发环境、生产环境),来实现优化:

  • 很多时候开发环境中一些配置的设置,比如:热模块替换(HMR)、代理设置、静态文件服务等等,是生产环境不需要的。因此根据环境区分配置,可以使项目结构更清晰、便于管理和维护
  • 从性能优化的角度来看的话,在开发环境中,也不需要生产环境所需要的一些插件,比如代码压缩、Tree Shaking 等。根据环境区分配置,可以使得在开发过程中能够避免运行一些耗时的插件。就比如上面的这张截图,对于 HtmlWebpackPlugin 这个插件来说,我们只期望它在生产环境才去进行一些代码压缩的行为,因此通过三元表达式,根据环境来决定需要设置的配置参数。简单说一下配置项的作用:
    • removeComments:删除 HTML 中的注释
    • collapseWhitespace:删除 HTML 中的空白字符
    • removeRedundantAttributes:删除多余的属性
    • useShortDoctype:使用简化的文档类型
    • removeEmptyAttributes:删除空属性
  • 根据环境区分配置还能提升灵活性,可以在启动开发服务器时使用不同的命令行参数或环境变量

在 CRA 中,配置文件不止一个,还存在另一个 webpackDevServer.config.js 文件,它又是做什么用的?

它是用于配置开发服务器 webpack-dev-server 的行为。这里也拿一些配置项举几个例子,方便理解:

  • historyApiFallback:用于单页面应用,处理 HTML5 History API 路由问题
  • publicPath:指定输出文件的公共路径
  • compress:启用 gzip 压缩

创建 Compiler 对象

Webpack 初始化一个 Compiler 对象,该对象负责控制整个构建过程

加载插件

Webpack 读取配置中的插件,并调用插件的 apply 方法,让插件可以注册钩子函数

一个 plugin 的基本结构:

class DemoPlugin {
  // options: 接收 plugin 的配置项
  constructor(options) {
    // 获取配置项,初始化插件
  }
 
  // apply 是与 webpack 通信的桥梁
  apply(compiler) {
    // 获取 compiler,可以通过 compiler 对象访问 compilation 对象
  }
}

apply 内部可以包含任何自定义逻辑,这些逻辑将在 Webpack 的特定生命周期钩子被触发时执行。插件可以利用这些钩子来修改构建结果、添加新的资产、或者执行其他任何必要的操作

比如:

apply(compiler) {
  compiler.hooks.compile.tap('DemoPlugin', (compilationParams) => {
    // 在编译器开始读取 Records 之前执行的操作
  });
 
  compiler.hooks.compilation.tap('DemoPlugin', (compilation) => {
    // 在创建新的 compilation 之前执行的操作
  });
}

编译阶段 (Compilation)

确定入口

Webpack 根据配置中的 entry 找到所有入口文件

创建 Compilation 对象

每当检测到文件变化时,Webpack 都会创建一个新的 Compilation 对象,该对象包含了当前的模块资源、编译生成资源、变化的文件等

编译模块

  • 递归编译:从入口文件开始,会递归地解析每个模块所依赖的其他模块,形成一个依赖关系图
  • loader 加载器处理:在解析模块时,会使用配置中的 loader 对模块进行转换,例如通过 ts-loader 将 TypeScript 转换为 JavaScript
  • 构建模块:会将模块转换后的内容封装成一个个的模块对象

生成资源阶段 (Make)

  • 完成模块编译:在编译完所有模块后,会得到一个模块对象组成的列表
  • 优化模块:可能会根据配置对模块进行优化,例如合并模块、摇树优化(Tree Shaking)等
  • 确定 chunks:根据模块之间的依赖关系,将模块组合成多个 chunks,每个 chunk 对应一个输出文件。

优化阶段 (Seal)

  • 优化 chunks:进一步优化 chunks,比如合并相同的模块、删除无用的代码等
  • 生成 chunks:根据优化后的 chunks 生成最终输出的资源

发射阶段 (Emit)

  • 资源输出:将编译和优化后的资源发射到输出目录,如 dist 文件夹
  • 文件写入:将生成的文件写入到文件系统中

完成阶段 (After)

  • 完成通知:在所有文件写入完成后,通知插件构建过程结束
  • 清理工作:插件可以进行清理工作,例如删除临时文件、日志记录等

完成阶段也可以让 plugin 介入,只要在 apply 中注册 hook 即可:

apply(compiler) {
 // 注册完成阶段的钩子
 compiler.hooks.done.tap('AfterBuildPlugin', (stats) => {
   // 完成通知
   console.log('Webpack build is finished!');
 
   // 清理工作
   this.cleanup();
 });
}

创建自定义 Webpack 插件

编写插件

Webpack 插件是一个具有 apply 方法的 JS 对象。 apply 方法会被 Webpack compiler 调用,并且在整个编译生命周期都可以访问 compiler 对象

// RemoveConsolePlugin.js
const pluginName = 'RemoveConsolePlugin';
 
class RemoveConsolePlugin {
 
// 由于不需要从外部传入 options
// 因此这里就不显示地定义 constructor 了
// constructor (options) {...}
 
apply(compiler) {
    compiler.hooks.emit.tapAsync(
      pluginName,
      (compilation, callback) => {
        Object.keys(compilation.assets).forEach((filename) => {
          // 仅处理 .js 文件
          if (filename.endsWith(".js")) {
            const asset = compilation.assets[filename];
            let content = asset.source();
 
            // 使用正则表达式移除整个 console.log 语句
            // 匹配 console.log( 之后的任意字符,直到遇到闭合的括号
            const consoleLogRegex = new RegExp(
              "console\\.log\\(.*?\\)",
              "g"
            );
 
            const withoutConsole = content.replace(consoleLogRegex, "");
 
            // 更新资源
            compilation.assets[filename] = {
              source: () => withoutConsole,
              size: () => Buffer.byteLength(withoutConsole, "utf8"),
            };
          }
        });
 
        callback();
      }
    );
  }
}
 
module.exports = RemoveConsolePlugin;

compiler hook 的 tap 方法的第一个参数,应该是大驼峰式命名的插件名称。建议为此使用一个常量,以便它可以在所有 hook 中重复使用

compiler.hooks.emit:可以通过 compiler 去获取一些 hook,在这里选择 emit 这个 hook:

在 asset 被输出到 output 之前,完成对 console.log 语句的删除

compilation 实例能够访问所有的模块和它们的依赖(大部分是循环依赖)。它会对应用程序的依赖图中所有模块,进行字面上的编译 (literal compilation)

在这里,通过 compilation 实例获取到 assets,这里面就存着所有被处理的文件了。考虑到插件的运行会影响打包的速度,这里仅对 .js 文件做删除 console.log 语句的处理

通过调用 asset.source() 来获取文件的源代码,之后就是很熟悉的字符串的正则匹配和替换了

在 Webpack 配置中使用插件

回到 webpack.config.js 文件中,引入 RemoveConsolePlugin 并添加到插件数组中:

// webpack.config.js
const RemoveConsolePlugin = require("../src/RemoveConsolePlugin");
 
module.exports = {
  // ...其他配置...
  plugins: [
    new RemoveConsolePlugin(),
    // ...其他插件...
  ],
};

问题修复

运行之后发现 ouput 有问题:

console.log 语句确实是被删除了,但是留下了一堆逗号,导致打包后的文件异常了

先取消使用自定义插件,再打包一次看看:

可以看到,输出的文件里,每一行 console.log 语句后面都跟着一个逗号。所以使用正则的方式去删除 console.log 语句,还得给正则表达式加上一个是否以逗号结尾的匹配规则:(,|$)

const consoleLogRegex = new RegExp(
  "console\\.log\\(.*?\\)(,|$)",
  "g"
);

这样就解决了这个问题

扩展思考

  • 我们实现的自定义插件只是删除了 console 语句中的 log 而已,console 语句还有很多种类型,如 warndebuginfo 等。由于我们的 plugin 不支持 options 的配置,在功能的全面性上较差,该如何改进?
  • 除了正则去删除 console 语句之外,还有其他方式吗?能结合 AST 去做这件事情不?