IOS9兼容性问题解决: Attempting to change configurable attribute

问题出现

快报前方测试传来情报: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 中这么两句代码:

1
2
3
_defineProperty(KbArticleCenter, "name", "kb-article-center");

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

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

1
2
3
4
5
class KbArticleCenter {
static name = "kb-article-center";
static instances = [];
// ......... 省略一堆类的成员定义代码
}

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

解决问题

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

1
2
3
4
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 的:

1
2
3
4
5
6
7
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 位置处。我们贴出此处的代码:

1
2
3
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 节点拿出来:

1
2
3
const tmp = document.createElement("div");
tmp.innerHTML = renderedHtml;
const frag = (this.adEl = tmp.firstElementChild);

而 firstElementChild 的兼容性就好多了:

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

总结

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

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

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

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