作用域和闭包

802 阅读7分钟

前言

这一块的概念其实被反复讲了很多次,每个人都有自己不一样的理解。两者的重要性不言而喻,基本我们每天的代码都会多多少少接触到,而两者的又有千丝万缕的联系。以此做梳理前端知识体系的入口,我觉的有一定的门槛,但是对后续学习而言,这个基础打扎实了,以后万千大楼平地起便有了可能。

作用域

概念:这是一套规则,一套可以用来存储变量的,且方便后续查找变量的规则。

我们通过一个例子来说明这个概念,“ var=1,里面发生了什么 ”,

  • 首先编译器会询问作用域内是否存在 a 这个名称的变量,存在的话,编译器会直接忽略声明,然后继续编译。但不存在的话,它会要求作用域声明出一个变量,并命名为a。
  • 接着,引擎开始执行,在作用域里面查找叫做 a 的变量,找到的话 执行 =2 的赋值操作,没有的话,会继续从局部找到全局,再找不到的话,会直接抛出异常。
    ( 引擎:负责程序的编译和执行过程,编译器:负责语法分析和代码生成 )
    可见作用域是一个存储变量的地方,无论是引擎还是编译器都要向它拿变量或者添加变量。 接着说明个规则,
    查找规则:我画了一个简陋的图,如图有两层作用域,当在fn 里面需要a时,本身fn是没有的,但是它会向上查找,一层层网上寻找,直到全局环境,找不到的话就报异常。而且作用域会在找到第一个匹配的标识符停止,那以上不过有多少个相同的变量都会被屏蔽掉,这叫“遮蔽效应”。

词法作用域

作用域有两种工作模型: 词法作用域和动态作用域,大部分编程语言使用的都是词法作用域,JS也是。动态作用域可自行查阅了解。

概念:顾名思义就是定义在词法阶段的作用域,何谓词法阶段,就是语言编译器的第一个阶段词法化的过程,将字符串分解组成有含义的代码块。简单点说就是由你在写代码时将变量和块作用域写在哪里决定的。
所以变量也好,作用域也好,早在书写的时候就可以预见到了,这也是区别动态作用域的一点,因为动态作用域是在执行代码的是才确定的。

作用域的具体类型分两种:函数作用域和块作用域

function  fn () {
 var a = 1
 console.log(a)
}

这样形成一个函数作用域,函数内的变量可以再整个函数范围内使用及复用,其中有嵌套他的作用域的话,也能使用。而且外部是无法主动访问内部的变量的,除非使用闭包。这种作用域有几个特点,隐藏内容实现,因为外部无法访问到,而且可以避免同名标识符的冲突。但是也个有问题,一旦定义了一个函数,它将势必“污染”全局作用域,其次无法自动执行,需要手动调动 fn()。

因此便衍生出了,一种概念 IIFE,又名立即执行函数

(function () {
    var a = 2;
    console.log(a)
})()
!function(){
   var a = 2;
   console.log(a)
}()

当然类似的写法有好多种如 !,~,+,-等开头的,原理是第一个()将里面包裹函数,成了一个表达式,第二个()执行这个表达式,于是函数就执行了。

块作用域,代码块{},也能有属于自己的变量和函数

在let,const 出来之前,js并没有具体的块作域的功能,但倒是有几个不显而易见的语法,
with(),这个语法很少用,但是它声明出来的内容仅在里面有用,外面并不能用到。

var obj = {a: 1,b:2,c:3}
with(obj) {
  a = 3;
  b = 4;
  c = 5;
}

其次就是 try/catch,这里的catch分句也会形成一个块作用域。但是这两种算是很少用,也很难用,对于要是使用块作用来讲的话,直到es6推出了let,const。

let,const 其所在的区域,大部分是{},会自行形成块级作用域,并且不会出现变量提升,不可重复定义或赋值(const),形成暂时性死区(不声明,不可直接使用)。大大提升了代码的质量。

提升

变量提升是js中非常有趣的一点,在不使用let或const的时候,你要好好分析,变量的执行顺序并适当用到变量提升的知识。但这往往也是令人头大。

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

来看看这段代码会打印什么吧。答案是2,因为var a 在编译时被提升了,于是后续的赋值操作也能继续.当然就算不定义a 在浏览器里面也会正常执行,因为这时候的 a 会自动挂载在windwo下面,也就是window.a
首先明确一点,包括变量和函数在内的所有声明都会在代码被执行前首先被处理。 而它们的声明从代码出现的位置被移动到最上面的过程,叫做“提升”
注意一点,函数声明会被提升,函数表达式却不会。

function foo () {}   //会提升
let foo = function bar () {}  //这里只会吧 foo 提升上去,后面的bar 不会赋值

当出现多个重复的声明时,函数会被先提升,然后才是变量

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

这里的 function foo 会被提升首先执行,然后执行 foo(), 最后再重新赋值。尽管中间插了 var foo,但是它是重复声明,会被忽略,以函数为主。这也符合函数的JS一等公民的说法。

闭包

这个概念其实很难讲,很多文章会把闭包讲的很复杂,虽然他也确实可以变得很复杂,但我觉得还是按简单的,通俗易懂的说法来讲比较好,深度化的东西,只能看自己慢慢探索。
概念:函数可以记住并访问所在的词法作用域,就算函数在外部执行,这个函数就是一个闭包

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

闭包的使用场景很多,定时器,事件监听,ajax请求,Web Workers等实际上都是闭包的使用。它能使外部访问函数内部的变量,做到即是函数执行结束了,内部的变量也不会被垃圾回收机制回收掉。其内部的作用域依赖存在,可供随时使用。但是缺点也是显而易见的,因为变量无法被回收,当你的代码里面存在大量的闭包时,有可能导致内存泄露
ES6的模块化本身其实就是闭包思想的体现。

看个例子

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

这是道老题目了,答案是 输出5次6, 因为延时的回调会在循环结束之后执行,这时候的 i 是全局 i,也就是6。用作用域的角度来讲,因为五个延迟回调的函数虽然是闭包且保持这对值得引用,但是它们共享整个全局作用域,所以值是一样的。
怎么依次输出1-5呢,搞个单纯作用域

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

通过IIFE 形成一个新的作用域,代码执行结束后,每个作用域依然引用着正确的变量。
ES6来做更简单

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

结语

以上概念均来自于《你不知道的JavaScript》,这算是对书上知识的总结。有很多细节的地方,没有提到,感兴趣的同学可以去翻翻这本书。
以前总是看文章啊,看书之类的。但是这样的吸收效率其实很低,不久后就忘了。于是想着把它输出出去,事实证明输出才是真正去了解和掌握的过程。写文章真的累,要思考逻辑是否连贯,行文是否清晰,就算全部照搬文字,也要思考如何搬运。期间也加深了理解。
能力有限,不足也有,轻喷。