rollup构建后Reflect.construct兼容性问题分析

业务中一些 jssdk 会通过 rollup 构建打包,并以 umd 产物方式发布到 cdn 给到业务页面使用。

但近期我在一个 jssdk(fet-block)项目中,碰到了上线后“低端机型”sentry 出现 js 报错的情况,最终发现原因出在 rollup 构建后的代码中存在使用 Reflect.construct 进行继承父类构造函数的情况,由于 Reflect.construct 在部分机型存在兼容问题导致低端机报错。

下面具体分析该 js 兼容问题。

来自 sentry 的报错

下图是项目灰度上线后收到的 sentry 新增错误:

报错内容显示:t_resetXhrToUnset is not a function,其中所提示的 t._resetXhrToUnset 的调用方式在项目源码中大概如下所示:

1
2
3
4
5
6
7
8
9
10
class MyClass extends window.XMLHttpRequest {
constructor() {
...
super()
this._resetXhrToUnset()
}
_resetXhrToUnset() {
...
}
}

其实代码很简单,我是在项目中创建了一个 class 类,该类继承自原生的 window.XMLHttpRequest 类。同时,我使用了最新的 Es 语法,直接通过 extends 关键字实现类的继承。

而 sentry 报错提示的位置,则是在我 MyClass 的构造函数中,当我调用 this._resetXhrToUnset 的时候提示这个方法不存在。但这个方法非常明确是存在的!难道是有人在使用我们的 XHR class 的时候,强行改掉了我们某些函数的 this 指向,导致调用 constructor 构造函数的时候,this 已经不再指向本实例?

但从代码看实在是看不出来啊!而且 sentry 报错堆栈中也很难看出来到底构造器里的 this 是否被改了,有点难。

复现

于是找到类似的低端机(ios<=13 版本)进行复现,确实复现了相关错误。于是把 rollup 构建时候的压缩去掉,打日志或断点分析,最终发现问题代码出在下图 11 行这里_this = _super.call(this)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var MyClass = (function (_window$XMLHttpReques) {
// 通过_inherits继承父类
_inherits(MyClass, _window$XMLHttpReques);
//获取父类构造函数
var _super = _createSuper(MyClass);
//声明子类构造函数
function MyClass() {
var _this;
_classCallCheck(this, MyClass);
// 执行父类构造函数,并把返回结果作为子类实例
_this = _super.call(this);
_this._resetXhrToUnset();
return _this;
}
return MyClass;
})(window.XMLHttpRequest);

原因在于_super.call(this)返回的对象并不是 MyClass 的实例,而变成了父类 window.XMLHttpRequest 的实例,那父类 class 里面肯定没有 _resetXhrToUnset 方法啊,于是第 12 行就报错了。

追踪原因

我们仔细剖析下为何这里_super.call 之后的 _this 在低端机上不符合预期了。看下上面 MyClass 函数的内容,他其实就是我们源码中的 MyClass 构造函数。

他里面需要调用父类构造函数(即源码角度的 super() 这一句),于是他先在第五行 var _super = _createSuper(MyClass);创建了父类的构造函数执行器,然后在 MyClass 真正要构造的时候就执行父类构造器—即第 11 行。同时,他把_super.call 返回的结果_this 他在末尾 return _this就当做子类的实例直接返回给调用者了。

事实上,最后返回的_this 的确是子类 MyClass 的实例,因为 rollup 在_super.call(this) 里面做了处理,尽管你看起来是在调用父类构造函数,但最终返回时,会变成子类的实例。这是 rollup 在 _super 函数中做的特殊处理,也是本文中所描述的兼容性 bug 问题的根源—某些低端机上他返回的_this 并不是子类实例。

那么,接下来,我们就去研究_createSuper 函数,看看他如何执行父类 XMLHttpRequest 的构造函数,且还能返回一个子类 MyClass 的实例的。

_createSuper

来到 createSuper 函数,下面通过注释进行逻辑说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function _createSuper(Derived) {
// 先判断设备是否支持 Reflect 反射
var hasNativeReflectConstruct = _isNativeReflectConstruct$1();
// 再返回一个高阶函数---这个就是前面讲到的 super 函数执行器。
return function _createSuperInternal() {
//先拿出子类构造器的原型(即 MyClass.__proto__),这里 Super就是 XMLHttpRequest 了
var Super = _getPrototypeOf(Derived),
result;
if (hasNativeReflectConstruct) {
// 若支持反射,则执行父类构造函数 Super,但是构造的实例让他指向 NewTarget,即指向子类。于是 Reflect.construct 返回的实例对象其实是个子类的实例。
var NewTarget = _getPrototypeOf(this).constructor;
result = Reflect.construct(Super, arguments, NewTarget);
} else {
// 若不支持反射,则调用父类构造函数,但把 this 指向改成子类实例,于是构造函数返回的 this 就是子类实例。
result = Super.apply(this, arguments);
}
return _possibleConstructorReturn(this, result);
};
}

总体上这个函数的功能是返回一个“能调用父类构造函数的高阶函数”,而这个高阶函数内的逻辑则是:

去执行父类的构造函数,但执行归执行,返回实例指针的时候却要返回子类当前实例

不知道 rollup 抽了什么风,他在完成上面这句加粗了的这句话的任务的时候,竟然优先想尝试用 Reflect.construct 来完成,不支持的情况下才采用 Super.apply 来完成。造孽啊~

我们看 MDN 的文档

newTarget 的功能: The value of the new.target expression inside target. Defaults to target. Generally, target specifies the logic to initialize the object, while newTarget.prototype specifies the prototype of the constructed object.

他有 3 个参数:Reflect.construct(target, argumentsListArr, newTarget)。 前面 2 个参数比较容易理解,他就是调用 target 函数来完成一个类似于new target()的构造功能,第二个参数则是传给 target 构造器的参数列表。 第三个参数则是用来改变构造出来实例的继承者的,因为默认构造实例肯定是属于 target 这个类的实例,但如果你传了一个新的 newTarget,则他会让调用构造出来的实例对象的__proto__指向第三个参数的 newTarget.prototype,那最终效果就是 构造出来的实例变成了 newTarget 的实例

回到上文 _createSuperInternal,他里面也调用了 Reflect.construct 并且传入了第三个参数 newTarget。而上文中 NewTarget 又是这样来的:

1
2
var NewTarget = _getPrototypeOf(this).constructor;
result = Reflect.construct(Super, arguments, NewTarget);

_getPrototypeOf(this)就是说本次调用的 this 的原型,那本次调用时候的 this 必然是子类 MyClass 的实例,而 MyClass 实例的原型就是 MyClass.prototype, 而MyClass.prototype.constructor则是 MyClass 自身。

于是,上面的 Reflect.construct 其实是 Reflect.construct(XMLHttpRequest, arguments, MyClass),所以返回值必然是一个由 XMLHttpRequest 构造出来的对象,但是构造完之后呢顺便把他改成了 MyClass 的实例。看起来合情合理,也没有什么逻辑问题~

Reflect.construct 第三个参数不生效

既然 super 构造器构造完之后的实例,确实已经修改过原型,并且指向 MyClass.prototype,也就意味着我可以继续访问到子类的方法咯。

1
2
_this = _super.call(this);
_this._resetXhrToUnset();

然而调试发现,在低端机上上述代码返回的 _this 实则并没有子类的_resetXhrToUnset 方法。仔细检查 _this.__proto__发现,他并没有指向 MyClass.prototype,而是指向了 XMLHttpRequest.prototype。

检测方法为:检查 _this.proto.constructor 是否是 MyClass 自身。当在低端机上打印时,会发现打印出了原生 XMLHttpRequest:

经过单独写测试代码测验,发现在这类低端机上,Reflect 功能虽然可用,但是:

  1. Reflect.construct 的第三个参数 newTarget 不生效
  2. 当 Reflect.construct 第三个参数是“非 native class”的时候,也能生效,但是如果是像 XMLHttpRequest 这样的浏览器原生 class,则不生效。而我的本项目代码恰好是去继承浏览器 native 的 class,所以造成该问题。

修复 Reflect 特性检测函数

为了让低端机不要走到使用 Reflect 的代码,我计划修复_createSuper 代码中的 _isNativeReflectConstruct$1() 函数,让他做到更准确的检测设备是否支持 Reflect 特性。当检测到不支持的 Reflect 的时候,在本项目中我暂且直接放弃了对低端手机的支持(因为项目场景特殊,所以可以简单粗暴处理)。

以下是修正后的 Reflect 检测函数(新增部分就是我额外增加的检测代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function _isNativeReflectConstruct() {
if (typeof Reflect === 'undefined' || !Reflect.construct) return false;
if ((Reflect.construct as any).sham) return false;
// 这里是新增部分 start
function SubClass() { };
const nativeXHR = getNativeXHr();
const res = Reflect.construct(nativeXHR, [], SubClass);
if (_getPrototypeOf(res) !== SubClass.prototype) return false;
// 这里是新增部分 end
if (typeof Proxy === 'function') return true;
try {
Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () { }));
return true;
} catch (e) {
return false;
}
}

这里检测是否支持第三个参数 newTarget 的技术原理是:

  1. 拿出浏览器原生的 XMLHttpRequest class
  2. 通过 Reflect.construct 做一次原生 XMLHttpRequest 的构造,并把实例结果成为第三个参数 SubClass 的实例
  3. 检查新构造出来的 res 结果,看看他是否真正指向了 SubClass.prototype。如果没有,则认为是低端机,对 Reflect 的支持有 bug。

额外辅助代码

再贴一点 isNativeReflectConstruct 函数用到的辅助代码:

1
2
3
4
5
6
7
8
9
10
11
// 寻找原生 xmlHttpRequest
function getNativeXHr() {
let time = 0;
let xhrProto = window.XMLHttpRequest.prototype;
while (_getPrototypeOf(xhrProto) !== XMLHttpRequestEventTarget.prototype) {
xhrProto = _getPrototypeOf(xhrProto);
time += 1;
if (!xhrProto) break;
}
return xhrProto?.constructor;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 寻找对象原型
function _getPrototypeOf(o) {
_getPrototypeOf = Object.setPrototypeOf
? Object.getPrototypeOf.bind()
: function _getPrototypeOf(o) {
return o.__proto__ || Object.getPrototypeOf(o);
};
return _getPrototypeOf(o);
}
// 设置对象原型
function _setPrototypeOf(o, p) {
_setPrototypeOf = Object.setPrototypeOf
? Object.setPrototypeOf.bind()
: function _setPrototypeOf(o, p) {
o.__proto__ = p;
return o;
};
return _setPrototypeOf(o, p);
}

rollup 构建结果中的 inherit 继承代码

顺便学习下 rollup 构建后的继承实现吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function");
}
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
writable: true,
configurable: true,
},
});
Object.defineProperty(subClass, "prototype", {
writable: false,
});
// 让构造器也能有__proto__继承关系
if (superClass) _setPrototypeOf(subClass, superClass);
}

总结

  1. Reflect.construct 可以用来执行构造函数完成类似于 new xxx 的构造实例的效果
  2. Reflect.construct 的第三个参数可以实现执行构造器的同时,改掉构造器中的 this 以及构造器中的 new.target,最终可以让构造器默认返回的实例变成第三个参数 newTarget 的实例。
  3. 低端机在 Reflect.construct 的第三个参数的支持上有 bug,例如当第三个参数是浏览器原生 class 的时候,则会不生效
  4. rollup 构建结果中的 Reflect 支持度检测代码不够完善,需要增加额外的 Reflect.construct 功能性检测。否则 ios13 以下机器会有问题。