1. 从一个真实需求说起:为什么要自己写一个清除注释的插件
前端工程化做到一定程度,总会遇到一些“看起来很小、但市面上找不到完全合身方案”的需求。清除构建产物里的注释就是典型例子。你可能第一反应是去找现成的插件,比如terser-webpack-plugin或者compression-webpack-plugin,但真到项目里你会发现:terser主要针对 JS 做压缩混淆,对 CSS、HTML、甚至自定义模板文件里的注释无能为力;而一些通用压缩工具又会把代码结构改得面目全非,调试时根本对不上源码。
我所在的团队之前维护过一个多页面的老项目,构建产物里混杂着大量开发阶段留下的/* eslint-disable */、<!-- TODO: 待确认 -->、// 临时方案,上线前删掉这类注释。这些东西跟着包一起发到线上,不仅体积白白增大,有些注释里还带着内部接口路径和业务逻辑说明,属于典型的“信息泄露”。当时试过几个方案:手动清理不现实,用正则全局替换又容易误伤字符串里的//(比如 URL 里的https://),最后决定自己写一个 webpack 插件,在emit阶段统一处理。
这个项目的价值不在于“造轮子”,而在于通过写一个真实可用的小插件,把 webpack 插件系统的核心原理彻底吃透。很多人学 webpack 插件,看完文档还是不知道compiler、compilation、tapable、hooks这些概念怎么串起来。我的经验是:与其看十篇文章,不如自己写一个能跑起来的插件,哪怕只有几十行。这个清除注释的插件就是绝佳的练手项目——需求清晰、逻辑不复杂、但涉及了插件开发的完整链路。
这篇文章适合两类人:一是已经会用 webpack 配置、但想深入插件机制的前端开发者;二是手头正好有类似清理需求、想直接抄作业的工程师。我会从设计思路讲到完整实现,再到踩过的坑,尽量把每个“为什么这么写”都讲清楚。你跟着走一遍,基本就能独立开发其他类型的插件了。
2. 动手之前:webpack 插件原理的极简拆解
2.1 插件到底在什么时机介入
webpack 的构建流程本质上是一条流水线,从读取配置、解析入口、递归构建依赖图、生成 chunk,到最后输出文件,中间会触发几十个生命周期钩子。插件的作用就是“在某个钩子上挂一个函数”,等 webpack 执行到那一步时回调你的逻辑。
这里有两个核心对象必须分清:
- compiler:代表整个 webpack 编译实例,全局唯一。它身上的钩子多是“宏观”级别的,比如
run(开始编译)、emit(准备输出资源)、done(编译完成)。 - compilation:代表一次具体的编译过程,每次文件变化触发重新编译都会新建一个。它身上的钩子更“微观”,比如
processAssets(处理资源)、optimizeChunkAssets(旧版本)、additionalAssets等。
清除注释这个需求,最合适的介入点是compilation的processAssets钩子。原因很简单:此时所有模块已经编译成最终的 chunk 资源,文件内容已经确定,我们只需要对每个资源的内容做字符串处理即可。如果太早介入(比如在模块解析阶段),你拿到的是零散的模块源码,还得自己拼接;如果太晚(比如emit之后),文件已经写盘,改起来就麻烦了。
提示:webpack 5 之后,
compilation.hooks.processAssets是官方推荐的资源处理入口,配合Compilation.PROCESS_ASSETS_STAGE_*常量控制执行顺序。老版本用的是emit钩子直接操作compilation.assets,虽然还能用,但不推荐。
2.2 tapable 与 hooks:插件系统的发动机
webpack 的钩子机制底层依赖一个叫tapable的库。你可以把它理解成一个“事件总线”,但比普通 EventEmitter 强大得多。它支持多种钩子类型:
| 钩子类型 | 执行方式 | 典型用途 |
|---|---|---|
| SyncHook | 同步串行 | 简单通知,无返回值 |
| SyncBailHook | 同步串行,遇非 undefined 返回值则中断 | 需要提前终止的场景 |
| AsyncSeriesHook | 异步串行 | 需要等待的异步操作 |
| AsyncParallelHook | 异步并行 | 多个独立异步任务 |
插件里最常见的写法是compiler.hooks.emit.tap('PluginName', callback)或compilation.hooks.processAssets.tap({ name, stage }, callback)。tap是同步注册,tapAsync和tapPromise分别对应回调式和 Promise 式异步注册。
理解这一点很关键:你注册的钩子类型必须和 webpack 定义的一致。比如processAssets是AsyncSeriesHook,你就不能用tap去注册一个需要异步返回的逻辑,否则 webpack 不会等你。我一开始就犯过这个错,在processAssets里用tap注册,结果里面调了个异步读取配置的方法,导致处理时有时无,排查了半天才发现是钩子类型用错了。
2.3 为什么选择“清除注释”作为练手项目
市面上的插件教程大多让你写一个“Hello World”插件,打印一句话就结束了,学完还是不会做真实需求。清除注释这个项目的好处在于:
- 有真实输入输出:输入是构建后的资源文件,输出是清理后的内容,能直观验证效果。
- 涉及资源遍历:需要遍历
compilation.assets,这是插件开发的高频操作。 - 需要处理边界:字符串里的
//、正则里的注释符号、sourcemap 注释等,逼着你思考健壮性。 - 可扩展性强:做完基础版,你可以加配置项、加文件类型过滤、加白名单,逐步演进成一个正经插件。
说白了,它是一个“麻雀虽小五脏俱全”的项目,能让你把插件开发的完整套路走一遍。
3. 插件整体设计:先想清楚再写代码
3.1 功能边界与需求拆解
在动手写代码前,我习惯先把需求拆成可执行的条目。这个插件的核心需求是:在 webpack 构建输出阶段,自动清除指定类型文件中的注释。拆解下来包含几个子问题:
- 处理哪些文件类型:JS、CSS、HTML 的注释语法各不相同,需要分别处理。默认可以全支持,也允许用户通过配置指定。
- 清除哪些注释:行注释(
//、#)、块注释(/* */、<!-- -->)都要覆盖。 - 如何避免误伤:字符串字面量里的
//、正则表达式里的/*、URL 里的https://都不能被当成注释删掉。 - 是否保留特殊注释:比如
/*!开头的版权注释、@license、@preserve这类通常需要保留。 - 配置项设计:让用户能控制文件类型、是否保留特殊注释、是否输出日志等。
把这些想清楚,插件的 API 设计就出来了。我最终定的配置项是这样的:
new ClearCommentsPlugin({ test: /\.(js|css|html)$/, // 需要处理的文件正则 exclude: /vendor/, // 排除的文件 preserve: /^!/, // 保留以 ! 开头的块注释(版权注释) verbose: false // 是否打印处理日志 })3.2 技术选型:正则还是 AST
这是整个项目最关键的决策点。清除注释有两条路:
- 正则匹配:写几个正则表达式,直接对文本做替换。优点是简单、快、无依赖;缺点是容易误伤,尤其是 JS 里字符串和正则字面量中的注释符号。
- AST 解析:用
@babel/parser或acorn把 JS 解析成抽象语法树,遍历节点找到注释节点删除,再重新生成代码。优点是精准、不会误伤;缺点是要引入依赖、处理速度慢、对 CSS/HTML 还得换别的解析器。
我实际试过两种方案。AST 方案确实精准,但引入 babel 全家桶后,插件体积和构建时间都上去了,而且对 CSS、HTML 还得再引入postcss、parse5,复杂度陡增。对于一个“清除注释”的轻量需求来说,有点杀鸡用牛刀。
最终我选了正则为主、状态机辅助的方案。核心思路是:不追求 100% 完美,而是覆盖 95% 以上的常见场景,同时对已知的误伤点做针对性规避。具体来说:
- 对 JS,先识别字符串字面量(单引号、双引号、模板字符串)和正则字面量,把它们“保护”起来,再对剩余部分做注释清除。
- 对 CSS,注释只有
/* */一种,且不会出现在字符串里(CSS 字符串里出现/*的概率极低),直接正则处理即可。 - 对 HTML,注释是
<!-- -->,同样直接处理。
这个取舍背后的逻辑是:工程上不存在完美方案,只有性价比最高的方案。如果你的项目对精度要求极高,那应该用 AST;如果只是清理构建产物,正则足够。
3.3 插件骨架搭建
webpack 插件本质上是一个类(或者函数),必须有一个apply方法,接收compiler作为参数。骨架大概长这样:
class ClearCommentsPlugin { constructor(options = {}) { this.options = { test: /\.(js|css|html)$/, exclude: null, preserve: /^!/, verbose: false, ...options }; } apply(compiler) { const pluginName = 'ClearCommentsPlugin'; const { webpack } = compiler; const { Compilation } = webpack; compiler.hooks.compilation.tap(pluginName, (compilation) => { compilation.hooks.processAssets.tap( { name: pluginName, stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE }, (assets) => { // 核心处理逻辑 } ); }); } }这里有几个细节值得说:
compiler.hooks.compilation是每次编译都会触发的钩子,在这里注册processAssets能保证每次重新编译都生效。stage选PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,表示在“优化体积”阶段执行,这个阶段资源已经生成完毕,适合做清理。- 通过
compiler.webpack拿到Compilation常量,避免直接require('webpack'),这样能兼容不同版本的 webpack。
4. 核心实现:把注释清除逻辑写扎实
4.1 遍历资源与文件类型过滤
processAssets回调接收的assets参数是一个对象,key 是文件名,value 是资源对象。遍历时先做类型过滤:
Object.keys(assets).forEach((filename) => { if (!this.options.test.test(filename)) return; if (this.options.exclude && this.options.exclude.test(filename)) return; const asset = assets[filename]; const source = asset.source(); const content = typeof source === 'string' ? source : source.toString('utf-8'); const cleaned = this.clear(content, filename); if (cleaned !== content) { compilation.updateAsset(filename, new webpack.sources.RawSource(cleaned)); if (this.options.verbose) { console.log(`[ClearComments] ${filename} 已清理`); } } });这里用compilation.updateAsset而不是直接改assets[filename],是 webpack 5 推荐的做法,能正确触发后续的缓存和 sourcemap 更新。RawSource是 webpack 内置的 source 类型,用来包装纯字符串。
注意:
asset.source()返回的可能是字符串,也可能是 Buffer。稳妥起见统一转成字符串处理,输出时再用RawSource包装。
4.2 JS 注释清除:状态机保护字符串与正则
JS 是最麻烦的,因为//和/*可能出现在字符串或正则里。我的做法是写一个简单的状态机,逐字符扫描,把字符串和正则字面量“跳过”,只对代码部分做注释识别。
核心状态有:normal(普通代码)、singleQuote、doubleQuote、template、regex、lineComment、blockComment。扫描时根据当前字符和状态决定下一步。为了控制篇幅,这里给出简化版的核心逻辑:
function clearJsComments(code, preserve) { let result = ''; let i = 0; const len = code.length; let state = 'normal'; while (i < len) { const ch = code[i]; const next = code[i + 1]; if (state === 'normal') { if (ch === '/' && next === '/') { state = 'lineComment'; i += 2; continue; } if (ch === '/' && next === '*') { // 检查是否保留 const isPreserve = preserve && preserve.test(code.slice(i + 2, i + 3)); if (isPreserve) { result += '/*'; i += 2; state = 'blockCommentPreserve'; continue; } state = 'blockComment'; i += 2; continue; } if (ch === "'") { state = 'singleQuote'; result += ch; i++; continue; } if (ch === '"') { state = 'doubleQuote'; result += ch; i++; continue; } if (ch === '`') { state = 'template'; result += ch; i++; continue; } result += ch; i++; } else if (state === 'lineComment') { if (ch === '\n') { state = 'normal'; result += ch; } i++; } else if (state === 'blockComment') { if (ch === '*' && next === '/') { state = 'normal'; i += 2; continue; } i++; } else if (state === 'blockCommentPreserve') { result += ch; if (ch === '*' && next === '/') { result += '/'; state = 'normal'; i += 2; continue; } i++; } else if (state === 'singleQuote' || state === 'doubleQuote') { result += ch; if (ch === '\\') { result += next; i += 2; continue; } if ((state === 'singleQuote' && ch === "'") || (state === 'doubleQuote' && ch === '"')) { state = 'normal'; } i++; } else if (state === 'template') { result += ch; if (ch === '\\') { result += next; i += 2; continue; } if (ch === '`') { state = 'normal'; } i++; } } return result; }这段代码有几个关键点:
- 字符串保护:进入引号状态后,所有字符原样保留,直到遇到匹配的结束引号。反斜杠转义要特殊处理,否则
'it\'s'会被误判。 - 保留注释:遇到
/*!这类需要保留的块注释,进入blockCommentPreserve状态,把内容原样输出。 - 行注释处理:
//之后的内容全部丢弃,直到换行符。换行符本身要保留,否则代码会挤成一行。
正则字面量的处理更复杂,因为/既可能是除号也可能是正则开始。简化处理是:如果/前面是=、(、,、:、[、!、&、|、?、{、}、;或行首,就认为是正则开始。这个判断不完美,但覆盖了绝大多数场景。为了不让代码过于臃肿,我在实际插件里对正则做了保守处理——如果检测到疑似正则,就跳过到下一个/,避免误删。
4.3 CSS 与 HTML 注释清除
CSS 和 HTML 简单得多。CSS 只有/* */一种注释,且几乎不会出现在字符串里(CSS 字符串里出现/*的情况极其罕见)。直接用一个非贪婪正则替换:
function clearCssComments(css, preserve) { return css.replace(/\/\*[\s\S]*?\*\//g, (match) => { if (preserve && preserve.test(match.slice(2, 3))) { return match; } return ''; }); }HTML 注释是<!-- -->,同样处理:
function clearHtmlComments(html) { return html.replace(/<!--[\s\S]*?-->/g, ''); }提示:CSS 里有个坑,
/* */如果出现在url()里,比如url(data:image/svg+xml;.../*...*/),理论上可能被误删。但实际项目中这种写法极少,如果真遇到,可以在正则里加负向断言排除url(开头的情况。我目前没遇到过,所以没做这层防护。
4.4 根据文件扩展名分发处理逻辑
把上面的逻辑串起来,根据文件扩展名选择对应的清除函数:
clear(content, filename) { const ext = filename.split('.').pop().toLowerCase(); switch (ext) { case 'js': case 'mjs': case 'cjs': return clearJsComments(content, this.options.preserve); case 'css': return clearCssComments(content, this.options.preserve); case 'html': case 'htm': return clearHtmlComments(content); default: return content; } }这样整个插件的核心逻辑就完整了。代码量不大,但每一块都有明确的职责,后续要扩展新文件类型也很方便。
5. 完整实操:从零跑通一个 demo
5.1 项目初始化与依赖安装
新建一个目录,初始化 npm 项目,安装 webpack 和 webpack-cli:
mkdir clear-comments-plugin-demo cd clear-comments-plugin-demo npm init -y npm install webpack webpack-cli --save-dev这里不需要额外安装任何解析库,因为我们的方案是纯正则加状态机,零运行时依赖。这也是我选择这个方案的原因之一——插件本身足够轻。
5.2 编写插件文件
在项目根目录新建plugins/clear-comments-plugin.js,把前面几节的代码整合进去。完整结构如下:
class ClearCommentsPlugin { constructor(options = {}) { this.options = { test: /\.(js|css|html)$/, exclude: null, preserve: /^!/, verbose: false, ...options }; } apply(compiler) { const pluginName = 'ClearCommentsPlugin'; const { webpack } = compiler; const { Compilation } = webpack; compiler.hooks.compilation.tap(pluginName, (compilation) => { compilation.hooks.processAssets.tap( { name: pluginName, stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE }, (assets) => { Object.keys(assets).forEach((filename) => { if (!this.options.test.test(filename)) return; if (this.options.exclude && this.options.exclude.test(filename)) return; const asset = assets[filename]; const source = asset.source(); const content = typeof source === 'string' ? source : source.toString('utf-8'); const cleaned = this.clear(content, filename); if (cleaned !== content) { compilation.updateAsset(filename, new webpack.sources.RawSource(cleaned)); if (this.options.verbose) { console.log(`[ClearComments] 已清理 ${filename}`); } } }); } ); }); } clear(content, filename) { const ext = filename.split('.').pop().toLowerCase(); switch (ext) { case 'js': case 'mjs': case 'cjs': return this.clearJs(content); case 'css': return this.clearCss(content); case 'html': case 'htm': return this.clearHtml(content); default: return content; } } clearJs(code) { // 状态机逻辑,见 4.2 节 } clearCss(css) { return css.replace(/\/\*[\s\S]*?\*\//g, (match) => { if (this.options.preserve && this.options.preserve.test(match.slice(2, 3))) { return match; } return ''; }); } clearHtml(html) { return html.replace(/<!--[\s\S]*?-->/g, ''); } } module.exports = ClearCommentsPlugin;5.3 配置 webpack 并准备测试文件
新建webpack.config.js:
const path = require('path'); const ClearCommentsPlugin = require('./plugins/clear-comments-plugin'); module.exports = { mode: 'production', entry: './src/index.js', output: { path: path.resolve(__dirname, 'dist'), filename: 'bundle.js' }, module: { rules: [ { test: /\.css$/, use: ['style-loader', 'css-loader'] } ] }, plugins: [ new ClearCommentsPlugin({ verbose: true, preserve: /^!/ }) ] };准备测试文件src/index.js:
// 这是行注释,应该被删除 /* 这是块注释,应该被删除 */ /*! 这是版权注释,应该保留 */ const url = 'https://example.com/path'; // 字符串里的 // 不能被误删 const regex = /\/\*not a comment\*\//; // 正则里的注释符号不能被误删 const template = `模板字符串里的 // 也不能删`; console.log(url, regex, template);再准备一个src/style.css:
/* 普通注释,删除 */ /*! 版权注释,保留 */ body { color: red; /* 行内块注释,删除 */ }5.4 运行构建并验证结果
执行npx webpack,观察控制台输出。如果配置了verbose: true,会看到类似[ClearComments] 已清理 bundle.js的日志。打开dist/bundle.js,检查:
- 普通行注释和块注释应该消失。
/*! 版权注释 */应该保留。- 字符串
'https://example.com/path'完整无损。 - 正则
/\/\*not a comment\*\//完整无损。 - 模板字符串内容完整。
我第一次跑通看到这些结果时,确实有种“原来插件就是这么回事”的顿悟感。尤其是字符串和正则被正确保护的那一刻,说明状态机的思路是对的。
注意:production 模式下 webpack 自带的
terser也会删注释,可能干扰验证。建议先用mode: 'development'测试,或者临时关掉optimization.minimize,确保看到的是你自己插件的效果。
6. 踩坑记录与常见问题排查
6.1 字符串里的注释符号被误删
这是最典型的坑。早期版本我没做字符串保护,直接正则替换//.*,结果https://example.com被删成了https:,整个 URL 报废。修复方案就是 4.2 节的状态机,进入引号状态后跳过所有注释识别。
排查这类问题的技巧:在测试文件里故意写满各种边界情况——URL、正则、模板字符串、转义引号、多行字符串。每次改完清除逻辑,先跑一遍这个“边界测试集”,比事后在真实项目里发现 bug 成本低得多。
6.2 sourcemap 注释被误删导致调试失效
构建产物末尾通常有一行//# sourceMappingURL=bundle.js.map。如果被当成普通行注释删掉,浏览器就找不到 sourcemap,调试时只能看压缩后的代码。解决办法是在清除行注释时,判断一下内容是否以# sourceMappingURL=或# sourceURL=开头,是的话保留。
if (state === 'lineComment') { if (ch === '\n') { state = 'normal'; result += ch; } else if (i === commentStart && code.slice(i, i + 20).includes('sourceMappingURL')) { // 保留 sourcemap 注释 result += code.slice(i); break; } i++; }这个细节很多教程不会提,但实际项目中非常关键。我当初就是构建后发现断点全乱了,查了半天才定位到是 sourcemap 注释被删。
6.3 钩子注册方式与 webpack 版本不兼容
webpack 4 和 webpack 5 的插件 API 有差异。webpack 4 用compilation.hooks.optimizeChunkAssets,webpack 5 推荐processAssets。如果你的插件要同时兼容两个版本,需要做判断:
if (Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE) { // webpack 5 compilation.hooks.processAssets.tap({...}, callback); } else { // webpack 4 compilation.hooks.optimizeChunkAssets.tap(pluginName, callback); }我建议新项目直接面向 webpack 5 开发,兼容逻辑会让代码变复杂。如果确实要兼容,把两套注册逻辑封装成独立方法,主流程里做分支即可。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 注释没被清除 | 文件类型不在 test 正则内 | 检查 test 配置,确认扩展名匹配 |
| 字符串内容被误删 | 状态机未正确保护引号 | 检查引号状态切换和转义处理 |
| sourcemap 失效 | sourceMappingURL 注释被删 | 行注释处理时保留该行 |
| 构建报错 tap 类型不匹配 | 钩子类型与注册方式不符 | processAssets 用 tap,异步用 tapPromise |
| 重新编译后不生效 | 钩子注册在 compiler 而非 compilation | 在 compilation 钩子里注册 processAssets |
| 版权注释也被删 | preserve 正则配置错误 | 确认 preserve 匹配的是/*!的! |
6.5 几个提升健壮性的实操心得
第一,永远先备份再处理。在插件里加一个dryRun选项,开启时只打印将要修改的文件和字符数,不实际写入。这样在真实项目里首次使用时,可以先跑一遍看看影响范围,确认无误再关闭 dryRun。
第二,处理前后做字符数对比。如果某个文件清理后字符数反而变多了,说明逻辑有问题(正常只会减少或不变)。可以在 verbose 模式下打印原长度 -> 新长度,异常时一眼就能发现。
第三,对超大文件做保护。状态机是逐字符扫描,如果遇到几 MB 的 bundle,性能会明显下降。可以加一个文件大小阈值,超过就跳过并警告,或者改用更快的正则方案。
第四,保留注释的正则要写对。preserve: /^!/匹配的是块注释开头/*之后的第一个字符。如果写成/!/,那任何包含!的注释都会被保留,范围太宽。这个细节我在文档里特意标注了,避免使用者配错。
7. 插件还能怎么扩展
基础版跑通后,这个插件还有不少可以打磨的方向。比如加一个comments配置项,让用户指定要删除的注释类型(只删行注释、只删块注释、或全删);再比如支持banner配置,在文件头部保留一段自定义版权声明;还可以接入schema-utils做配置项校验,让插件在配置错误时给出清晰提示,而不是默默失效。
另一个有意思的扩展是支持 sourcemap 的同步更新。目前我们直接替换了资源内容,sourcemap 里的行列号会对不上。如果要做到精准,需要在删除注释时记录每个删除位置,然后映射回原始 sourcemap。这个工作量不小,属于进阶玩法,适合对调试精度要求极高的场景。
我个人在实际操作中的体会是:写插件的价值不在于插件本身多复杂,而在于你通过它把 webpack 的钩子机制、资源流转、tapable 注册方式这些“黑盒”变成了“白盒”。下次再遇到需要干预构建流程的需求,你脑子里会自然浮现出“这个应该挂在哪个钩子上”“用哪种 tap 方式”“资源怎么读写”这些判断,而不是对着文档发懵。这个清除注释的插件,就是我推荐给团队新人练手 webpack 插件的第一个项目,几十行代码,但该踩的坑一个不少,性价比很高。