引言
先说个真实的场景。前阵子帮朋友看一个已经上线的 Vue 中后台项目,本来只是想确认一下某个权限校验逻辑写在哪,结果打开dist目录,随便格式化了一个 chunk 文件,不到二十分钟就把整套业务逻辑摸了个七七八八:接口地址、参数拼接规则、角色判断分支、甚至连内部使用的密钥字段名都清清楚楚。那一刻他的表情挺复杂的——不是因为代码写得不好,而是因为压根没想过打包产物会这么"透明"。这件事之后我就把手上几个 Vue 项目的构建配置翻出来重新捋了一遍,核心就做了两件事:把代码混淆真正配置到位,并且把这套配置抽成自研构建插件,让它能跟项目里已有的自定义插件和平共处。
这篇内容就是那轮折腾的完整记录。我会讲清楚 Vue 项目里代码混淆到底该在构建管线的哪一步动手、为什么市面上的现成方案我最后没直接用、一个带完整注释的混淆插件长什么样、配置项每一项取值背后的取舍逻辑,以及上线之后被用户反馈逼着改配置的那些坑。适合已经能把 Vue 项目正常打包上线、但对构建产物安全性没什么概念的开发者,也适合手里有自研构建插件、需要把混淆能力嵌进去的中高级同学。零基础也能看,我会把每个概念的来龙去脉讲明白。
1. 先搞清楚混淆要解决的到底是什么问题
1.1 打包产物里裸奔的到底是什么
很多人对"打包"有个误解,觉得代码经过了 webpack 或者 Vite 处理,就已经"编译"过了,别人看不懂。实际上打包工具做的事情更接近"搬运 + 整理":把散落在几十上百个文件里的模块拼到一起,顺手用 Terser 或 esbuild 把变量名压短、把空白去掉、把没用的分支删掉。这个过程确实让代码变短了,但它并没有改变代码的结构,函数还是那个函数,if还是那个if,只是换了个更短的名字。
问题就出在这个"只改局部变量名"上。Terser 默认的 mangling 只作用于函数作用域内的局部变量和参数,对于对象属性、类成员、导出的函数名,它是不动的。所以你会看到压缩后的产物里,function a(b,c){return b.userInfo.token===c}这种局部变量确实短了,但userInfo、token这些属性名原封不动。而 Vue 项目里绝大多数有价值的业务信息,恰恰都藏在属性名和字符串字面量里。
具体来说,一个没做任何额外处理的 Vue 产物里,下面这些东西基本是明文可读的:接口路径字符串,比如/api/v1/order/refund/apply;业务提示文案,比如"该订单已超过可退款期限";角色和权限的枚举值,比如'super_admin'、'finance_auditor';路由的 path 和 name;Pinia store 里定义的 action 名;甚至一些写死在代码里的阈值和系数。把这些串起来,一个懂行的人不需要源码就能还原出相当完整的业务模型。这不是危言耸听,我自己就是这么干的。
1.2 压缩 / 混淆 / 加密:三道容易混为一谈的工序
这三个词经常被混着用,但它们的目的大不相同,搞混了会导致预期错位。
压缩的核心目标是体积。Terser、esbuild、SWC 都属于这一类,做的事情是删除空白和注释、缩短局部变量名、合并等价表达式、摇掉不可达代码。它顺带降低了可读性,但这只是副作用,不是设计目标。
混淆的核心目标是可读性对抗。它会重命名标识符(包括部分属性名)、把字符串拆开或者编码进一个字符串数组、把线性的控制流打散成状态机、插入永远走不到的死代码分支。代价是体积膨胀、运行时有额外开销、构建变慢。
加密在前端基本是个伪命题。因为解密逻辑和密钥必须一起发给浏览器,攻击者拿到密钥只是时间问题,最多就是多一层 base64 或者异或。任何声称能对前端 JS 做"加密"的方案,本质上都是强度更高的混淆。
这个认知非常关键,直接决定了你配置参数的取向。如果目标是"让竞争对手多花两周时间来抄我的逻辑",那配置可以激进一点;如果目标是"绝对不让任何人看到任何东西",那无论怎么配都达不到,过度配置只会把体积和性能拖垮。我个人的定位是:把逆向成本从"半小时"提升到"几天到一两周",同时保证首屏体积增长控制在可接受范围内。这个目标是可以量化验证的,后面第 6 节会讲怎么验证。
2. 方案选型:为什么最后落到自研插件上
2.1 四种主流做法的横向对比
在决定自己写插件之前,我把能想到的路子都试了一遍。下面这张表是我当时整理出来的,现在回头看结论依然成立。
| 方案 | 实现原理 | 优势 | 主要问题 | 适合的场景 |
|---|---|---|---|---|
| 构建器自带的 Terser/esbuild 压缩 | AST 层面的局部变量重命名 | 零成本、零依赖、速度快 | 属性名、字符串完全不动,防护强度极低 | 所有项目的基础配置 |
| 开启属性名 mangle | 用正则或 AST 改写对象属性 | 配置简单,体积收益明显 | 误伤率极高,Vue 的 props、路由 meta 极易崩 | 纯工具库,无运行时反射 |
| 通用混淆插件的默认配置 | 调库做 AST 级改写 | 强度高、参数丰富 | 全局一刀切,体积暴涨,和自定义构建插件容易冲突 | 单页应用、逻辑集中 |
| 自研插件封装混淆能力 | 自己控制挂载时机、作用范围、参数分级 | 可以精准白名单、按 chunk 分级、兼容已有插件 | 需要自己维护和测试 | 中大型业务项目 |
我一开始走的是第三条路,直接装了一个现成的混淆插件,用它的默认配置打包。结果第一次构建就翻车了:产物从 1.8MB 涨到 5.2MB,首屏白屏时间多了将近一秒,而且有个页面的下拉框数据不显示了——后来查到原因是这个页面的组件通过字符串动态访问了一个对象的属性,属性名被混淆之后obj['someKey']拿不到了。这次经历让我意识到,通用方案的问题不在于能力不够,而在于它没有业务上下文。
2.2 自研插件的边界与代价
自研插件最大的价值在于"我知道我的项目长什么样"。我可以明确告诉混淆器:这一批 chunk 里有依赖字符串反射的代码,属性名一律不许动;那一个 chunk 是纯算法模块,可以上最强的控制流平坦化。这种精细度是通用插件给不了的,因为它不认识你的业务。
代价也很实际,主要有三块。第一是需要自己维护,混淆库版本升级、配置项语义调整都要跟着改;第二是必须建立回归测试,每次改混淆配置都得跑一遍核心流程;第三是排查成本变高,线上出问题时错误堆栈看着像天书,必须提前规划好 sourcemap 的处理策略(第 6 节会详细说)。
我的判断标准是:如果项目只有一个表单页,直接用现成插件加个白名单就够了;如果项目有几十个路由、多个业务域、还有自研的构建插件在往产物里注入东西,那自研封装带来的可控性是值得的。这一节的结论不适用于所有情况,你自己权衡。
3. 插件挂载点:混淆代码到底该在哪一步动手
3.1 Vite/Rollup 构建管线的几个候选钩子
Vite 生产构建底层跑的是 Rollup,插件钩子的执行顺序大致是这样:options→buildStart→resolveId→load→transform→moduleParsed→renderChunk→generateBundle→writeBundle→closeBundle。理论上,transform、renderChunk、generateBundle都能拿到代码,但它们的时机差别很大。
transform是逐模块处理的,这时候代码还没打包,一个模块可能被多个 chunk 引用,在这里混淆会导致重复处理,而且模块之间的导入导出关系还没最终确定,混淆后可能连不上。renderChunk拿到的是已经拼接好的单个 chunk 代码,时机不错,但它是一次处理一个 chunk,看不到完整的 bundle,也没法优雅地处理 sourcemap 的合并。
generateBundle是最合适的。它拿到的是完整的 bundle 对象,你能遍历所有 chunk 和 asset,知道哪个文件叫什么名字、有多大、引用了谁。更重要的是,在这个阶段修改chunk.code和chunk.map会被写入最终的产物,而你还有机会根据文件名做 include/exclude 判断。唯一的注意点是别再用writeBundle去改代码,那时候文件已经落盘了。
3.2 generateBundle 为什么是最优解
除了能看到全量产物,generateBundle还有两个容易被忽略的好处。一是它天然只跑一次,不存在重复混淆的风险,这对体积控制很致命——我见过有人同时用了renderChunk和generateBundle两个钩子处理同一份代码,结果产物被混淆了两遍,体积翻了将近四倍,代码里还多了一堆无意义的状态机。二是它能和enforce: 'post'配合,保证自己排在所有业务插件之后执行,拿到的是别人处理完的最终代码。
还有一个细节值得说:generateBundle里的chunk.modules保留了这个 chunk 包含的原始模块信息。如果你想做"只混淆业务代码、不混淆第三方依赖"这种精细控制,可以遍历chunk.modules的路径来判断,比单纯靠文件名匹配可靠得多。我在实际项目里就是用这个思路把node_modules里的代码全部排除在外的,因为第三方库自己可能也依赖属性名反射,混淆它们纯粹是给自己找麻烦。
3.3 和项目里已有自定义构建插件的共存规则
这是标题里专门提到的"自定义插件适用",也是我在实际项目里踩得最疼的地方。很多项目的构建配置里不止有官方插件,还有自己写的插件,比如自动注入版本号、自动压缩图片、自动生成预加载清单、自动往 HTML 里塞埋点脚本。这些插件有的在generateBundle阶段往 bundle 里加资源,有的直接改chunk.code。
共存的核心原则是三条。第一,执行顺序要显式约定。混淆插件设成enforce: 'post',保证它最后跑;如果你的自定义插件需要改代码,让它排在混淆前面。第二,自定义插件注入的代码要进白名单。比如你的插件往产物里注入了一段带固定函数名的初始化代码,而这个函数名是通过字符串在别处调用的,那就必须把这个名字加进reservedNames,否则混淆后调用链直接断掉。第三,不要在自定义插件里对已经混淆过的代码做正则替换。这个坑我踩过:一个自动替换接口域名的插件用字符串匹配去找域名,而混淆把字符串编码进了数组,正则自然匹配不到,结果线上接口全打到了测试环境。
一个稳妥的写法是在插件之间用共享的配置对象通信,而不是靠执行顺序的隐式假设。比如把白名单和排除规则放在一个单独的配置文件里,混淆插件和你的自定义插件都读它,这样谁改了什么一目了然。
4. 完整实现:一个可落地的混淆插件
4.1 插件骨架代码
下面这个插件是我现在项目里在用的版本,做了精简但保留了核心逻辑。依赖只有一个混淆库,其余全是原生能力。
// build/plugins/vite-plugin-obfuscate.js import JavaScriptObfuscator from 'javascript-obfuscator' // 简易 glob 匹配,避免为了一个函数再引入 minimatch function matchAny(fileName, patterns) { if (!patterns || patterns.length === 0) return false return patterns.some((p) => { // 把 **/*.js 这类通配符转成等效正则 const reg = new RegExp( '^' + p .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*\*/g, '.*') .replace(/\*/g, '[^/]*') + '$' ) return reg.test(fileName) }) } export default function obfuscatePlugin(userOptions = {}) { const { include = ['assets/**/*.js'], exclude = [], // 只混淆体积大于该值的 chunk,太小的文件混淆收益低、膨胀比例高 minSize = 10 * 1024, // 是否继承已有的 sourcemap,正式环境建议 false keepSourceMap = false, obfuscatorOptions = {}, // 允许外部按文件名动态决定这组配置用不用,返回 false 则跳过 shouldObfuscate = () => true, } = userOptions || {} return { name: 'vite-plugin-obfuscate', // 只在生产构建时生效,dev 下跳过,否则热更新会慢到怀疑人生 apply: 'build', // 排到所有插件之后,保证拿到的是最终形态的代码 enforce: 'post', generateBundle(_outputOptions, bundle) { for (const [fileName, item] of Object.entries(bundle)) { // 只处理 js chunk,css、图片等静态资源直接跳过 if (item.type !== 'chunk') continue if (!matchAny(fileName, include)) continue if (matchAny(fileName, exclude)) continue if (item.code.length < minSize) continue if (!shouldObfuscate(fileName, item)) continue const result = JavaScriptObfuscator.obfuscate(item.code, { // 关键:混淆器内部生成的 sourcemap 要基于原始输入文件名 inputFileName: fileName, sourceMap: keepSourceMap, ...obfuscatorOptions, }) item.code = result.getObfuscatedCode() if (keepSourceMap && result.getSourceMap()) { item.map = JSON.parse(result.getSourceMap()) } } }, } }这段代码里有几个设计取舍值得说明。minSize这个参数是我后来加的,起因是发现一些小 chunk 混淆后体积从 3KB 涨到 14KB,膨胀率接近 400%,而那些文件里其实没什么敏感逻辑,性价比极低。shouldObfuscate这个回调是给复杂项目留的口子,比如你可以根据 chunk 里是否包含某些模块来决定跳过,比单纯的路径匹配灵活得多。apply: 'build'是硬性要求,我在开发环境试过一次,热更新从 200ms 变成 4s,完全没法用。
4.2 接入 vite.config.js
接入本身很简单,难的是参数怎么定。
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import obfuscate from './build/plugins/vite-plugin-obfuscate' export default defineConfig({ plugins: [ vue(), // 你的其他自定义插件放这里,确保它们在混淆之前执行 obfuscate({ include: ['assets/index-*.js', 'assets/order-*.js'], exclude: ['assets/vendor-*.js', 'assets/chunk-libs-*.js'], minSize: 20 * 1024, keepSourceMap: false, obfuscatorOptions: { compact: true, identifierNamesGenerator: 'hexadecimal', renameGlobals: false, // Vue 项目这一项必须是 false,原因见第 7 节 renameProperties: false, stringArray: true, stringArrayEncoding: ['base64'], stringArrayThreshold: 0.75, splitStrings: true, splitStringsChunkLength: 8, controlFlowFlattening: true, controlFlowFlatteningThreshold: 0.5, deadCodeInjection: false, selfDefending: true, disableConsoleOutput: true, sourceMap: false, seed: 20240601, reservedNames: ['__vite__mapDeps', 'initApp', 'reportError'], }, }), ], build: { // 关掉生产环境的 sourcemap,或者改成 'hidden' 后单独上传 sourcemap: false, }, })seed这个参数不是必填的,但强烈建议填。混淆器的随机化如果没有固定种子,每次构建产物的哈希都不一样,会导致 CDN 缓存完全失效、增量构建比对困难。固定种子之后,同样的输入必然产出同样的输出,构建结果可复现,排查问题的时候能省很多力气。
4.3 webpack 项目怎么平移
如果项目还在 webpack 体系里,思路完全一样,只是钩子换了地方。核心是用compilation.hooks.processAssets,并且指定stage为Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE之后、PROCESS_ASSETS_STAGE_REPORT之前,这样能保证优化已经做完、报告还没生成。遍历compilation.assets,对.js后缀的资源调用同样的混淆函数,拿到新代码后用compilation.updateAsset替换掉。要注意 webpack 5 里 asset 的 source 是Source对象,取值要用.source(),赋值要用new webpack.sources.RawSource(code),直接改字符串是不生效的。这一块我在迁移的时候卡了半小时,因为不报错,只是安静地什么都没发生。
5. 配置项逐条注释与取值建议
5.1 控制流与结构类配置
这一组决定代码"看起来有多乱",也直接决定体积和运行开销。
controlFlowFlattening(控制流平坦化)是最有效也是最贵的一项。它把顺序执行的语句打散成一个while(true) + switch的状态机,靠一个随机的顺序数组来驱动执行。开了之后代码几乎不可能被人肉阅读。controlFlowFlatteningThreshold控制的是应用比例,取值 0 到 1。我在业务 chunk 上给 0.4 到 0.6,因为给到 1 之后我实测过,某个 800KB 的 chunk 膨胀到了 2.3MB,而且移动端上执行时间多了将近 30%。这个阈值本质上是拿体积换强度,没有银弹。
deadCodeInjection(死代码注入)我默认关掉。它的原理是往代码里塞永远执行不到的假分支,这些假分支还会引用字符串数组,所以会显著增加体积。真正的问题是它的收益在"人工智能辅助阅读"已经很成熟的今天非常有限,而成本是实打实的。如果你的项目是那种核心算法必须死守的类型,可以开个 0.2 到 0.3 试试。
selfDefending(自我保护)建议开。它会让格式化工具处理代码时直接报错,挡住那些想用美化工具快速看代码的人。代价是它会禁止任何对混淆后代码的进一步加工,所以你必须保证混淆是最后一步,后面不能有任何插件再改代码,否则直接运行时报错。
compact保持 true,simplify也可以保持默认的 true,这两项基本没有副作用。
5.2 字符串与数组类配置
这部分是我认为投入产出比最高的一组,因为业务信息大部分藏在字符串里。
stringArray把代码里散落的字符串全部抽到一个数组里,然后靠索引去取。这一步之后,产物里已经看不到连续的字符串了。stringArrayThreshold控制抽取比例,我一般给 0.7 到 0.8,不给 1 的原因是有时候保留一部分明文字符串反而让代码更像"正常代码",迷惑性更强。
stringArrayEncoding设置为['base64'],把字符串数组里的每一项都做 base64 编码。这里有个性能坑:官方还支持rc4,强度更高,但每次取字符串都要做一次解密运算,如果一个循环里高频访问字符串,性能下降会很明显。我测试过一个批量格式化数据的场景,用 rc4 之后页面卡顿肉眼可见。
splitStrings配合splitStringsChunkLength是我很推荐的一组。它把长字符串切成几段分别存放,运行时再拼起来。对接口地址这种长字符串特别有效,因为逆向的人搜不到完整的路径。splitStringsChunkLength给 6 到 10 比较合适,太小会产生大量数组项,反而增加管理开销。
5.3 标识符与属性名类配置
这一组是最需要谨慎的,配错了直接导致线上崩溃。
identifierNamesGenerator我选hexadecimal,生成的变量名是_0x4a2b这种形式。也有人喜欢mangled,生成a、b、c这种短名,体积更小但可读性恢复起来也容易一些。看你的取向。
renameGlobals必须 false。开启后它会去重命名全局变量,而 Vue 项目里挂载在window上的东西太多了——埋点 SDK、地图实例、富文本编辑器、各种第三方脚本的全局对象。一旦被改名,所有依赖全局名的代码全部失效。我在早期测试时开过一次,结果是页面白屏,控制台报的是xxx is not defined,排查了半天才反应过来。
renameProperties必须 false。这一项的原理是重命名对象属性,包括obj.foo这种点访问和obj['foo']这种字符串访问。听上去很美,但对 Vue 项目是灾难:组件的props名字、emits事件名、路由meta字段、Pinia 的 state 键名,全都是靠字符串反射的。改了之后模板里{{ userInfo.name }}还在找name,但你对象上已经变成_0x1a了,结果就是满屏undefined。如果确实想改属性名,必须配合reservedNames列出所有不能动的名字,工作量巨大,收益不成正比,我不建议在 Vue 项目里碰这个开关。
5.4 调试保护与体积取舍
disableConsoleOutput会重写console的一系列方法,让它们在运行时不输出内容。这个在正式环境是有意义的,能挡住一部分通过控制台注入的方式。但灰度期我建议先关掉,因为你自己也要看日志。等全量稳定之后再打开。
debugProtection可以让开发者工具变得难以使用,但它会在运行时常驻一个检测循环,对低端机型的性能有影响。我现在的做法是默认不开,只在个别核心 chunk 上开。
unicodeEscapeSequence把字符串转成 Unicode 转义序列,视觉上更乱,但对中文项目来说会显著增大体积,因为一个中文字符变成\uXXXX就是 6 个字符。我一般不单独开这一项,靠stringArray加编码已经够了。
下面这张表是我目前几个项目在用的配置组合,按 chunk 类型分级,可以直接参考。
| 配置项 | 核心业务 chunk | 一般页面 chunk | 第三方库 chunk |
|---|---|---|---|
| controlFlowFlattening | true | true | 不处理 |
| controlFlowFlatteningThreshold | 0.5 | 0.3 | 不处理 |
| deadCodeInjection | false | false | 不处理 |
| stringArray | true | true | 不处理 |
| stringArrayThreshold | 0.8 | 0.6 | 不处理 |
| stringArrayEncoding | base64 | base64 | 不处理 |
| splitStrings | true | false | 不处理 |
| renameProperties | false | false | false |
| selfDefending | true | false | 不处理 |
| disableConsoleOutput | true | true | 不处理 |
6. 效果验证与 sourcemap 的处理
6.1 混淆前后对比
配完之后必须做量化验证,不能凭感觉。我通常准备三组数据:产物总体积、首屏 chunk 的 gzip 体积、以及首屏关键指标。上次的实测数据大概是这样的:原始产物 2.1MB,gzip 后 620KB;只做压缩不做混淆是 1.9MB / 580KB;加上我这套混淆配置之后是 4.6MB / 1.4MB。体积膨胀率 140%,gzip 后膨胀率 141%,这个数字看着吓人,但因为是按 chunk 分级的,首屏真正要加载的那个 chunk 只从 180KB 涨到 340KB,首屏时间增加了约 160ms。这个代价我认为是可以接受的。
如果实测发现某个 chunk 膨胀得离谱,通常有两个原因。一是controlFlowFlatteningThreshold给太高了,二是这个 chunk 里本来就有大量短小的字符串,抽取到数组之后每个字符串都要带一份索引和一条调用语句,不但没变小反而变大了。这种情况就把它加进 exclude 名单。
6.2 sourcemap 的三种策略
混淆之后,线上报错的堆栈全是_0x4a2b这种形式,没有 sourcemap 基本没法排查。但把 sourcemap 直接部署上去又等于把源码拱手让人,所以策略必须想清楚。
第一种是完全不生成,keepSourceMap: false且build.sourcemap: false。最安全,但排查全靠猜。适合那种逻辑简单、出错概率低的小项目,或者团队里有完善的用户行为录制能还原现场的情况。
第二种是hidden 模式,生成 sourcemap 文件但不写在 JS 末尾引用。产物在浏览器里不主动加载它,你可以在需要的时候手动关联。这个折中方案我用得最多,配置上把build.sourcemap设成'hidden',同时插件里keepSourceMap保持 true 让混淆器也产出对应的映射。
第三种是只上传不部署,把 sourcemap 传到自建的错误监控平台或者内部的对象存储里,产物本身完全不带。这是最推荐的方案,安全性和可排查性都拿到了。要注意的是混淆器生成的 sourcemap 需要和压缩阶段生成的 sourcemap 正确串联,如果发现映射位置不对,检查一下inputFileName是否和实际文件名一致,这个参数填错会导致整个映射偏移。
6.3 灰度期怎么保留排查能力
灰度期和全量期用两套配置,这是我踩过坑之后固定下来的做法。灰度期间disableConsoleOutput: false、selfDefending: false、controlFlowFlatteningThreshold降到 0.2 左右,并且保留 sourcemap。这样线上出问题的时候能快速定位。等灰度稳定、全量之后,再切到激进配置重新构建一次。
这里有个容易忽略的点:从灰度配置切到全量配置,产物哈希会变,等于重新发布一次。所以最好在灰度前就规划好,灰度用小流量验证功能正确性,全量上强度验证防护效果,两阶段的目标本来就不一样。我见过有的团队图省事一直用灰度配置上线,结果防护强度约等于没做。
7. 踩坑实录:那些让人半夜爬起来改配置的问题
7.1 属性名相关的白屏与数据丢失
属性名被改名导致的问题是最常见的,表现形态还特别隐蔽。有一次的现象是:列表能正常渲染,但每一行的操作按钮点下去没反应。查了半天发现是按钮的权限判断函数里用了actionMap[actionKey],actionKey是后端返回的字符串,而actionMap的键名被混淆改掉了。因为混淆是构建时的静态改写,它改不了运行时从接口拿到的字符串,两边就对不上了。
这类问题的根治办法只有一个:任何通过字符串动态访问的属性名,都不能参与重命名。所以我在 Vue 项目里干脆把renameProperties关死。如果哪天真的需要开,那就老老实实用reservedNames把所有动态访问的键名列全,同时把selfDefending关了方便调试。
7.2 字符串编码后和后端对不上
这个坑特别典型。某个页面的逻辑是先构造一个签名字符串,再和接口返回的值比对。混淆把前端字符串编码进了数组,运行时拼接出来的其实还是原文——这一点是没问题的。真正的问题出在我们自己写的一个自定义构建插件上,它用正则去替换产物里的接口域名,字符串被编码之后正则匹配不到,域名没替换成功,线上所有请求打到了错误的环境。
解决办法是让自定义插件在混淆之前执行,或者干脆不要把需要后续处理的字符串纳入混淆范围。可以给混淆器传reservedStrings,把域名这类需要在构建后阶段继续处理的字符串保留为明文。这个参数不常用,但在多插件协作的场景下是必需品。
7.3 懒加载 chunk 找不到
路由懒加载在 Vite 里会生成import('./order-detail.js')这样的动态导入,打包后变成__vite__mapDeps之类的一张映射表和一段运行时加载逻辑。如果这张映射表里的文件名被混淆改了,或者运行时查找依赖的函数名被改了,就会报Failed to fetch dynamically imported module。
我在这个问题上吃了两次亏,现在的做法是在reservedNames里固定保留构建器生成的那批内部标识符,比如__vite__mapDeps、__vite__preload这类以双下划线开头的名字。更保险的做法是用变量把vite的版本锁死,因为不同版本生成的内部标识符名字不一样,升级时需要重新确认白名单。
7.4 常见问题速查表
| 现象 | 最可能的原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 页面整体白屏 | 全局变量被重命名 | 看控制台是否有 xxx is not defined | renameGlobals设为 false |
| 部分组件数据不显示 | 属性名被改名 | 对比混淆前后同名对象的键 | renameProperties设为 false |
| 权限判断全部失效 | 枚举字符串与后端不一致 | 抓包看请求参数 | 用reservedStrings保留枚举值 |
| 动态 import 报错 | 构建器内部标识符被改 | 搜索 chunk 加载失败的模块名 | 白名单保留内部标识符 |
| 体积暴涨三倍以上 | 多个钩子重复混淆 | 搜索产物里的状态机特征 | 只保留generateBundle一处 |
| 页面卡顿明显 | 控制流或 rc4 开销过大 | 用性能面板看长任务 | 降低阈值或换 base64 |
| 构建时间翻倍 | 混淆范围过大 | 看构建日志里各插件耗时 | 提高minSize,排除依赖 |
| 格式化工具报错 | selfDefending生效 | 换个工具再试 | 属正常现象,确定是最后一步即可 |
8. 上线前后的几条工程化建议
8.1 按业务敏感度做分级混淆
一刀切是效率最低的做法。我现在会把产物按业务域拆成三类。第一类是核心算法和权限逻辑,上最强配置,体积膨胀也认。第二类是普通业务页面,中等强度,重点做字符串抽取和编码。第三类是第三方依赖和纯展示型页面,完全不混淆。
分级的实现方式很简单,就是include和exclude配不同的模式,或者干脆实例化多个混淆插件,每个插件负责一批文件。要注意的是多实例的时候每个实例都会遍历一遍 bundle,虽然跳过的很快,但配置多了还是有开销,我一般控制在三个实例以内。
8.2 构建耗时与缓存
混淆是整个构建流程里最慢的一步。我实测过,2MB 左右的产物加上中等强度的混淆,构建时间从 25 秒增加到 70 秒左右。CI 上如果每次全量构建,等起来很痛苦。
能做的优化有几件事。提升minSize阈值,把小文件排除掉,这一项在真实项目里能省 20% 左右的时间。把exclude配得精确一点,别用**/*这种全量匹配。在 CI 上开启构建缓存,但要保证混淆插件的配置参与了缓存键的计算,否则改了配置没生效会很难查。还有一点,如果你们用 monorepo,把第三方依赖的构建产物单独缓存,它们本来就不参与混淆。
8.3 上线前的回归清单
混淆改的是代码本身,所以任何依赖字符串、依赖反射、依赖标识符名字的功能都有风险。我现在的检查清单固定包含这些:登录和登出全流程、至少三个涉及权限判断的页面、所有动态路由的跳转、文件上传下载、富文本编辑器的初始化、地图和图表组件的渲染、支付或订单提交流程、以及全局错误上报是否能正常把堆栈传回去。
最后这一项经常被忽略但很重要。混淆之后错误堆栈里的函数名全变了,如果你的监控平台解析不了 sourcemap,上报上来的就是一堆乱码。上线前一定要在测试环境手动触发一次异常,确认监控平台能正确还原。这件事我吃过亏,线上出了一批错误,结果因为映射没配对,花了两天才定位到真正的问题代码。
说个我自己在实际操作中的体会:混淆配置这件事,第一次配的时候总想一步到位,把强度拉满,结果往往是被线上问题教做人。后来我改成渐进式的做法——先用保守配置上线跑一周,收集一下有没有异常,然后再逐项加码,每次只动一到两个参数,观察体积和错误率的变化。这个过程慢一点,但心里踏实。另外一定要把配置文件和插件源码纳入代码评审,混淆配置的改动本质上和业务代码改动一样危险,只是它出事的时候表现得特别突然。
如果你手上的项目还在用默认打包配置,我建议先别急着上重武器,先花十分钟把产物格式化看一眼,看看里面到底暴露了什么。多数人看完那一眼,就会知道该做什么了。