本章通过介绍V8引擎的编译解析原理、JS的基本执行流程以及一些常用优化方法,让读者对JavaScript执行机制有比较深入地了解,从而编写出更优美、执行效率更高的代码,并且可以更快速地解决开发中的问题。
一、JavaScript代码编译解析流程
如图1.1所示,在V8引擎中,JavaScript编译解析分为以下几个阶段:
1)解析器将源码生成一棵抽象语法树。
2)V8基线编译器Ignition将语法树生成字节码并解释执行字节码。
3)V8优化编译器TurboFan将字节码生成高度优化的机器码。
下面会展开介绍每一个阶段具体是怎么执行的。
图1.1 代码执行流程
从源代码到AST
浏览器有一套自己的AST规则和解析器,解析器将源代码解析为AST。因为解析代码需要时间,所以 JavaScript 引擎会尽可能避免完全解析源代码文件。另一方面,在一次用户访问中,页面中会有很多代码不会被执行到,比如,通过用户交互行为触发的动作。
正因为如此,所有主流浏览器都实现了惰性解析(Lazy Parsing)。解析器不必为每个函数生成 AST(Abstract Syntax tree,抽象语法树),而是可以决定“预解析”(Pre-parsing)或“完全解析”它所遇到的函数。
预解析会检查源代码的语法并抛出语法错误,但不会解析函数中变量的作用域或生成 AST。完全解析则将分析函数体并生成源代码对应的 AST 数据结构。相比正常解析,预解析的速度快了 2 倍。
生成 AST 主要经过两个阶段:分词和语义分析。AST 旨在通过一种结构化的树形数据结构来描述源代码的具体语法组成,常用于语法检查(静态代码分析)、代码混淆、代码优化等。
图1.2 源码到AST
从AST到字节码
基线编译器Ignition负责将AST编译成字节码并解释执行。字节码是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。
V8 引入 JIT(Just In Time,即时编译)技术,通过 Ignition 基线编译器快速生成字节码进行执行。在执行代码之前只需要几微秒就能编译代码,如果是第一次执行的字节码,解释器 Ignition 会逐条解释执行。
和之前的基线编译器 Full-Codegen 相比,Ignition 生成的是体积更小的字节码(Full-Codegen 生成的是机器码)。字节码可以直接被优化编译器 TurboFan 用于生成图(TurboFan 对代码的优化基于图),避免优化编译器在优化代码时需要对 JavaScript 源代码重新进行解析。
从字节码到机器码
编译器TurboFan负责在特定的场景下将字节码编译成更高效的机器码,编译器需要考虑的函数输入类型变化越少,生成的代码就越小、越快。
Ignition 在执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。
众所周知,JavaScript 是弱类型语言。ECMAScript 标准中有大量的多义性和类型判断,因此通过基线编译器生成的代码执行效率低下。
举个例子,+ 运算符的一个操作数就可能是整数、浮点数、字符串、布尔值以及其它的引用类型,更别提它们之间的各种组合(可以感受一下 ECMAScript 标准中对于 + 的定义)。
function addTwo(a, b) {
return a + b;
}
addTwo(2, 3); // 3
addTwo(8.6, 2.2); // 10.8
addTwo("hello ", "world"); // "hello world"
addTwo("true or ", false); // "true or false"
// 还有很多组合...
但这并不意味着 JavaScript 代码没有办法被优化。对于特定的程序逻辑,其接收的参数往往是类型固定的。正因为如此,V8 引入了类型反馈技术。在进行运算的时候,V8 使用类型反馈对所有参数进行动态检查。
简单来说,对于重复执行的代码,如果多次执行都传入类型相同的参数,那么 V8 会假设之后每一次执行的参数类型也是相同的,并对代码进行优化。优化后的代码中会保留基本的类型检查。如果之后的每次执行参数类型未改变,V8 将一直执行优化过的代码。而当之后某一次执行时传入的参数类型发生变化时,V8 将会“撤销”之前的优化操作,这一步称为“去优化”(Deoptimization)。
下面我们稍微修改一下上面的代码,分析其在 V8 中的优化过程。
// example2.js
function addTwo (a, b) {
return a + b;
}
for (let j = 0; j < 100000; j++) {
if (j < 80000) {
addTwo(10, 10);
} else {
addTwo('hello', 'world');
}
}
d8 --trace-opt --trace-deopt example2.js
[marking 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> for optimized recompilation, reason: hot and stable]
[compiling method 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> using TurboFan OSR]
[optimizing 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> - took 5.268, 5.305, 0.023 ms]
[deoptimizing (DEOPT soft): begin 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> (opt #0) @2, FP to SP delta: 96, caller sp: 0x7ffee48218c8]
;;; deoptimize at <example2.js:10:5>, Insufficient type feedback for call
reading input frame => bytecode_offset=80, args=1, height=5, retval=0(#0); inputs:
0: 0x2ecfb2a5f229 ; [fp - 16] 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)>
1: 0x2ecfbcf815c1 ; [fp + 16] 0x2ecfbcf815c1 <JSGlobal Object>
2: 0x2ecfb2a418c9 ; [fp - 80] 0x2ecfb2a418c9 <NativeContext[253]>
3: 0x2ecf2a140d09 ; (literal 4) 0x2ecf2a140d09 <Odd Oddball: optimized_out>
4: 0x000000027100 ; rcx 80000
5: 0x2ecfb2a5f299 ; (literal 6) 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)>
6: 0x2ecfb2a5efd1 ; (literal 7) 0x2ecfb2a5efd1 <String[#5]: hello>
7: 0x2ecfb2a5efe9 ; (literal 8) 0x2ecfb2a5efe9 <String[#5]: world>
8: 0x2ecf2a140d09 ; (literal 4) 0x2ecf2a140d09 <Odd Oddball: optimized_out>
translating interpreted frame => bytecode_offset=80, variable_frame_size=48, frame_size=104
0x7ffee48218c0: [top + 96] <- 0x2ecfbcf815c1 <JSGlobal Object> ; stack parameter (input #1)
-------------------------
0x7ffee48218b8: [top + 88] <- 0x00010bd36b5a ; caller's pc
0x7ffee48218b0: [top + 80] <- 0x7ffee48218d8 ; caller's fp
0x7ffee48218a8: [top + 72] <- 0x2ecfb2a418c9 <NativeContext[253]> ; context (input #2)
0x7ffee48218a0: [top + 64] <- 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> ; function (input #0)
0x7ffee4821898: [top + 56] <- 0x2ecfb2a5f141 <BytecodeArray[99]> ; bytecode array
0x7ffee4821890: [top + 48] <- 0x00000000010a <Smi 133> ; bytecode offset
-------------------------
0x7ffee4821888: [top + 40] <- 0x2ecf2a140d09 <Odd Oddball: optimized_out> ; stack parameter (input #3)
0x7ffee4821880: [top + 32] <- 0x000000027100 <Smi 80000> ; stack parameter (input #4)
0x7ffee4821878: [top + 24] <- 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)> ; stack parameter (input #5)
0x7ffee4821870: [top + 16] <- 0x2ecfb2a5efd1 <String[#5]: hello> ; stack parameter (input #6)
0x7ffee4821868: [top + 8] <- 0x2ecfb2a5efe9 <String[#5]: world> ; stack parameter (input #7)
0x7ffee4821860: [top + 0] <- 0x2ecf2a140d09 <Odd Oddball: optimized_out> ; accumulator (input #8)
[deoptimizing (soft): end 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> @2 => node=80, pc=0x00010bd394e0, caller sp=0x7ffee48218c8, took 0.331 ms]
[marking 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> for optimized recompilation, reason: hot and stable]
[marking 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)> for optimized recompilation, reason: small function]
[compiling method 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)> using TurboFan]
[compiling method 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> using TurboFan OSR]
[optimizing 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> - took 0.161, 0.441, 0.018 ms]
[optimizing 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)> - took 0.096, 0.231, 0.007 ms]
[completed optimizing 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)>]
在这段代码中,我们执行了 100,000 次 + 操作,其中前 80,000 次是两个整数相加,后 20,000 次是两个字符串相加。
通过跟踪 V8 的优化记录,我们可以看到,代码第 10 行(即第 80,001 次执行时)由于参数类型由整数变为字符串,触发了去优化操作。需要注意的是,去优化的开销昂贵,在实际编写函数时要尽量避免触发去优化。
以上是JS代码的底层编译解释流程,那么在过程中,JS代码究竟是怎样执行的呢?这就是下一章要讲的内容。
二、Javascript代码执行流程
在介绍具体的执行流程之前,先将执行过程中的一些概念做下说明:
(1)作用域,可以从中访问变量的“区域”。
(2)词法作用域,在词法阶段的作用域,换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变。
(3)块作用域,由花括号{}创建的范围。
(4)作用域链, 函数可以上升到它的外部环境(词法上)来搜索一个变量,它可以一直向上查找,直到它到达全局作用域。
(5)同步,次执行一件事, “同步”引擎一次只执行一行,JavaScript是同步的。
(6)异步,同时做多个事,JS通过浏览器API模拟异步行为。
(7)事件循环(Event Loop) ,浏览器API完成函数调用的过程,将回调函数推送到回调队列(callback queue),然后当堆栈为空时,它将回调函数推送到调用堆栈。
(8)堆栈,种数据结构,只能将元素推入并弹出顶部元素。想想堆叠一个字形的塔楼; 你不能删除中间块,后进先出。
(9)堆,变量存储在内存中。
(10)调用堆栈 — 函数调用的队列,它实现了堆栈数据类型,这意味着一次可以运行一个函数。调用函数将其推入堆栈并从函数返回将其弹出堆栈。
(11)执行上下文,当函数放入到调用堆栈时由JS创建的环境。
(12)闭包,当在另一个函数内创建一个函数时,它“记住”它在以后调用时创建的环境。
(13)垃圾收集,当内存中的变量被自动删除时,因为它不再使用,引擎要处理掉它。
(14)变量的提升,当变量内存没有赋值时会被提升到全局的顶部并设置为undefined。
(15)this,由JavaScript为每个新的执行上下文自动创建的变量/关键字。
调用堆栈(Call Stack)
先看下面的代码:
var myOtherVar = 10
function a() {
console.log('myVar', myVar)
b()
}
function b() {
console.log('myOtherVar', myOtherVar)
c()
}
function c() {
console.log('Hello world!')
}
a()
var myVar = 5
有几个点需要注意:
●变量声明的位置(一个在上,一个在下)
●函数a调用下面定义的函数b, 函数b调用函数c
当它被执行时你期望发生什么?是否发生错误,因为b在a之后声明或者一切正常?console.log 打印的变量又是怎么样?
以下是打印结果:
"myVar" undefined
"myOtherVar" 10
"Hello world!"
来分解一下上述的执行步骤:
1)变量和函数声明(创建阶段)
第一步是在内存中为所有变量和函数分配空间。但请注意,除了undefined之外,尚未为变量分配值。因此,myVar在被打印时的值是undefined,因为JS引擎从顶部开始逐行执行代码。
函数与变量不一样,函数可以一次声明和初始化,这意味着它们可以在任何地方被调用。
所以以上代码看起来像这样子:
var myOtherVar = undefined
var myVar = undefined
function a() {...}
function b() {...}
function c() {...}
这些都存在于JS创建的全局上下文中,因为它位于全局空间中。
在全局上下文中,JS还添加了:
(1)全局对象(浏览器中是 window 对象,NodeJs 中是 global 对象)。
(2)this 指向全局对象。
2)执行
接下来,JS 引擎会逐行执行代码。myOtherVar = 10在全局上下文中,myOtherVar被赋值为10,已经创建了所有函数,下一步是执行函数 a()。每次调用函数时,都会为该函数创建一个新的上下文(重复步骤1),并将其放入调用堆栈。
function a() {
console.log('myVar', myVar)
b()
}
如下步骤:
(1)创建新的函数上下文。
(2)a 函数里面没有声明变量和函数。
(3)函数内部创建了 this 并指向全局对象(window)。
(4)接着引用了外部变量 myVar,myVar 属于全局作用域的。
(5)接着调用函数 b ,函数b的过程跟 a一样,这里不做分析。如图1.3所示。
图1.3 调用堆栈的执行示意图
(1)创建全局上下文,全局变量和函数。
(2)每个函数的调用,会创建一个上下文,外部环境的引用及 this。
(3)函数执行结束后会从堆栈中弹出,并且它的执行上下文被垃圾收集回收(闭包除外)。
(4)当调用堆栈为空时,它将从事件队列中获取事件。
作用域及作用域链
在前面的示例中,所有内容都是全局作用域的,这意味着我们可以从代码中的任何位置访问它。现在,介绍下私有作用域以及如何定义作用域。
1)函数/词法作用域
考虑如下代码:
function a() {
var myOtherVar = 'inside A'
b()
}
function b() {
var myVar = 'inside B'
console.log('myOtherVar:', myOtherVar)
function c() {
console.log('myVar:', myVar)
}
c()
}
var myOtherVar = 'global otherVar'
var myVar = 'global myVar'
a()
需要注意以下几点:
(1)全局作用域和函数内部都声明了变量。
(2)函数c现在在函数b中声明。
打印结果如下:
myOtherVar: "global otherVar"
myVar: "inside B"
执行步骤:
(1)全局创建和声明 - 创建内存中的所有函数和变量以及全局对象和 this。
(2)执行 - 它逐行读取代码,给变量赋值,并执行函数a。
(3)函数a创建一个新的上下文并被放入堆栈,在上下文中创建变量myOtherVar,然后调用函数b。
(4)函数b 也会创建一个新的上下文,同样也被放入堆栈中。
(5)函数b的上下文中创建了 myVar 变量,并声明函数c。
上面提到每个新上下文会创建的外部引用,外部引用取决于函数在代码中声明的位置。
(1)函数b试图打印myOtherVar,但这个变量并不存在于函数b中,函数b 就会使用它的外部引用上作用域链向上找。由于函数b是全局声明的,而不是在函数a内部声明的,所以它使用全局变量myOtherVar。
(2)函数c执行步骤一样。由于函数c本身没有变量myVar,所以它通过作用域链向上找,也就是函数b,因为myVar是函数b内部声明过。如图1.4所示。
图1.4 执行示意图
请记住,外部引用是单向的,它不是双向关系。例如,函数b不能直接跳到函数c的上下文中并从那里获取变量。
最好将它看作一个只能在一个方向上运行的链(范围链)。
● a -> global
● c -> b -> global
在上面的图中,你可能注意到,函数是创建新作用域的一种方式。(除了全局作用域)然而,还有另一种方法可以创建新的作用域,就是块作用域。
2)块作用域
下面代码中,我们有两个变量和两个循环,在循环重新声明相同的变量,会打印什么?
function loopScope () {
var i = 50
var j = 99
for (var i = 0; i < 10; i++) {}
console.log('i =', i)
for (let j = 0; j < 10; j++) {}
console.log('j =', j)
}
loopScope()
打印结果:
i = 10
j = 99
第一个循环覆盖了var i,对于不知情的开发人员来说,这可能会导致bug。
第二个循环,每次迭代创建了自己作用域和变量。这是因为它使用let关键字,它与var相同,只是let有自己的块作用域。另一个关键字是const,它与let相同,但const常量且无法更改(指内存地址)。
块作用域是由大括号 {} 创建的作用域,再看一个例子:
function blockScope () {
let a = 5
{
const blockedVar = 'blocked'
var b = 11
a = 9000
}
console.log('a =', a)
console.log('b =', b)
console.log('blockedVar =', blockedVar)
}
blockScope()
打印结果:
a = 9000
b = 11
ReferenceError: blockedVar is not defined
(1)a是块作用域,但它在函数中,而不是嵌套的,本例中使用var是一样的。
(2)对于块作用域的变量,它的行为类似于函数,注意var b可以在外部访问,但是const blockedVar不能。
(3)在块内部,从作用域链向上找到 a 并将let a更改为9000。
使用块作用域可以使代码更清晰,更安全,应该尽可能地使用它。
事件循环(Event Loop)
1)为什么JavaScript是单线程?
JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
2)任务队列
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。
JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。如图1.5所示。
图1.5 主线程和任务队列示意图
只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。
3)事件和回调函数
"任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。
"任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。
所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。
4) Event Loop
主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。
为了更好地理解Event Loop,请看图1.6。
图1.6 浏览器事件环
上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
执行栈中的代码(同步任务),总是在读取"任务队列"(异步任务)之前执行。请看下面这个例子。
var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function (){};
req.onerror = function (){};
req.send();
上面代码中的req.send方法是Ajax操作向服务器发送数据,它是一个异步任务,意味着只有当前脚本的所有代码执行完,系统才会去读取"任务队列"。所以,它与下面的写法等价。
var req = new XMLHttpRequest();
req.open('GET', url);
req.send();
req.onload = function (){};
req.onerror = function (){};
也就是说,指定回调函数的部分(onload和onerror),在send()方法的前面或后面无关紧要,因为它们属于执行栈的一部分,系统总是执行完它们,才会去读取"任务队列"。再来看异步代码:
function logMessage2 () {
console.log('Message 2')
}
console.log('Message 1')
setTimeout(logMessage2, 1000)
console.log('Message 3')
上述代码主要是将一些 message 打印到控制台。利用setTimeout函数来延迟一条消息。我们知道js是同步,来看看输出结果:
Message 1
Message 3
Message 2
(1)打印 Message 1
(2)调用 setTimeout
(3)打印 Message 3
(4)打印 Message 2
它记录消息3,稍后,它会记录消息2
setTimeout是一个 API,和大多数浏览器 API一样,当它被调用时,它会向浏览器发送一些数据和回调。我们这边是延迟一秒打印 Message 2。
调用完setTimeout 后,我们的代码继续运行,没有暂停,打印 Message 3 并执行一些必须先执行的操作。
浏览器等待一秒钟,它就会将数据传递给我们的回调函数并将其添加到事件/回调队列中( event/callback queue)。然后停留在队列中,只有当调用堆栈(call stack)为空时才会被压入堆栈,如图1.7所示。
图1.7 事件循环演示
三、JavaScript 的性能优化
虽然在 V8 诞生之初,也出现过一系列针对 V8 而专门优化 JavaScript 性能的方案,比如隐藏类、内联缓存等概念都是那时候提出来的。不过随着 V8 的架构调整,你越来越不需要这些微优化策略了,相反,对于优化 JavaScript 执行效率,你应该将优化的中心聚焦在单次脚本的执行时间和脚本的网络下载上,主要关注以下三个方面的内容。
尽可能遵从工作线程
主线程被阻塞会导致用户交互的延迟,所以应该尽可能减少主线程上的工作。关键就是要识别并避免会导致主线程中某些任务长时间运行的解析行为。
1)避免使用大量的内联脚本
内联脚本是在主线程中处理的,根据之前的说法,应该尽量避免这样做。事实上,除了异步和延迟加载之外,任何 JavaScript 的加载都会阻塞主线程。
2)避免嵌套外层函数
懒编译也是发生在主线程上的。不过,如果处理得当的话,懒解析可以加快启动速度。想要强制进行全解析的话,可以使用诸如 optimize.js(已经不维护)这样的工具来决定进行全解析或者懒解析。
3)减少 JavaScript 文件的容量,分解超过 100kB 的文件
将大文件分解成小文件以最大化并行脚本的加载速度。“2019 年 JavaScript 的性能开销”一文比较了 Facebook 网站和 Reddit 网站的文件大小。前者通过在 300 多个请求中拆分大约 6MB 的 JavaScript ,成功将解析和编译工作在主线程上的占比控制到 30%;相反,Reddit 的主线程上进行解析和编译工作的达到了将近 80%。
使用JSON而不是对象字面量—偶尔
在 JavaScript 中,解析 JSON 比解析对象字面量来得更加高效。在不同的主流 JavaScript 执行引擎中分别解析一个 8MB 大小的文件,前者的解析速度最高可以提升 2 倍。
JSON 解析如此高效的两个原因:
(1)JSON 是单字符串 token,而对象字面量可能包含大量的嵌套对象和 token。
(2)语法对上下文是敏感的。解析器逐字检查源代码,并不知道某个代码块是一个对象字面量。而左大括号不仅可以表明它是一个对象字面量,还可以表明它是一个解构对象或者箭头函数。
不过,值得注意的是,JSON.parse 同样会阻塞主线程。对于超过 1MB 的文件,可以使用 FlatBuffers 提高解析效率。
最大化代码缓存
最后,你可以通过完全规避解析来提高解析效率。对于服务端编译来说, WebAssembly是个不错的选择。然而,它没办法替代 JavaScript。对于 JS,更合适的方法是最大化代码缓存。
值得注意的是,缓存并不是任何时候都生效的。在执行结束之前编译的任何代码都会被缓存 —— 这意味着处理器、监听器等不会被缓存。为了最大化代码缓存,你必须最大化执行结束之前编译的代码数量。其中一个方法就是使用立即执行函数(IIFE)启发式:解析器会通过启发式的方法标识出这些 IIFE 函数,它们会在稍后立即被编译。因此,使用启发式的方法可以确保一个函数在脚本执行结束之前被编译。
此外,缓存是基于单个脚本执行的。这意味着更新脚本将会使缓存失效。V8 团队建议可以分割脚本或者合并脚本,从而实现代码缓存。但是,这两个建议是互相矛盾的。
2022加油✦
往期推荐
致力于互联网教育技术的创新和推广
扫码关注我们 | @学而思网校技术团队