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 | class MyClass extends window.XMLHttpRequest { |
其实代码很简单,我是在项目中创建了一个 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 | var MyClass = (function (_window$XMLHttpReques) { |
原因在于_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 | function _createSuper(Derived) { |
总体上这个函数的功能是返回一个“能调用父类构造函数的高阶函数”,而这个高阶函数内的逻辑则是:
去执行父类的构造函数,但执行归执行,返回实例指针的时候却要返回子类当前实例。
不知道 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 | var NewTarget = _getPrototypeOf(this).constructor; |
_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 | _this = _super.call(this); |
然而调试发现,在低端机上上述代码返回的 _this 实则并没有子类的_resetXhrToUnset 方法。仔细检查 _this.__proto__
发现,他并没有指向 MyClass.prototype,而是指向了 XMLHttpRequest.prototype。
检测方法为:检查 _this.proto.constructor 是否是 MyClass 自身。当在低端机上打印时,会发现打印出了原生 XMLHttpRequest:
经过单独写测试代码测验,发现在这类低端机上,Reflect 功能虽然可用,但是:
- Reflect.construct 的第三个参数 newTarget 不生效
- 当 Reflect.construct 第三个参数是“非 native class”的时候,也能生效,但是如果是像 XMLHttpRequest 这样的浏览器原生 class,则不生效。而我的本项目代码恰好是去继承浏览器 native 的 class,所以造成该问题。
修复 Reflect 特性检测函数
为了让低端机不要走到使用 Reflect 的代码,我计划修复_createSuper 代码中的 _isNativeReflectConstruct$1()
函数,让他做到更准确的检测设备是否支持 Reflect 特性。当检测到不支持的 Reflect 的时候,在本项目中我暂且直接放弃了对低端手机的支持(因为项目场景特殊,所以可以简单粗暴处理)。
以下是修正后的 Reflect 检测函数(新增部分就是我额外增加的检测代码):
1 | function _isNativeReflectConstruct() { |
这里检测是否支持第三个参数 newTarget 的技术原理是:
- 拿出浏览器原生的 XMLHttpRequest class
- 通过 Reflect.construct 做一次原生 XMLHttpRequest 的构造,并把实例结果成为第三个参数 SubClass 的实例
- 检查新构造出来的 res 结果,看看他是否真正指向了 SubClass.prototype。如果没有,则认为是低端机,对 Reflect 的支持有 bug。
额外辅助代码
再贴一点 isNativeReflectConstruct 函数用到的辅助代码:
1 | // 寻找原生 xmlHttpRequest |
1 | // 寻找对象原型 |
rollup 构建结果中的 inherit 继承代码
顺便学习下 rollup 构建后的继承实现吧:
1 | function _inherits(subClass, superClass) { |
总结
- Reflect.construct 可以用来执行构造函数完成类似于 new xxx 的构造实例的效果
- Reflect.construct 的第三个参数可以实现执行构造器的同时,改掉构造器中的 this 以及构造器中的 new.target,最终可以让构造器默认返回的实例变成第三个参数 newTarget 的实例。
- 低端机在 Reflect.construct 的第三个参数的支持上有 bug,例如当第三个参数是浏览器原生 class 的时候,则会不生效
- rollup 构建结果中的 Reflect 支持度检测代码不够完善,需要增加额外的 Reflect.construct 功能性检测。否则 ios13 以下机器会有问题。