AfterPack 改变的是代码读起来的样子,保留的是代码做的事。混淆后的构建与你交给它的构建返回相同的值,抛出相同类型的错误,写出相同的输出,对页面做出相同的改动。本页说明引擎 0.2.3 及以后版本的这份契约:哪些保持不变,每个版本如何检验,以及输出有意不同的少数几处。
哪些保持不变
- 结果与错误。 函数返回相同的值。原来会抛错的代码仍然抛出同一类型的错误。
- 副作用及其顺序。 控制台输出、DOM 改动、网络请求和存储写入以相同的顺序、相同的值发生。
- 异步顺序。
async函数、Promise 和定时器按相同的顺序完成。 - 全局接口。 经典脚本定义与之前相同的全局变量,因此页面上的其他脚本仍然可以调用它的函数、读取它的变量。ES 模块不会向
window添加任何东西,也不会覆盖任何全局变量。含有import或export的文件按模块读取;对于不含它们的文件,由框架插件、CLI 或sourceType告诉引擎。 - 你的代码所依赖的语言规则。 严格模式下的错误、
this与调用接收者、getter 与 setter、类字段、BigInt运算,以及eval和with的作用域,都按原样运行。 - 你的代码读取的名称。 在读取
this.constructor.name或new.target.name的文件中(自定义错误类就会这样做),类和构造函数保留你写下的名称,例外情况见有意差异。
使用不同种子的两次构建形态不同,但行为永远相同。
哪些会按设计改变
- 代码的文本。
fn.toString()的返回值、行号和列号,以及调用栈中的名称都会改变。要读取生产环境的调用栈,请私下保留一份 source map。 - 体积与速度。 输出更大,从
medium起每个操作要做更多工作。实测数据见性能。 - 检查自身的代码。 当引擎发现代码在运行时读取自身结构,例如
fn.toString(),它会停止构建并指出该模式。用reflection.allow确认该模式,或用paths.exclude让该文件原样交付。
每个版本如何检验
从引擎 0.2.3 起,每个引擎版本在发布前都要通过一道语义关卡。它把每个程序运行两次,一次按原样,一次经过混淆,然后比较两次运行做了什么:返回值、抛出的错误、控制台输出,以及异步工作的顺序。任何差异都会让该版本无法发布,除非它属于下面列出的有意差异。
这道关卡运行:
- 小而有针对性的程序,每个都瞄准一条代码变换工具可能弄错的语言规则:类与私有字段、闭包与作用域、生成器、
async顺序、模板字面量、BigInt、eval与with、严格模式、标签与switch。覆盖每一个预设、大量种子,以及原生引擎和 WebAssembly 引擎。 - 真实的库,固定版本,各由一个小脚本驱动:处理日期的 dayjs、Moment.js 和 Luxon,大量使用
BigInt的密码学库 viem、ethers 和 @noble/curves,以及 Comlink、lodash 的_.template和 get-intrinsic。 - Chromium 中的页面,按顺序加载多个经典脚本、带或不带 import 的 ES 模块、内联事件处理器和自定义元素,以及一个单 chunk 的 Vue 应用。每个页面都必须表现相同,并且让
window与原始版本完全一致:没有新增的全局变量,也没有被覆盖的全局变量。
有意差异
这些差异,加上表格后面的类名差异,是这道关卡唯一允许的差异。表中每一行都涉及替换内置对象、把实例包进 Proxy、不用 new 调用类,或读取 JavaScript 引擎错误消息的代码,因此大多数应用永远不会遇到。它们只出现在引擎重写的类中,从 light 起生效;minify 会让类保持原样。
| 你的代码 | 原生行为 | 混淆后的输出 |
|---|---|---|
在一个本身是 Proxy(由基类构造函数返回)或原型链中有 Proxy 的实例上定义公有类字段 | 只会触发代理的 defineProperty 陷阱 | 每个字段还会触发 has 陷阱;对于对象上不存在的字段,还会触发 set 陷阱及其转发到的一切 |
不用 new 调用类的构造函数 | 在求值任何参数默认值或解构之前就抛出 TypeError | 先求值参数默认值和解构,然后抛出同样的 TypeError |
在替换了 Function.prototype.call 之后,或在带有自有 call 属性的方法上,调用私有方法或 super.method() | 直接调用该方法 | 经由被替换的 call 调用 |
读取由私有字段检查、私有字段重复初始化或不用 new 调用类所产生的 TypeError 的消息 | JavaScript 引擎自己的消息 | AfterPack 自己的消息,错误类型相同 |
在文件加载之前替换 TypeError、Object、Object.defineProperty、Reflect.construct、Reflect.ownKeys、WeakMap 或 WeakSet | 类仍然使用原始的内置对象 | 被重写的类使用替换后的版本 |
类名和构造函数名在少数情况下会不同,在每个预设下都是如此。只以其他方式读取名称的文件,例如 err.constructor.name 或 const { name } = new.target,得到的是改名后的名称。在确实读取 this.constructor.name 或 new.target.name 的文件中,以下情况仍会显示改名后的名称:以 new (class X {})() 构造的类、由默认参数命名的匿名类、在声明它的 switch case 之前的某个 case 中被构造的函数,以及只通过其他引用构造的函数,例如 new ns.F()、别名或 Reflect.construct。
哪些保持你写的样子
只要无法低成本地忠实重写,以下结构就保持原样。它们周围的代码仍会被重命名和编码,with 和直接 eval 周围除外。
async函数和await。delete,以及作用于单个名称的typeof。catch (e)块中声明了var e的函数。- 直接在
switch分支中声明的let或const。 - 被调用者带括号或是
super成员的可选调用或标签:(o?.m)?.()、(o?.m)`x`、super.m?.()。 - 给自身名称赋值的类、以可选调用、标签或带括号的被调用者形式调用
super成员的类,或派生构造函数中顶层super()调用不恰好为一次的类。 minify和light下的循环。with语句或直接eval周围的作用域。从它向外直到文件顶层的每一层作用域中,名称都保留原来的写法,with语句体内的全局变量也保持原样。构建会用DIAG_DYNAMIC_SCOPE_NATIVE报告这一点。如果被求值的代码不需要局部变量,就用间接方式调用eval,即(0, eval)(code),或者把需要eval或with的代码移到单独的文件中。
引擎 0.2.3 之前
引擎 0.2.2 及更早版本在关卡现在覆盖的一些情况下没有满足这份契约:与其他脚本共享的经典脚本顶层名称、不含 import 的 ES 模块、模板字面量中的日期等对象、被当作构造函数使用的函数、覆盖继承访问器的类字段、引用自身的函数、medium 及以上的 BigInt 运算、eval 和 with、用 var 重新声明自身变量的 catch 块、async 顺序、带括号的可选调用、常量 ** 表达式,以及在运行时读取的类名。请用引擎 0.2.3 或更高版本重新构建;更新日志列出了每一项修复。
报告差异
本页未列出的差异就是一个 bug。请在 github.com/afterpack-dev/afterpack/issues 提交 issue,并附上一个小的复现。在修复之前,paths.exclude 可以让受影响的文件原样交付。
下一步
- 诊断:构建可能报告的每个代码,以及如何处理。
- AfterPack 的工作原理:各种变换对你的代码做了什么。
- 预设:每个级别应用了什么。
- 配置:
reflection.allow、paths.exclude,以及本页链接到的其他键。