作用域、执行上下文、作用域链和闭包
「2020-08-28 发布,部分内容可能已过时,请合理参考」关于作用域以及执行上下文、闭包等概念都是JS的基础知识,可能平时学习时并没有仔细研究过,对其缺乏一定的认识,这往往会变成深入了解JS的绊脚石。再者其也成为面试的频考点,如果了解这方面的知识,面试就会游刃有余,当然对提升自己对JS的认识也有很大的帮助
变量声明与提升
对于如:JS、Java等高级编程语言,计算机本身是不认识也就读不懂其含义也就无法正常运行,因此对于高级编程语言而言,在真正的被计算机执行前都会进行一次编译,编译成计算机可以读懂的0、1
指令才可以被执行。我们知道JS有自己的V8引擎,它会对JS做解释运行,也就会涉及到变量声明,变量调用等等一系列操作,那么JS引擎是如何处理变量的,这里我们往下看。
变量声明
首先要知道JS是动态解释脚本语言,也就是会一行一行解释,但进行解释前JS引擎会对代码进行语法分析,也就是变量声明等一系列的处理,生成对应的抽象语法树(AST),然后进行编译器/优化器转化成字节码或机器码执行。如下面一段代码,这是一段很普通的定义变量的代码块,结果已经标识出来了,看和你想的一致不。首先语法分析时,JS引擎会扫描当前(这里指全局)作用域的变量,如上name、age然后定义他们,但并没有赋值也就是值为undefined
,所以如果当前作用域一个变量没有定义前去访问它并不会报错,而是打印出undefined
(1,2,4行)也就是未定义值的变量。而赋值只会在JS引擎解释到当前赋值操作时才会真正赋值(6行),然后在7行再次打印name
时,就正确打印了name的值,因为此时name已经进行赋值了,而最后一行访问不存在的addr变量时则会报错。
console.log(age); // undefined
console.log(name); // undefined
var name;
console.log(name); // undefined
var age = 1;
name = "Tom";
console.log(name); // Tom
console.log(addr); // addr is not defined
2
3
4
5
6
7
8
上面我们已经了解了JS会先进行语法分析声明变量,然后动态解释脚本进行赋值、执行等相关操作,以上都是关于普通变量的声明,而函数的声明会有点点区别,接着看下面代码:
run(); // run...
function run() { console.log('run...'); };
2
上面代码定义了一个名为run
的普通函数,而在首行定义函数前就执行了此函数,却正确的打印出了值,读者可能会在此产生疑问,前面同样是提前访问变量却打印的时undefined,按理说这里直接执行函数会报错啊(undefined()?
),带着疑问我们一探究竟。原来在语法分析时,函数会作为一等公民,会被提前到所有普通变量声明前进行声明,也就是说会先声明函数,紧接着是普通变量(针对当前作用域来说的),并且和普通变量声明不一样的是,函数声明会带上自己的实体也就是提前声明时就关联到自己的值了,所以为什么以上直接执行run
时可以正确执行,就是因为run首先被提前声明了,并且也有对应的值。
不过一等公民也是有约束的,不是所有的函数都会在提前声明有自己的实体,只有函数式声明才会满足一等公民的要求,而变量式声明的函数和普通变量没区别,来看下面代码run
还是会正常执行,而walk
函数将会报错walk is not a function
,此时的walk就是undefined,相当于执行了undefined()
当然会报错。
run(); // run...
// 函数式声明
function run() { console.log('run...'); };
walk(); // walk is not a function
// 变量式申明 并不会带上自己的实体
var walk = function() { console.log('walk...'); };
2
3
4
5
6
7
以上便于关于变量声明的理解和一些细节问题,接下来看下什么是变量提升、暂时性死区等等
变量提升和暂时性死区
所谓的变量提升是关于作用域的问题,在JS中有全局作用域、块级作用域和函数作用域说法,或者说当前作用域和其他作用域(关于作用域的概念下面会讲,这里简单的了解下不做过深解释)。JS中变量的声明只能在当前作用域或当前作用域以内的作用域访问到,若在某个代码块中声明的变量是不能在外部作用域中访问到的。
var global = "global"; // 全局作用域 声明了变量 global
{
var inner = "inner"; // 当前代码块作用域中声明了 inner 变量
console.log(global); // global
}
console.log(inner); // inner
2
3
4
5
6
如上代码分别定义了全局变量global
和局部变量inner
,正如前面讲的当前作用域以内的作用域的可以访问到当前作用域的变量,第4行打印出了全局变量global,而在最后一行打印内部作用域变量inner时也打印出了正确的值,这和我们讲的就有出入了,这里就需要了解什么是变量提升了。在ES6以前声明变量都是用关键字var
进行声明的,而这种声明有一个坏处就是所有的变量都会提升到全局作用域,这也就是为什么最后一行可以访问到inner的原因,很显然这种声明会有很多弊端,比如内部作用域声明的变量若是和全局变量同名将会覆盖全局的变量,也就会污染全局变量。
为解决这个问题,ES6推出了新的声明变量的关键字let
和const
,前者声明的变量可以不用初始化赋值,并且可以在后面修改,而后者定义的变量都是常量,也就是赋值后不能再修改,并且初始化时必须赋值否则报错,两者声明的变量都是块级作用域(也就是当前作用域的),都不会发生变量提升,并且不能在当前作用域被重复定义。
let global = "global"; // 全局作用域 声明了变量 global
{
let inner = "inner"; // 当前代码块作用域中声明了 inner 变量
console.log(global); // global
}
console.log(inner); // inner is not defined
let global = "global2"; // global has already been declared
2
3
4
5
6
7
如上换成了let定义变量后,在第6行访问块级作用域变量inner时报错,最后一行再次定义已经定义过的global时也会报错。接下来看看暂时性死区,前面用var声明变量前,访问该变量会打印undefined而不会报错,那全部换成let声明后呢,来看下面一段代码:
console.log(block); // block is not defined
let block = "block";
{
console.log(block); // Cannot access 'block' before initialization
let block = "inner block";
console.log(block); // inner block
}
console.log(block); // block
2
3
4
5
6
7
8
和前面的代码一样,只不过换成了let关键字。当运行时第一行就会报错not defined
说是没定义,而在代码块中第一次访问(第4行)block时却也会报错Cannot access 'block' before initialization
,不是说内部作用域可以访问外部作用域吗,为什么这里会报错。当前代码块中{}
也声明了block
(第5行),而访问变量时会优先取当前作用域的变量。这里需要知道用let关键字变量并没有改变JS引擎会提前声明变量的规则,JS首先会对当前作用域的变量进行申明,如全局的block、块级(局部)作用域的block,此时并不能被访问到,只不过JS引擎自己知道已有这些变量,只是我们不能使用,所以在第4行时访问block,此时访问的是当前块级作用域的block,但是还不能使用,所以直接访问会报错。也就是let/const定义的变量未声明之前是不能使用的,这就是暂时性死区。
再来看看const,定义时必须要赋值否则报错,定义后不能修改:
const a; // Missing initializer in const declaration
const b = 2;
b = 1; // Assignment to constant variable
2
3
4
以上就是关于变量声明、变量提升和暂时性死区相关概念,接下来看作用域。
作用域
作用域和词法环境其实很简单,说白了就是关于变量能为谁提供服务、变量的可见性和作用范围。为了不让变量提升影响到,以下都会以let关键字进行变量的声明,首先我们来看下面一段代码:
// global scope
let name = "global";
{ // block scope
let block = "block";
}
function fn() {
let age = 1;
console.log(age);
}
fn(); // 1
console.log(name); // global
console.log(block); // block is not defined
console.log(age); // age is not defined
2
3
4
5
6
7
8
9
10
11
12
13
从上面就已经展示了作用域的概念,可以正常访问全局的name
,但当访问全局中没有定义的block、age
变量时就会报错,因为他们没有在全局中定义。所以作用域就像个堡垒一样把控着变量的可见性,让不同作用域的变量互不干扰和污染。
在JS中有全局作用域
、函数作用域
和块级作用域
之分,所谓全局作用域就是在代码最顶层定义的变量,如在浏览器宿主环境中全局定义的变量都可以用window访问到,node中都会被global访问到。诸如在函数体中定义的变量、方法等等都是函数作用域中的变量,在函数外部是访问不到的,外界只能执行函数然后才会知道内部的内容。而块级作用域就是用let/const
关键字定义的变量只能在当前代码块中使用,如:{}
、函数体
等代码块,也是外部不能访问。
作用域是可以嵌套的,就好比我们的代码一样嵌套函数等等,内部作用域可以访问外部作用域的变量,而外部无法访问内部变量:
let out = "out"; // 1
function start() {
let name = "start";
console.log(out); // out 2
function inner () {
let age = 1;
console.log(name); // start 3
}
}
start();
2
3
4
5
6
7
8
9
10
如上代码有三个作用域:全局、函数start、函数inner,从访问方向上只能从内部访问外部也就是3>2>1
,反之是不可以的。
词法环境
在展开作用域链前先来说说词法环境,在JS引擎刚开始是对代码进行静态分析时,会对每个变量、函数标记它们的位置、作用域等一些信息,这样在运行相关代码时可以访问到自己作用域的属性。就比如一个函数在返回函数时,不管这个函数最后在哪调用,都可以正确的访问到自己的词法环境中提供的变量。
function outer() {
let name = "outer";
return function() {
console.log(name);
}
}
const fn = outer();
// 不管这个函数在哪调用,都会正确的打印出name的值
fn(); // outer
2
3
4
5
6
7
8
9
以上代码会返回有一个匿名函数,函数内部会打印outer内部定义的变量的name,当执行这个匿名函数时总是会打印出outer
。因为返回的匿名函数会记住自己的此法环境,记录着它出生时周围的环境作用域信息,所以在执行它的时候总能够得到正确的值。
作用域链
作用域链是一种访问方式,可以类比一下原型链。如下代码,当执行app方法时可以正确的打印出name的值,代码中包含了全局作用域和函数作用域,也就是说作用域链决定了你可以访问到哪些不属于当前作用域的一些变量等信息,前面也说了内部作用域可以访问外部的作用域,外部不能访问内部。根据词法环境最初定义app方法时,会记住自己的当前的作用域,如果访问当前作用域不存在的变量时,会访问外层的作用域,直到null。
let name = "outer";
function app() {
console.log(name);
}
2
3
4
从当前作用域指向外层作用域,外层也指向它的外层作用域直到null,这就是作用域链,和原型链的概念差不多,但原型链针对对象属性而言的,作用域链是针对变量可访性的。
执行栈
由于JavaScript是单线程的,所有的代码都会在同一个地方执行即:执行栈(Call Stack),它是个LIFO结构(后进先出),用于储存所有代码执行期间的执行上下文。要明白执行栈首先来了解下JS的内存结构,看下图简单的画了JS内存部分图: 在JS最初运行时,会以整体script代码为基础创建全局上下文,然后推入执行栈中,当遇到一个函数时会为函数创建新的执行上下文,并推入执行栈中。对于一些异步任务,JS会在 Eventloop 的作用下,不断将任务队列中的任务送入执行栈中执行。当当的任务执行完毕时会从执行栈中弹出,进行垃圾回收并释放掉相关的资源。
执行上下文
执行上下文主要是为当前代码块服务的,在刚开始运行代码时,会创建全局上下文并推入执行栈中。执行上下文包含了当前运行代码的变量对象和词法环境。对于作用域的范围有全局变量对象GO和局部变量对象VO,变量对象主要存储var声明的变量。而词法环境则记录了代码的位置和作用域let/const、函数等变量以及外部的词法环境。当运行一个新的函数时,也会创建一个新的函数上下文并推入执行栈中,代码在运行时会从执行上下文中访问到范围内的变量。
闭包
闭包是前端的高频话题,那么究竟什么是闭包呢?来看一段代码:
for (var i = 0; i < 5; i ++) {
setTimeout(() => console.log(i));
}
// 以上都会打印 5 5 5 5 5
2
3
4
以上代码打印的值都是5,为什么呢?首先这个会涉及到异步编程和eventloop,循环体中的代码并不会执行,而在循环完后执行,此时i已经变成5了,因为setTimeout内部引用到了外部的作用域,所以可以访问到i的值5。那么只要给每次循环内的代码重新引用每次循环的i的值,就可以打印出正确的值,简单试下闭包:
for (var i = 0; i < 5; i ++) {
((i) => setTimeout(() => console.log(i)))(i);
}
// 0, 1, 2, 3, 4
2
3
4
上面对setTimeout包了一层使其变为IIFE(立即执行函数),并将每次循环时的i作为参数传递进去,这样setTimeout执行时就可以正确访问到i的值了。
当内部作用域引用了外部作用域的参数、变量或函数时,就会形成闭包。
由于全局作用域的存在,访问到外部变量的函数都会存在闭包,只是这种闭包并没有什么意义。
闭包的优点和缺点
闭包有很多优点,可以对内部功能进行封装,防止变量污染,如下定义一个计数器:
function counter() {
let count = 0;
return function () {
return count++;
}
}
let count = counter()
console.log(count()); // 0
console.log(count()); // 1
2
3
4
5
6
7
8
9
当一个函数被返回出来的时并形成闭包时,生成的对象会一直对函数内部的作用域变量进行引用,所以如果不清除掉外部变量,就会存在内存泄漏的风险。针对以上代码在不用count时,手动清除变量,进行内存的回收:
count = null; // 垃圾回收
总结
本文主要讲了变量声明、提升和暂时性死区的本质,接着又讲了什么是作用域、执行上下文、作用域链和执行栈的作用。希望通过本文可以让你对JS有更深的认识。