前言
JavaScript绝对是最火的编程语言之一,一直具有很大的用户群,随着在服务端的使用(NodeJs),更是爆发了极强的生命力,编程语言分为编译型语言和解释型语言两类,编译型语言在执行之前要先进行完全编译,而解释型语言一边编译一边执行,很明显解释型语言的执行速度是慢于编译型语言的,而JavaScript就是一种解释型脚本语言,支持动态类型、弱类型、基于原型的语言,内置支持类型。鉴于JavaScript都是在前端执行,而且需要及时响应用户,这就要求JavaScript可以快速的解析及执行。
随着Web相关技术的发展,JavaScript所要承担的工作也越来越多,早就超越了“表单验证”的范畴,这就更需要快速的解析和执行JavaScript脚本。V8就是为解决这一问题而生的,在NodeJs中也是采用该JS引擎来解析JavaScript。
1. JavaScript引擎

JavaScript本质上是一种解释型语言,与编译型语言不同的是它需要一边解析一边执行,而编译型语言在执行时已经完成编译,可直接执行,有更快的执行速度(如上图所示)。JavaScript代码是在浏览器端解析和执行的,如果过程中消耗的时间太长,会影响用户体验。那么提高JavaScript的解析速度就是当务之急。JavaScript引擎和渲染引擎的关系如下图所示:

JavaScript是解释型语言,为了提高性能,引入了Java虚拟机和C++编译器中的众多技术。现在JavaScript引擎的执行过程大致是:
源代码 --> 词法分析、语法分析 --> 抽象语法树(AST) --> 字节码 --> JIT --> 本地代码(V8没有中间字节码)
V8更加直接的将抽象语法树通过JIT技术转换成本地代码,放弃了在字节码阶段可以进行的一些性能优化,但保证了执行速度。在V8生成本地代码后,也会通过Profiler采集一些信息,来优化本地代码。虽然,少了生成字节码这一阶段的性能优化,但极大减少了转换时间。
1.1 V8
V8是一个JavaScript引擎实现,最初由一些语言方面专家设计,后被谷歌收购,随后谷歌对其进行了开源。V8使用C++开发,在运行JavaScript之前,相比其他的JavaScript引擎转换成字节码或解释执行,V8将其编译成原生机器码(IA-32、x86-64、ARM、MIPS CPUs),并且使用了如内联缓存(inline caching)等方法来提高性能。有了这些功能,JavaScript程序在V8下的运行速度媲美二进制程序。V8支持众多操作系统,如windows、linux、android等,也支持其他硬件架构,如IA32、X64、ARM等,具有很好的可移植性和跨平台性。
1.1.1 数据表示
JavaScript是一种动态类型语言,在编译时并不能准确知道变量的类型,只可以在运行时确定,这就不像C++或者Java等静态类型语言,在编译时就可以确切的知道变量类型。然而,在运行时计算和决定类型,会严重影响语言性能,这也就是JavaScript运行效率比C++或者Java低很多的原因之一。
在C++中,源代码需要经过编译才能执行,在生成本地代码的过程中,变量的地址和类型已经确定,运行本地代码时利用数组和位移就可以存取变量和方法的地址,不需要再进行额外的查找,几个机器指令即可完成,节省了确定类型和地址的时间。由于JavaScript是弱类型语言,那就不能像C++那样在执行时已经知道变量的类型和地址,需要临时确定。JavaScript和C++有以下几个区别:
- 编译确定位置,C++编译阶段确定位置偏移信息,在执行时直接存取,JavaScript在执行阶段确定,而且执行期间可以修改对象属性;
- 偏移信息共享,C++有类型定义,执行时不能动态改变,可共享偏移信息,JavaScript每个对象都是自描述,属性和位置偏移信息都包含在自身的结构中;
- 偏移信息查找,C++查找偏移地址很简单,在编译代码阶段,对使用的某类型成员变量直接设置偏移位置,JavaScript中使用一个对象,需要通过属性名匹配才能找到相应的值,需要更多的操作。
在代码执行过程中,变量的存取是非常普遍和频繁的,通过偏移量来存取,使用少数两个汇编指令就能完成,如果通过属性名匹配则需要更多汇编指令,也需要更多的内存空间。示例如下:

在JavaScript中,除boolean、number、string、null、undefined这五个原始类型之外,其他的数据类型都是对象,V8使用一种特殊的方式来表示它们,进而优化JavaScript内部表示问题。
在V8中,数据的内部表示有数据的实际内容和数据的句柄构成。数据的实际内容是变长的,类型也是不同的;句柄则大小固定,包含指向数据的指针。这种设计可以方便V8进行垃圾回收和移动数据内容,如果直接使用指针的话就会出问题或者需要更大的开销,使用句柄的话,只需要修改句柄中的指针即可,使用者使用的还是句柄,指针改动是对使用者透明的。
1.1.2 工作过程
前面有过介绍,V8在执行JavaScript的过程中,主要有两个阶段:编译和执行。与C++的执行前完全编译不同的是,JavaScript需要在用户使用时完成编译和执行。在V8中,JavaScript相关代码并非一下完成编译的,而是在某些代码需要执行时,才会进行编译,这就提高了响应时间,减少了时间开销。在V8中,源代码先被解析器转变为抽象语法树(AST),然后使用JIT编译器的全代码生成器从AST直接生成本地可执行代码。这个过程不同于Java先生成字节码或中间表示,减少了AST到字节码的转换时间,提高了代码的执行速度。但由于缺少了转换为字节码这一中间过程,也就减少了优化代码的机会。
V8编译本地代码时使用的主要类如下所示:
- Script:表示JavaScript代码,即包含源代,又包含编译之后生成的本地代码,既是编译入口,和又是运行入口;
- Compiler:编译器类,辅助Script类来编译生成代码,调用解释器(Parser)来生成AST和全代码生成器,将AST转变为本地代码;
- AstNode:抽象语法树节点类,是其他所有节点的基类,包含非常多的子类,后面会针对不同的子类生成不同的本地代码;
- AstVisitor:抽象语法树的访问者类,主要用来遍历异构的抽象语法树;
- FullCodeGenerator:AstVisitor类的子类,通过遍历AST来为JavaScript生成本地可执行代码;

JavaScript代码编译的过程大致为:Script类调用Compiler类的Compile函数为其生成本地代码。Compile函数先使用Parser类生成AST,再使用FullCodeGenerator类通过AstVisitor的遍历来生成本地代码。本地代码与具体的硬件平台密切相关,FullCodeGenerator使用多个后端来生成与平台相匹配的本地汇编代码。由于FullCodeGenerator通过遍历AST来为每个节点生成相应的汇编代码,缺失了全局视图,节点之间的优化也就无从谈起了。
由于V8缺少了生成中间代码这一环节,缺少了必要的优化,为了提升性能,V8会在生成本地代码后,使用数据分析器(profiler)采集一些信息,然后根据这些信息将本地代码进行优化,生成更高效的本地代码。下面介绍一下运行阶段,该阶段使用的主要类如下所示:
- Script:表示JavaScript代码,即包含源代,又包含编译之后生成的本地代码,既是编译入口,和又是运行入口;
- Execution:运行代码的辅助类,包含一些重要函数,如Call函数,它辅助进入和执行Script代码;
- JSFunction:需要执行的JavaScript函数表示类;
- Runtime:运行这些本地代码的辅助类,主要提供运行时所需的辅助函数,如属性访问、类型转换、编译、算术、位操作、比较、正则表达式等;
- Heap:运行本地代码需要使用的内存堆类;
- MarkCompactController:垃圾回收机制的主要实现类,用来标记、清除和整理等基本的垃圾回收过程;
- SweeperThread:负责垃圾回收的线程;

- 首先根据需要编译和生成这些本地代码,也就是使用编译阶段那些类和操作。在V8中,函数是一个基本单位,当某个JavaScript函数被调用时,V8会查找该函数是否已经生成本地代码,如果已经生成,则直接调用该函数;否则V8会生成属于该函数的本地代码。这就节约了时间,减少了处理那些使用不到的代码的时间。
- 其次,执行编译后的代码为JavaScript构建JS对象,这需要Runtime类来辅助创建对象,并需要从Heap类分配内存。
- 再次,借助Runtime类中的辅助函数来完成一些功能,如属性访问。
- 最后,将不用的内存空间进行标记清除和垃圾回收。
注意:
在执行编译之前,V8会构建众多全局对象并加载一些内置的库(如math库),来构建一个运行环境沙箱。而且JavaScript源代码中,并非所有的函数都被编译生成本地代码,而是延迟编译,在调用时才会编译。
通过以上对JavaScript引擎的介绍,我们就能明白JS代码是如何被编译执行的,接下来让我们深入理解一下函数。
2. 函数
2.1 函数出现的目的
函数是迄今为止发明出来的用于节约空间和提高性能的最重要的手段
注意:没有之一
2.2 函数的执行机制
怎么去解释函数的执行机制呢?
个人认为,要解释这个问题,就必须从计算机的一些底层原理着手,比如编译原理、计算机组成原理等。
执行一个函数会发生什么?
参考以下代码:
function sayHello(){
let str = 'hello kel';
console.log(str);
}
2.2.1 函数创建
函数不是平白无故产生的,你要去创建一个函数,而创建函数的时候,究竟发生了什么呢?
第一步:开辟一个新的堆内存
为什么呢?因为每个字母都是要存储空间的,只要有数据,就一定得有存储数据的地方。而计算机组成原理中,堆允许程序在运行时动态的申请某个大小的内存空间,所以可以在程序运行的时候,为函数申请内存。
第二步:创建一个函数
sayHello,然后把这个函数体中的代码放入这个堆内存中
想一下函数体是以什么样的形式放入堆内存中的?很明星,是以字符串的形式。为什么呢?我们来看一下sayHello函数体的代码是什么,如下:
let str = 'hello kel';
console.log(str);
这些语句以什么形式的结构放入堆内存中比较好呢?不用考虑也知道是字符串,因为没有规律。而如果是对象的话,则就有规律可循,可以按照键值对的形式存储在堆内存中,而没有规律的通常都是以字符串的形式。
第三步:在当前执行上下文中声明
sayHello函数变量,函数声明和定义会提升到最前面
注意:sayHello是存储于上下文堆栈中,并指向一个堆内存,而该堆内存中存放在函数体
第四步:把开辟的堆内存地址赋值给函数变量
sayHello
此处的赋值属于***引用赋值***
从计算机组成原理角度出发,内存分为好几个区域,比如代码区域、栈区域、堆区域等。
理解这几个区域有一个最关键的点,就是要明白每一个存储空间的内存地址都是不一样。也就是说,赋值(引用类型)的操作就是将堆区域的某一个地址,通过总线管道流入(复制)到对应栈区域的某一个地址中,从而使栈区域的某一个地址内的存储空间中有了引用堆区域数据的地址。
2.2.2 函数执行
我们知道,函数体的代码是保存在堆内存中的,而且是以字符串形式。那么我们要执行堆内存中的代码,首先要做的是将字符串形式的代码转换为真正的JS代码。
如何将堆内存中的代码字符串转换为真正的
JS代码?
每一个函数调用,都会在函数上下文堆栈(函数调用栈)中创建帧
为什么函数执行要在栈中进行呢?
因为栈是先进后出的数据结构,这也就意味着可以很好的保存和恢复调用现场,并且管理函数执行以及变量作用域。函数调用栈在程序运行时就会产生,并且一开始加入到栈里面的是全局执行上下文帧,始终位于栈底。每进入一个不同的运行环境都会创建一个相应的执行上下文(Execution Context),并将其推入当前调用栈中,使其位于栈顶位置。当栈顶函数运行完成后,其对应的函数执行上下文将会被调用栈弹出,并将上下文控制权移到当前调用栈的下一个执行上下文。
总体执行过程如下:
第一步:会形成一个供代码执行的执行环境,也就是一块栈内存
这里有几个问题:
- 这个供代码执行的环境是什么?
- 这个栈内存是怎么分配出来的?
- 这个栈内存的内部是一种什么样的?
第二步:将存储于堆内存中的代码字符串复制一份到新开辟的栈内存中,使其变成真正的
JS代码
第三步:先对形参进行赋值,再进行变量提升,比如将
var、function变量提升
第四步:在这个新开辟的作用域中自上而下执行
最后:将执行结果返回给当前调用的函数
执行上下文的创建:
执行上下文周期如下图所示:

执行上下文可理解为当前的执行环境,分为全局执行上下文和函数执行上下文。创建执行上下文的过程分为三部分:
1.创建变量对象(Variable Object)
2.建立作用域链(Scope Chain)
3.确定this的指向
创建变量对象:

- 创建arguments对象:检查当前上下文中的参数,建立该对象的属性与属性值,仅在函数环境(非箭头函数)中进行,全局环境没有此过程。
- 检查当前上下文的函数声明:按代码顺序查找,将找到的函数声明提前,如果当前上下文的变量对象没有该函数名属性,则在变量对象上以函数名建立一个属性,属性值则为指向该函数所在的堆内存地址的引用,如果存在,则会被新的引用覆盖。
- 检查当前上下文的变量声明:按代码顺序查找,将找到的变量提前声明,如果当前上下文的变量对象没有该变量名属性,则在变量对象上以变量名建立一个属性,属性值为undefined,如果存在,则忽略该变量声明。
函数声明和变量声明提升是在创建变量对象中进行的,且函数声明优先级高于变量声明。
变量提升的具体原因:在创建阶段,函数声明存储在环境中,而变量会被设置为undefined(在var的情况下)或保持未初始化(在let和const的情况下)。所以这就是为什么在声明之前访问var定义的变量(尽管是undefined),但如果在声明之前访问let和const定义的变量就会提示引用错误的原因。此时let和const处于未初始化状态不能使用,只有进入执行阶段,变量对象中的变量属性进行赋值后,变量对象(Variable Object)转变为活动对象(Active Object)后,let和const才能允许访问。
建立作用域链:
通俗理解,作用域链由当前执行上下文的变量对象(未进入执行阶段前,执行阶段会转变成活动对象)与上层执行上下文的一系列活动对象组成,它保证了当前执行上下文对符合访问权限的变量和函数的有序访问。
可以通过一个例子简单理解:
var num = 30;
function test() {
var a = 10;
function innerTest() {
var b = 20;
return a + b;
}
innerTest();
}
test();
在上面的例子中,当代码执行到调用innerTest函数,进入innerTest函数执行上下文。全局执行上下文和test函数执行上下文已进入执行阶段,innerTest函数执行上下文在编译阶段创建变量对象,所以他们的活动对象和变量对象分别是AO(global)、AO(test)和AO(innerTest),而innerTest的作用域链由当前执行环境的变量对象(未进入执行阶段)与上层环境的一系列活动对象组成,如下:
EC(innerTest) = {
//变量对象
VO: {b: undefined},
//作用域链
scopeChain: [VO(innerTest), AO(test), AO(global)],
//this指向
this: window
}
深入理解的话,创建作用域链,也就是创建词法环境,而词法环境有两个组成部分:
- 环境记录:存储变量和函数声明的实际位置
- 对外部环境的引用:可以访问其外部词法环境
词法环境类型伪代码如下:
// 第一种类型: 全局环境
GlobalExectionContext = { // 全局执行上下文
LexicalEnvironment: { // 词法环境
EnvironmentRecord: { // 环境记录
Type: "Object", // 全局环境
// 标识符绑定在这里
outer: <null> // 对外部环境的引用
}
}
// 第二种类型: 函数环境
FunctionExectionContext = { // 函数执行上下文
LexicalEnvironment: { // 词法环境
EnvironmentRecord: { // 环境记录
Type: "Declarative", // 函数环境
// 标识符绑定在这里 // 对外部环境的引用
outer: <Global or outer function environment reference>
}
}
创建变量对象,也就是创建变量环境,而变量环境也是一个词法环境。在ES6中,词法环境和变量环境的区别在于前者用于存储函数声明和变量(let和const)绑定,而后者仅用于存储变量(var)绑定。
如例子:
let a = 20;
const b = 30;
var c;
function multiply(e, f) {
var g = 20;
return e * f * g;
}
c = multiply(20, 30);
执行上下文的词法环境表示如下:
GlobalExectionContext = {
ThisBinding: <Global Object>,
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 标识符绑定在这里
a: < uninitialized >,
b: < uninitialized >,
multiply: < func >
}
outer: <null>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 标识符绑定在这里
c: undefined,
}
outer: <null>
}
}
FunctionExectionContext = {
ThisBinding: <Global Object>,
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 标识符绑定在这里
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 标识符绑定在这里
g: undefined
},
outer: <GlobalLexicalEnvironment>
}
}
通过上述解释,相信大家对作用域已经有较深入理解了,文章后面还会通过实际例子更加通俗易懂的解释作用域链。现在我们回过头来回答一下上面有关栈内存的那几个问题。
栈内存是怎么分配出来?
首先,你要明白JS的栈内存是系统自动分配的,大小固定。想一想,如果自动适应的话,那就基本不存在除死循环这种情况之外的栈溢出了。
这个栈内存的内部是什么样的?
我们来看一张图:

上图显示了一次函数调用的栈结构,从结构中我们可以看到,内部有哪些东西,比如实参、局部变量、返回地址等。
大家看到一个Return Addr,这个Addr主要目的是让子程序能够多次被调用。
看下面的例子:
function main() {
say()
// TODO:
say()
}
上面代码中,在main函数中进行了多次调用子程序say函数,在底层实现上面,是通过在栈结构中保存一个Return Addr来保存函数的起始运行地址,当第一个say函数运行完之后,Return Addr就会指向起始运行地址,以备后面多次调用子程序。
接下来我们再看一个例子,更直观的探索一下作用域链。
//定义一个全局变量 x
var x = 1
function A(y) {
//定义一个局部变量 x
var x = 2
function B(z) {
//定义一个内部函数 B
console.log(x + y + z)
}
//返回函数B的引用
return B
}
//执行A,返回B
var C = A(1)
//执行函数B
C(1)
执行A函数时
JS引擎构造的ECStack结构如下:

执行B函数时
JS引擎构造的ECStack结构如下:

我们着重看下B的执行上下文
EC(B) = {
/[scope]:AO(A),
var AO(B) = {
z:1,
arguments:[],
this:window
},
scopeChain:<AO(B),B[[scope]]>
}
这是在执行B函数时,创建的B函数的执行上下文(一个对象结构)。里面有一个AO(B),这是B函数的活动对象。
同时,这里还定义了[scope]属性,我们可以理解为指针,[scope]指向了AO(A),而AO(A)就是A函数的活动对象。
函数活动对象保存着局部变量、参数数据、this属性。这也是为什么你可以在函数内部使用this和arguments的原因。
scopeChain是作用域链,熟悉数据结构的同学肯定知道我想说什么了,其实函数作用域链本质就是链表,执行哪个函数,那链表就初始化为哪个函数的作用域,然后把当前指向的函数活动对象放到scopeChain链表的表头中。
比如执行B函数,那B函数的链表看起来就是AO(B)-->AO(A)。
但是别忘了,A函数也有自己的链表,AO(A)-->VO(G)。所以整个链表就串联起来了,B函数的链表(作用域链)就是:AO(B)-->AO(A)-->VO(G)。
在这个链表(作用域链)中根据标识符查找变量。
3. 线程
浏览器是多进程的,但JS引擎是运行在单线程上,单线程又是如何调度各种不同的任务的呢?这里JS引擎线程需要有其他线程的配合才能完成浏览器的任务调度。除了JS引擎线程外,还有事件触发线程、定时器触发线程、网络请求线程。
3.1 网页的线程
永远只有JS引擎线程在执行JS脚本程序,其他三个线程只负责将满足触发条件的处理函数推进事件队列,等待JS引擎线程执行,不参与代码解析与执行。
- JS引擎线程:也称为JS内核,负责解析执行JavaScript脚本程序的主线程(例如V8);
- 事件触发线程:归属于浏览器内核进程,不受JS引擎线程控制。主要用于控制事件(例如鼠标、键盘等事件),当该事件被触发时候,事件触发线程就会把该事件的处理函数推进事件队列,等待JS引擎线程执行;
- 定时器触发线程:主要控制计时器setInterval和延时器setTimeout,用于定时器的计时,计时完毕,满足定时器的触发条件,则将定时器的处理函数推进事件队列中,等待JS引擎线程执行。
- 网络请求线程

3.2 任务源
从上图可以看出,存在着不同的任务源,会产生不同类型的任务并加入到不同的队列中。
3.2.1 宏任务
宏任务(macro-task)可分为同步任务和异步任务:
- 同步任务指的是在JS引擎主线程上按顺序执行的任务,如script内的代码,只要前一个任务执行完毕后,才能执行后一个任务,形成一个执行栈(函数调用栈)。
- 异步任务指的是不直接进入JS引擎主线程,而是满足触发条件时,相关线程将该异步任务推进任务队列(task queue),等待JS引擎主线程上的任务执行完毕,空闲时读取执行的任务,例如异步ajax、DOM事件、setTimeout等。
理解宏任务中同步任务和异步任务的执行顺序,那么就理解了JS异步执行机制--事件循环(Event Loop)。
3.2.2 微任务
微任务的任务源有Promise、process.nextTick等。
JS引擎遇到一个异步事件后并不会一直等待其返回结果,而是将这个事件挂起交由其他线程处理,JS主线程继续执行调用栈中的其他任务。当一个异步事件返回结果后,JS引擎会将其事件回调加入与当前调用栈不同的另一个队列中,我们称之为事件队列。被放入事件队列的事件回调并不会立即执行,而是等待当前调用栈中的所有任务执行完毕,JS主线程处于闲置状态时,JS主线程会去查找事件队列是否有任务。如果有,那么JS主线程会根据任务类型从中取出任务依次执行。
以下是各类任务执行顺序:

举个例子:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
执行过程如下:
-
代码块通过编译后,进入执行阶段,当JS引擎主线程执行到console.log('script start');,JS引擎主线程认为该任务是同步任务,所以立即执行输出script start,然后继续向下执行;
-
JS引擎主线程执行到setTimeout(function() { console.log('setTimeout'); }, 0);,JS引擎主线程认为setTimeout是异步任务源,则向浏览器内核进程申请开启定时器线程进行计时和控制该setTimeout任务。由于W3C在HTML标准中规定setTimeout低于4ms的时间间隔算为4ms,那么当计时到4ms时,定时器线程就把该回调处理函数推进任务队列中等待主线程执行,然后JS引擎主线程继续向下执行;
-
JS引擎主线程执行到Promise.resolve().then(function(){ console.log('promise1'); }).then(function(){ console.log('promise2'); });,JS引擎主线程认为Promise是一个微任务源,就把该任务推入微任务队列,等待执行;
-
JS引擎主线程执行到console.log('script end');,JS引擎主线程认为该任务是同步任务,所以立即执行输出script end;
-
JS主线程上的宏任务执行完毕,则开始检测是否存在可执行的微任务,检测到一个Promise微任务,那么立刻执行,输出promise1和promise2;
-
微任务执行完毕,JS主线程开始读取任务队列中的事件任务setTimeout,推入主线程形成新宏任务,然后在主线程中执行,输出setTimeout;
最后输出结果即为:
script start
script end
promise1
promise2
setTimeout