完全搞懂JavaScript中的做用域和闭包

1、做用域

  • 做用域是什么

几乎全部的编程语言都有一个基本功能,就是可以存储变量的值,而且能在以后对这个值进行访问和修改。ajax

那这些变量存储在哪里?怎么找到它?由于只有找到它才能对它进行访问和修改。编程

简单来讲,做用域就是一套规则,用于肯定在何处以及如何查找变量(标识符)。数组


那么问题来了,究竟在哪里设置这些做用域的规则呢?怎样设置?闭包

首先,咱们要知道,一段代码在执行以前会经历三个步骤,统称为“编译”。编程语言

  1. 分词/词法分析

这个过程会将字符串分解成有意义的代码块,这些代码块称为词法单元函数

var a = 1;
// 这段代码会被分解为五个词法单元:
var 、 a 、 = 、 1 、 ;
  1. 解析/语法分析

这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的表明语法结构的树。这个树称为“抽象语法树(AST)code

  1. 代码生成

这个过程是将AST转换为可执行的代码事件

简单来讲,用某种方法能够将
var a = 2; 
的抽象语法树(AST)转化为一组机器指令,
指令用来建立一个叫做a的变量,并将一个值2存在a中

在这个过程当中,有3个重要的角色:ip

  1. 引擎:从头至尾负责整个JavaScript程序的编译及执行过程
  2. 编译器:负责语法分析及代码生成
  3. 做用域(今天的主角):负责收集并维护由全部声明的变量(标识符)注册的一系列查询,并实施一套严格的规则,肯定当前执行的代码对这些变量的访问权限。

因此,看似简单的一段代码 var a = 1; 编译器是怎么处理的呢?作用域

var a = 1;
  1. 首先,遇到 var a, 编译器会询问做用域是否已经有一个该名称的变量存在于同一个做用域中。若是是,编译器会忽略该声明,继续下一步。不然编译器会要求做用域在当前做用域中声明一个新变量,并命名为a
  2. 其次,编译器会为引擎生成运行时所需的代码,用来处理 a = 1 这个赋值操做。引擎运行时首先询问做用域,当前做用域是否存在一个叫a的变量,若是是,引擎会使用这个变量,不然引擎会继续查找该变量,若是找到了,就会将1赋值给它,不然引擎会抛出一个异常。

那么,引擎是如何查找变量的?

引擎会为变量 a 进行LHS查询(左侧)。另一个叫RHS查询(右侧)

简单来讲,LHS查询就是试图找到变量的容器自己(好比a);而RHS查询就是查询某个变量的值(好比1)

总结:做用域就是根据名称查找变量的一套规则


  • 做用域嵌套

当一个块或函数嵌套在另外一个块或函数中时,就发生了做用域的嵌套。所以,在当前做用域中没法找到某个变量时,引擎就会在外层嵌套的做用域中继续查找,直到找到该变量,或抵达最外层的做用域(也就是全局做用域)为止。

function add(a) {
  console.log(a + b)
}

var b = 2;

add(1) // 3

在add()内部对b进行RHS查询,发现查询不到,但能够在上一级做用域(这里是全局做用域)中查询到。

怎么区分LHS和RHS查询?思考如下代码

function add(a) {
  // 对b进行RHS查询 没法找到(未声明)
  console.log(a + b) // 对变量b来讲,取值操做
  b = a // 对变量b来讲,赋值操做
}

add(1) // ReferenceError: b is not defined
function add(a) {
  // 对b进行LHS查询,没法找到,会自动建立一个全局变量window.b(非严格模式)
  b = a  // 对变量b来讲,赋值操做
  console.log(a + b)// 对变量b来讲,取值操做
}

add(1) // 2

总结:若是查找变量的目的是赋值,则进行LHS查询;若是是取值,则进行RHS查询


  • 词法做用域

做用域有两种主要的工做模型。第一种最为广泛,也是重点,叫做词法做用域,另外一种叫做动态做用域(几乎不用)

简单来讲,词法做用域就是定义在词法阶段的做用域(通俗易懂的说,就是在写代码时变量或者函数声明的位置)。

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

foo(2) // 2, 4, 12
  1. 全局做用域中有1个变量:foo
  2. foo做用域中有3个变量:a、b、bar
  3. bar做用域中有1个变量:c

变量查找的过程:首先从最内部的做用域(即bar函数)的做用域开始查找,引擎没法找到变量a,所以会到上一级做用域(foo函数)中继续查找,在这里找到了变量a,所以引擎使用了这个引用。变量b同理,对于变量c来讲,引擎在bar函数中的做用域就找到了它。

注意:做用域查找会在找到第一个匹配的变量(标识符)时中止查找


  • 函数做用域

简单来讲,函数做用域是指,属于这个函数的所有变量均可以在这个函数范围内使用及复用(复用:即在嵌套的其余做用域中也可使用)。

var a = 1

// 定义一个函数包裹代码块,造成函数做用域
function foo() {
  var a = 2
  console.log(a) // 2
}

foo()
console.log(a) // 1

你会以为,若是我要使用函数做用域,那么我必须定义一个foo函数,这让全局做用域多了个函数,污染了全局做用域,且必须执行一次该函数才能运行其中的代码块。

那有没有一种办法,可让我不污染全局做用域(即不定义新的具名函数),且函数能够自动执行呢?

你必定想到了,IIFE(当即执行函数)

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

这种写法,实际上不是一个函数声明,而是一个函数表达式。要区分这二者,最简单的方法就是看function关键字是否出如今第一个位置(第一个词),若是是,那么是函数声明,不然是一个函数表达式。

  • 块做用域

尽管你可能没写过块做用域的代码,但你必定对下面的代码块很熟悉:

for(var i = 0; i < 5; i++) {
  console.log(i)
}

咱们在for循环的头部定义了变量i,是由于想在for循环内部的上下文中使用i,而忽略了最重要的一点:i会被绑定在外部做用域(即全局做用域中)。

ES6改变了这种状况,引入let关键字,提供另外一种声明变量的方式。

{
  let a = 2;
  console.log(a) // 2
}
console.log(a) // ReferenceError: a is not defined

讨论一下以前的for循环

for(let i = 0; i < 5; i++) {
  console.log(i)
}
console.log(i) // ReferenceError: i is not defined

这里,for循环头部的i绑定在循环内部,其实它在每一次循环中,对i进行了从新赋值。

{
  let j;
  for(let j = 0; j < 5; j++) {
    let i = j // 每次循环从新赋值
    console.log(i)
  }
  j++
}
console.log(i) // ReferenceError: i is not defined

小知识:其实在ES6以前,使用try/catch结构(在catch分句中)也有块做用域


  • 提高

先有鸡(声明)仍是先有蛋(赋值)?

简单来讲,一个做用域中,包括变量和函数在内的全部声明都会在任何代码被执行前首先被 “移动” 到做用域的最顶端,这个过程就叫做提高。

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

// 引擎解析:
var a
a = 2
console.log(a) // 2
console.log(a) // undefined
var a = 2

//引擎解析:
var a
console.log(a) // undefined
a = 2

能够发现,当JavaScript看到 var a = 2; 时,会分红两个阶段,编译阶段执行阶段

编译阶段:定义声明,var a

执行阶段: 赋值声明,a = 2

结论:先有蛋(声明),后有鸡(赋值)。

  • 函数优先

函数和变量都会提高,但函数会首先被提高,而后是变量。

foo() // 2

var foo = 1

function foo() {
  console.log(2)
}

foo = function() {
  console.log(3)
}

// 引擎解析:
function foo() {...}
foo()
foo = function() {...}

多个同名函数,后面的会覆盖前面的函数

foo() // 3

var foo = 1

function foo() {
  console.log(2)
}

function foo() {
  console.log(3)
}

提高不受条件判断控制

foo() // 2

if (true) {
  function foo() {
    console.log(1)
  }
} else {
  function foo() {
    console.log(2)
  }
}

注意:尽可能避免普通的var声明和函数声明混合在一块儿使用。

2、闭包

  • 定义:当函数能够记住并访问所在的词法做用域时,就产生了闭包,即便函数是在当前词法做用域以外执行。

秘诀:JavaScript中闭包无处不在,你只须要可以识别并拥抱它。

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

var baz = foo()
baz() // 2 快看啊,这就是闭包!!!

函数bar()的词法做用域可以访问foo()的内部做用域,而后将bar()自己看成一个值类型进行传递。

正常状况下,当foo()执行后,foo()内部的做用域都会被销毁(引擎的垃圾回收机制),而闭包的“神奇”之处就是能够阻止这件事请的发生。事实上foo()内部的做用域依然存在,否则bar()里面没法访问到foo()做用域内的变量a

foo()执行后,bar()依然持有该做用域的引用,而这个引用就叫做闭包

总结:不管什么时候何地,若是将函数看成值类型进行传递,你就会看到闭包在这些函数中的应用(定时器,ajax请求,事件监听器...)。

我相信你懂了!

回顾一下以前提到的for循环

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

指望:每秒依次打印一、二、三、四、5...9

结果:每秒打印的都是10

稍稍改进一下代码(利用IIFE)

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

问题解决!对了,咱们差点忘了let关键字

for(var i = 0; i < 10; i++) {
  let j = i // 闭包的块做用域
  setTimeout(function timer() {
    console.log(j)
  }, j * 1000)
}

还记得吗?以前有提到,for循环头部的let声明在每次迭代都会从新声明赋值,并且每一个迭代都会使用上一个迭代结束的值来进行此次值的初始化。

最终版:

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

好了,如今你确定懂了!

总结:当函数能够记住并访问所在的词法做用域,即便函数是在当前的词法做用域以外执行,就产生了闭包

若是你坚持看到了这里,我替你感到高兴,由于你已经掌握了JavaScript中的做用域和闭包,这些知识都是进阶必备的,若是有不理解的,花时间多看几遍,相信你必定能够掌握其中的精髓。

都到这儿了!

点个关注再走呗!!