在协作开发中,经常会遇到一个问题,自己定义的对象可能在未知的情况下,在别的地方被他人拓展或者删除了一些属性,导致出现不可控的问题。虽然可以依靠团队内的约定和开发规则来进行约束,但是还是存在风险。所以如果我们希望一个对象不被随意重写,就可以用到Object.preventExtensions ,Object.seal ,Object.freeze这些方法了。
简单介绍一下这几个方法
Object.preventExtension:让一个对象变的不可扩展无法添加新的属性,而且不可逆
Object.seal:等于在阻止添加新属性的基础上,让所有现有属性标记为不可配置,类似把所有属性的configurable都设为了false
Object.freeze:让一个对象被冻结,无法添加、删除已有属性,也不能修改该对象已有属性的可枚举性、可配置性、可写性,以及也不能修改已有属性的值。
一个例子
class FiberNode {
constructor() {
this.actualStartTime = 0;
Object.preventExtensions(this);
}
}
const node1 = new FiberNode();
const node2 = new FiberNode();
像上面这样一段代码,可以让FiberNode的实例对象不可被扩展。但事实上在某些情况下Chrome浏览器环境中,这样可能会引发严重的性能问题。
react的一个issue就是关于这个问题的(https://github.com/facebook/react/issues/14365)
我们知道react会创建很多FiberNode的实例,而actualStartTime会存储 performance.now()返回的一个精确到毫秒的 DOMHighResTimeStamp,例如424432.32132。这看上去似乎没有什么问题,那么到底是为什么会引发性能问题呢?让我们来一步步分析一下。
Hidden Classes 隐藏类
我们简化一下node1和node2,大概可以得到
const node1 = { actualStartTime: 0 };
const node2 = { actualStartTime: 0 };
像这样有相同属性,且属性的顺序,且属性的configurable、enumerable、writable这些都一致(值可以不同)的情况下,在v8里就称他们有相同的形状(Shape)
为什么要有Shape呢?因为很大部分对象的属性值这些configurable、enumerable、writable的信息都是相同的,然后也会出现像上面node1和node2这样属性名也相同的对象,如果每个对象都各自存储一份,就很浪费内存了,所以会把Shape分开存储。多个有相同Shape的对象就可以复用一个了。
![ccd09753c246ce54147501c601e270c0.png](https://i-blog.csdnimg.cn/blog_migrate/5dc9fe7372732be8ee7785cc172460df.png)
但由于js中对象可以动态的添加属性,那在添加了属性之后Shape又会产生什么变化呢?
这就涉及到了Transition 链。
Transition 链
当一个对象在后面又添加了属性时,就会如下图,继续创建一个Shape,包含引入的新属性。
之前的Shape会指向这个新的Shape,如果继续添加新属性则重复这个操作,形成一个类似双向链表的结构,称作Transition 链。
而对象会指向那个最新的Shape。从末端遍历回起始,则可以得到整个对象的属性信息或者之前的某个属性的信息(当然这样如果想获取Transition 链中接近起始位置的属性效率不高,v8会通过ShapeTable等方式进行优化,我们这里暂时不细说)。
![076fd37147443f4dc55d737464e5ce3b.png](https://i-blog.csdnimg.cn/blog_migrate/b31c0c728414713f6c32f57619aa8e5b.jpeg)
而如果开始有多个对象指向同一个Shape,其中一个对象添加了属性则如下图:
![03e71bb54f8214c4ab2013c46882eb0c.png](https://i-blog.csdnimg.cn/blog_migrate/fece161e017a1914522b63890be66d5f.jpeg)
当原本指向同一个Shape的对象,分别添加了不同的属性则不再是类似链表结构了,而是形成了一个类似树的结构。
![c1422997cf4ec6a7f9b431e48a58d256.png](https://i-blog.csdnimg.cn/blog_migrate/a7df80e7de6fea40165c7a04219e1863.jpeg)
我们再回到最初提到的Object.preventExtensions,在对一个对象进行了Object.preventExtensions之后,Transition 链又会变成怎么样呢?
![c3f87cafbef5a2acfc6e3990f0079600.png](https://i-blog.csdnimg.cn/blog_migrate/6d684cb91323560f6ea6faaf397abcda.jpeg)
如图上所示,preventExtensions之后生成了一个新的Shape指向之前的Shape,但这个新的Shape不会关联新的属性信息,只是作为一个标记存在。
结合之前的FiberNode的代码来看,你可能会觉得完全没没问题啊,大家都以为是会变成如下图一样。
![98480c837c5657d248611a337b190d96.png](https://i-blog.csdnimg.cn/blog_migrate/f341697839fbcd0b4e2d315f02357be6.jpeg)
但事实上,如果b.x = 4的话,就是如图这样了,但b.x却是等于了一个小数。这有什么问题呢,我们继续看 。
number类型
我们都知道无论是9还是9.9还是-9在js中都是number类型,而在ECMAScript 标准中也规定了number是作为64-bit(双精度)浮点值来表示。但事实上js引擎为了性能的优化,在内部会用其他的方式来表示。
SMI 与 HeapNumber
例如31位有符号整数范围内的小整数,在v8引擎里会存储为SMI(Small Integer).而这个范围以外的number就会以堆对象(HeapObject)或者称为HeapNumber的方式来存储。v8对SMI有进行特殊的优化。
在v8中会将js的值(无论是对象、数字还是字符串)在v8堆上进行分配,然后就可以得到指向这个对象的一个指针。为了进行优化,v8使用了一项指针标记(pointer tagging)的技术,通过这个方式,存储一个smi就不需要额外的分配空间了,而值被存储到了指针本身。这样节约了内存空间,也提升了访问速度。
但仅仅是SMI和HeapNumber的性能差别,一般还不至于引发我们上面提到的问题。别着急我们继续往下看。
MutableHeapNumber
let obj = {
x: 2,
y: 2.2
}
我们来看这么一个对象,从上文可以了解到,因为2是31位有符号整数范围内的小整数,所以可以以SMI的形式来存储。而2.2不属于这个范围,所以以HeapNumber的形式来存储,所以会有一个单独的内存实体来存储数值2.2,而obj.y指向这个实体。
当我们继续进行obj.x += 1的操作,因为加一后等于3,也属于smi范围,所以可以原地更新。但是y就不一样了,如果是obj.y += 1,又会重新生成一个新的HeapNumber,然后让obj.y指向这个新实体。
然鹅试想一下,如果不停的给y加一加一,按照上面说的方式需要生成多少个新的HeapNumber 即使有垃圾回收,也是一个巨大的浪费。
所以就出现了MutableHeapNumber,类似HeapNumber会分配空间存储它的值,但是允许直接更改它的值,而不需要每次都创建一个新的实体。
不过这样又引发了一个问题,当像这样
let y = obj.y;
obj.y += 1;
我们平时直观的感觉,好像理所应当因为 obj.y = 2.2,值是一个number又不是object类型,y是保持obj.y之前的值(2.2),不受后面obj.y的改变的影响。
但在v8引擎内部,如果obj.y采用了MutableHeapNumber,那在赋值给y的时候事实上是将MutableHeapNumber的引用给了y,那后续再发生修改,y再去取值也会改变了。所以为了保证y的值还是2.2,v8又做了一个re-boxed的操作,将2.2又另外装箱成了一个HeapNumber。通过这种方式来保证y的值不被改变。
说了这么多,怎么感觉离问题越来越远了 那让我们来结合一下上面提到的知识分析一下问题。
整理一下
const node1 = { actualStartTime: 0 };
const node2 = { actualStartTime: 0 };
还是这个简化后的代码,它们的Shape是什么样的呢?actualStartTime的初始值为0,是31位有符号整数范围内的小整数。number是SMI还是Double类型也和writable、configurable、enumerable一样会作为属性信息。所以如图:
![795c78881406331eac1cc612a5027593.png](https://i-blog.csdnimg.cn/blog_migrate/b1e83e665237485a335fe5cdb770a196.jpeg)
但如果属性值分别为SMI和Double类型
const node1 = { actualStartTime: 0 };
const node2 = { actualStartTime: 0.2 };
比如这样,就不能合并成一个Shape.
而如果是
const node1 = { actualStartTime: 0 };
node.actualStartTime = 0.3;
旧的Shape会失效,如果没有其他对象指向它则之后会被垃圾回收,node1指向新的Shape.
![61890dda5923296a86167455256cba11.png](https://i-blog.csdnimg.cn/blog_migrate/532a409c26337c3d96480933298af3db.jpeg)
回到最初的起点 ♀️
class FiberNode {
constructor() {
this.actualStartTime = 0;
Object.preventExtensions(this);
}
}
const node1 = new FiberNode();
const node2 = new FiberNode();
而回到最初的代码,在Object.preventExtensions后大概变成这样
![a36ca607e9e3b923f91f20922e51ad9f.png](https://i-blog.csdnimg.cn/blog_migrate/61a9e3d5749aa0402af7a7caa7e04c8b.jpeg)
到目前为止还没有什么问题,但当 node1.actualStartTime = performance.now() 的时候,
从表面来看Object.preventExtensions不允许对象添加新的属性,但是可以修改actualStartTime的值。
但在引擎内部,它就迷惑了。
![82e5e2f1a18ca019e2f7775d083b135f.png](https://i-blog.csdnimg.cn/blog_migrate/93d841c14b858cd6991eb75ea91d1ba5.jpeg)
明明说好了不可扩展,这里怎么又要扩展出新的Shape了❓于是v8不知道要如何处理这种情况,于是就新建了一个独立的Shape,如图:
![0ce8a577573feb55cf8437c299b08cf7.png](https://i-blog.csdnimg.cn/blog_migrate/2d00c4d1fb00c303a844dd7a2cad11b7.jpeg)
当fiberNode越来越多,这样的独立的Shape也愈来愈多,最终造成了性能问题。
想要解决这个问题,可以在初始化的时候就让actualStartTime等于一个double类型的值。
![bbf165ed4c033586e14c8559fc869b03.png](https://i-blog.csdnimg.cn/blog_migrate/4a75d1380263da44c32f903c0b16122e.jpeg)
不过v8在7.4版本里也修复了这个问题,对这种情况进行了正确的处理。
![bfc7c018ede32be5da66d8356f5f6034.png](https://i-blog.csdnimg.cn/blog_migrate/95bc7557aa2f343ce975f6fd289ae910.jpeg)
不会都再形成一个独立的Shape。而是如图一样能形成正确的Transition 链,没有被引用的Shape也能正常的进行GC,也不会引发性能问题。
![3d476b88dfa2246a985128f89f9134bb.png](https://i-blog.csdnimg.cn/blog_migrate/c775185a7ec591d85b7ee2f79a4eb73d.jpeg)
虽然这个问题最后被官方解决了,不过在这个过程中,也能了解到v8引擎对number和对象的以下幕后的处理。
写的比较仓促如果有问题欢迎指正和补充
参考资料
[1] https://v8.dev/blog/react-cliff
[2] JavaScript engine fundamentals: Shapes and Inline Caches
[3] https://v8.dev/blog/pointer-compression
[4] Fast propertiesin V8
[5] Elements kindsin V8