V8 是谷歌用于编译 JavaScript 的引擎,Firefox 同样也有一个,叫 SpiderMonkey,它和 V8 有一些不同,但总体颇为相似。我们将在本篇文章中讨论 V8。
V8 引擎的一些基础点:
当我们把压缩、混淆以及做了各种处理的 JavaScript 放到 V8 引擎中解析时,到底发生了些什么?
下图阐述了整个流程,接下来我们会对其中的每个步骤进行详细说明:
在本篇文章中,我们将探讨 JavaScript 代码是如何被解析的,以及如何最大程度的优化 JavaScript 的编译效率。V8 里的优化编译器(又名 Turbofan)拿到 JavaScript 代码之后,会将其转化成高效率的机器码,因此,我们能向其输入越多的代码,我们的应用就会越快。附注一点,Chrome 里的解释器称作 Ignition。
整个过程中的第一步是解析 JavaScript。首先探讨什么是解析。
解析有两个阶段:
哪一种方式更好则需要根据实际情况来决定。
下面来看一段代码。
// 变量声明会被立即解析
const a = 1;
const b = 2;
// 目前不需要的暂时不解析
function add(a, b) {
return a + b;
}
// add 方法被执行到了,所以需要返回解析该方法
add(a, b);
变量声明会被立即解析,函数则会被懒解析,但上述代码里紧接着就执行了 add(a, b),说明 add 方法是马上就需要用到的,所以这种情况下,把 add 函数进行即时解析会更高效。
为了让 add 方法被立即解析,我们可以这样做:
// 变量声明会被立即解析
const a = 1;
const b = 2;
// 函数声明也会被立即解析
var add = (function(a, b) {
return a + b;
})();
// add 方法已经被解析过了,所以这段代码可以立即执行
add(a, b);
这就是大多数模块被创建的过程。那么,立即解析会是高效 JavaScript 应用的最好方式吗?
我们可以用 optimize-js[2] 这个工具对公共库代码进行完全的立即解析处理,比如对比较有名的 lodash 进行处理后,优化效果是很显著的:
必须声明的是,该结果是在 Chrome 浏览器中得到的,其它环境的结果则无法保证:
如果您需要优化应用,必须在所有的环境中进行测试。
另一个解析相关的建议是不要让函数嵌套:
// 糟糕的方式
function sumOfSquares(a, b) {
// 这里将被反复懒解析
function square(num) {
return num * num;
}
return square(a) + square(b);
}
改进后的方式如下:
function square(num) {
return num * num;
}
// 好的方式
function sumOfSquares(a, b) {
return square(a) + square(b);
}
sumOfSquares(a, b);
上述示例中,square 方法只被懒解析了一次。
Chrome 有时候会重写 JavaScript 代码,内联函数即是这样一种情况。
下面是一个代码示例:
const square = (x) => { return x * x }
const callFunction100Times = (func) => {
for(let i = 100; i < 100; i++) {
// func 参数会被调用100次
func(2)
}
}
callFunction100Times(square)
上述代码会被 V8 引擎进行如下优化:
const square = (x) => { return x * x }
const callFunction100Times = (func) => {
for(let i = 100; i < 100; i++) {
// 函数被内联后就不会被持续调用了
return x * x
}
}
callFunction100Times(square)
从上面可以看出,V8 实际上会把 square 函数体内联,以消除调用函数的步骤。这对提高代码的性能是很有用处的。
上述方法存在一点问题,让我们看看下面这段代码:
const square = (x) => { return x * x }
const cube = (x) => { return x * x * x }
const callFunction100Times = (func) => {
for(let i = 100; i < 100; i++) {
// 函数被内联后就不会被持续调用了
func(2)
}
}
callFunction100Times(square)
callFunction100Times(cube)
上面的代码中先会调用 square 函数 100 次,紧接着又会调用 cube 函数 100 次。在调用 cube 之前,我们必须先对 callFunction100Times 进行反优化,因为我们已经内联了 square 函数。在这个例子中,square 函数似乎会比 cube 函数快,但实际上,因为反优化的这个步骤,使得整个执行过程变得更长了。
谈到对象,V8 引擎底层有个类型系统可以区分它们:
对象具有相同的键,这些键没有区别。
// 单态示例
const person = { name: 'John' }
const person2 = { name: 'Paul' }
对象有相似的结构,并存在一些细微的差别。
// 多态示例
const person = { name: 'John' }
const person2 = { name: 'Paul', age: 27 }
这两个对象完全不同,不能比较。
// 复杂态示例
const person = { name: 'John' }
const building = { rooms: ['cafe', 'meeting room A', 'meeting room B'], doors: 27 }
现在我们了解了 V8 里的不同对象,接下来看看 V8 引擎是如何优化对象的。
隐藏类是 V8 区分对象的方式。
让我们将这个过程分解一下。
首先声明一个对象:
const obj = { name: 'John'}
V8 会为这个对象声明一个 classId。
const objClassId = ['name', 1]
然后对象会按如下方式被创建:
const obj = {...objClassId, 'John'}
然后当我们获取对象里的 name 属性时:
obj.name
V8 会做如下查找:
obj[getProp(obj[0], name)]
这就是 V8 创建对象的过程,接下来看看如何优化对象以及重用 classId。
应该尽量将属性放在构造器中声明,以保证对象的结构不变,从而让 V8 可以优化对象。
class Point {
constructor(x,y) {
this.x = x
this.y = y
}
}
const p1 = new Point(11, 22) // 隐藏的 classId 被创建
const p2 = new Point(33, 44)
应该保证属性的顺序不变,如下面这个示例:
const obj = { a: 1 } // 隐藏的 classId 被创建
obj.b = 3
const obj2 = { b: 3 } // 另一个隐藏的 classId 被创建
obj2.a = 1
// 这样会更好
const obj = { a: 1 } // 隐藏的 classId 被创建
obj.b = 3
const obj2 = { a: 1 } // 隐藏类被复用
obj2.b = 3
接下来我们看一下其它的 JavaScript 代码优化建议。
当参数被传进函数中时,保证参数的类型一致是很重要的。如果参数的类型不同,Turbofan 在尝试优化 4 次之后就会放弃。
下面是一个例子:
function add(x,y) {
return x + y
}
add(1,2) // 单态
add('a', 'b') // 多态
add(true, false)
add({},{})
add([],[]) // 复杂态 - 在这个阶段, 已经尝试了4+次, 不会再做优化了
另一个建议是保证在全局作用域下声明类:
// 不要这样做
function createPoint(x, y) {
class Point {
constructor(x,y) {
this.x = x
this.y = y
}
}
// 每次都会重新创建一个 point 对象
return new Point(x,y)
}
function length(point) {
//...
}
希望大家学到了一些 V8 底层的知识,知道如何去编写更优的 JavaScript 代码。
[1]
https://alligator.io/js/v8-engine/: https://alligator.io/js/v8-engine/
[2]
optimize-js: https://nolanlawson.github.io/test-optimize-js/