JavaScript中的作用域与闭包

190 阅读5分钟

img

作用域

  • JavaScript引擎从头到尾负责JavaScript程序的编译以及执行。
  • 编译器:负责语法分析以及代码生成等。

image-20230827202917009.png

  • 作用域:收集并维护所有编译阶段所声名的变量组成一系列严格的查询规则(其实就是作用域链),用来确定当前所执行的代码是否拥有对这些变量的访问权限

作用域链

作用域链中的每个作用域都包含了在该作用域中声明的变量,以及对外部作用域的引用。这些引用形成了一个链条,允许引擎按照特定的规则(LHS、RHS等)在不同的作用域中(逐级向上)查找变量的值。

  • LHS:在赋值操作中查找变量的存放位置,为赋值操作寻找目标。

  • RHS:查找变量中存放的。(字面量不会进行RHS查询,比如数字“ 5 ”)。

    JavaScript引擎、作用域与编译器之间的关系

image-20230827221342347.png

词法作用域

作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域。另外一种叫作动态作用域,仍有一些编程语言在使用(比如Bash脚本、Perl中的一些模式等),而JavaScript所采用词法作用域

对词法作用域的理解

在代码编写后,JavaScript 解析器会对代码进行分析,识别出变量声明和作用域嵌套关系。这个过程在语法分析阶段完成。在这个阶段,解析器会创建作用域链,并确定变量在不同作用域中的可见性和访问权限。

因此,词法作用域是在代码编写阶段就确定的,它在代码执行之前就已经被建立起来,换句话说,词法作用域是由你在写代码时将变量和块作用域(ES6)写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的,因为有evalwith这俩小子搞特殊😫)。这也是为什么 JavaScript 的作用域是词法作用域(也叫静态作用域)的原因,因为作用域的规则是在代码编写时静态决定的,而不是在代码执行时动态确定的。

注意:

​ JavaScript解析器 !=  整个JavaScript引擎

示例代码 ​

 function foo(a) {
     var b = a * 2;
     function bar(c) {
       console.log(a, b, c); // 2,4 12
     }
     bar(b * 3);
  }
 foo(2);

作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。(有先找自己的,自己没有就找别人的,别人没有那就玩完了😏,此时报错在向你招手😍。)

函数作用域与块作用域

函数作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(嵌套的作用域中也可以使用,毕竟有作用域链查找)

​ 示例代码 ​

 function foo(a) { // <-- 开端
     var b = a;
     console.log(b);
  }// <-- 结尾
 foo(2);

注意:JavaScript中var声名的变量只存在函数作用域全局作用域,而块作用域是在ES6letconst中才存在。

块作用域

​ 示例代码 ​

 if (666) {
   var a = 10;
   console.log(a); // 10
 }
 console.log(a); // 依旧可以访问到 a ===> 10

而let与const不存在此问题

  if (666) { // <-- 块作用域Start
   let a = 10;
   console.log(a); // 10
 }// <-- 块作用域End
 console.log(a); // Uncaught ReferenceError: a is not defined
  if (666) {// <-- 块作用域Start
   const b = 20;
   console.log(b); // 20
 }// <-- 块作用域End
 console.log(b); // Uncaught ReferenceError: b is not defined

for循环与块作用域

 for (var i = 1; i <= 3; i++) {
   setTimeout(function timer() {
     console.log(i);
   }, i * 1000);
 }

image-20230828005302817.png

执行了3次没错,但是为什么都是4?这是由于var出手了😁,请看下图

image-20230828003912982.png

那该如何做呢?

用let😙

 for (let i = 1; i <= 3; i++) {
   setTimeout(function timer() {
     console.log(i);
   }, i * 1000);
 }

image-20230828005347437.png

image-20230828005159085.png

使用let关键字声明变量i,会在每次循环迭代时创建一个新的块级作用域(变量i在循环过程中不止被声明一次)。每个迭代都会创建一个新的作用域,每个迭代都会使用上一个迭代结束时的初始化 i这个变量,确保在定时器回调函数内部捕获到的i值是该迭代中的值。

var的变量提升

变量和函数(函数首先会被提升,然后才是变量)在内的所有声明都会在编译阶段,也就是任何代码被执行前首先被处理。

只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。

var声名提升示例代码 ​

 console.log(a);
 var a = 2;

提升后:

 var a; // 声名提升
 console.log(a); // undefined
 a = 2; // 赋值操作留在原地

​ 函数声名提升示例代码 ​

 foo(); // 1
 var foo;
 function foo() {
   console.log(1);
 }
 foo = function () {
   console.log(2);
 };

提升后:

 function foo() {
   console.log(1);
 }
 var foo; // 变量声明被提升,但由于函数声明存在,被忽略(因为重名了)
 foo(); // 1
 foo = function () {
   console.log(2);
 };

var foo被忽略是因为函数声明的提升优先于变量声明的提升,因此在遇到这种情况时,函数声明会覆盖同名的变量声明。

另一种情况: 存在多个同名的函数声明,后面的函数声明会覆盖前面的函数声明。

 foo(); // 3
 function foo() {
   console.log(1);
 }
 var foo = function () {
   console.log(2);
 };
 function foo() {
   console.log(3);
 }

提升后

 // 后来居上!(被覆盖了)
 function foo() {
   console.log(1);
 } 
 function foo() {
   console.log(3);
 }
 var foo; // 变量声明被提升,但由于函数声明存在,被忽略(因为重名了)
 foo();// 3
 foo = function () {
   console.log(2);
 };

闭包

  • 当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
  • 无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
 function foo() {
   var a = 2;
   function bar() {
     console.log(a);
   }
   return bar;
 }
 var baz = foo();
 baz(); // 2 

函数bar()的词法作用域能够访问foo()的内部作用域,然后将bar()函数本身当作一个值类型(baz)进行传递。赋值给变量baz并调用baz(),实际上只是通过不同的标识符引用调用了内部的函数bar()。

基于bar()所声明的词法作用域位置,它拥有涵盖foo()内部作用域的引用,使得该作用域(foo)能够一直存活,以供bar()在之后任何时间进行引用。

bar()依然持有对该作用域的引用,而这个引用就叫作闭包。

参考资料来源:《你不知道的JavaScript 上卷》—— Kyle Simpson