JavaScript面试考点之作用域和作用域链、执行上下文和执行栈

1、作用域和作用域链

1)作用域

作用域是变量(变量作用域又称上下文)和函数生效(能被访问)的区域。作用域决定了代码区块中变量和其他资源的可见性。

一般把作用域分为:全局作用域、函数作用域、块级作用域。

a、全局作用域:任何不在函数中或大括号中声明的变量,都是在全局作用域下。全局作用域下声明的变量可以在程序的任意位置访问到。

b、函数作用域:如果一个变量在函数内部声明的,它就是在函数作用域下,这些变量只能在函数内部访问,不能再函数以外访问。

c、块级作用域:ES6引入了let和const关键字,在大括号中使用let和const声明的变量存在于块级作用域中,在大括号外不能被访问。

d、词法作用域又称静态作用域,即变量创建时就确定好了,而非执行阶段确定。

2)作用域链:当在Javascript中使用一个变量的时候,首先Javascript引擎会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域。如果在全局作用域里仍然找不到该变量,它就会在全局范围内隐式声明该变量(非严格模式下)或是直接报错。

作用域不销毁的情况:

2、执行上下文和执行栈

1)执行上下文

执行上下文是一种对Javascript代码执行环境的抽象概念,也就是说只要有Javascript代码运行,那么它就一定是运行在执行上下文中。

执行上下文的类型分为三种:

a、全局执行上下文:只有一个,浏览器中的全局对象就是window对象,this指向这个全局对象。

b、函数执行上下文:存在无数个,只有在函数被调用的时候才会被创建,每次调用函数都会创建一个新的执行上下文。

c、Eval 函数执行上下文: 指的是运行在eval函数中的代码,很少用而且不建议使用。

紫色框住的部分为全局上下文,蓝色和橘色框起来的是不同的函数上下文。只有全局上下文(的变量)能被其他任何上下文访问。

可以有任意多个函数上下文,每次调用函数创建一个新的上下文,会创建一个私有作用域,函数内部声明的任何变量都不能在当前函数作用域外部直接访问

2)执行上下文的生命周期:创建阶段 → 执行阶段 → 回收阶段

创建阶段

创建阶段即当函数被调用,但未执行任何其内部代码之前。创建阶段做了三件事:

a、确定 this 的值,也被称为This Binding。

b、LexicalEnvironment(词法环境) 组件被创建。词法环境有两个组成部分:

全局环境:是一个没有外部环境的词法环境,其外部环境引用为null,有一个全局对象,this的值指向这个全局对象。

函数环境:用户在函数中定义的变量被存储在环境记录中,包含了arguments对象,外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境。

c、VariableEnvironment(变量环境) 组件被创建。变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。在 ES6 中,词法环境和变量环境的区别在于前者用于存储函数声明和变量(let和const)绑定,而后者仅用于存储变量(var)绑定。

全局环境
函数环境

解析:let和const定义的变量a和b在创建阶段没有被赋值,但var声明的变量从在创建阶段被赋值为undefined。

这是因为,创建阶段,会在代码中扫描变量和函数声明,然后将函数声明存储在环境中,但变量会被初始化为undefined(var声明的情况下)和保持uninitialized(未初始化状态)(使用let和const声明的情况下),这就是变量提升的实际原因。

执行阶段

在这阶段,执行变量赋值、代码执行。如果Javascript引擎在源代码中声明的实际位置找不到变量的值,那么将为其分配undefined值。

回收阶段

执行上下文出栈等待虚拟机回收执行上下文。

3)执行栈

执行栈,也叫调用栈,具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文。

解析:创建全局上下文请压入执行栈;

first函数被调用,创建函数执行上下文并压入栈;

执行first函数过程遇到second函数,再创建一个函数执行上下文并压入栈;

second函数执行完毕,对应的函数执行上下文被推出执行栈,执行下一个执行上下文first函数;

first函数执行完毕,对应的函数执行上下文也被推出栈中,然后执行全局上下文;

所有代码执行完毕,全局上下文也会被推出栈中,程序结束;

3、变量提升

变量提升(Hoisting)被认为是, Javascript中执行上下文 (特别是创建和执行阶段)工作方式的一种认识。我们可以理解成,在编译的阶段,js引擎帮我们把变量和函数的声明放在最前面,但实际上变量和函数声明在代码里的位置是不会动的。

1)创建阶段

用var关键字声明的变量是以默认值undefined来存储的。

用let,const声明的变量以uninitialized来存储的。这里就解释了,为什么我们用let和const声明的,不能在它之前使用,也就是暂时性死区

2)执行阶段

由于函数是以对整个函数代码的引用来存储的,我们甚至可以在创建函数的那一行之前调用它们,也是能够正常的运行。

当我们引用一个在其声明前用var关键字声明的变量时,它将简单地返回其存储的默认值:undefined

ES6中引出了const和let,就是为了防止意外地引用未定义的变量,就像我们用var关键字一样,每当我们试图访问未初始化的变量时,我们希望它会抛出一个ReferenceError

4、闭包

JavasSript 语言的特别之处就在于:函数内部可以直接读取全局变量,但是在函数外部无法读取函数内部的局部变量。

闭包其实就是一个可以访问其他函数内部变量的函数。创建闭包的最常见的方式就是在一个函数内创建另一个函数,创建的函数可以 访问到当前函数的局部变量。闭包的本质是当前环境中存在指向父级作用域的引用。

因为通常情况下,函数内部变量是无法在外部访问的(即全局变量和局部变量的区别),因此使用闭包的作用,就具备实现了能在外部访问某个函数内部变量的功能,让这些内部变量的值始终可以保存在内存中。

缺点:a、闭包会使得函数中的变量都被保存在内存中,内存消耗很大,可能导致内存泄露。在不使用闭包时,把被引用的变量设置为null,即手动清除变量

b、闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。

1)闭包常用的用途

a、闭包的第一个用途是使我们在函数外部能够访问到函数内部的变量。通过使用闭包,我们可以通过在外部调用闭包函数,从而在外部访问到函数内部的变量,可以使用这种方法来创建私有变量。

b、函数的另一个用途是使已经运行结束的函数上下文中的变量对象继续留在内存中,延长声明周期。因为闭包函数保留了这个变量对象的引用,所以这个变量对象不会被回收。

数据存放的正确规则是:局部、占用空间确定的数据,一般会存放在栈中,否则就在堆中(也有例外)。

上图中画红框的位置我们能看到一个内部的对象[[Scopes]],其中存放着变量 a,该对象是被存放在堆上的,其中包含了闭包、全局对象等等内容,因此我们能通过闭包访问到本该销毁的变量。

2)闭包产生的原因

首先需要理解作用域链的基本概念。其实很简单,当访问一个变量时,代码解释器会首先在当前的作用域查找,如果没找到,就去父级作用域去查找,直到找到该变量或者不存在父级作用域中,这样的链路就是作用域链。

上述代码中,fun1 函数的作用域指向全局作用域(window)和它自己本身;fun2 函数的作用域指向全局作用域 (window)、fun1 和它本身;而作用域是从最底层向上找,直到找到全局作用域 window 为止,如果全局还没有的话就会报错。作用域链是指当前函数一般都会存在上层函数的作用域的引用,那么他们就形成了一条作用域链。

闭包产生的本质就是:当前环境中存在指向父级作用域的引用。

从上述代码可以看出,这里 result 会拿到父级作用域中的变量,输出 2。因为在当前环境中,含有对 fun2 函数的引用,fun2函数恰恰引用了 window、fun1 和 fun2 的作用域。因此 fun2 函数是可以访问到 fun1 函数的作用域的变量。

那是不是只有返回函数才算是产生了闭包呢?其实也不是,回到闭包的本质,我们只需要让父级作用域的引用存在即可。

可以看出,其中实现的结果和前一段代码的效果其实是一样的,就是在给 fun3 函数赋值后,fun3 函数就拥有了 window、fun1 和 fun3本身这几个作用域的访问权限;然后还是从下往上查找,直到找到 fun1 的作用域中存在 a 这个变量;因此输出的结果还是2,最后产生了闭包,形式变了,本质没有改变。

3)闭包的表现形式

a、返回一个函数

b、在定时器、事件监听、Ajax 请求、Web Workers 或者任何异步中,只要使用了回调函数,实际上就是在使用闭包。

c、作为函数参数传递的形式

d、IIFE(立即执行函数),创建了闭包,保存了全局作用域(window)和当前函数的作用域,因此可以输出全局的变量。

IIFE 这个函数会稍微有些特殊,算是一种自执行匿名函数,这个匿名函数拥有独立的作用域。这不仅可以避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域,我们经常能在高级的 JavaScript 编程中看见此类函数。

面试题

上面这段代码执行之后,从控制台执行的结果可以看出来,结果输出的是 5 个 6,那么一般面试官都会先问为什么都是 6?我想让你实现输出 1、2、3、4、5 的话怎么办呢?

为什么都是 6?

a、setTimeout为宏任务,由于 JS 中单线程eventLoop 机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后 setTimeout 中的回调才依次执行。

b、因为setTimeout函数也是一种闭包,往上找它的父级作用域链就是 window,变量 i 为 window 上的全局变量,开始执行setTimeout 之前变量 i 已经就是 6 了,因此最后输出的连续就都是 6。

我想让你实现输出 1、2、3、4、5 的话怎么办呢?

1)利用IIFE

可以利用 IIFE(立即执行函数),当每次 for 循环时,把此时的变量 i 传递到定时器中,然后执行。

2)使用 ES6 中的 let

ES6 中新增的 let 定义变量的方式,使得 ES6 之后 JS 发生革命性的变化,让 JS 有了块级作用域,代码的作用域以块级为单位进行执行。

3)定时器传入第三个参数

setTimeout 作为经常使用的定时器,它是存在第三个参数的,日常工作中我们经常使用的一般是前两个,一个是回调函数,另外一个是时间,而第三个参数用得比较少。

5、let、const、var 的区别

var是ES5提出的,let和const是ES6提出的。ES5中作用域有:全局作用域、函数作用域。没有块作用域的概念。

var不存在块级作用域。let和const存在块级作用域。

var允许重复声明变量。let和const在同一作用域不允许重复声明变量。

var声明的变量存在变量提升(将变量提升到当前作用域的顶部)。即变量可以在声明之前调用,值为undefined。let和const不存在变量提升。即它们所声明的变量一定要在声明后使用,否则报ReferenceError错。

const是用来定义常量的,而且定义的时候必须初始化(如果定义的时候不初始化值的话就会报错),且定义后不可以修改。

是否能修改声明的变量?var和let可以。

const定义的基本数据类型的变量确实不能修改,那引用数据类型呢?

对象是引用类型的,const定义变量中保存的仅是对象的指针,这就意味着,const仅保证指针不发生改变,修改对象的属性不会改变对象的指针,所以是被允许的。也就是说const定义的引用类型只要指针不发生改变,其他的不论如何改变都是允许的。

你可能感兴趣的:(JavaScript面试考点之作用域和作用域链、执行上下文和执行栈)