你不知道的 JS 学习笔记:作用域和闭包

139 阅读6分钟

第一章:作用域是什么

1.1 编译原理

  • 编译过程
    • 分词 / 词法分析:将一个语句分解为有意义的代码块,即:词法单元
      • 比如: let a = 2; 被分解为:let、a、=、2、; 5 个词法单元
      • 分词:无状态解析规则,词法分析:有状态解析规则
    • 解析 / 语法分析:词法单元 -> 抽象语法树(Abstract Syntax Tree,AST)
    • 代码生成:AST -> 可执行代码
  • JS 代码在执行前都需要编译

1.2 理解作用域

  • 编译过程重要参与对象
    • 引擎:负责 JS 编译和执行过程
    • 编译器:负责编译过程
    • 作用域:收集并维护所有声明的标识符(变量),通过规则限制代码对标识符的访问权限
  • 变量赋值的两个操作
    • 在当前作用域中声明一个变量(如果之前存在则忽略)
    • 运行时引擎在作用域中查找该变量,找到了就赋值
  • 编译器基础术语
    • 赋值操作使用 LHS,获取目标变量的值使用 RHS
      • LHS:从左侧查找,即:查找某个容器本身
        • 比如:a = 2,需要找到为 = 2 赋值的目标
      • RHS:从右侧查找,即:查找某个变量的值(retrieve his source value)
        • 比如:console.log(a);,这里 a 没有被赋值,但需要找到 a 对应的 value

1.3 作用域嵌套

  • 在当前作用域找不到变量,就向上层作用域查找,直到找到该变量,或者到最外层作用域(全局作用域)为止

1.4 异常

  • 如果在 RHS 在任何作用域都找不到,会抛出 ReferenceError 异常
  • 非严格模式下,如果在全局作用域也找不到,会在全局作用域创建一个该名称的变量
  • 严格模式禁止自动隐式或自动创建全局变量
  • 如果 RHS 找到一个变量,但操作不合理,比如引用 null ,会抛出 TypeError 异常
  • ReferenceError 说明作用域判断异常,TypeError 说明作用域判断成功但对值的操作失败了

第二章:词法作用域

2.1 词法阶段

  • 词法作用域由变量和块作用于写在哪里决定
  • 作用域查找会在匹配第一个标识符时停止

2.2 欺骗词法

  • 欺骗词法作用域会导致性能下降
  • eval([str]) 函数:
    • 通常被用来执行动态创建的代码
    • 字符串作为参数,内容视未好行书写时就存在于程序中的位置
    • 可以修改词法作用域
  • with 关键字
    • 重复引用同一个对象中多个属性的快捷方式

    • 将一个对象的引用当作作用域来处理, 将对象的属性当作作用域中的标识符来处理,创建了一个新的词法作用域

    • 非严格模式,with 会造成变量泄漏到全局作用域,因为非严格模式会隐式创建全局变量

      var obj = {
        a: 1,
        b: 2
      }
      
      function foo(obj) {
        with (obj) {
          a = 2;
        }
      }
      
      console.log(foo(obj.a)); // undefined
      console.log(foo(a)); // 2
      
      • with 根据传递的对象凭空创建了一个全新的词法作用域

2.3 性能

  • JS 引擎在编译阶段对静态代码优化时,并不能确认 eval 和 with 内部的代码,最糟糕的情况是优化的代码可能完全是无效的

第三章:函数作用域和块作用域

3.1 函数中的作用域

  • 函数的全部变量在函数内部都可以被访问,从外部则无法访问

3.2 隐藏内部实现

  • 最小特权原则:软件设计中,应该最小限度的暴露必要的内容,比如内部函数或者内部类
  • 隐藏作用域的变量和函数可以避免同名标识符的冲突
    • 避免全局变量的使用
    • 全局命名空间:通过对象实现,将对外暴露的功能都作为这个对象的属性
    • 模块管理:通过管理器将库的标识符显式导入另一个特定的作用域中

3.3 函数作用域

  • 函数表达式
    • 声明函数的 function 不在第一个位置
      var a = 2;
      
      (function foo() {
        var a = 3;
        console.log( a );
      })();
      
      console.log( a ) // 2
      
    • 函数表达式可以将函数隐藏在自己的作用域,外部无法访问,不会污染外部作用域
  • 匿名函数:省略函数名称
    setTimeout( function() {
          console.log( 1 );
    }, 1000);
    
  • 具名函数:声明函数名称
  • 立即执行函数表达式(IIFE,Immediately Invoked Function Expression)
    • 函数表达式末尾加上一个 () 让函数立即执行
    • 可以当作函数调用并传递参数
      var a = 2;
      
      (function foo( global) {
            var a = 3;
            console.log( a ); // 3
            console.log( global.a ); // 2
      })(window);
      

3.4 块作用域

  • 使用 var 申明变量会属于外部作用域
  • try ... catch 的 catch 部分会创建块作用域
  • let 关键字
    • 将变量绑定到所在的作用域中,外部变量无法访问
    • let 声明的代码在 let 之前无法被访问
      {
        console.log( bar ); // ReferenceError
        let bar = 2;
      }
      
  • const 关键字
    • 可以创建块作用域变量,但值是固定的

第四章:提升

  • var a = 2;var 2 属于编译阶段任务,a = 2 属于解释阶段任务
  • 提升过程:现有声明,再有赋值。所有声明都被提升到各自作用域顶端
  • var 的变量声明会被提升
  • 函数的声明会被提升,函数表达式的声明不会被提升
    • 因为函数表达式的变量赋值会被提升,但还是 undefined,变量的函数操作就属于 TypeError

第五章:作用域闭包

  • 当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行
    function foo() {
      var a = 2;
    
      function bar() {
        console.log( a );
      }
    
      return bar;
    }
    
    var baz = foo();
    baz(); // 2
    
    • bar() 的词法作用域能够访问 foo() 内部作用域,将 bar() 函数本身作为一个值类型进行传递
    • 在自己定义的词法作用域以外的地方执行,依然持有对该作用的引用,这个引用就是闭包
    • 函数可以记住并访问所在的词法作用域, 即使函数是在当前词法作用域之外执行,这时就产生了闭包
    • 闭包使得函数可以继续访问定义时对词法作用域
  • 闭包应用示例:setTimeout
    function wait(message) {
      setTimeout( function timer() {
        console.log( message );
      }, 1000);
    }
    wait("hello message");
    
    • timer 函数具有涵盖 wait 作用域的闭包,因此还有对变量 message 的引用
  • 循环和闭包
    • 循环输出问题

      for (var i = 1; i <= 5; i++) {
        setTimeout(function timer() {
          console.log(i);
        }, i * 1000);
      }
      
      • 以上代码会连续输出 5 个 6

      • 延迟函数的回调会在循环结束时执行,即使延迟时间为 0

      • 问题原因:i 是共享的全局作用域,所以即使循环了 5 次,也相当于只有一个变量

      • 解决方案

        • 使用 IIFE 立即执行函数, 并且需要一个变量来存储每个迭代中的 i (即传入的参数)
          for (var i = 1; i <= 5; i++) {
            (function (j) {
              setTimeout(function timer() {
            	console.log(j);
              }, i * 1000);
            })(i);
          }
          
        • 使用 let 关键字
          • 将一个块转换成一个可以被关闭的作用域
          for (let i = 1; i <= 5; i++) {
          setTimeout(function timer() {
              console.log(i);
          }, i * 1000);
          }
          
  • 模块
    • 返回一个含有属性引用的对象将函数传递到词发作用域外部
    • 类似 React 和 Vue 的 hook 模式
      function useState() {
        var someThing = "wujie";
      
        function getSomeThing() {
          return someThing;
        }
      
        return {
          getSomeThing: getSomeThing,
        };
      }
      
      var state = useState();
      console.log(state.getSomeThing()); // wujie
      
    • 模块模式的两个必备条件
      • 必须有外部封闭函数,且该函数至少被调用一次(注:每次调用都会创建新的模块)
      • 封闭函数必须返回至少一个内部函数(闭包)