前言
作用域 、 执行上下文 、 闭包 这几个概念,作为前端开发人员应该都不会陌生。它们都是JavaScript引擎执行时变量管理和查找的机制。每一个概念网上都有很多文章去描述和解释,学习者也都能通过这些机制去分析一些代码的执行情况。但其实它们是有关联的,在有一定基础的情况下,把他们合在一起分析,看引擎是如何去应用这几个概念,并且看它们之间如何相互作用的,会让你对它们有更透彻的理解。
本文会带你从这几个概念出发,从一些简单的代码入手,结合chrome控制台,让你一步步理解它们。深度参考了深入理解javascript原型和闭包(完结)的闭包部分,写的非常透彻,感兴趣的可以深入阅读。
执行上下文
简而言之,执行上下文是评估和执行 JavaScript 代码的环境的抽象概念。每当 Javascript 代码在运行的时候,它都是在执行上下文中运行。
这个是网上对于执行上下文一个普遍的定义。但是具体来说是什么呢?
我们都知道JavaScript是一种解释型语言,但它的变量提升行为又表示它不是一行一行解释代码的,导致这有一些争议,关于这一部分,可以参考这篇文章,文章中说了,在执行到一个具体的函数,就会先执行其 执行上下文 ,再执行代码。而变量提升就是执行上下文中的一环。因此,执行上下文可以理解为: 在函数(script标签理解为一个全局函数)执行之前,会对当前代码进行词法分析并完成变量的声明和初始化
注:除了全局上下文和函数上下文,还有eval上下文,现在不推荐使用,不在此过多讨论
对于不同情况,执行上下文的处理逻辑各不相同。并且对全局执行上下文和函数执行上下文处理也不一样
全局执行上下文
console.log(v1);
// console.log(v2);
// console.log(v3);
console.log(f1);
console.log(f2);
console.log(this);
var v1 = 10; // 声明,初始化为undefined
let v2 = 20; // 声明,不会初始化,访问报错
var f1 = function () {}; // 声明,初始化为undefined
function f2() {} // 声明,并赋值
/**
* 全局环境执行上下文为:
* v1: undefined
* v2: not initialization
* f1: undefined
* f2: f f2() {}
* this: Window
*/
在全局执行上下文中,执行的逻辑如下
- 普通变量(包括函数表达式):声明,赋值为undefined
- let const 变量: 声明,但不初始化
- 函数声明:赋值
- this:赋值
函数执行上下文
debugger;
function f1(a1) {
debugger;
console.log(i1);
console.log(fi1);
console.log(this);
console.log(arguments); // 声明,并赋值
console.log(a1); // 声明,并赋值
var i1 = 30; // 声明,初始化为undefined
function fi1() {} // 声明,并赋值
}
f1(20);
/**
* 函数f1执行上下文为:
* i1: undefined
* fi1: f fi1() {}
* a1: 20
* arguments: [20]
* this: Window
*/
在函数执行上下文中,除了执行全局上下文相同逻辑外,还会:
- 参数:赋值
- arguments:赋值
执行上下文栈
执行全局代码时,会产生一个执行上下文环境,每次调用函数都又会产生执行上下文环境。当函数调用完成时,这个上下文环境以及其中的数据都会被消除,再重新回到全局上下文环境。处于活动状态的执行上下文环境只有一个。
如何管理这些执行上下文呢?其实这是一个压栈出栈的过程,JavaScript 引擎创建了 执行上下文栈 来管理执行上下文。
debugger; // 1. 全局执行上下文入栈
var v1 = 10;
function f1(y) {
debugger; // 3. f1函数执行上下文入栈
var c = 5;
console.log(y + c);
}
function f2(x) {
debugger; // 2. f2函数执行上下文入栈
var b = 5;
f1(x + b);
debugger; // 4. f1函数执行上下文出栈
}
f2(v1);
debugger; // 5. f2函数执行上下文出栈
理解执行上下文可以通过chrome的devtools调试工具查看
如上:调用堆栈可以等同于执行上下文栈,而范围里面的本地等同于当前的执行上下文。注意:范围表示的是作用域链,后面会讲解。
作用域
作用域是在运行时代码中的某些特定部分中变量,函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。
作用域只是一个“地盘”,一个抽象的概念,其中没有变量。要通过作用域对应的执行上下文环境来获取变量的值。 如果要查找一个作用域下某个变量的值,就需要找到这个作用域对应的执行上下文环境,再在其中寻找变量的值。
我们都知道,作用域分 全局作用域, 函数作用域 和 块级作用域 。比如全局作用域的地盘对应的是全局执行上下文。函数作用域的地盘对应的是函数执行上下文,每次调用里面会更新。
debugger; // 全局作用域
var v1 = 10,
v2 = 20;
function f1() {
debugger; // f1作用域
var v1 = 100;
let v3 = 300;
function f2(arg) {
debugger; // f2 作用域
var v1 = 1000,
v4 = 4000;
}
f2(1); // 每次调用产生一个执行上下文
f2(2);
}
f1();
for (let i = 0; i < 5; i++) {
// 块级作用域
setTimeout(() => {
debugger;
console.log(i);
});
}
作用域链
自由变量
前面讲了,在函数的执行期间,会有一个作用域管理可以访问的变量和函数,而这个作用域的范围即为该函数的执行上下文。那如果访问到执行上下文没定义的变量呢?
如果一个变量x在作用域F中没有声明,即称这个变量x为作用域F中的自由变量。
var v1 = 10;
function f1() {
var v2 = 20;
console.log(v1 + v2); // v1为f1作用域的自由变量
}
作用域链
那这个变量会怎么查找呢?答案就是通过作用域链一级一级往上查找。
作用域链是一种作用域一层一层往上的关系。其中比较重要的就是上级的定义:上下级关系的确定看函数是在哪个作用域下创建的,而非在哪个作用域下调用的。
debugger;
var v1 = 10;
function f1() {
debugger;
var v2 = 20;
function fi() {
debugger;
/**
* 自由变量通过作用域链获取;作用域为词法作用域,由函数定义时确认
* 因为fi是定义到f1当中的,虽然在后面通过f2调用,但是获取到f1中的v2
*/
console.log(v1 + v2);
}
return fi;
}
var fi = f1();
function f2() {
debugger;
var v2 = 200;
fi();
}
f2();
疑问
根据上面的定义,这里有一个疑问。作用域里的变量是通过执行上下文获取的,而执行上下文是一个入栈出栈的过程。一个 内部函数inner 定义在 外部函数outer 中且引用了outer中的变量被返回出来,它的上级作用域是outer,当执行完outer得到inner,这时根据执行栈机制,outer的执行上下文已经推出调用栈,里面变量会被回收。当执行inner时,怎么通过作用域链拿到outer执行上下文中的变量,如上面例子所示。
闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。 —《你不知道的JavaScript(上)》
闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。 — 《JavaScript高级程序设计(4)》
上面的两段是对闭包的不同的解释。可以看到,都和作用域相关。其实闭包的文字解释比较难一句话解释清楚。通过 devtools 观察到的现象,结合上面的一些概念,可以定义如下:
当函数中引用了作用域链上定义过的自由变量,即会产生闭包
上面的 疑问 就是一种典型的闭包现象,下面这种常规的嵌套函数也形成了闭包。
变量保存
不管是常规的嵌套函数也好,还是 疑问 中的现象也好。我们知道,作用域链不是通过执行上下文栈查找变量的,尤其是 疑问 中体现的更明显。像后者这种情况,如果上级作用域的执行上下文已经不再调用栈中,内部变量按理应该被回收了,那当前作用域是从哪获取到上级作用域中的变量的呢?
我们还是可以通过 devtools 观察到 chrome 中的处理方式。先说结论
在函数声明时,引擎会递归查看函数内部进行词法分析,判断其中是否有 自由变量 ;如果有,根据当前的作用域及作用域链,找到自由变量的声明,并将其所在的作用域上的执行上下文绑定到函数声明上。
function f1() {
debugger;
var v11 = {
name: "v11",
age: 20,
};
var v12 = 30;
function f2() {
debugger;
var v21 = 40;
function f3() {
debugger;
function f4() {
debugger;
console.log(v11.age, v21);
}
f4();
}
f3();
}
f2();
}
f1();
如上所示,当声明f2时,可以看到,在内部函数f4中引用了f1作用域中自由变量v11,因此会将f1的执行上下文绑定到f2的声明对象上,并且处理为只绑定使用的变量
后记
通过对这几个概念的分析,并结合代码和调试工具,可以观察到在简单的情况下引擎具体是怎么执行的,实际开发中复杂情况也可以以此分析。在这之中也有一些一笔带过的点,比如 this,它是一个单独的专题,有具体的绑定规则,和 动态作用域 相关,而我们分析的是作用域是 词法作用域 ,一种 静态作用域 ,可以通过《你不知道的JavaScript(上)》了解。