资讯动态

从零实现Webpack清除注释插件:深入compiler与compilation原理

发布时间:2026/10/15 5:01:11 来源:尧图企业网站定制
1. 为什么要自己写一个清除注释的插件前端工程化走到今天webpack 的插件机制算是整个构建体系里最值得啃的一块骨头。很多人用了一两年 webpack配置写得飞起但一提到 plugin 内部到底怎么运转、tapable 的钩子是怎么被触发的、compiler 和 compilation 到底谁管什么脑子里还是一团浆糊。我自己也是从这个阶段过来的后来逼着自己动手写了一个小插件——清除文件注释才真正把这条链路捋顺。这个插件的需求很朴素打包产物里经常残留大量注释比如 license 头、开发时留下的 TODO、调试用的说明块。这些注释在开发阶段有用但上线后既占体积又暴露内部信息。市面上虽然有 terser 之类的压缩工具能顺手干掉注释但那是压缩器的附带功能我想做的是一个独立的、可控的、能精确指定清除范围的插件。更重要的是通过实现它能把 webpack 插件的完整生命周期走一遍。这篇文章适合两类人一类是已经会配 webpack、想深入插件原理的初中级前端另一类是手上有类似“对产物做二次加工”需求、想找个模板直接改的开发者。我会从设计思路讲到核心代码再到实际踩过的坑尽量把每一步的“为什么”说清楚。读完你不仅能得到一个能用的插件还能自己照着扩展出压缩图片、注入版本号、替换占位符之类的工具。2. 插件原理拆解webpack 到底给了我们什么2.1 compiler 与 compilation 的分工要写插件先得搞清楚 webpack 把什么东西交到了你手上。初始化插件时apply 方法会收到一个 compiler 对象。这个 compiler 是全局唯一的代表整个构建过程它身上挂着大量的钩子hooks比如 run、compile、emit、done。而 compilation 则是每次编译的产物代表“这一次”构建它也有自己的钩子比如 optimize、processAssets。打个比方compiler 像是一家工厂compilation 像是工厂里正在跑的一条生产线。工厂可以跑很多次生产watch 模式下每次文件变动就是一次新编译但工厂本身只有一个。清除注释这件事本质上是对“最终产物”动手所以最合适的时机是在 compilation 的 processAssets 阶段或者 compiler 的 emit 阶段。2.2 tapable 钩子的注册方式webpack 的钩子基于 tapable 这个库注册方式有同步、异步、promise 等好几种。我们最常用的是 tap同步和 tapAsync回调式异步。注册的时候要传一个名字这个名字会在调试时显示所以起个有意义的名字很重要别用 plugin 这种糊弄自己。compiler.hooks.emit.tapAsync(ClearCommentPlugin, (compilation, callback) { // 在这里处理 compilation.assets callback(); });上面这段就是最经典的写法。emit 钩子在生成文件到输出目录之前触发此时 compilation.assets 里存着所有即将写盘的文件内容我们改完再 callbackwebpack 就会把改过的内容写出去。2.3 为什么选 processAssets 而不是 emit早期教程大多用 emit因为它直观。但 webpack 5 之后官方更推荐用 compilation.hooks.processAssets原因是这个钩子有明确的 stage 概念能保证你的处理顺序和其他插件不打架。比如压缩插件也在处理 assets如果你用 emit可能压缩已经跑完了你再去注释就白费功夫。用 processAssets 并指定合适的 stage能精确控制先后。const { Compilation, sources } require(webpack); compilation.hooks.processAssets.tap( { name: ClearCommentPlugin, stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE, }, (assets) { // 处理 assets } );这里的 PROCESS_ASSETS_STAGE_OPTIMIZE 表示在优化阶段处理比默认阶段晚能拿到相对成熟的产物。3. 核心实现从零写出可用的清除逻辑3.1 插件的基本骨架一个 webpack 插件本质上就是一个类类里必须有一个 apply 方法。构造函数用来接收用户传的配置项apply 用来注册钩子。骨架大概长这样class ClearCommentPlugin { constructor(options {}) { this.options Object.assign({ test: /\.js$/, exclude: /node_modules/, }, options); } apply(compiler) { const { Compilation, sources } compiler.webpack; compiler.hooks.thisCompilation.tap(ClearCommentPlugin, (compilation) { compilation.hooks.processAssets.tap( { name: ClearCommentPlugin, stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE, }, (assets) { this.processAssets(compilation, assets); } ); }); } }注意这里用了 compiler.webpack 来拿 Compilation 和 sources这是 webpack 5 的推荐做法避免直接 require(webpack) 导致版本不一致的问题。3.2 遍历 assets 并筛选目标文件assets 是一个对象key 是文件名value 是 source 对象。我们要根据配置的 test 和 exclude 决定处理哪些文件。这里有个细节test 可能是正则也可能是函数得做兼容。processAssets(compilation, assets) { const { test, exclude } this.options; Object.keys(assets).forEach((filename) { if (!this.matchFile(filename, test, exclude)) return; const asset assets[filename]; const source asset.source(); const result this.removeComments(source); compilation.updateAsset(filename, new sources.RawSource(result)); }); } matchFile(filename, test, exclude) { const testPass typeof test function ? test(filename) : test.test(filename); if (!testPass) return false; if (!exclude) return true; const excludePass typeof exclude function ? exclude(filename) : exclude.test(filename); return !excludePass; }updateAsset 是 webpack 5 提供的更新方法比直接改 assets[filename] 更规范能触发后续插件的监听。3.3 注释清除的核心算法清除注释听起来简单但真写起来坑不少。最粗暴的做法是用正则把 /* */ 和 // 全删掉但这样会误伤字符串里的内容比如const url http://example.com里的 // 会被当成注释。所以必须做词法层面的判断。我的做法是逐字符扫描维护一个状态机区分“普通代码”“字符串”“模板字符串”“正则字面量”“单行注释”“多行注释”这几种状态。只有处于注释状态时才跳过字符其他状态原样保留。removeComments(code) { let result ; let i 0; const len code.length; let state code; // code | line | block | single | double | template | regex while (i len) { const char code[i]; const next code[i 1]; if (state code) { if (char / next /) { state line; i 2; continue; } if (char / next *) { state block; i 2; continue; } if (char ) state double; else if (char ) state single; else if (char ) state template; result char; i; continue; } if (state line) { if (char \n) { state code; result char; } i; continue; } if (state block) { if (char * next /) { state code; i 2; continue; } i; continue; } // 字符串状态处理注意转义 if (state double || state single) { if (char \\) { result char (next || ); i 2; continue; } if ((state double char ) || (state single char )) { state code; } result char; i; continue; } if (state template) { if (char \\) { result char (next || ); i 2; continue; } if (char ) state code; result char; i; continue; } } return result; }这段代码是插件的灵魂。它保证了字符串里的 // 和 /* 不会被误删同时正确处理了转义字符。实测下来对绝大多数业务代码都能正确处理。3.4 保留特定注释的开关有时候 license 注释是必须保留的比如某些开源协议要求产物里带上版权声明。所以插件应该支持一个 preserve 选项传入正则匹配到的注释不删。实现上在进入注释状态时先记录起始位置退出时取出这段注释内容如果匹配 preserve 就把它加回 result。这个逻辑稍微绕一点但很实用。if (state block) { if (char * next /) { const comment code.slice(blockStart, i 2); if (this.preserve this.preserve.test(comment)) { result comment; } state code; i 2; continue; } i; continue; }blockStart 在进入 block 状态时记录line 状态同理。4. 完整实操把插件跑起来并验证效果4.1 项目结构与依赖安装新建一个目录初始化 npm装 webpack 和 webpack-cli。我用的版本是 webpack 5.88、webpack-cli 5.1这个组合比较稳。mkdir clear-comment-demo cd clear-comment-demo npm init -y npm install webpack webpack-cli --save-dev目录结构建议这样clear-comment-demo/ ├── src/ │ ├── index.js │ └── utils.js ├── plugins/ │ └── clear-comment-plugin.js ├── webpack.config.js └── package.json把插件单独放 plugins 目录方便复用和测试。4.2 编写测试用的源文件src/index.js 里故意塞各种注释和字符串用来验证插件的健壮性/* 这是文件头注释应该被删除 */ import { formatUrl } from ./utils.js; // 单行注释应该被删除 const API http://example.com/api; // 行尾注释 const regex /\/\/not-a-comment/; // 正则里的斜杠 /** * 多行注释 * 应该被删除 */ function main() { const tpl 模板字符串里的 // 不是注释; console.log(API, regex, tpl, formatUrl(test)); } main();src/utils.js// 工具函数 export function formatUrl(path) { /* 拼接路径 */ return https://example.com/${path}; }4.3 配置 webpack 并接入插件webpack.config.jsconst path require(path); const ClearCommentPlugin require(./plugins/clear-comment-plugin); module.exports { mode: production, entry: ./src/index.js, output: { path: path.resolve(__dirname, dist), filename: bundle.js, }, plugins: [ new ClearCommentPlugin({ test: /\.js$/, exclude: /vendor/, preserve: /license|preserve/, }), ], };注意 mode 设成 production 时 webpack 自己也会压缩为了单独验证插件效果可以先设成 none 或者 development把 terser 关掉。4.4 运行构建并对比产物执行 npx webpack然后打开 dist/bundle.js 看效果。如果一切正常你会发现文件头的 /* */ 没了单行 // 没了行尾注释没了字符串里的 http:// 完好无损模板字符串里的 // 完好无损正则里的 // 完好无损这一步的验证非常关键很多人写完插件不验证边界情况上线后才发现字符串被截断那就麻烦了。我建议至少准备 10 个以上的边界用例包括 URL、正则、模板字符串、转义引号、嵌套注释等。4.5 参数选择与性能考量清除注释是纯字符串处理性能主要取决于文件大小和数量。对于几 MB 的 bundle逐字符扫描大概几十毫秒可以接受。但如果项目有上百个 chunk累计起来就有点可观了。优化思路有两个一是只处理 .js 文件跳过 .css、.map、图片等二是对于超过一定体积的文件可以考虑用更快的正则预处理先粗筛掉明显没有注释的文件。实测下来加一个if (!code.includes(//) !code.includes(/*)) return code;的快速判断能省掉大量无注释文件的扫描开销。5. 常见问题与排查技巧实录5.1 注释删不干净或误删代码最常见的现象是字符串里的内容被误删。比如const a //hello变成了const a 直接语法错误。这基本是状态机没处理好字符串状态导致的。排查方法很简单把出问题的文件单独拿出来在 removeComments 里打日志看状态切换的时机对不对。另一个坑是正则字面量。/abc/里的斜杠和注释的斜杠长得一样如果状态机不区分就会把后面的代码当成注释。判断正则比较麻烦需要看前一个非空白字符是不是运算符或关键字。如果项目里正则用得不多可以在配置里加个开关遇到疑似正则就保守处理。5.2 与其他插件冲突如果同时用了压缩插件可能出现你的插件跑完了压缩插件又把注释加回来某些 license 提取插件会这么做。解决办法是调整 processAssets 的 stage让你的插件在压缩之后跑或者干脆用 emit 钩子保证是最后一道工序。还有一种情况是 sourcemap 对不上。你改了 assets 内容但没更新 sourcemap调试时行号会错乱。webpack 5 的 updateAsset 会自动处理 source 和 sourcemap 的关联但如果你手动改 assets[filename]就得自己调 compilation.updateAsset 并传入新的 source 和 sourceMap。5.3 常见问题速查表问题现象可能原因解决思路字符串被截断状态机未区分字符串检查 single/double/template 状态切换正则被误删未识别正则字面量增加 regex 状态或保守跳过注释没删掉钩子时机太早改用 processAssets 的 OPTIMIZE 阶段sourcemap 错乱未更新 sourcemap用 updateAsset 并传 sourceMap构建变慢全量扫描大文件加快速判断跳过无注释文件license 被删未配置 preserve传入 preserve 正则保留5.4 几个我踩过的坑第一个坑是\r\n换行。Windows 下文件是 CRLF我的状态机只判断了\n导致单行注释删完后残留一个\r虽然不影响运行但产物里多了一堆空行。后来改成判断\n时把前面的\r也一起处理。第二个坑是 shebang。有些入口文件第一行是#!/usr/bin/env node这行以 # 开头不是注释但也不能删。我的处理是在扫描前先判断开头是不是 #!是的话跳过第一行。第三个坑是 webpack 5 的 asset source 类型。有些 asset 的 source 不是字符串而是 Buffer直接调 source() 可能拿到二进制。所以处理前要判断文件扩展名只处理文本类文件。6. 插件还能怎么扩展这个插件的骨架其实是个通用模板把 removeComments 换成别的处理函数就能干很多事。比如把 console.log 删掉、把 process.env.NODE_ENV 替换成具体值、给每个 chunk 头部注入版本号和构建时间、把特定的占位符替换成 CDN 地址。我自己后来基于它扩展了一个“敏感信息扫描”插件在 processAssets 阶段扫描产物里有没有硬编码的密钥、内网地址发现就报警。原理完全一样只是处理逻辑从删除变成了检测。如果你想继续深入建议下一步研究 tapable 的 AsyncSeriesHook 和 AsyncParallelHook理解 webpack 内部多个插件是怎么并行和串行调度的。再往后可以看看 loader 和 plugin 的边界什么场景该用 loader、什么场景该用 plugin这个判断力是工程化能力的分水岭。最后分享一个小技巧调试插件时在 apply 里打印 compiler.hooks 上所有钩子的名字能快速知道有哪些时机可用。Object.keys(compiler.hooks).forEach(k console.log(k))这一行能省掉大量翻文档的时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑