写在前面
大家好,我是一溪风月🌳,目前是一名前端工程师,给自己的定位是一个coder,毫无疑问每个编程语言都有属于自己的运行逻辑,相信很多人没有思考过你编写的代码究竟是怎么运行的,这篇文章我们将深入学习一下JavaScript的运行原理,我们会由浅入深的进行讲解,首先我们会了解一下在JS引擎下JS代码究竟是如何进行解析的,然后我们会从代码执行的底层的角度来查看JS在执行的时候内存堆栈的变化,通过这个来弄明白我们的代码从编写到执行整个过程究竟经历了什么,好了,让我们一起来看看吧!
一.JavaScript代码的执行
JavaScript代码下载下来之后是如何执行的?我们知道浏览器是由两部分组成的,以Webkit内核为例:
- Webcore:负责HTML的解析,布局和渲染等等工作。
- JavaScriptCore:解析和执行JavaScript代码。

另外一个比较强大的JavaScript引擎就是V8
二.V8引擎架构及执行流程
首先我们来看下官方对V8的定义:
- V8是用C++编写的Google开源的高性能的JavaScript和WebAssembly引擎,它用于Chrome和Node.js等。
- 它实现ECMAScript和WebAssembly,并在Window7或更高的版本,macOS10-12+和使用x64,IA-32,ARM或MIPS处理器的Linux系统上运行。
- V8可以独立运行,也可以嵌入到任何C++应用程序中。

V8引擎本身的源码非常复杂,大概有超过100w行C++代码,通过了解它的架构,我们可以知道它是如何对JavaScript执行的。
Parse模块会将JavaScript代码转换为AST(抽象语法树)这是因为解释器并并不直接认识JavaScript代码
- 如果函数没有调用,那么是不会被转换成AST的
- Parse的V8官方文档:v8.dev/blog/scanne...
Ignition是一个解释器,会将AST转换为ByteCode(字节码)
- 同时会收集TurboFan优化所需的信息(比如函数参数的类型信息,有了类型才能机行真实的运算)
- 如果函数只调用一次,Ignition会解释执行ByteCode
- Ignition的V8官方文档: v8.dev/blog/igniti...
TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码
- 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换为优化机器码,提高代码的执行性能。
- 但是机器码实际上会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如Sum函数原来执行的是number类型,后来转换成了string类型)之前优化的机器码并不能正确的处理运算,就会逆向的转换为字节码。
- TurboFan的V8官方文档: v8.dev/blog/turbof...
上方的这个图就是V8引擎对JavaScript代码的解析流程,比如我们有如下的一个代码块。
js
var num1 = 20;
var num2 = 40;
var result = num1 + num2;
JS 引擎的基本执行流程 :首先解析引擎会对这个代码进行解析psrse
然后回将这个进行解析到的内容转为AST抽象语法树,之后会通过Ignition
模块转为bytecode
字节码,然后结束运行。并且Ignition
模块会进行收集信息,比如类型信息,然后通过TurboFan转换为优化后的机器码也就是MachineCode
然后得到最后的运行结果。如果在运行的过程中数据的类型发生了变化,就会通过反优化Deoptimization
将对使用的机器码转换为字节码。
三.JavaScript执行原理-版本说明
在ECMA早期的版本中(ECMAScript3) 代码的执行流程的术语和ECMAScript5以及之后的术语会有所区别:
- 目前网上大多数流行的说法都是基于ECMAScript3版本的解析,并且在面试的时候大多数都是ECMAScript3的版本内容。
- 但是ECMAScript3终将过去,ECMAScript5必然会成为主流,所以最好理解ECMAScript5甚至包含ECMAScript6以及更好的版本内容。
- 事实上在TC39(ECMAScript5)的最新描述中,和ECMAScript5之后的版本又出现了一定的差异。
我们将按照如下的内容进行学习:
- 通过ECMAScript3中的概念学习JavaScript执行原理,作用域,作用域链,闭包等概念。
- 通过ECMAScript5中的概念学习块级作用域,let,const等概念。
💡Tips:事实上,它们只是在对某些概念上的描述不太一样,在整体思路上都是一致的。
四.解析过程重点概念解析
Global Object(GO)
:JS引擎在执行代码之前,会在堆内存中创建一个全局对象,这个对象简称GO,该对象中所有的作用域(scope)都可以访问,里面包含Date
Array
Number
setTimeout
setInterval
除此之外还有一个window属性指向自己。
执行上下文栈(ECS)
:JS引擎内部有一个执行上下文栈,它是用于执行代码的调用栈,当全局代码进行执行的时候,会首先创建一个Global Excution Context简称(GEC),GEC会放到ECS中进行执行,GEC被放到ECS中包含两部分内容:
- 第一部分:在代码执行前,在parser转换AST的过程中,会将全局定义的变量,函数加入到GlobalObject中,但是并不会进行赋值,这个过程也称之为变量的作用域提升。
- 第二部分:在代码执行中对变量进行赋值,或者执行其他函数。
Variable Object(VO)
:每一个执行上下文会关联一个VO对象,变量和函数会被添加到这个VO对象中,对于一个函数而言,函数的参数也会被添加到VO对象中去,当全局代码执行的时候VO就是GO了。
十.全局代码的执行过程
首先我们在开始执行代码前,所有的代码都需要经过JS引擎的解析,JS的源代码会先经过Parser模块进行解析,这些所有的代码进行解析之后会存放在VO对象中,简而言之就是存放在变量对象中,上述我们也描述过,当代码是全局代码(全局上下文)的时候VO就是GO对象,如果有函数,函数参数也会被放到全局上下文中。
比如我们通过代码来举一个例子
js
var message = "Global Message"
function foo(){
var message = "Foo Message"
}
var num1 = 10;
var num2 = 20;
var result = num1 + num2;
console.log(result)
这段代码首先会通过Parser进行JS引擎进行解析形成AST语法树,我们在这个代码中定义的标识符存放到一个VO对象中去,如果JS引擎会提前进行函数的创建,因为在JS中函数是可以提前被访问的。

然后这段代码会一次从上往下进行赋值和执行,当执行完毕当前执行上下文会弹出执行上下文栈。

十一.函数代码的执行
在上述的全局代码中,我们可以了解全局代码的执行流程,但是上述的代码中我们并没有对函数进行执行,函数仅仅是进行了赋值操作,那么函数代码是如何进行执行的哪?
在执行的过程中执行到一个函数的时候时,会根据函数体创建一个函数执行上下文,并且压入到EC Stack中,我们对上述的代码进行简单的改造来看看函数代码是如何进行执行的。
js
var message = "Global Message"
function foo(num){
var message = "Foo Message"
var age = 18
var height = 1.88
console.log("foo Function")
}
foo(123)
var num1 = 10;
var num2 = 20;
var result = num1 + num2;
console.log(result)
每个执行上下文都会关联一个VO,那么函数执行上下文关联的VO是什么哪?
- 当进入一个函数执行上下文的时候,简单那来说也就是函数执行的时候,会创建一个AO对象(Activation Object)。
- 这个AO对象会使用argument作为初始化,并且初始值是传入的参数。
- 这个AO对象会作为执行上下文的VO来存放变量的初始化。
函数执行之前:

函数执行之后:

🚨需要注意的是在进行函数代码的执行的时候会创建一个AO对象,并且每一个AO对象仅仅在执行之前才会创建,当一个函数被调用一次之后,会弹出当前上下文栈,会重新创建一个新的AO对象,这个AO对象和上一个AO并不是一个AO对象。
十二.函数的多次调用
当然除了上述我们对函数进行简单的调用之后,我们还可以对函数多次进行调用,代码基本的执行逻辑和上述函数执行过程是一样的,不同点在于AO的销毁和创建,首先我们将我们的代码进行部分的修改。
js
var message = "Global Message"
function foo(num){
var message = "Foo Message"
var age = 18;
var height = 1.88;
console.log("foo function")
}
foo(123)
foo(321)
foo(111)
foo(222)
当进行执行的时候AO中的值会进行赋值,其内存就会发生如下的变化,当执行完毕执行上下文中的AO会弹出当前的栈,接下来会进行下一个代码的执行,具体操作为:
- 销毁当前AO对象(可能会销毁),创建新的AO对象,每执行一个就会创建一个新的。
- 在作用域上下文中会添加一个新的VO对象也就是这个AO进行关联并执行内部代码。
- 当重复调用这个函数的时候会依次进行如上的操作。
十三.函数的相互调用
在很多的时候我们的函数会进行嵌套的调用,这个时候他的内存地址会发生什么样的变化?
js
var message = "Global Message"
var obj = {
name:"why"
}
function bar(){
console.log("bar function")
var address = "bar"
}
function foo(num){
var message = "Foo Message"
bar()
var age = 18
var height = 1.88
console.log("foo function")
}
foo(123)
首先首先JS引擎会将全部的代码进行解析,然后进行执行和赋值,当执行到我们的函数的时候,会创建一个AO然后会执行函数体中的代码,当执行的时候会执行到我们的bar
函数,所以我们在这里会创建一个新的上下文,放入到执行栈的栈顶(首先先执行栈顶)也就是会直接执行bar函数
的内容,等到bar
函数的内容执行完毕就会弹出当前的这个函数,之后继续执行foo
函数中的代码。
十四.总结
这篇文章到这里就要结束了🐵 这篇文章我们了解了JS代码的运行原理,我们首先学习了JS代码的解析和执行过程,然后我们学习了JS中比较重要的两个概念GO和VO,并且在全局的代码下VO就是GO,学习了这些内容之后我们学习了一下函数代码的执行,当我们在进行函数代码的执行时候,我们会创建一个AO对象,当函数执行完毕的时候我们会从执行上下文栈中弹出这个AO~。