问题出现
快报前方测试传来情报: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基本跟源码一致。
最终,终端同学给出报错日志如下:
报错信息为: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)"
仔细观察错误堆栈,发现问题出现在源码 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 都不可用:
因此,果断换一个实现思路:通过 innerHTML 把dom字符串转换为一个父div的子dom节点,然后通过父div的 firstElementChild 方法把这个dom节点拿出来:
const tmp = document.createElement('div')
tmp.innerHTML = renderedHtml
const frag = this.adEl = tmp.firstElementChild
而firstElementChild的兼容性就好多了:
总结
不同版本的浏览器的确会有很多细节上不同的实现,我们写代码时最好多注意些:
* 对于已知的差异,做好特性检测和兼容
* 对于未知的,尽量写代码时防患于未然。例如本文的场景下,就要记得不要采用跟一些保留字冲突的属性名,很明显:假如基础知识更扎实一些便不会犯下错误。
* 对于一些较偏门的 API (尤其是从网上抄来的),要最好去查一下规范和 can i use 的支持情况