☰
从零实现webpack清除注释插件:深入理解compiler与compilation机制
2026/10/15 5:01:21 网站建设 项目流程

1. 从一个真实需求说起:为什么要自己写一个清除注释的插件

接手过一个老项目,代码里充斥着各种历史遗留的注释——有调试时留下的console.log说明,有被注释掉的废弃代码块,还有大段大段用//和/* */包裹的"暂时保留"逻辑。项目跑起来没问题,但打包体积比预期大了将近三分之一,而且代码审查时满屏的注释噪音让人抓狂。

一开始想的是用现成的工具,比如terser的comments: false配置,或者strip-comments这类库。但实际用下来发现几个问题:一是这些工具大多作用于最终产物,没法在 webpack 的构建流程中精细控制;二是有些库对/*!开头的保留注释处理得不够灵活;三是我想借这个机会彻底搞明白 webpack 插件的运行机制,而不是永远停留在"抄配置"的阶段。

所以就有了这个项目——用 webpack 写一个清除文件注释的插件。它不是一个复杂的工具,但麻雀虽小五脏俱全,涉及 webpack 插件的核心概念:compiler和compilation对象、tapable钩子系统、emit阶段的资源处理、RawSource的使用等等。搞懂这一个插件,基本上 webpack 插件开发的套路就通了。

这篇文章适合两类人:一是想学习 webpack 插件原理但不知道从哪下手的前端开发者,二是手头有类似需求、想找一个可复用方案的工程师。我会从 webpack 插件的底层机制讲起,然后一步步实现这个清除注释的插件,最后分享几个我在调试过程中踩过的坑和总结的技巧。全程不贴大段源码,重点讲清楚"为什么这么做"。

2. webpack 插件到底是怎么跑起来的

2.1 compiler 和 compilation:两个必须分清楚的对象

很多人写 webpack 插件时最困惑的就是:compiler和compilation到底有什么区别?什么时候该用哪个?

打个比方。compiler就像是整个工厂,它代表了 webpack 从启动到关闭的完整生命周期,全局只有一个。你在compiler上注册的钩子,比如beforeRun、run、watchRun、emit、done,都是工厂级别的事件——工厂开工、工厂下班、工厂出货。

而compilation更像是工厂里的一条生产线,每次文件发生变化触发重新构建时,webpack 都会创建一个新的compilation对象。它负责具体的模块解析、依赖收集、代码生成。所以compilation上的钩子,比如buildModule、optimizeChunks、processAssets,都是针对"这一轮构建"的。

对于我们的清除注释插件来说,核心逻辑应该挂在compilation的processAssets钩子上。原因很简单:注释清除是针对每个资源文件的操作,而资源文件是在compilation阶段生成和处理的。如果你挂在compiler的emit上,虽然也能拿到资源,但那时候compilation已经快结束了,处理起来不够灵活。

这里有个细节值得注意:webpack 5 之后,compilation.hooks.processAssets取代了以前直接操作compilation.assets的方式。官方推荐用processAssets钩子,因为它提供了明确的stage参数,让你能控制处理时机。比如PROCESS_ASSETS_STAGE_OPTIMIZE表示在优化阶段处理,PROCESS_ASSETS_STAGE_REPORT表示在报告阶段处理。我们的插件应该在OPTIMIZE或ADDITIONS阶段介入,确保在最终输出前完成注释清除。

2.2 tapable 钩子系统:插件的"插座"机制

webpack 的插件系统建立在tapable这个库之上。你可以把tapable理解成一套"插座"——webpack 在关键节点预留了各种类型的插座,插件通过tap方法把自己的逻辑"插"进去。

tapable提供了多种钩子类型,常见的有:

  • SyncHook:同步钩子,按注册顺序依次执行,不关心返回值。
  • SyncBailHook:同步钩子,但如果某个回调返回非undefined,就停止后续执行。
  • AsyncSeriesHook:异步串行钩子,前一个完成才执行下一个。
  • AsyncParallelHook:异步并行钩子,所有回调同时执行。

processAssets是一个AsyncSeriesHook,意味着你可以注册异步处理函数,webpack 会等所有处理完成后再继续。这对我们来说很重要——如果清除注释的操作涉及读取文件或调用外部工具,异步处理是必须的。

注册钩子的写法很直观:

compilation.hooks.processAssets.tapAsync( { name: 'ClearCommentsPlugin', stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE }, (assets, callback) => { // 处理逻辑 callback(); } );

name是插件标识,方便调试时定位;stage控制执行时机;回调里拿到assets对象,它是一个键值对集合,键是文件名,值是对应的资源对象。

2.3 资源对象与 RawSource:怎么读写文件内容

在processAssets钩子里拿到的assets,每个值都是一个Source实例。webpack 提供了几种Source类型:

  • RawSource:最基础的源,直接包装字符串或 Buffer。
  • OriginalSource:带 source map 信息的源。
  • ConcatSource:多个源拼接。
  • ReplaceSource:支持替换操作的源。

我们要做的是读取资源内容、清除注释、再写回去。最直接的方式是用asset.source.source()拿到原始字符串,处理完之后用new webpack.sources.RawSource(newContent)创建新源,然后通过compilation.updateAsset(fileName, newSource)更新。

这里有个容易忽略的点:asset.source.source()返回的可能是string或Buffer。对于文本文件(.js、.css、.html),通常是字符串;对于图片等二进制文件,是Buffer。我们的插件只处理文本文件,所以需要先判断类型,遇到Buffer直接跳过,避免把二进制内容当字符串处理导致文件损坏。

3. 清除注释的核心逻辑:正则、AST 还是状态机

3.1 三种方案的取舍

清除注释听起来简单,但真要做对并不容易。我试过三种方案,各有优劣。

方案一:正则表达式。最直观,写几个replace就完事。但正则处理注释有个经典难题:字符串里的//和/* */不能被误删。比如const url = "https://example.com"里的//就不是注释。用正则要处理这种情况,得写很复杂的负向断言,而且容易漏掉边界情况。

方案二:AST 解析。用acorn或@babel/parser把代码解析成抽象语法树,遍历节点找到注释,再根据位置信息删除。这是最准确的方式,但代价是性能开销大,而且需要针对不同语言(JS、CSS、HTML)用不同的解析器。

方案三:状态机逐字符扫描。自己维护一个状态变量,标记当前是否在字符串中、是否在注释中、是否在正则表达式中,逐字符扫描。这种方式性能好,准确性也够用,实现起来比正则复杂但比 AST 轻量。

最终我选择了状态机方案。原因有三:一是它不依赖外部解析器,插件体积小;二是性能可控,对于大文件也不会太慢;三是实现过程本身能加深对词法分析的理解,符合"学习插件原理"的初衷。

3.2 状态机的状态设计

状态机的核心是定义清楚有哪些状态,以及状态之间如何转移。对于 JavaScript 代码,我定义了以下几个状态:

  • NORMAL:正常代码状态。
  • IN_SINGLE_QUOTE:单引号字符串中。
  • IN_DOUBLE_QUOTE:双引号字符串中。
  • IN_TEMPLATE:模板字符串中。
  • IN_LINE_COMMENT:单行注释中。
  • IN_BLOCK_COMMENT:块注释中。
  • IN_REGEX:正则表达式中。

状态转移的关键在于识别特殊字符序列。比如在NORMAL状态下遇到//就进入IN_LINE_COMMENT,遇到/*就进入IN_BLOCK_COMMENT,遇到'就进入IN_SINGLE_QUOTE。在字符串状态下,遇到对应的结束引号(且前面不是转义符)就回到NORMAL。

这里最麻烦的是正则表达式的识别。因为/既可能是除号,也可能是正则的开始。判断依据是看前一个非空白字符:如果前一个字符是标识符、数字、)、],那/大概率是除号;否则可能是正则。这个规则不是 100% 准确,但对于大多数代码够用了。

3.3 保留注释的处理

有些注释是不能删的,比如:

  • /*! ... */:通常用于版权声明,很多压缩工具默认保留。
  • // @license ...:许可证声明。
  • /* eslint-disable */:ESLint 指令注释,删了可能导致 lint 报错。
  • // #__PURE__:标记纯函数调用,影响 tree-shaking。

我的插件设计了一个preserve配置项,默认保留/*!开头的注释和包含@license、@preserve、eslint的注释。实现上就是在进入注释状态时,先记录注释的起始位置,等注释结束时提取内容判断是否需要保留。如果需要保留,就把这段内容原样写回输出。

这个设计参考了terser的comments选项思路,但更轻量。实际用下来,保留/*!和eslint相关注释是最实用的两个规则,其他都可以按需配置。

4. 插件完整实现:从骨架到可用

4.1 插件的基本结构

一个 webpack 插件本质上是一个类,类上有一个apply方法,接收compiler作为参数。基本骨架长这样:

class ClearCommentsPlugin { constructor(options = {}) { this.options = { preserve: ['/*!', '@license', '@preserve', 'eslint'], test: /\.(js|css|html)$/, ...options }; } apply(compiler) { const pluginName = 'ClearCommentsPlugin'; const { webpack } = compiler; const { Compilation, sources } = webpack; compiler.hooks.thisCompilation.tap(pluginName, (compilation) => { compilation.hooks.processAssets.tapAsync( { name: pluginName, stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE }, (assets, callback) => { Object.keys(assets).forEach((fileName) => { if (!this.options.test.test(fileName)) return; const asset = assets[fileName]; const source = asset.source.source(); if (typeof source !== 'string') return; const cleaned = this.clearComments(source); compilation.updateAsset(fileName, new sources.RawSource(cleaned)); }); callback(); } ); }); } clearComments(code) { // 状态机逻辑 } }

几个关键点解释一下。thisCompilation钩子确保每次编译都能拿到新的compilation对象,避免在 watch 模式下重复注册。test选项用正则过滤文件类型,默认只处理 JS、CSS、HTML。updateAsset是 webpack 5 提供的 API,用来替换资源内容。

4.2 状态机核心代码拆解

状态机的实现是插件的核心,我把它拆成几个部分来讲。

首先是初始化:

clearComments(code) { let result = ''; let state = 'NORMAL'; let i = 0; let commentStart = -1; const len = code.length; while (i < len) { const char = code[i]; const next = code[i + 1]; switch (state) { case 'NORMAL': // 处理各种进入特殊状态的字符 break; // 其他状态... } } return result; }

在NORMAL状态下,需要判断当前字符是否触发状态转移:

if (char === '/' && next === '/') { state = 'IN_LINE_COMMENT'; commentStart = i; i += 2; continue; } if (char === '/' && next === '*') { state = 'IN_BLOCK_COMMENT'; commentStart = i; i += 2; continue; } if (char === "'") { state = 'IN_SINGLE_QUOTE'; result += char; i++; continue; } // 双引号、模板字符串、正则类似 result += char; i++;

在IN_LINE_COMMENT状态下,一直读到换行符为止:

if (char === '\n') { const comment = code.slice(commentStart, i); if (this.shouldPreserve(comment)) { result += comment; } result += '\n'; state = 'NORMAL'; i++; continue; } i++;

在IN_BLOCK_COMMENT状态下,读到*/为止:

if (char === '*' && next === '/') { const comment = code.slice(commentStart, i + 2); if (this.shouldPreserve(comment)) { result += comment; } state = 'NORMAL'; i += 2; continue; } i++;

字符串状态的处理要注意转义符。比如'it\'s'里的\'不能当作字符串结束:

if (char === '\\') { result += char + (next || ''); i += 2; continue; } if (char === "'") { state = 'NORMAL'; } result += char; i++;

模板字符串还要处理${}里的表达式,这个稍微复杂一点,需要维护一个嵌套深度计数。为了简化,我的实现里对模板字符串内的${}做了保守处理——遇到${就临时切回NORMAL状态,遇到匹配的}再切回来。实际测试下来,对于大多数代码这个策略够用。

4.3 保留注释的判断逻辑

shouldPreserve方法负责判断注释是否要保留:

shouldPreserve(comment) { return this.options.preserve.some((keyword) => { if (keyword.startsWith('/*')) { return comment.startsWith(keyword); } return comment.includes(keyword); }); }

这个逻辑很简单:遍历preserve数组,如果注释以/*开头的关键词开头(比如/*!),或者包含某个关键词(比如@license),就保留。实际使用时,你可以根据项目需要调整这个数组。比如有些项目希望保留TODO注释,加一个'TODO'进去就行。

4.4 在 webpack 配置中启用插件

插件写好后,在webpack.config.js里这样用:

const ClearCommentsPlugin = require('./plugins/clear-comments-plugin'); module.exports = { // 其他配置... plugins: [ new ClearCommentsPlugin({ preserve: ['/*!', '@license', 'eslint-disable'], test: /\.(js|css)$/ }) ] };

如果你只想在生产环境启用,可以这样写:

plugins: [ process.env.NODE_ENV === 'production' && new ClearCommentsPlugin() ].filter(Boolean)

实测下来,这个插件对打包体积的优化效果取决于项目里注释的多少。我测试的那个老项目,JS 文件平均减少了 15% 到 20% 的体积,CSS 文件减少了约 10%。当然,如果你已经用了terser的comments: false,效果会叠加但不会翻倍,因为terser本身也会删注释。这个插件的价值更多在于精细控制和学习插件原理。

5. 调试过程中踩过的坑和排查思路

5.1 二进制文件被误处理导致图片损坏

第一次跑通插件后,我发现打包出来的图片全挂了。排查了半天才意识到,test正则写的是/\.(js|css|html)$/,但assets里除了这些还有.png、.jpg、.woff等文件。虽然正则过滤了,但我在过滤之前就调用了asset.source.source(),对于二进制文件返回的是Buffer,然后typeof source !== 'string'的判断虽然跳过了处理,但某些情况下Buffer会被隐式转换成字符串,导致后续updateAsset写入了损坏的内容。

修复方法很简单:把类型判断提前,遇到非字符串直接return,不做任何操作。另外,test正则最好加上更严格的边界,比如/\.(js|mjs|cjs|css|html)$/,避免匹配到意外文件。

提示:处理assets时,永远先判断source类型,再决定是否处理。二进制文件的处理逻辑和文本文件完全不同,混在一起容易出问题。

5.2 source map 丢失导致调试困难

用RawSource替换原资源后,source map 信息就丢了。开发环境下这很致命,浏览器里断点打不上,报错行号也对不上。

解决方案有两个。一是只在生产环境启用插件,开发环境不启用。二是用SourceMapSource替代RawSource,把原来的 source map 传进去。第二种方式更优雅,但实现起来复杂一些,需要从asset.source里提取sourceAndMap()的结果,处理后再包装回去。

我的选择是第一种——开发环境不启用。因为清除注释本身就是为了减小生产包体积,开发阶段保留注释反而有利于调试。如果你确实需要在开发环境用,建议用SourceMapSource,代码大概是这样:

const { source, map } = asset.source.sourceAndMap(); const cleaned = this.clearComments(source); compilation.updateAsset( fileName, new sources.SourceMapSource(cleaned, fileName, map) );

5.3 正则表达式识别错误导致代码被截断

状态机里最棘手的部分就是正则表达式的识别。我遇到过一个案例:代码里有const a = b / c / d;,这是连续除法,但状态机把第一个/后面的c当成了正则内容,一直读到第二个/才结束,结果中间的代码被误判。

后来我加了一个启发式判断:在NORMAL状态下遇到/时,往前看最近的非空白字符。如果是标识符字符、数字、)、]、},就认为是除号;否则才进入正则状态。这个规则覆盖了绝大多数情况,但仍有边界情况,比如if (a) /regex/.test(b)这种写法。对于这种极端情况,我的建议是:如果你的项目里大量使用这种风格,还是老老实实用 AST 方案,别跟状态机较劲。

5.4 watch 模式下重复注册钩子

在 watch 模式下,每次文件变化都会触发重新编译。如果插件在compiler.hooks.compile或compiler.hooks.emit上注册钩子,可能会重复注册,导致同一个文件被处理多次。

正确的做法是用thisCompilation钩子,它每次编译只触发一次,而且能拿到最新的compilation对象。我一开始用的是compiler.hooks.emit,结果 watch 模式下第二次编译时注释清除逻辑跑了三遍,虽然结果一样,但性能浪费明显。换成thisCompilation后问题解决。

6. 从这个小插件延伸出去:webpack 插件开发的通用套路

写完这个插件后,我回头总结了一下 webpack 插件开发的通用步骤,基本上可以套用到任何插件上。

第一步:确定介入时机。问自己:我的逻辑是在编译前、编译中还是编译后执行?是全局一次还是每次编译都执行?根据答案选择compiler或compilation上的钩子。常见的钩子有beforeRun、run、thisCompilation、processAssets、done。

第二步:确定操作对象。是操作模块、chunk 还是最终资源?模块级别的操作挂在compilation.hooks.buildModule上,chunk 级别的挂在optimizeChunks上,资源级别的挂在processAssets上。

第三步:处理资源读写。webpack 5 推荐用compilation.assets配合sources系列类来读写。记住RawSource用于纯文本,SourceMapSource用于需要保留 source map 的场景,ConcatSource用于拼接。

第四步:处理异步和错误。tapAsync的回调必须调用callback(),否则构建会卡住。如果处理过程中出错,调用callback(error)让 webpack 抛出错误。tapPromise则返回 Promise,出错时 reject。

第五步:考虑 watch 模式和缓存。确保钩子不会重复注册,处理逻辑是幂等的。如果插件依赖外部文件,要考虑缓存失效的问题。

这个清除注释的插件虽然简单,但把这五步都走了一遍。搞懂它之后,再去看html-webpack-plugin、mini-css-extract-plugin这些复杂插件的源码,会发现它们的核心结构是一样的,只是处理逻辑更复杂而已。

最后分享一个我个人的经验:写 webpack 插件时,善用compilation.getLogger打日志。它比console.log更规范,输出的日志会带上插件名和编译轮次,排查问题时一目了然。比如:

const logger = compilation.getLogger('ClearCommentsPlugin'); logger.info(`Processed ${fileName}, removed ${removedCount} comments`);

这个日志在stats输出里能看到,不会污染控制台,但需要时又能查到。对于调试插件来说,这个技巧比到处console.log优雅得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询