Babel loose 模式踩坑记录
Babel loose 模式踩坑记录
目录
| 编号 | 坑 | 关键词 |
|---|---|---|
| 1 | super.<accessor> = value 被编译成 this.<accessor> = value |
accessor、receiver、constantSuper |
0. 背景知识:loose、spec 与 assumptions
loose / spec 不是两种“构建模式”,而是 Babel 把 ES6 class 降级到 ES5 时的两套实现策略:
| spec(默认) | loose | |
|---|---|---|
| 目标 | 忠实复现 ES2015 语义 | 用“通常成立”的假设换体积与性能 |
| 继承 | _inherits + Reflect.construct + _possibleConstructorReturn |
_inheritsLoose(直接换 prototype) |
super 属性访问 |
走 _get / _set helper |
直接当 Super.prototype.x / this.x |
| 产物 | 大、慢 | 小、快 |
开关来自 loose 选项,它会被映射成一组 assumptions:
1 | // @babel/plugin-transform-classes/lib/index.js |
其中与本篇相关的只有一条:**constantSuper**。它的含义是“superclass 绑定是常量,因此 super.prop 可以当作 Super.prototype.prop 处理”。
何时会触发降级:只有当目标环境不支持 class 时,preset-env 才会引入 class transform。典型差异:
1 | // 不会降级(Node 原生支持 class),super 原样保留 |
这也解释了一个常见困惑:同一个坑,在单测里测不出来,在打包产物里却必现。
坑 1:super.<accessor> = value 被编译成 this.<accessor> = value
触发条件
同时满足以下三点:
- 构建走 loose(或显式开启
assumptions.constantSuper); - 子类覆写了基类的 accessor(getter/setter);
- 覆写体里用
super.<prop> = value转发。
现象
对子类实例的该属性赋值时无限递归,直到栈溢出:
1 | RangeError: Maximum call stack size exceeded |
调用栈上重复的永远只有子类 setter 这一帧。
最小复现
1 | class Base { |
原理
super.x = v 的规范语义是:
1 | 对 GetPrototypeOf(homeObject) 做 [[Set]]: |
也就是“找到基类 setter,用实例作为 this 调用”,并不是“给基类原型赋值”。
spec 模式会忠实地生成这段语义:
1 | set: function set (v) { |
而 loose 模式走了一条捷径(@babel/helper-replace-supers):
1 | // looseHandlers.set (:166-172) |
于是 super.x = v 变成 this.x = v。this.x 会沿实例原型链重新查找 x,第一个命中的就是子类自己重写后的 setter:
1 | this.x = v |
为什么对数据属性没事:若基类的 x 是普通数据属性,super.x = v 与 this.x = v 都表现为“在实例上定义/写入 x”,结果一致。只有 accessor 会丢 receiver,这是这条假设的失效边界。
连带影响:getter 同样是错的
读方向 super.x 被编成 Base.prototype.x,基类 getter 的 this 变成原型对象:
1 | get: function get () { |
如果基类 getter 是 return this._x,这里读到的是 Base.prototype._x,即 undefined。
所以这个坑的完整表现是:**写 → 爆栈;读 → 恒为 undefined**。写会先把问题暴露出来,读的问题更隐蔽。
为什么本地 / 单测复现不了
仓库 babel.config.js 若是:
1 | [require('@babel/preset-env'), { targets: { node: 'current' }, loose: true }] |
则 class 不被降级,super.x = v 原样保留,语义正确,测试全绿。
实测两种配置:
1 | engine-build (无 targets + loose) → 有 class 降级,setter = this.x = v (复现) |
结论:降级相关的问题,必须在降级产物里复现。 要么在真实构建/预览里验证,要么在测试里显式用 preset-env 无 targets + loose 编译后再执行。
离线验证脚本(不依赖业务工程)
1 | const babel = require('@babel/core'); |
把 loose 换成 false,产物变成 _set(_getPrototypeOf(...), "x", v, this, true),不再报错——即可确认根因。
检测方法
1 | # 找出所有对 super 属性赋值的位置 |
对每一处确认基类对应成员是否为 accessor。是 accessor 的基本都有风险。
静态分析难做到 100% 准确(基类可能在别的文件/依赖里),所以更稳妥的是构建层根治(见下)。
修复
核心原则:不要让 super 的语义交给编译器去“近似”。把 target 和 receiver 显式写出来。
1 | // 推荐:等价于 spec 产物,任何构建配置下行为一致 |
Reflect.get(target, key, receiver) / Reflect.set(target, key, value, receiver) 是 ES2015 内建函数:
receiver决定 getter/setter 内部的this,传实例即可;- 是普通函数调用,任何 transform 都不会改写它;
_sethelper 内部本就等价于Reflect.set,所以这就是 spec 模式会生成的语义。
几种写法对比:
| 写法 | 评价 |
|---|---|
Reflect.get/set(Base.prototype, key, this) |
推荐,语义等价 spec,稳定 |
target 用 Object.getPrototypeOf(Sub.prototype) |
重构更安全(自动跟随基类),多一次 getPrototypeOf(性能可忽略) |
Object.getOwnPropertyDescriptor(Base.prototype, key).set.call(this, v) |
等价但啰嗦;基类 accessor 被重定义时会拿到旧引用 |
getter 直接 return this._x |
仅适用于基类 getter 无副作用逻辑的情况(有清理逻辑就不能替代) |
反例:不要用 Object.getPrototypeOf(this) 当 target。this 可能是更深层子类实例,会指向错误的原型,等于换个方式重蹈覆辙。
加固
上层:把已知的
super.<accessor> =全部改成Reflect.get/set;底层(根治):在构建链里关掉这条假设,一次性覆盖所有类与用户脚本:
1
{ loose: true, assumptions: { constantSuper: false } }
代价是产物略大略慢,且影响面是全量 class,需要单独评估与回归。
注意:不要指望改 Babel 上游。loose 模式下
super.x = v → this.x = v是既定设计(文档明确 loose 不保证 spec 兼容),不是 bug。