1

问题出现

快报前方测试传来情报:IOS版在IOS9系统下无法请求和展示文中广告!

排查和定位

  • 首先确认bug出现环境:老机型 IOS9,其他高版本的IOS机型正常
  • 排除法缩小问题范围:请远在北京的这位测试同学通过HTTP代理抓包的方式查看是否拉取了我们的jssdk,以及是否发起了广告拉取请求。结果是:js已拉取,但下一步的ajax请求未发出;这便说明问题肯定出现在jssdk的加载或执行过程当中了。
  • 检查了 babel 编译配置,目标 broswser 中写的是 "latest 3 safari version",经检查的确不包含 safari9,于是改成 "last 10 safari version",然而让测试测验后并不能奏效。
  • 请求IOS终端同学帮忙查看终端日志,寻找js报错的原因。

    这里由于 webpack 默认的打包方式会将模块打包为 eval() 执行块,非常不利于定位代码具体位置。因此我将 webpack 打包配置的 devtool 修改为 "source-map", 这样打包出来的js基本跟源码一致。

    最终,终端同学给出报错日志如下:

image.png

报错信息为:Attempting to change configurable attribute。但由于是编译后polyfill之后的代码,因为较难判断出来是谁造成的。只看到报错的函数为:_defineProperty

分析问题

经过仔细阅读报错消息,我们可以得出结论:这是因为我们修改了一个 unconfigurable 的属性。

我们知道,在 ES5 中,JavaScript 提供了一个 Object.defineProperty 的方法,从而可以定义属性的 descriptor;而对于定义为 "configurable: false" 的属性来说,它是无法被修改的(特指通过Object.defineProperty再次修改描述,或通过 delete 运算符删除),而对于定义为 "writebale: false" 的属性来说,是指的它无法被赋值运算符"="来修改。

那么,很明显我们的错误提醒说明我们的代码中做了 Object.defineProperty 或 delete 一个不可更改的属性的操作。于是,我们看看是谁调用了 _defineProperty 这个函数,最终找到bundle.js中这么两句代码:

_defineProperty(KbArticleCenter, "name", 'kb-article-center');

_defineProperty(KbArticleCenter, "instances", []);
 

其中 KbArticleCenter 在我的源码中是一个 class ,而 name 和 instances 是两个类静态成员。源码如下所示:

class KbArticleCenter {
  static name = 'kb-article-center'
  static instances = []
// ......... 省略一堆类的成员定义代码
}
 

难道说:类的静态成员在 babel 编译之后,会出现不兼容 IOS9 的情况? 带着疑问我去搜索了 plugin-proposal-class-properties 插件的issue,但并没有收获。

解决问题

最后,还是回到编译后的代码来查看,忽然间恍然大悟,我们知道:一个 class 类在 babel 编译后实际上会转换为一个普通的 JavaScript 函数,如下:

function KbArticleCenter(options) {
   // ..... 省略一坨构造函数代码
   this.init();
}

而我们的静态成员则会被通过 Object.defineProperty 的方式直接添加到该函数自身上面。例如我们在类型中定义的 static name 属性则被转变为: _defineProperty(KbArticleCenter, "name", 'kb-article-center');

然而,别忘了,对于 JavaScript 函数来说,它自身便拥有一个同名的 name 属性,我们这里如果又通过 defineProperty 的方式重写它,则意味着必须要求原来的 name 属性是可以 configurable 的 (即 configuable: true)。

在正常的现代浏览器中,我们一个 JavaScript 函数的 name 属性其实默认 configuable 是 true 的。例如如下代码的输出结果中显示 name 是可 configurable 的:

var foo = function() {}
Object.getOwnPropertyDescriptor(foo, 'name')

// configurable: true
// enumerable: false
// value: "foo"
// writable: false

然而,我深刻怀疑在 safari9 当中,name 属性是 uncofigurable 的。由于没有测试机,所以直接将 name 属性改成 compName,重新打包交给测试验证!

又出问题

交给测试验证后,终端看日志出现了新的报错:"Unhandled Promise Rejection: NotSupportedError (DOM Exception 9)"

image.png

仔细观察错误堆栈,发现问题出现在源码 initDom 函数的 createContextualFragment 位置处。我们贴出此处的代码:

const frag = this.adEl = document.createRange().createContextualFragment(renderedHtml).firstElementChild

此处代码的功能是基于 artTemplate 渲染出来的dom字符串生成一个原生dom节点,这里的思路是借助了 Range 类型的 createContextualFragment 方法。其中 Range 接口表示一个包含节点与文本节点的一部分的文档片段,通过 createContextualFragment 即可把一段html内容转换为 DocumentFragment 文档片段。

为什么不用 document.createDocumentFragment来创建文档片段呢?因为我们这里是基于字符串创建dom,而不是直接创建dom。

然而,查阅MDN发现,createContextualFragment 是一个实验性的 API,尽量不要在生产环境使用。事实上我们发现,整个 Range API 在 ios9 都不可用:

image.png

因此,果断换一个实现思路:通过 innerHTML 把dom字符串转换为一个父div的子dom节点,然后通过父div的 firstElementChild 方法把这个dom节点拿出来:

const tmp = document.createElement('div')
tmp.innerHTML = renderedHtml
const frag = this.adEl = tmp.firstElementChild

而firstElementChild的兼容性就好多了:

image.png
至此,问题算解决了。

总结

不同版本的浏览器的确会有很多细节上不同的实现,我们写代码时最好多注意些:

  * 对于已知的差异,做好特性检测和兼容

  * 对于未知的,尽量写代码时防患于未然。例如本文的场景下,就要记得不要采用跟一些保留字冲突的属性名,很明显:假如基础知识更扎实一些便不会犯下错误。

  * 对于一些较偏门的 API (尤其是从网上抄来的),要最好去查一下规范和 can i use 的支持情况


sheldon
947 声望1.6k 粉丝

echo sheldoncui