我们编写的JS代码在运行时经过了两个步骤:
- 编译过程:在编译过程中生成执行上下文(Execution Context),和生成可执行代码。
- 执行过程:执行可执行的代码,输出结果。
在创建执行上下文的时候会先确定当前代码所在的执行环境。 执行环境有三种:
- 全局环境(Global code):JS代码运行时会首先进入全局环境,对应创建全局执行上下文
- 函数环境(Function code):当调用函数时,会创建这个函数的执行上下文
- eval环境(不推荐使用,暂且忽略)
JS代码运行时会首先进入全局环境,创建全局执行上下文。全局环境只有一个,全局执行上下文也只有一个。但是在这个全局环境中可能有一个或多个函数,有一次或多次调用或在某个函数中嵌套调用的场景。
在代码的执行过程中,如果遇到了调用函数的语法,JS引擎会把这个被调用的函数加入到一个叫函数调用栈(call stack) 的数据结构中。之前那篇《内存机制》的博文中了解过堆、栈等数据结构,函数调用栈也是一种栈数据结构,它遵循先入后出,后入先出(LIFO:"Last In First Out") 规则。
函数调用栈(call stack)
function fn1(){
let fn1 = "fn1";
console.log(fn1);
fn2();
fn3();
}
function fn2(){
let fn2 = "fn2";
console.log(fn2);
}
function fn3(){
let fn3 = "fn3";
console.log(fn3);
}
fn1();
在函数调用栈中,最先入栈的是全局上下文,它永远在栈底。用户关闭浏览器时出栈。栈顶是当时正在执行的函数上下文。 上面的代码执行过程中,fn1先入栈,fn2再入栈。待fn2执行完毕后fn2出栈,然后fn3入栈,待fn3执行完毕后fn3出栈,再等待fn1执行完毕后fn1出栈。
Chrome V8引擎 Sources面板中可以看见Call Stack中入栈顺序
看一个递归的例子:
function fn(num){
if( num == 0 ) return;
console.log(num);
--num;
fn(num);
}
fn(3);
满足条件调用自己两次,Call Stack中入栈三个fn函数执行上下文。
通过上例可知道,即使是同一个函数,入栈时会新创建一个执行上下文。
根据这个过程,可以得出一些结论:
- 浏览器中JavaScript是单线程执行。
- 单线程的JS需要同步执行代码。遇到函数调用时,正在执行的函数上下文处在栈顶,其他上下文要等待执行。
- 全局上下文只有一个,它首先入栈,一直在栈底,关闭浏览器时出栈。
- 调用一个函数会在栈中加入对应这个函数的执行上下文,即使是这个函数调用自身。
关于闭包
结合前一篇《内存机制》的文章,用执行上下文的特性解释闭包
function count(){
let num = 0;
return function(){
return ++num;
}
}
let add = count();
console.log(add());
console.log(add());
输出结果:
1
2
- 函数count中返回一个匿名函数,并没有执行。
- let add = count()语句 调用count的同时(count入栈)返回的匿名函数缓存了起来。这个匿名函数返回了对上一级作用域(count函数级作用域)中num变量的引用。
- 第一次执行console.log(add())语句输出1,第二次输出2,说明那个匿名函数对num的引用没有断,依然保存在内存中。所以第二次输出2。
如果代码改成:
function count(){
let num = 0;
return function(){
return ++num;
}
}
console.log(count()());
console.log(count()());
输出结果:
1
1
- 没有了let add = count();这行语句。就没有把count返回的匿名函数缓存。
- 如果count()()这样调用的话,切断了匿名函数对变量num的引用。即每次新入栈一个匿名函数马上又出栈,每次创建一个新的内存空间输出1。