前言
在讲解执行上下文之前,先来看一段代码:
console.log(myname);
showMsg();
var myname = "page_not_found";
function showMsg() {
console.log("function console");
}
稍有 js 基础的同学都知道,这段代码并不会报错,代码会输出如下结果:
这是因为JavaScript 引擎会将函数和变量的声明提升到作用域的顶部,这个特性我们一般称作变量提升或声明提升。所以上面代码相当于下面的代码:
//变量和函数的声明被提升到了作用域顶部
var myname; // 已声明未初始化的变量默认值为undefined
function showMsg() {
console.log("function console");
}
//赋值&执行代码
console.log(myname);
showMsg();
myname = "page_not_found";
对于 let或const 声明的变量:
console.log(myname);
let myname = "page_not_found";
执行结果:

let/const 声明之前的执行瞬间被称为暂时性死区,在此阶段的变量引用均会输出 ReferenceError。
到这里,我们不得不发出疑问,为什么var声明的变量以及函数的声明可以被提升?为什么 let 和 const 声明的变量在声明前不可用?要回答这个问题,我们需要搞清楚 js 引擎是如何执行 js 代码的。
js 执行流程
js 代码的执行分为两个阶段,分别为编译阶段和执行阶段。 js 代码在执行前都会被编译。
因此,变量提升以及暂时性死区形成的实际原因是:在编译阶段,js 对代码的声明做了特别的处理,如果是 var 声明的就提升,如果是 let 声明的就标记为ReferenceError。
当然,这只是一个笼统的解释,想要了解内部的细节,我们必须要去了解执行上下文。
执行上下文
概念
js 代码在编译阶段,会先创建执行上下文。执行上下文是 JavaScript 执行一段代码时的运行环境,包括了代码执行的所有信息。
种类
执行上下文有三种:
- 全局执行上下文:当 JavaScript 执行全局代码的时候,会创建全局执行上下文。它会做两件事:创建一个全局的
window对象(浏览器的情况下),并且设置this的值等于这个全局对象。全局上下文只有一个。 - 函数执行上下文:函数被调用时才被创建,每次调用函数都会创建一个新的函数执行上下文。函数执行结束后,其上下文会被销毁。
- Eval 执行上下文:运行在
eval函数中的代码,不建议使用。
执行上下文的属性
执行上下文主要包含三个属性:词法环境,变量环境以及 this 值。
词法环境(lexical environment)
词法环境存储let、const声明的变量(标记为uninitialized)绑定。词法环境由环境记录和对外部环境的引用两部分组成:
- 环境记录:存储变量和函数声明的实际位置。
- 对外部环境的引用:表示其可以访问的外部词法环境。
词法环境有两种类型:
- 全局词法环境:全局词法环境没有外部环境,故其对外部环境的引用为
null。包含被定义的全局变量以及全局对象(浏览器中为window),this指向该全局对象。 - 函数词法环境:对外部环境的引用可以是全局词法环境,也可以是包裹该函数的外部函数的函数词法环境。函数词法环境包含
arguments对象以及函数中被定义的变量。
变量环境(variable environment)
变量环境也是一个词法环境,故其具有词法环境的所有属性。不同于词法环境,变量环境存储**函数声明和 var 声明的变量(标记为 undefined)**绑定。
this值
在全局上下文中,this 值指向全局对象(浏览器中为 window);在函数执行上下文中,this 值取决于函数的调用方式。如果函数是作为一个对象的方法且被该对象调用,则 this 指向这个对象,否则 this 的值指向全局对象或者undefined(严格模式下)。
执行上下文的执行阶段
每个执行上下文都有会经历三个阶段:
-
创建阶段:
会做代码执行前的准备工作,如声明提升等都是在此阶段做的。此时还没有执行代码。
lexical environment(词法环境)被创建。variable environment(变量环境)被创建。
-
执行阶段:
逐行执行代码,完成所有变量的分配。
-
销毁阶段
销毁执行上下文。
js 是如何实现块作用域的
在 ES6 之前,js 只有两种作用域,即全局作用域和函数作用域;继 ES6,let 和 const被添加到 js 之后,js 新增了块级作用域。对于块级作用域,执行上下文又是如何处理的呢,接下来通过一个例子来分析其处理过程:
function foo(){
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a)
console.log(b)
}
console.log(b)
console.log(c)
console.log(d)
}
foo()
- 第一步编译并创建函数foo 的执行上下文。
由上图可知,在编译阶段:
- 在函数中,由 var 声明的变量放在变量环境中,初始化为
undefined。 - 在函数的非块作用域中,由 let 声明的变量放在词法环境中,被标记为
uninitialized。 - 在函数内部的块作用域中,由let 声明的变量并没有进行处理。
编译阶段就分析到这里。
- 第二步执行代码。
如图所示,当执行到代码块中时,变量环境中的 a 被赋值为 1,词法环境中的 b 被赋值为 2。
可以看出,在函数中,块作用域内通过 let 声明的变量和块作用域外通过 let声明的变量各自独立。在词法环境内部,通过一个栈来对变量进行管理。函数最外层的变量处于栈底。执行到块作用域时,将该作用域块的内部变量压到栈顶;块作用域执行完成后,块作用域的变量信息从栈中弹出。
引擎查找变量 a 的方式已经在图中标出:从词法环境的栈顶向下查找,词法环境中如果没有找到,则继续在变量环境中查找。
- 块作用域执行结束后其变量信息从栈顶弹出。
小结:块作用域就是通过词法环境中的栈实现的,变量提升通过变量环境实现。
至此,我们对执行上下文已经有了一个比较清晰的认识了,不过现在还存在几个问题:对于执行上下文js 引擎是如何管理的?以及执行上下文之间是如何建立联系的?要弄清楚这个问题,就要去了解执行上下文栈。
执行上下文栈
管理执行上下文的栈结构叫做执行上下文栈,也称调用栈。
JavaScript 引擎处理代码文件时,它会创建一个全局执行上下文并压入调用栈。当函数被调用时,会创建一个函数执行上下文并压入调用栈中。
在 js 中,函数名是指向函数对象的指针,函数名后接小括号对时,表示函数调用。
先看下面代码:
var a = "global";
function g() {
var a = "local";
function f() {
console.log(a);
}
return f();
}
g();
调用栈中的执行上下文的调用过程如图:
简单分析下:js 引擎加载代码时,首先创建一个全局执行上下文并压入调用栈,函数 g被调用后,创建函数 g 的执行上下文压入栈中,在函数 g 内部又调用了函数 f,函数 f 执行完毕,从栈中弹出,紧接着函数 g 也执行结束,从栈中弹出。一旦所有代码都执行完毕,全局执行上下文也会从调用栈中弹出。
上面的代码中,函数 f 输出了变量 a,不过函数 f 内部并没有定义变量 a,这个时候引擎会去函数 f 外部查找a。不过这个时候就出现问题了,在代码中有两个变量 a,一个是全局变量 a;另一个是函数 g 内的变量 a,js 引擎到底会找到他们中的哪一个呢?这个就涉及到了 js 引擎访问变量的规则,也就是作用域。
作用域
作用域是程序源代码中定义变量的区域,本质上它是程序存储和访问变量的规则。
js 作用域种类
js 有三种作用域
- 全局作用域
- 函数作用域
- 块级作用域(ES6新增)
作用域模型
有两种作用域模型:词法作用域和动态作用域,二者的区别在于划分作用域的时机:
- 词法作用域:也成为静态作用域。在代码书写的时候完成划分,作用域链沿着它声明的位置向外延伸。大多数语言都属于该模型(包括 js)。
- 动态作用域:在代码运行时完成划分,作用域链沿着它的调用栈向外延伸。比较冷门,
Bash、Perl等语言采用该模型。
js 是词法作用域模型,因此,在代码定义时,就已经确定好作用域了。换句话说,js 引擎会从函数声明的位置来查找变量。
结合执行上下文来理解,我们上面只分析了词法/变量环境中的环境记录,还有一个对外部环境的引用没有提到,而这个对外部环境的引用,会指向外部的执行上下文,js 引擎会根据这个引用的指向去查找变量。
函数作用域
function f() {
console.log(a);
}
function g() {
var a = "local";
f();
}
var a = "global";
g();
示意图:
分析过程:在编译阶段,函数 f 中使用了变量 a,js 引擎首先在当前上下文中查找该变量,没有找到则沿着对外部环境的引用所指向的执行上下文中查找,以此类推,直到在全局执行上下文也没有找到时,会报出错误提醒我们该变量不存在。
作用域嵌套作用域,在内层作用域找不到的变量,js 引擎会依照函数定义的位置向上层作用域寻找,就此形成了作用域链。因此作用域链也是在编译阶段就创建好了。
块级作用域
var a = "global"
let b = 10
let test = "test1"
function f() {
let ly = "qu"
var a = "local_f"
{
let a = "block_f"
console.log(test)
}
}
function g() {
var a = "local_g"
let test = "test2"
{
let test = "test3"
f()
}
}
g()
示意图:
查找顺序已经标注,先沿着当前执行上下文的词法环境的栈顶向下查找,然后再去变量环境中查找,接着去函数声明的位置,即全局执行上下文中查找,仍旧是先在词法环境中查找,最后在变量环境中成功找到了变量 a。
补充知识
语句和表达式
表达式和语句的区别是,一条语句执行一个动作,一个表达式会产生一个值。
一个简单的鉴别方法是,将代码放入浏览器的控制台执行,浏览器返回值的就是表达式,不返回值的就是语句;或者将代码放入 console.log()中,有输出的就是表达式,否则是语句。
如图,函数的定义是语句:
如图,赋值操作是语句:
语句和表达式的特点是:表达式永远不会在编译阶段执行。
在使用 React 的 JSX 语法时,JSX 的大括号内只能包括表达式,不能包括语句。
同名变量或函数
-
同名的二者都是变量,无疑,后声明的变量会覆盖先声明的变量。
-
同名的二者都是函数时,与变量相同,后声明的函数会覆盖先声明的函数。
function f() { console.log('ly_qu'); } f(); function showName() { console.log('page_not_found'); } f(); //输出page_not_found -
同名的二者既有变量又有函数时,在编译阶段,变量的声明会被忽略。
f() //输出 function var f = function() { // 函数表达式 console.log("variable") } function f() { // 函数声明 console.log('function') } f() //输出 variable要清楚一点,代码第二行用
var定义的f是一个变量,它保存了一个函数对象的内存地址。上面的代码相当于下面的写法:
var f = undefined; function f() { console.log('function') } f() //输出 function f = function() { console.log("variable") } f() //输出 variable
深入理解调用栈
用一个对比的例子加深对调用栈的理解。这是上面讲解执行上下文栈时的例子:
var a = "global";
function g() {
var a = "local";
function f() {
console.log(a);
}
return f();
}
g();
调用栈的出入栈操作:
ECStack.push(<g> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();
稍微改动一下:
var a = "global";
function g() {
var a = "local";
function f() {
console.log(a);
}
return f;
}
g()();
调用栈的出入栈操作:
ECStack.push(<g> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();
两段代码都输出 local,但是调用栈的顺序却不同。
总结
js 的执行机制是先编译,再执行。
在编译期,会创建相应的执行上下文,执行上下文之间的切换通过调用栈来调度。
执行上下文具有变量环境和词法环境。变量环境实现了变量提升;词法环境实现了暂时性死区,且词法环境通过一个栈结构实现了块级作用域。
js 基于词法作用域模型,所以 js 引擎会从函数声明的位置去寻找变量。
了解了 js 中表达式与语句的区别,表达式不会在编译期间执行。
当声明的函数和变量同名时,编译期变量声明会被忽略。