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
2
3
4
// @babel/plugin-transform-classes/lib/index.js
const constantSuper = api.assumption("constantSuper") ?? loose;
const superIsCallableConstructor = api.assumption("superIsCallableConstructor") ?? loose;
// ...

其中与本篇相关的只有一条:**constantSuper**。它的含义是“superclass 绑定是常量,因此 super.prop 可以当作 Super.prototype.prop 处理”。

何时会触发降级:只有当目标环境不支持 class 时,preset-env 才会引入 class transform。典型差异:

1
2
3
4
5
// 不会降级(Node 原生支持 class),super 原样保留
{ targets: { node: 'current' } }

// 会降级到 ES5,进入 loose/spec 分支
{ loose: true, ignoreBrowsersListConfig: true } // 无 targets

这也解释了一个常见困惑:同一个坑,在单测里测不出来,在打包产物里却必现。


坑 1:super.<accessor> = value 被编译成 this.<accessor> = value

触发条件

同时满足以下三点:

  1. 构建走 loose(或显式开启 assumptions.constantSuper);
  2. 子类覆写了基类的 accessor(getter/setter);
  3. 覆写体里用 super.<prop> = value 转发。

现象

对子类实例的该属性赋值时无限递归,直到栈溢出:

1
2
3
4
RangeError: Maximum call stack size exceeded
at Derived.set (file.ts:194:23)
at Derived.set (file.ts:194:23)
...

调用栈上重复的永远只有子类 setter 这一帧。

最小复现

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base {
_x = null;
get x () { return this._x; }
set x (v) { if (v === this._x) return; this._x = v; }
}

class Derived extends Base {
get x () { return super.x; }
set x (v) { super.x = v; }
}

const d = new Derived();
d.x = 1; // loose 构建下 → RangeError: Maximum call stack size exceeded

原理

super.x = v 的规范语义是:

1
2
3
4
5
对 GetPrototypeOf(homeObject) 做 [[Set]]:
target = Base.prototype
key = "x"
value = v
receiver = this(当前实例)

也就是“找到基类 setter,用实例作为 this 调用”,并不是“给基类原型赋值”。

spec 模式会忠实地生成这段语义:

1
2
3
set: function set (v) {
_set(_getPrototypeOf(Derived.prototype), "x", v, this, true);
}

而 loose 模式走了一条捷径(@babel/helper-replace-supers):

1
2
3
4
5
6
7
8
9
10
// looseHandlers.set  (:166-172)
set (superMember, value) {
return assignmentExpression("=",
memberExpression(thisExpression(), prop), value); // → this.x = value
}

// looseHandlers.get (:147-164)
get (superMember) {
return memberExpression(memberExpression(getSuperRef(), 'prototype'), prop);
} // → Base.prototype.x

于是 super.x = v 变成 this.x = v。this.x 会沿实例原型链重新查找 x,第一个命中的就是子类自己重写后的 setter:

1
2
3
4
5
6
this.x = v
│
▼
Derived.prototype.set x
│ this.x = v
└────────► 回到自身,无限循环

为什么对数据属性没事:若基类的 x 是普通数据属性,super.x = v 与 this.x = v 都表现为“在实例上定义/写入 x”,结果一致。只有 accessor 会丢 receiver,这是这条假设的失效边界。

连带影响:getter 同样是错的

读方向 super.x 被编成 Base.prototype.x,基类 getter 的 this 变成原型对象:

1
2
3
get: function get () {
return _Base.prototype.x; // 在原型上读,而不是在实例上读
}

如果基类 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
2
engine-build (无 targets + loose) → 有 class 降级,setter = this.x = v   (复现)
jest/node-current → 无降级,super.x = v 原样保留 (不复现)

结论:降级相关的问题,必须在降级产物里复现。 要么在真实构建/预览里验证,要么在测试里显式用 preset-env 无 targets + loose 编译后再执行。

离线验证脚本(不依赖业务工程)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const babel = require('@babel/core');
const presetEnv = require('@babel/preset-env');

const src = `
class Base { _x = 0; get x(){ return this._x; } set x(v){ this._x = v; } }
class D extends Base { get x(){ return super.x; } set x(v){ super.x = v; } }
globalThis.__D = D;
`;

(async () => {
const { code } = await babel.transformAsync(src, {
presets: [[presetEnv, { loose: true, ignoreBrowserslistConfig: true }]],
configFile: false, babelrc: false,
});
console.log(code.match(/set: function set[\s\S]*?\}/)[0]); // this.x = v
(0, eval)(code);
new globalThis.__D().x = 1; // RangeError: Maximum call stack size exceeded
})();

把 loose 换成 false,产物变成 _set(_getPrototypeOf(...), "x", v, this, true),不再报错——即可确认根因。

检测方法

1
2
# 找出所有对 super 属性赋值的位置
rg 'super\.[A-Za-z_$][\w$]*\s*=' --type ts

对每一处确认基类对应成员是否为 accessor。是 accessor 的基本都有风险。

静态分析难做到 100% 准确(基类可能在别的文件/依赖里),所以更稳妥的是构建层根治(见下)。

修复

核心原则:不要让 super 的语义交给编译器去“近似”。把 target 和 receiver 显式写出来。

1
2
3
// 推荐:等价于 spec 产物,任何构建配置下行为一致
get x () { return Reflect.get(Base.prototype, 'x', this); }
set x (v) { Reflect.set(Base.prototype, 'x', v, this); }

Reflect.get(target, key, receiver) / Reflect.set(target, key, value, receiver) 是 ES2015 内建函数:

  • receiver 决定 getter/setter 内部的 this,传实例即可;
  • 是普通函数调用,任何 transform 都不会改写它;
  • _set helper 内部本就等价于 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。