写一个干掉代码中的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)、代理设置、静态文件服务等等,是生产环境不需要的。因此根据环境区分配置,可以使项目结构更清晰、便于管理和维护
- 热模块替换:webpack-dev-server 支持
hot
模式,在试图重新加载整个页面之前,hot
模式会尝试使用 HMR 来更新
- 热模块替换:webpack-dev-server 支持
- 从性能优化的角度来看的话,在开发环境中,也不需要生产环境所需要的一些插件,比如代码压缩、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
语句还有很多种类型,如warn
、debug
、info
等。由于我们的 plugin 不支持options
的配置,在功能的全面性上较差,该如何改进? - 除了正则去删除
console
语句之外,还有其他方式吗?能结合 AST 去做这件事情不?