震惊!小前端竟发现了 iOS 非 14.5 版本能遍历出不可枚举属性的 BUG

avatar
前端工程师 @公众号:ELab团队

结论

FYI: 以下说的 api-monitor 是一个库,引入这个库,可自动上报服务端接口的耗时、接口错误信息、错误码、HTTP 状态码等信息,用于监控接口质量。

在开始前,先看一段代码,问能不能进入 for 循环?

var origin = {};

Object.defineProperty(origin, 'username', {

    get() {

        return 'mbj';

    },

    set() {},

    configurable: true,

    enumerable: true,

});

var obj = Object.create(origin);

Object.defineProperty(obj, 'username', {

    get: function () {

        return 'wmy';

    },

    set: function () {},

});



console.log('obj', obj);



for (var attr in obj) {

    console.log('attr', attr);

    console.log(

        `obj.${attr}属性描述符`,

        Object.getOwnPropertyDescriptor(obj, attr)

    );

    console.log(

        `obj.__proto__.${attr}属性描述符`,

        Object.getOwnPropertyDescriptor(obj.__proto__, attr)

    );

}

结果

移动端 ios14.5beta 版本移动端 ios 非 14.5 版本AndroidPC Chrome
是否能进入 for 循环不能不能

从上面结果可以看出,核心的问题在于:如果一个对象的上有一个属性,不可枚举,并且 prototype 上也有个属性,可枚举,那这属性用 for in 可枚举吗?

结果是:

移动端 ios14.5beta 版本和 PC Chrome移动端 ios 非 14.5 版本 和 Android
for in 是否可枚举

回到对 XMLHttprequest 的影响,首先在 api-monitor 代理 XMLHttprequest 之前,就有库对 XMLHttprequest 进行过代理了,并且有

window. __real=window.XMLHttpRequest

window.XMLHttpRequest= function {

    this._xhr = new window.__real();

    for (var attr in this._xhr) {

        var type = '';

        try {

            type = typeof this._xhr[attr];

        } catch (e) {}

        if (type === 'function') {

            this[attr] = hookfun(attr);

        } else {

            Object.defineProperty(this, attr, {

                get: genGetter(attr),

                set: genSetter(attr),

            });

        }

    }

}

window.XMLHttpRequest.prototype = window.__real.prototype

注意这里用 Object.defineProperty 定义了属性并且 enumerable 和 configurable 为 false,到 api-monitor 再代理 XMLHttpRequest 对象时,其实是别人已经代理过的。

此时

var xml=new window.XMLHttpRequest()

就符合上述那段代码了,比如 xml 上的一个 onreadystatechange 属性,其实

描述符
xml.onreadystatechangeenumerable 和 configurable 为 false
xml.__proto__.onreadystatechangeenumerable 和 configurable 为 true

于是 api-monitor 在通过 for in 遍历这个 xml 实现代理功能的时候,在 ios 14.5 上就遍历不到 onreadystatechange,responseText 这些属性,导致获取不到数据响应。

总结

如果一个对象上有一个属性,不可枚举,并且对象的 prototype 上也有个属性,可枚举,则

  1. 移动端 ios14.5beta 版本和 PC Chrome,不可枚举
  2. 移动端 ios 非 14.5 版本 和 Android,可枚举

从这次事件看出,xml 的不确定性和历史遗留问题比较多,通过遍历 xml 实例代理真正的 xml 不可靠。

下面为定位这个问题的具体过程分析,有兴趣的同学可以看看

api-monitor 拦截 XMLHttprequest 方式

image.png

api-monitor 用自己实现的 XMLHttprequest 覆盖了 window.XMLHttprequest,当使用者在使用 xml 的时候,其实是在使用 api-monitor 提供的 xml,xml 实例代理了真正的 xml 实例,如调用 xml.open,会调用真正 xml 实例的 open 方法,如果给 xml.onreadystatechange 赋值,会给真正的 xml 实例赋值。

实现代码主要是基于ajax-hook

非 IOS 14.5 beta 版本

可以从 XMLHttprequest 的实例上通过 for in 遍历出所有的属性和方法,这就没问题,api-monitor 提供的 xml 能代理到真行 xml 实例的所有方法和属性。

代码如下:

// 遍历真正的xml实例

var xhr = new window.XMLHttpRequest();

console.log('enter????', Object.keys(xhr));

for (var attr in xhr) {

    console.log('attr ', attr);

    var type = '';

    if (attr === 'xhr') continue;

    try {

        type = typeof xhr[attr]; // May cause exception on some browser

    } catch (e) {}

    if (type === 'function') {

        // 代理真正xml的方法

        this[attr] = hookFunction(attr);

    } else {

        // 代理真正xml的属性

        Object.defineProperty(this, attr, {

            get: getterFactory(attr),

            set: setterFactory(attr),

            enumerable: true,

        });

    }

}

下图所示日志是在用 for in 遍历真正的 xml 实例的输出:

ios 14.5 beta

在通过 for in 遍历 xhr 实例的时候,ios 14.5 不能遍历出 onreadystatechange,ontimeout,timeout 等属性,因此,在给 xml 的 onreadystatechange 赋值时,就不能给真正的 xml 实例挂载 onreadystatechange 方法,导致没法响应数据。

代码同上,下图所示日志是在用 for in 遍历真正的 xml 实例的输出:

遍历对比

下面通过三种方式来遍历真正的 xml 对象,对比在 ios14.5beta 和非 14.5 的结果区别

遍历方式移动端 ios14.5beta 版本移动端 ios 非 14.5 版本PC Chrome
Object.keys所有的方法所有的方法
for in所有的方法所有的方法和属性所有的方法和属性
Object.getOwnPropertyNames()所有的方法和属性所有的方法和属性

由上述结果,我们可以猜测在移动端属性和方法是直接挂载在 xml 实例上的,只不过属性是不可遍历的,我通过如下代码来获取下属性的描述符

console.log(

            'descriptor ',

            Object.getOwnPropertyDescriptor(xhr, 'responseType')

        );

console.log(

            'descriptor ',

            Object.getOwnPropertyDescriptor(xhr, 'send')

        );

在移动端 ios 得到的结果都是

// responseType

{

    configurable:false,

    enumerable:false,

    get(){},

    set(){},

}



// send

{

    configurable: true,

    enumerable: true,

    value:function(){}

    w​ritable: true

}

这个结果验证了我们的猜想,那就矛盾了,为什么移动端 ios 非 14.5 版本能遍历出不可枚举的属性,猜测是苹果爸爸在 14.5 beta 修复了这个 bug,我们继续往下看。

柳暗花明又一村

打印了在 api-monitor 里面获取的 xmlhttprequest,发现这个 xmlhttprequest 也是被其他业务覆盖的,也就是上面我说的真正的 xml 实例其实也是别人代理的一个 xml 实例,并不是原生的 xml 实例。从下述打印出来的实现方式来看,方法挂载在实例上,属性通过如下方式定义在实例上

Object.defineProperty(this, attr, {

                get: genGetter(attr),

                set: genSetter(attr),

            });

configurable、enumerable 的默认值都是 false,因此理论上,这个实例应该是不能通过 for in 遍历出来的,但是移动端 ios 非 14.5 版本和 Android 能遍历出来,PC Chrome 和 ios 14.5 版本不能。 又发现

window.__real.prototype === window.XMLHttpRequest.prototype   // true

因此猜测跟原型上的属性有关,于是打印出所有 window.__real.prototype 属性的属性描述符,发现 configurable,enumerable 都为 true,结合上面 xml 实例的属性描述符 configurable,enumerable 都为 false,得出最开始的结论。

我的打印代码和别人实现的 XMLHttpRequest:

if (typeof window !== 'undefined') {

  console.log('fake', window.XMLHttpRequest);

  const foo = new window.XMLHttpRequest();

  console.log('open', foo.open);

  console.log('send', foo.send);

  console.log('real ', window.__real);

  console.log(

    'isEqual',

    window.__real,

    window.__real.prototype === window.XMLHttpRequest.prototype

  );  // 这里打印出来是true

}



// 打印出来的window.XMLHttpRequest

window.__real=window.XMLHttpRequest

window.XMLHttpRequest= function {

    this._xhr = new window.__real();

    for (var attr in this._xhr) {

        var type = '';

        try {

            type = typeof this._xhr[attr];

        } catch (e) {}

        if (type === 'function') {

            this[attr] = hookfun(attr);

        } else {

            Object.defineProperty(this, attr, {

                get: genGetter(attr),

                set: genSetter(attr),

            });

        }

    }

}

window.XMLHttpRequest.prototype = window.__real.prototype

❤️ 谢谢支持

以上便是本次分享的全部内容,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 三连哦~。

欢迎关注公众号 ELab团队 收货大厂一手好文章~