有一天上线后大佬反馈了一个问题,他刚发的动态在生成分享卡片的时候,卡片底部的小程序码丢失了,然而其他小伙伴都表示在自己手机上运行正常。事实上大佬也说除了这条动态以外,其它都是正常的。
说明这个 BUG 需要特定的动态卡片 + 特定的设备才能复现,所幸坐我对面的小姐姐手机与大佬是同款,也能复现 BUG,避免了作为社恐的我要去找大佬借手机测试的尴尬。
先交代一下项目背景,这是一个微信小程序项目,其中生成分享卡片功能用到的是一个叫 wxml2canvas[1] 的库,然而该库目前看上去已经「年久失修」,上面所说的 BUG 就是因为这个库,本文分享一下排查该 BUG 的过程、以及如何从 ECMAScript 规范中找到关于 Object.keys()
返回顺序的规范定义,最后介绍一下在 V8 引擎中是如何处理对象属性的。
希望大家在阅读本文后,不会再因为搞不懂 Object.keys()
输出的顺序而犯错导致产生莫名其妙的 BUG。
本文很长,如果你不想阅读整篇文章,可以阅读这段摘要;如果你打算阅读整篇文章,那么你完全可以跳过本段。
如果阅读摘要时未能帮助你理解,可以跳转到对应章节进行详细阅读。
摘要:
wxml2canvas
在绘制的时候,会根据一个叫做 sorted
的对象对它的 keys 进行遍历,该对象的 key 为节点的 top 值,value 为节点元素;问题就是出在这里,该库作者误以为 Object.keys()
总是会按照实际创建属性的顺序返回,然而当 key 为正整数的时候,返回顺序就不符合原本的预期了,会出现了绘制顺序错乱,从而导致这个 BUG 的产生。2 . 如何解决这个 BUG
Object.keys()
按照属性实际创建的顺序返回,那只要将所有 key 都强制转换为浮点数就好了。3 . Object.keys()
是按照什么顺序返回值的?
Object.keys()
返回顺序与遍历对象属性时的顺序一样,调用的 [[OwnPropertyKeys]]()
内部方法。4 . V8 内部是如何处理对象属性的?
V8 在存储对象属性时,为了提高访问效率,会分为常规属性(properties) 和 排序属性(elements)
排序属性(elements) ,就是数组索引类型的属性(也就是正整数类型)。
常规属性(properties) ,就是字符串类型的属性(也包括负数、浮点数)。
以上两种属性都会存放在线性结构中,称为快属性。
然而这样每次查询都有一个间接层,会影响效率,所以 V8 引入对象内属性(in-object-properties) 。
V8 会为每一个对象关联一个隐藏类,用于记录该对象的形状,相同形状的对象会共用同一个隐藏类。
当对象添加、删除属性的时候,会创建一个新的对应的隐藏类,并重新关联。
对象内属性会将部分常规属性直接放在对象第一层,所以它访问效率是最高的。
当常规属性的数量少于对象初始化时的属性数量时,常规属性会直接作为对象内属性存放。
虽然快属性访问速度快,但是从线性结构中添加或删除时执行效率会非常低,因此如果属性特别多、或出现添加和删除属性时,就会将常规属性从线性存储改为字典存储,这就是慢属性。
可以看一下这两张图帮助理解:
V8 常规属性和排序属性
V8 对象内属性、快属性和慢属性
图片出处:《图解 Google V8》 —— 极客时间[5]
由于是特定的动态 + 特定的设备才能复现问题,可以很轻易地排除掉网络原因,通过在 wxml2canvas
输出绘制的节点列表,也能看到小程序码相关的节点。
既然 wxml2canvas
已经接受到小程序码的节点,却没有绘制出来,那么问题自然就出在 wxml2canvas
内部,不过已经见怪不怪了,在我加入项目以后就已经多次因为这操蛋的 wxml2canvas
出现各种问题而搞得头皮发麻,有机会一定要替换掉这个库,但由于已经有很多页面在依赖这个库,现在也只能硬着头皮上。
首先怀疑是小程序码节点的坐标位置不太对,通过对比,发现位置相差不大,排除该原因。
然后对比所有节点的绘制顺序,发现了一个不太寻常的点,在复现 BUG 的手机上,绘制小程序码节点的时机是比较靠前的,但由于它在卡片底部,所以在正常情况下,应该是比较靠后才对。
于是通过查看相关代码,果然发现了其中的玄机:
在绘制的时候,通过遍历 sorted
对象,从上往下、从左到右依次绘制,但是通过对比两台手机的 Object.keys()
,发现了它们的输出是不一样的,这时候我就明白怎么回事了。
先来说说这个 sorted
对象,它是一个 key 为节点 top 值,value 为所有相同 top 值(同一行)的元素数组。
下面是生成它的代码:
问题就发生在前面所说的 Object.keys()
这里,我们先来看个 :
const sorted = {}
sorted[300] = {}
sorted[200] = {}
sorted[100] = {}
console.log(Object.keys(sorted)) // 输出什么呢?
复制代码
相信大部分同学都知道答案是:[‘100', '200', '300’]。
如果在有浮点数的情况呢?
const sorted = {}
sorted[300] = {}
sorted[100] = {}
sorted[200] = {}
sorted[50.5] = {}
console.log(Object.keys(sorted)) // 这次又输出什么呢?
复制代码
会不会有同学以为答案是:['50.5', ‘100', '200', '300’] 呢?
但正确的答案应该是:[‘100', '200', '300’,’50.5’]。
所以我合理地猜测 wxml2canvas
的作者就是犯了这样的错误,他可能以为 Object.keys
会根据 key 从小到大的顺序返回,因此满足从上往下绘制的逻辑。但是他却没有考虑浮点数的情况,所以当某个节点 top 值为整数的时候,会比其他 top 值为浮点数的节点更早地绘制,导致绘制后面的节点时覆盖了前面的节点。
于是,当我把代码改成这样后,分享卡片的小程序码就正常绘制出来了:
Object
.keys(sorted)
+ .sort((a, b)=> a - b)
.forEach((top, topIndex) => {
// do something
}
复制代码
OK,搞定收工。
测试小姐姐:慢着!影响到其它地方了。
我一看,果然。于是再次经过对比,发现原来大部分情况下,top 值都会是浮点数,而本次出 BUG 的卡片小程序码只是非常凑巧地为整数,导致绘制顺序不对。
我才发现 wxml2canvas
原本的逻辑是想根据 sorted
创建的顺序来绘制,但是没有考虑 key 为整数的情况。
所以,最后通过这样修改解决问题:
_sortListByTop (list = []) {
let sorted = {};
// 粗略地认为2px相差的元素在同一行
list.forEach((item, index) => {
- let top = item.top;
+ let top = item.top.toFixed(6); // 强制添加小数点,将整数转为浮点数
if (!sorted[top]) {
if (sorted[top - 2]) {
top = top - 2;
}else if (sorted[top - 1]) {
top = top - 1;
} else if (sorted[top + 1]) {
top = top + 1;
} else if (sorted[top + 2]) {
top = top + 2;
} else {
sorted[top] = [];
}
}
sorted[top].push(item);
});
return sorted;
}
复制代码
很显然,是因为 wxml2canvas
作者对 Object.keys()
返回顺序的机制不了解,才导致出现这样的 BUG。
不知道是否也有同学犯过同样的错误,为避免再次出现这样的情况,非常有必要深入、全面地介绍一下 Object.keys()
的执行机制。
所以接下来就跟随我一探究竟吧。
可能会有同学说:Object.keys()
又不是什么新出的 API, Google 一下不就行了,何必大费周章写一篇文章来介绍呢?
的确通过搜索引擎可以很快就能知道 Object.keys()
的返回顺序是怎样的,但是很多都只流于表面,甚至我还见过这样片面的回答:数字排前面,字符串排后面。
所以这次我想试着追本溯源,通过第一手资料来获取信息,轻易相信口口相传得来的信息,都极有可能是片面的、甚至是错误的。
PS:其实不光技术,我们在对待其它不了解的事物都应保持同样的态度。
我们先来看看在 MDN[6] 上关于 Object.keys()
的描述:
Object.keys()
方法会返回一个由一个给定对象的自身可枚举属性组成的数组,数组中属性名的排列顺序和正常循环遍历该对象时返回的顺序一致 。
emmm... 并没有直接告诉我们输出顺序是什么,不过我们可以看看上面的 Polyfill[7] 是怎么写的:
if (!Object.keys) {
Object.keys = (function () {
var hasOwnProperty = Object.prototype.hasOwnProperty,
hasDontEnumBug = !({toString: null}).propertyIsEnumerable('toString'),
dontEnums = [
'toString',
'toLocaleString',
'valueOf',
'hasOwnProperty',
'isPrototypeOf',
'propertyIsEnumerable',
'constructor'
],
dontEnumsLength = dontEnums.length;
return function (obj) {
if (typeof obj !== 'object' && typeof obj !== 'function' || obj === null) throw new TypeError('Object.keys called on non-object');
var result = [];
for (var prop in obj) {
if (hasOwnProperty.call(obj, prop)) result.push(prop);
}
if (hasDontEnumBug) {
for (var i=0; i < dontEnumsLength; i++) {
if (hasOwnProperty.call(obj, dontEnums[i])) result.push(dontEnums[i]);
}
}
return result;
}
})()
};
复制代码
其实就是利用 for...in
来进行遍历,接下来我们可以再看看关于 for...in[8] 的文档,然而里面也没有告诉我们顺序是怎样的。
既然 MDN 上没有,那我们可以直接看 ECMAScript 规范,通常 MDN 上都会附上关于这个 API 的规范链接,我们直接点开最新(Living Standard)的那个,下面是关于 Object.keys 的规范定义[9]:
When the keys function is called with argument O, the following steps are taken:
- Let obj be ? ToObject[10](O).
- Let nameList be ? EnumerableOwnPropertyNames[11](obj, key).
- Return CreateArrayFromList[12](nameList).
对象属性列表是通过 EnumerableOwnPropertyNames
获取的,这是它的规范定义[13]:
The abstract operation EnumerableOwnPropertyNames takes arguments O (an Object) and kind (key, value, or key+value). It performs the following steps when called:
- Let ownKeys be ? O.[OwnPropertyKeys].
- Let properties be a new empty List.
- For each element key of ownKeys, do a. If Type(key) is String, then
b. Else, 1. Let value be ? Get(O, key). 2. If kind is value, append value to properties. 3. Else i. Assert: kind is key+value. ii. Let entry be ! CreateArrayFromList(« key, value »). iii. Append entry to properties.
a . Let desc be ? O.\[GetOwnProperty\][14].
b. If desc is not undefined and desc.[[Enumerable]] is true, then a. If kind is key, append key to properties.
- Return properties.
敲黑板!这里有个细节,请同学们多留意,后面会考。
我们接着探索,OwnPropertyKeys
最终返回的 OrdinaryOwnPropertyKeys
:
The [[OwnPropertyKeys]] internal method of an ordinary object O takes no arguments. It performs the following steps when called:
- Return ! OrdinaryOwnPropertyKeys(O)[15].
重头戏来了,关于 keys 如何排序就在 OrdinaryOwnPropertyKeys
的定义[16]中:
The abstract operation OrdinaryOwnPropertyKeys takes argument O (an Object). It performs the following steps when called:
- Let keys be a new empty List.
- For each own property key P of O such that P is an array index, in ascending numeric index order, do a. Add P as the last element of keys.
- For each own property key P of O such that Type(P) is String and P is not an array index, in ascending chronological order of property creation, do a. Add P as the last element of keys.
- For each own property key P of O such that Type(P) is Symbol, in ascending chronological order of property creation, do a. Add P as the last element of keys.
- Return keys.
到这里,我们已经知道我们想要的答案,这里总结一下:
Symbol
类型索引按属性创建时间以升序的顺序存入这里顺便也纠正一个普遍的误区:有些回答说将所有属性为数字类型的 key 从小到大排序,其实不然,还必须要符合 「合法的数组索引」 ,也即只有正整数才行,负数或者浮点数,一律当做字符串处理。
PS:严格来说对象属性没有数字类型的,无论是数字还是字符串,都会被当做字符串来处理。
我们结合上面的规范,来思考一下下面这段代码会输出什么:
if (!Object.keys) {
Object.keys = (function () {
var hasOwnProperty = Object.prototype.hasOwnProperty,
hasDontEnumBug = !({toString: null}).propertyIsEnumerable('toString'),
dontEnums = [
'toString',
'toLocaleString',
'valueOf',
'hasOwnProperty',
'isPrototypeOf',
'propertyIsEnumerable',
'constructor'
],
dontEnumsLength = dontEnums.length;
return function (obj) {
if (typeof obj !== 'object' && typeof obj !== 'function' || obj === null) throw new TypeError('Object.keys called on non-object');
var result = [];
for (var prop in obj) {
if (hasOwnProperty.call(obj, prop)) result.push(prop);
}
if (hasDontEnumBug) {
for (var i=0; i < dontEnumsLength; i++) {
if (hasOwnProperty.call(obj, dontEnums[i])) result.push(dontEnums[i]);
}
}
return result;
}
})()
};
复制代码
请认真思考后,在这里核对你的答案是否正确:
查看结果
['1', '2', '-1', '1.1', 'c', 'b', 'a', 'd']
复制代码
是否与你想象的一致?你可能会奇怪为什么没有 Symbol
类型。
还记得前面敲黑板让同学们留意的地方吗,因为在 EnumerableOwnPropertyNames
的规范中规定了返回值只应包含字符串属性(上面说了数字其实也是字符串)。
所以 Symbol 属性是不会被返回的,可以看 MDN[17] 上关于 Object.getOwnPropertyNames()
的描述。
如果要返回 Symbol 属性可以用 Object.getOwnPropertySymbols()[18]。
看完 ECMAScript 的规范定义,相信你不会再搞错 Object.keys()
的输出顺序了。但是你好奇 V8 是如何处理对象属性的吗,下一节我们就来讲讲。
在 V8 的官方博客上有一篇文章《Fast properties in V8》[19](中译版[20]),非常详细地向我们解释了 V8 内部如何处理 JavaScript 的对象属性,强烈推荐阅读。
另外再推荐一下极客时间上的课程《图解 Google V8[21]》(毕竟本文借用了里面的图片,怎么好意思不推荐)。
本节内容主要参考这两个地方,下面我们来总结一下。
首先,V8 为了提高对象属性的访问效率,将属性分为两种类型:
所有的排序属性都会存放在一个线性结构中,线性结构的特点就是支持通过索引随机访问,所以能加快访问速度,对于存放在线性结构的属性都称为快属性。
常规属性也会存放在另一个线性结构中,可以看下面这张图帮助理解:
img
V8 排序属性和常规属性
但是常规属性还需要做一些额外的处理,这里我们要先介绍一下什么是隐藏类。
由于 JavaScript 在运行时是可以修改对象属性的,所以在查询的时候会比较慢,可以看回上面那张图,每次访问一个属性的时候都需要经过多一层的访问,而像 C++ 这类静态语言在声明对象之前需要定义这个对象的结构(形状),经过编译后每个对象的形状都是固定的,所以在访问的时候由于知道了属性的偏移量,自然就会比较快。
V8 采用的思路就是将这种机制应用在 JavaScript 对象中,所以引入了隐藏类的机制,你可以简单的理解隐藏类就是描述这个对象的形状、包括每个属性对应的位置,这样查询的时候就会快很多。
关于隐藏类还有几点要补充:
解释完隐藏类,我们再回头来讲讲常规属性,通过上面那张图我们很容易发现一个问题,那就是每次访问一个属性的时候,都需要经过一个间接层才能访问,这无疑降低了访问效率,为了解决这个问题,V8 又引入了一个叫做对象内属性,顾名思义,它会将某些属性直接存放在对象的第一层里,它的访问是最快的,如下图所示:
V8 对象内属性
但要注意,对象内属性只存放常规属性,排序属性依旧不变。而且需要常规属性的数量小于某个数量的时候才会直接存放对象内属性,那这个数量是多少呢?
答案是取决于对象初始化时的大小。
PS:有些文章说是少于 10 个属性时才会存放对象内属性,别被误导了。
除了对象内属性、快属性以外,还有一个慢属性。
为什么会有慢属性呢?快属性虽然访问很快,但是如果要从对象中添加或删除大量属性,则可能会产生大量时间和内存开销来维护隐藏类,所以在属性过多或者反复添加、删除属性时会将常规属性的存储方式从线性结构变成字典,也就是降低到慢属性,而由于慢属性的信息不会再存放在隐藏类中,所以它的访问会比快属性要慢,但是可以高效地添加和删除属性。可以通过下图帮助理解:
img
V8 慢属性
写到这里,我觉得自己对 V8 的快属性、慢属性这些知识已经非常了解,简直要牛逼到上天了。
但当我看到这段代码的时候:
function toFastProperties(obj) {
/*jshint -W027*/
function f() {}
f.prototype = obj;
ASSERT("%HasFastProperties", true, obj);
return f;
eval(obj);
}
复制代码
我的心情是这样的:
关于这段代码是如何能让 V8 使用对象快属性的可以看这篇文章:开启 V8 对象属性的“fast”模式[23]。
另外也可以看一下这段代码:to-fast-properties/index.js[24]。
当在开发时遇到一个简单的错误,通常可以很快地利用搜索引擎解决问题,但如果只是面向 Google 编程,可能在技术上很难会有进步,所以我们不光要能解决问题,还要理解这个产生问题的背后的原因到底是什么,也就是知其然更知其所以然。
真的非常建议每个 JavaScript 开发者都应该去了解一些关于 V8 或其它 JavaScript 引擎的知识,无论你是通过什么途径(真的没有打广告),这样能保证我们在编写 JavaScript 代码时出现问题可以更加地得心应手。
最后,本文篇幅有限,部分细节难免会有遗漏,非常建议有兴趣深入了解的同学可以延伸阅读下面的列表。
感谢阅读。
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/DPnS_9VfWmwTQt4Okg9ZOQ
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。