作用域与闭包

62 阅读9分钟

作用域是什么

用来存储变量并可以在之后快速找到变量的一套规则

编译原理

编译流程

  • 分词/词法分析 将代码拆分成词法单元 b比如 var a = 2 拆分成 var, a, = ,2
  • 解析/语法分析 将词法单元流(数组)转化成抽象语法树(AST)
  • 代码生成 将AST转化成可执行代码

介绍3个角色

  • 引擎:负责整个程序的编译与执行
  • 编译器:配合引擎,负责语法分析及代码生成
  • 作用域:配合引擎,收集变量并鉴定代码对变量的访问权限

再介绍两个术语

LHS

  1. 赋值操作的左侧,试图找到变量的容器本身从而进行赋值

    比如 var a = 2 LHS操作会试图找到a的容器,将2赋值给a

  2. 若找不到变量容器,严格模式下引擎就抛出ReferenceError异常,非严格模式下会隐式创建一个容器

RHS

  1. 赋值操作的非左侧,试图得到变量的值

    比如 console.log(a) RHS试图找到a的值,将他打印出来

  2. 若找不到变量引擎就抛出ReferenceError异常

    若对变量进行不合理操作引擎会抛出TypeError异常

引擎和作用域的对话

function foo(a) {
 console.log( a ); // 2
}
foo( 2 );

让我们把上面这段代码的处理过程想象成一段对话,这段对话可能是下面这样的。

引擎:我说作用域,我需要为foo进行RHS引用。你见过它吗?

作用域:别说,我还真见过,编译器那小子刚刚声明了它。它是一个函数,给你。

引擎:哥们太够意思了!好吧,我来执行一下foo。

引擎:作用域,还有个事儿。我需要为a进行LHS引用,这个你见过吗?

作用域:这个也见过,编译器最近把它声名为foo的一个形式参数了,拿去吧。

引擎:大恩不言谢,你总是这么棒。现在我要把2赋值给a。

引擎:哥们,不好意思又来打扰你。我要为console进行RHS引用,你见过它吗?

作用域:咱俩谁跟谁啊,再说我就是干这个。这个我也有,console是个内置对象。给你。

引擎:么么哒。我得看看这里面是不是有log(..)。太好了,找到了,是一个函数。

引擎:哥们,能帮我再找一下对a的RHS引用吗?虽然我记得它,但想再确认一次。

作用域:放心吧,这个变量没有变动过,拿走,不谢。

引擎:真棒。我来把a的值,也就是2,传递进log(..)。

词法作用域

词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时(除欺骗词法)会保持作用域不变

查找

  • 作用域查找会在找到第一个匹配的标识符时停止
  • 只会查找一级标识符 比如代码中引用了 foo.bar.baz, 词法作用域查找只会试图查找 foo 标识符, 找到这个变量后,对象属性访问规则会分别接管对 bar 和 baz 属性的访问。
  • 无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

欺骗词法

eval(..)和with会在运行时修改或创建新的作用域

影响性能: JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符,而eval(..)和with会使其内部的变量和函数定义位置不可控,因为不知到会接收什么代码,所以会影响性能

函数作用域

函数作用域是指属于这个函数的全部变量都可以在整个函数范围内使用以及复用

为什么要基于作用域做一些封装

  • 最小暴漏原则 :将一些原本应该是私有的变量或者方法封装起来,阻止外部对变量或函数进行访问
  • 规避冲突:很常见同名标识符之间的冲突
    let a 
    let a //Uncaught SyntaxError: Identifier 'a' has already been declared
    
    {let a}
    {let a}
    

作用域避免冲突的一些方法

前面提到封装可以避免冲突,还有别的一些方法可以避免

  • 全局命名空间: 程序中有很多三方库,这些库通常会在全局作用域中声明一个代表自己的变量

    var MyReallyCoolLibrary = 
      {  
        awesome: "stuff",   
        doSomething: function() {// ...},   
        doAnotherThing: function() {// ...}
      }
    
  • 模块管理 : 选择一些模块管理器,将库的标识符显式的导入另一个作用域中

    import React from "react";
    

这些避免冲突的方法都利用作用域规则来实现

IIFE

var a = 2
function foo(){
  var a = 3
  var b = 4
  console.log(a) //3
}
foo()
console.log(a) // 2
console.log(b) //b is not defined

虽然foo()可以避免命名冲突和防止外部访问,但任然有一些问题 比如

  1. foo()这个函数要声明,这意味着foo这个名称已经‘污染’了其所在作用域
  2. 必须显式的调用foo()这个函数才能运行其中的代码

IIFE可以解决这两个问题,可以不需要函数名并且能自动运行

var a = 2;
(function foo(){
   var a = 3
   var b = 4
   console.log(a) //3 
 })()
console.log(a) //2
console.log(b) //b is not defined

注意点: (function foo(){...}) 是函数表达式 表示foo这个函数自能在‘{...}’ 中被访问 外部不行 foo变量名被隐藏在自身中 这个表达式要通过‘()’执行 (function foo(){...})() 就会执行‘{...}’中的代码

function foo(){...}是声明一个函数,需要调用才能执行 foo()才能执行‘{...}’中的代码

同样我们也可以将foo省略,因为()将foo这个函数变成了一个表达式,所以并不需要foo这个变量对这个函数命名

var a = 2;
(function (){
   var a = 3
   var b = 4
   console.log(a) //3 
 })()
console.log(a) //2
console.log(b) //b is not defined

这就是匿名函数表达式,这很常见比如一些回调函数中 setTimeout(function (){...},1000)

IIFE中第一个()将函数变成表达式,第二个()用来执行,函数是有形参的,我们执行时可以在第二个()中传入对应的实参

var a = 2;
(function (global){
  var a = 3
  console.log(a) //3
  console.log(global.a) //2
})(window)

//////////////////////////////////////////////////
var a = 2
(function (callback){
   ...
   callback(window)
})(function callback(global){
      var a = 3
      cnsole.log(a) //3
      console.log(global.a) //2
  })

块作用域

块作用域作用:缩短 变量的声明 与 使用的地方 之间的距离, 并最大限度地本地化

简单的理解就是将变量限制在某一个块 '{...}' 中,外部访问不到

一写常见的可以形成块作用域的方式

try/catch

try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效

try{
  undefined();
}catch(err){
  console.log(err)
}
console.log(err) //ReferenceError: err not found
let

let关键字可以将变量绑定到所在的任意作用域中

{
 let a  = 1
 a = 2
}
console.log(a) //ReferenceError: a not found
const

const关键字也可以将常量绑定到所在的任意作用域中

{
  const a = 1
  a=2 //TypeError: Assignment to constant variable.
}
console.log(a); //ReferenceError: a is not defined

提升

回忆一下, 引擎会在解释JavaScript代码之前首先对其进行编译。 编译阶段中的一部分工作就是找到所有的声明, 并用合适的作用域机制(词法作用域)将它们关联起。为了更好的找到这些声明,会将包括变量和函数在内的所有声明 在任何代码被执行前 首先被处理

用一些简单的例子来理解

//变量提升
var a = 2              |    var a   
console.log(a)         |    a = 2
                       |    console.log(a)  //2


//函数提升
foo();                 |      function foo(){   
function foo(){        |        var a;
  console.log(a);      |        console.log( a ); // undefined 
   var a = 2;          |        a = 2;  
}                      |      }
                       |      foo();



foo()                  |   var foo
var foo = function(){  |   foo()   //TypeError
...                    |   foo = function(){..}
}                      |


//如果函数和变量都提升,先提升函数在提升变量, 重复的声明(foo)会被忽略
foo()                  |    function foo(){  
function foo(){        |     console.log(1)   
 console.log(1)        |    }  
}                      |    foo() //1
var foo = function(){  |    foo = function(){
 console.log(2)        |      console.log(2)
}                      |    }


//后面的声明会覆盖前面的声明
foo();                 |   function foo() {  
function foo(){        |    console.log( 3 )
 console.log( 1 )      |   }
}                      |   foo(); // 3 
function foo() {       |
 console.log( 3 )      |
}                      |


 //不受条件判断控制
foo()                               |  function foo(){console.log(2)}  
if(1){                              |  foo()   //2   
 function foo(){console.log(1)}     |  if(1){
}else{                              |  }else{}
 function foo(){console.log(2)}     |
}                                   |

闭包

定义

函数被外层作用域引用并可以访问其所在层的词法作用域时,就产生了闭包

一个例子

function foo() {
  var a = 2;
  function bar() {   
    console.log( a );
  }
return bar;
}
var baz = foo();
baz(); // 2

函数bar在外层赋值给baz,调用baz实际上是引用调用bar,即bar被外层引用。同时bar定义在foo内,其所在的词法作用域由于被bar所引用而无法被垃圾回收,这个引用就是闭包(创建闭包)

也就是说闭包使得 当函数在定义时的词法作用域之外被引用时,可以继续访问定义时的作用域(使用闭包)

再看个例子

function foo() {
  var a = 2;
  function baz() {
    console.log(a); //2
  }
  bar(baz);
}
function bar(fn) {
  fn();  //闭包了
}

将内部函数baz传递给bar在foo外部执行从而形成了闭包 ,也就可以访问foo的作用域

也就是说,无论通过何种手段将北部函数传递到其所在词法作用域外,他都会持有对其定义时的作用域的引用,无论在何处执行都会产生并使用闭包

闭包的应用--模块

闭包最大的一个应用就是模块

看例子

function CoolModule() {
  var something = "cool";
  var another = [1, 2, 3];
  function doSomething() {
    console.log(something);
  }
  function doAnother() {
    console.log(another.join(" ! "));
  }
  return {
    doSomething,
    doAnother
  };
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

通过调用CoolModel创建一个模块实例,返回的doSomething和doAnother可以看成CoolModel模块的API,foo接受CoolModel的返回值,之后可以通过foo访问模块的API

回到我们现在的开发习惯中 我们通常将模块写在一个独立的文件中然后export导出,在需要用到的地方import引入以达到异步加载模块的目的

其中export会将当前模块的一个标识符( 变量、 函数)导出为公共API,import可以将一个模块中的一个或多个API导入到当前作用域中, 并分别绑定在一个变量上 《你不知道的JavaScript》读后的个人总结