如何让你的Javascript 代码更快之内联缓存
几个月前在网上看到介绍angular 为什么这边快的演讲。演讲里面提到了三个原因,分别是内联缓存,布隆过滤器和angular 编译器。这里我们挑选其中的一条内联缓存,进行介绍一下。
背景
实际上,除了这个演讲中介绍的angular,rxjs也提到了这个概念。这篇文章 介绍了该特性对redux 的影响。这篇文章 介绍了对react 类式组件的影响。可以看出这个特性对于开源库的影响。
静态语言与动态语言
public class Dog {
private String name;
private int age;
public Dog(String name, int age) {
this.name = name;
this.age = age;
}
}
Dog dog= new Dog("大狗", 12);
dog.name;
dog.age;
const dog = { name: '大狗', age: 12 };
dog.name;
dog.age;
上面的第一段代码是Java,创建了一个对象,然后访问name
和age
属性。下面是对应的JavaScript 代码。Java 是静态语言,对象创建以后不会增加或者删减对象属性。因此每一个属性的偏移量在编译阶段就可以确定。但是JavaScript 是动态语言,在运行时可以通过dog.color = 'yellow'
动态增加属性。所以在运行期间,JavaScript 访问对象属性的速度一定比Java 这类静态语言慢。
隐藏类
为了提高属性的访问速度,JavaScript 引擎借鉴静态语言,提出了隐藏类的概念。实际上,隐藏类是学术上的叫法,不同的JavaScript 引擎有自己的叫法,V8 称其为Maps
,Chakra称为Types
,JavaScriptCore称为Structures
,SpiderMonkey称为Shapes
。这里我们采用隐藏类这种叫法。
引入隐藏类以后,对于任意一个JavaScript 对象都有一个隐藏的map
属性,指向该对象的隐藏类。可以通过下面的代码进行查看。
const dog = {name: '大狗', age: 12};
console.log(%DebugPrint(dog));
控制台输入node --allow-natives-syntax index.js
。
隐藏类中存储了一些关于对象属性的信息,其中就包括offset 偏移量。除了offset 还会存储其他的一些信息,因为与这里要讨论的问题关系不大,就没有画出来了。而dog
对象只会存储属性的值,即大狗
和12
。当我们进行dog.name
的属性访问时,会在隐藏类中找到对应的offset 是0,通过offset 就可以快速拿到属性值大狗
了。
为什么要把隐藏类和对象分开存储呢?主要是因为多个对象可以共用一个隐藏类。
上图所示的dog
和dog2
具有相同的结构,就会共用一个隐藏类,通过这种方式可以减少内存浪费。
const dog = {name: '大狗', age: 12};
const dog2 = {name: '小狗', age: 3};
console.log(%HaveSameMap(dog, dog2));
控制台输入node --allow-natives-syntax index.js
,返回true
。
隐藏类链
当我们对一个JavaScript 的属性进行删减的时候,会触发隐藏类的修改。
const dog = {name: '大狗', age: 12};
console.log(%DebugPrint(dog));
dog.color = '黄色';
console.log(%DebugPrint(dog));
DebugPrint: 000003F1AF5CD9C9: [JS_OBJECT_TYPE]
- map: 0x0089dfd35a09 <Map(HOLEY_ELEMENTS)> [FastProperties]
}
DebugPrint: 000003F1AF5CD9C9: [JS_OBJECT_TYPE]
- map: 0x0089dfd3a131 <Map(HOLEY_ELEMENTS)> [FastProperties]
}
输出结果中删除了map 以外的其他信息。
代码const dog = {name: '大狗', age: 12}
初始化dog
时,创建了隐藏类。此时,dog
的隐藏类地址是0x0089dfd35a09
。然后dog.color = '黄色'
添加color
属性,又创建了一个隐藏类,并更新dog
的map
指向,这时隐藏类地址是0x0089dfd3a131
。
图片如下所示:
所以在代码中,一般不要频繁的增加对象属性。因为每一次的操作都会造成隐藏类的修改。应该在初始化的时候,一次性为对象声明所有的属性。
上一小节的最后提到dog
和dog2
共用一个的隐藏类是因为它们的结构一样。这么说其实有点不够准确。下面的代码中,虽然dog
和dog2
的结构一样,属性的顺序也一样。但是它们的隐藏类是不一样的。
const dog = {};
dog.name = '大黄';
dog.age = 12;
const dog2 = { name: '小黄', age: 2 };
console.log(%HaveSameMap(dog, dog2));
输出结果是false
。
这是因为dog
初始化的时候,是一个空对象,对应空的隐藏类。然后添加name
属性,对应的隐藏类也进行创建,并且更新dog
的map
指向。添加age
属性的时候同理。
而dog2
初始化的时候,就直接具有name
和age
属性。从最后的结果上来看,两个对象都具有相同的结构,但是它们各自的隐藏类却不是相同的。
所以如果我们希望对象之间可以共用同一个隐藏类,除了属性的顺序和结构相同以外,还需要保证它们新增属性的顺序也一致。
内联缓存
接下来我们看看,如何利用隐藏类实现函数中对象属性的快速访问。内联缓存会为每一个JavaScript 函数维护一个反馈向量表。这个反馈向量表会记录当前函数调用的一些关键的中间数据。比如下面这样一个函数
function updateNode(o) {
let value = o.x;
......
}
当JavaScript 引擎执行o.x
的时候,会在反馈向量表中分配一个slot
,就是往反馈向量表里面添加一行如下的记录。
这条记录中的
map
就是对象o
的隐藏类地址,offset
就是对应属性x
在对象中的偏移量。
当下次调用updateNode
函数的时候,如果传入的参数具有相同的隐藏类,就可以直接从这条记录里面找出offset
偏移量,减少了查找过程。
但是这里有个要求,就是两次调用updateNode
函数时,传入的对象一定要具有相同的隐藏类。比如下面这样。
updateNode({x: 1, y: 2});
updateNode({x: 3, y: 6});
但是如果第三次这样调用updateNode({y: 9, x: 3, z: 10})
。从前面的内容,我们知道,第三次调用传入的对象的隐藏类与前两次传入对象的隐藏类不相同。于是,反馈向量表的内容,就变为了这样。
这种情况下,JavaScript 查找属性
x
的offset
偏移量时,就要比之前多对比一次,因为现在记录里面有了两个隐藏类。这样的话,速度也就比之前更慢一些。所以如果想要保持最快的速度,就要保证每次传给updateNode
函数的对象都具有相同隐藏类。
反馈向量表中的
state
表示当前隐藏类的个数,当只有一个隐藏类的时候,是Mono
,这种情况下,速度最快。2个到4个隐藏类时是Poly
,即多态。超过4个隐藏类,就是Mega
,表示超态。
总结
实际上,正如文章和文章 中提到的一样,对于一般的代码开发而言,正常使用JavaScript 就可以了。这种细节的优化就交给JavaScript 引擎,不需要特别关注。对于业务开发,有很多其他原因会影响项目性能,解决这些其他原因远比这点优化更加有效。当然对于angular 这种需要压榨出所有JavaScript 性能的库可能就需要关注了。
转载自:https://juejin.cn/post/7142725342308859935