本文是根据本身的理解翻译而来,若有疑惑可查看原文。javascript
本次暂定翻译三篇文章:java
在 JavaScript 中,值有 8 总类型(当前):Number
,String
,Symbol
,BigInt
,Boolean
,Undefined
,Null
,Object
。node
除了一个明显的例外,这些类型均可以用 typeof
直接查看。react
typeof 42;
// → 'number'
typeof 'foo';
// → 'string'
typeof Symbol('bar');
// → 'symbol'
typeof 42n;
// → 'bigint'
typeof true;
// → 'boolean'
typeof undefined;
// → 'undefined'
typeof null;
// → 'object' 🤔
typeof { x: 42 };
// → 'object'
复制代码
typeof null
返回的是 'object'
,而不是 'null'
,要了解为何,首先要把全部的 JavaScript 类型分红两组:git
照此来讲,null
表示「没有对象」,而 undefined
表示 「没有值」。github
按照这个思路,Brendan Eich 在设计 JavaScript 时,受到 Java 的影响,使得右手边的值执行 typeof
后都返回 object
。所以,即使规范里有 Null
类型,typeof null === 'object'
依然成立。编程
JavaScript 引擎可以在内存中表示任意的 JavaScript 值。然而,值得注意的是,JavaScript 引擎在内存中值类型的表现形式是不一样于 JavaScript 中的类型描述。数组
例如,42,在 JavaScript 中是 number
类型。ide
typeof 42;
// → 'number'
复制代码
在内存中有好多种方式表示整数,例如 42:svg
representation | bits |
---|---|
two’s complement 8-bit | 0010 1010 |
two’s complement 32-bit | 0000 0000 0000 0000 0000 0000 0010 1010 |
packed binary-coded decimal (BCD) | `0100 0010 |
` | |
32-bit IEEE-754 floating-point | `0100 0010 0010 1000 0000 0000 0000 0000 |
` | |
64-bit IEEE-754 floating-point | `0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 |
` |
ECMAScript 将数字标准化为 64 位浮点值,也称为双精度浮点或 Float64。可是,这并不意味着 JavaScript 引擎老是以 Float64 的形式存储数字 —— 这么作会很低效。引擎会选择其它的内部表现形式,除非观测到的行为彻底匹配 Float64。
在真实的 JavaScript 应用中,大多数数字都是合法的 ECMAScript 数组索引,即,属于 0 ~ 2³²−2 范围内的整数。
array[0]; // Smallest possible array index.
array[42];
array[2**32-2]; // Greatest possible array index.
复制代码
JavaScript 引擎会为数字选择最优存储的表达形式以此优化数组元素的访问效率。对于处理器的内存访问操做,数组索引必须是二进制补码的形式。用 Float64 表示数组是一种很费性能的行为,由于每次访问数组元素引擎都须要在 Float64 和二进制补码之间转换。
32 位的二进制补码表达形式对数组操做是颇有用的。一般来讲,处理器执行整型操做比执行浮点型操做要快得多。因此说,下面的例子,第一个循环比第二个循环快两倍。
for (let i = 0; i < 1000; ++i) {
// fast 🚀
}
for (let i = 0.1; i < 1000.1; ++i) {
// slow 🐌
}
复制代码
操做符也是同样。在下面的代码中,模运算符的性能取决于处理的是不是整数。
const remainder = value % divisor;
// Fast 🚀 if `value` and `divisor` are represented as integers,
// slow 🐌 otherwise.
复制代码
若是两个操做数都是整数的形式,CPU 就能够高效地计算出结果。若是除数是 2 的倍数,V8 还会有额外的捷径。对于值是浮点型的形式,计算过程会变得复杂耗时。
由于整型操做的执行速度一般比浮点型要快不少,因此,引擎就应该使用二进制补码来表示全部的整型和整型操做的结果。遗憾的是,那是有悖于 ECMAScript 规范的!ECMAScript 采用了 Float64,某些整数运算实际上产生的是浮点型。在下面这种状况下,对于可以产生正确的结果很重要。
// Float64 has a safe integer range of 53 bits. Beyond that range,
// you must lose precision.
2**53 === 2**53+1;
// → true
// Float64 supports negative zeros, so -1 * 0 must be -0, but
// there’s no way to represent negative zero in two’s complement.
-1*0 === -0;
// → true
// Float64 has infinities which can be produced through division
// by zero.
1/0 === Infinity;
// → true
-1/0 === -Infinity;
// → true
// Float64 also has NaNs.
0/0 === NaN;
复制代码
左边的值都是整型,而右边的倒是浮点型。以上的操做在使用 32 位二进制补码的形式是无法正确执行的。JavaScript 引擎必须确保整型操做被合理地处理以生成想要的 Float64 结果。
对于在 31 位有符号整数范围内的小整数,V8 有特殊的表示形式,称为 Smi。对于非 Smi 的数值会被表示为 HeapObject,它是内存中某些实体的地址。咱们使用一种特殊的 HeapObject,即所谓的 HeapNumber,来表示超出 Smi 范围的数字。
-Infinity // HeapNumber
-(2**30)-1 // HeapNumber
-(2**30) // Smi
-42 // Smi
-0 // HeapNumber
0 // Smi
4.2 // HeapNumber
42 // Smi
2**30-1 // Smi
2**30 // HeapNumber
Infinity // HeapNumber
NaN // HeapNumber
复制代码
如上所示,某些数字被表示为 Smi,其它数字被表示为 HeapNumber。V8 对 Smi 专门优化,由于在真实的 JavaScript 编程中,小的整数是很是广泛的。Smi 不必在内存中分配专用的实体,并且它本能够快速地整型操做。
有如下对象:
const o = {
x: 42, // Smi
y: 4.2, // HeapNumber
};
复制代码
x 的值 42 被编程为 Smi,所以它被存储在对象里。另外一方面值 4.2 须要一个独立的实例(空间)来保存这个值,而且这个对象会指向这个实体。
运行如下 JavaScript 代码片断:
o.x += 10;
// → o.x is now 52
o.y += 1;
// → o.y is now 5.2
复制代码
这种状况下,x 的值能够就地更新,由于新的值 52 也在 Smi 的范围内。
然而,新的值 y=5.2
不在 Smi 范围内且不一样于以前的值 4.2,所以 V8 为 y 从新分配了新的 HeapNumber 实体。
HeapNumber 是不可变的,它使得某些优化成为可能。例如,咱们把 y 赋值给 x:
o.x = o.y;
// → o.x is now 5.2
复制代码
咱们只须要把它链接到同一个 HeapNumber 上而不是从新分配一个新的实体(空间)。
HeapNumber 不可变也存在缺点,若是更新的值常常不在 Smi 的范围内,它就会变慢,例以下面的例子:
// Create a `HeapNumber` instance.
const o = { x: 0.1 };
for (let i = 0; i < 5; ++i) {
// Create an additional `HeapNumber` instance.
o.x += 1;
}
复制代码
第一行建立了一个 HeapNumber 实例,其初始值为 0.1。在循环体中值从 1.1,2.1,3.1,4.2 变到 5.1,一共建立了 6 个 HeapNumber 实例,其中 5 个会在循环结束后变没有任何用处。
为了不这种状况,做为优化,V8 提供了就地更新非 Smi 数值的方法。当一个字段对应着非 Smi 的数值,V8 会在 shape 上将这个字段标记为 Double,并分配一个保存 Float64 的 MutableHeapNumber 实体。
当字段里的值发生变化时,V8 没必要分配一个新的 HeapNumber,而是在 MutableHeapNumber 实体中就地更新。
然而,须要注意的是,MutableHeapNumber 中的值是能够改变的,因此值不该该传来传去的。
例如,你把 o.x
赋值给变量 y,你不但愿 y 会随着 o.x
的改变而改变!因此在给 y 赋值前,必须将 o.x
的值从新包装成 HeapNumber。
对于浮点型,V8 已经默默地包装了一下。可是,对于小的整数也采用和 MutableHeapNumber 相同的方法,就会显得浪费,由于 Smi 本就是一种更高效的表现形式。
const object = { x: 1 };
// → no “boxing” for `x` in object
object.x += 1;
// → update the value of `x` inside object
复制代码
为了不低效率,咱们会在 shape 上为小整数对应的字段上标记 Smi,而且会原地更新数值,只要这个数值在 Smi 范围内。
若是一个字段里包含的值在 Smi 范围内,以后又不属于 Smi 范围,这中间发生了什么?现有两个对象,它们的 x 属性值都是 Smi 表示形式。
const a = { x: 1 };
const b = { x: 2 };
// → objects have `x` as `Smi` field now
b.x = 0.2;
// → `b.x` is now represented as a `Double`
y = a.x;
复制代码
这两个对象都指向同一个 shape,x 属性特性 Representation 被标记为 Smi:
当 b.x
变成 Double 形式,V8 会建立一个新的 shape,属性 x 的 Representation 被标记为 Double 且指向以前的空 shape。V8 也会为属性 x 分配一个 MutableHeapNumber 实体用来保存值 0.2。而后让对象 b 指向新建立的 shape 而且内部偏移量为 0 的位置指向刚分配的 MutableHeapNumber 实体。最后,咱们把旧的 shape 标记为废弃的,并断开与过渡树(transition tree)的连接。这就完成了从空 shape 到新 shape 的过渡。
咱们不能同时彻底删除旧 shape,由于对象 a 还在使用,并且短期找到全部连接到旧 shape 的对象并更新它们,对 V8 来讲是笔很大的开销。相反,V8 不急着处理:只有在改变对象 a 的时候才开始迁移到新的 shape。最终,标记为废弃的 shape 会慢慢淡出视野并被垃圾回收机制抹除。
更棘手的问题是,若是对象上属性特性 Representation 发生变化的属性不是 shape 链上的最后一个,又会发生什么呢?
从产生分支的 shape 开始,咱们为属性 y 建立了一个新的过渡链且 y 被标记为 Double。咱们在使用新的过渡链时,也就意味着旧的过渡链将被废弃。在最后一步,咱们把实例 o 迁移到新的 shape 并用 MutableHeapNumber 保存 y 的值。就这样,新对象再也不使用老的那一套,一旦旧的 shape 上的连接都被移除掉,旧 shape 也会从过渡树上消失。
Object.preventExtensions()
防止将新属性添加到对象中。若是你这么作了,它将会抛异常。(若是是在非严格模式下,它不会抛异常而是默认什么都不作。)
const object = { x: 1 };
Object.preventExtensions(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
复制代码
Object.seal
与 Object.preventExtensions
类似,可是它会把全部的属性标记为不可配置,这就意味着你不能删除它们,或改变他们的可枚举性,可配置性,可写性。
const object = { x: 1 };
Object.seal(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
delete object.x;
// TypeError: Cannot delete property x
复制代码
Object.freeze
与 Object.seal
类似,可是它将全部属性标记为不可写以防止属性值被修改。
const object = { x: 1 };
Object.freeze(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
delete object.x;
// TypeError: Cannot delete property x
object.x = 3;
// TypeError: Cannot assign to read-only property x
复制代码
让咱们来思考一个具体的例子,有两个都只有属性 y 的对象,并阻止第二个对象有任何的扩展。
const a = { x: 1 };
const b = { x: 2 };
Object.preventExtensions(b);
复制代码
就如咱们所知的,从空的 shape 过渡到一个有属性 x (被标记为 Smi)的新 shape 上。当咱们阻止 b 的扩展时,咱们会过渡到标记为不可扩展的新 shape 上。这个新 shape 没有任何属性,仅仅做为一个标识。
注意,咱们不能就地更新有 x 的 shape,由于对象 a 依然是可扩展的。
让咱们用以上学到的知识来解析下 the recent React issue #14365。简单重现这个 bug:
const o = { x: 1, y: 2 };
Object.preventExtensions(o);
o.y = 0.2;
复制代码
有一个拥有两个字段的对象,并且它们的属性特性 Representation 被标记为 Smi。咱们阻止对象的进一步扩展,但最终咱们仍是强制改变第二字段的属性特性 Representation 的值(Double)。
就如以前学到的,大体流程以下:
每一个属性的特性 Representation 都被标记为 Smi,并最终过渡到被标记为不可扩展的 shape 上。
咱们须要将 y 的属性特性 Representation 标记为 Double,这意味着咱们须要从引入 y 属性以前的 shape 开始。在这个例子中,就是引入 x 属性的那个 shape。可是, V8 会很困惑,由于当前的 shape 是不可扩展的,而找到的 shape 倒是可扩展的。V8 不知道怎么去处理这个过渡树。所以,V8 再也不试图搞清楚这些关系,而是建立了一个独立的 shape,这个 shape 和先前的过渡树没有任何关联,并且也不被任何其它对象共享。能够把它看成孤立的 shape:
你能够想象,若是有不少对象的话,这样会变得很糟糕,由于整个 shape 系统已经失去价值。
在 React 的案例中,当开始分析数据时,FiberNode 上的一些字段须要记录时间戳。
class FiberNode {
constructor() {
this.actualStartTime = 0;
Object.preventExtensions(this);
}
}
const node1 = new FiberNode();
const node2 = new FiberNode();
复制代码
这些字段(好比,actualStartTime)初始化值是 0 或 -1,所以属性特性 Representation 为 Smi。可是,以后由 performance.now()
生成的浮点型数值被保存在这些字段中,所以属性特性 Representation 被标记为 Double。除此以外,React 还阻止 FiberNode 实例扩展属性。
刚开始的情况以下:
如预期,两个实例共享着 shape 树。可是以后,一旦你存储了真实的时间戳,V8 就会无从下手:
V8 前后给 node1,node2 分别分配了独立的 shape,且它们之间没有任何关联。真实中的 React 应用有着数万个这样的 FiberNode。你能够想象,这种状况将会严重影响到 V8 的性能。
幸运的是,这个问题在 V8 v7.4 中解决了。研发人员找到了改变属性特性 Representation 的方法,V8 终于知道它该怎么作了:
两个 FiberNode 实例指向不可扩展的 shape,shape 中的 actualStartTime
被标记为 Smi。当 node1.actualStartTime
被分配新的值时,将会生成一条新的过渡链,并且以前的过渡链会被标记为废弃的。
能够注意到,如今的过渡链能够正确的过渡转移。
当 node2.actualStartTime
也被从新分配时,全部的连接都指向了新的 shape,过渡树中废弃的部分将会被垃圾回收机制清除。
React 团队将 FiberNode 全部关于时间的字段都改为了 Double 形式从而缓解这个问题。
class FiberNode {
constructor() {
// Force `Double` representation from the start.
this.actualStartTime = Number.NaN;
// Later, you can still initialize to the value you want:
this.actualStartTime = 0;
Object.preventExtensions(this);
}
}
const node1 = new FiberNode();
const node2 = new FiberNode();
复制代码
由 React 具体的 Bug 引出了 V8 特有的问题,一般来讲,开发者没必要对某个版本的 JavaScript 引擎作优化。不过,当时事情向很差的方向发展时,咱们也不会一筹莫展。
要知道 JavaScript 引擎在幕后作了不少事,尽量的不要去混合类型。例如,给一个数字字段初始化为 null,这样作的话会使得一些优化化为泡影,并且可读性下降。
// Don’t do this!
class Point {
x = null;
y = null;
}
const p = new Point();
p.x = 0.1;
p.y = 402;
复制代码
换句话说,写可读性代码,性能天然会紧跟其后!
本文覆盖了一下几点:
基于以上的知识,咱们可使用一些 JavaScript 编程技巧来提高性能: