彻底搞懂JavaScript中的作用域和闭包

Posted 叫我欧文就好

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了彻底搞懂JavaScript中的作用域和闭包相关的知识,希望对你有一定的参考价值。

一、作用域

  • 作用域是什么

几乎所有的编程语言都有一个基本功能,就是能够存储变量的值,并且能在之后对这个值进行访问和修改。

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

简单来说,作用域就是一套规则,用于确定在何处以及如何查找变量(标识符)。


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

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

  1. 分词/词法分析

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

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

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

  1. 代码生成

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

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

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

  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声明和函数声明混合在一起使用。

二、闭包

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

秘诀: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)
}

期望:每秒依次打印1、2、3、4、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中的作用域和闭包,这些知识都是进阶必备的,如果有不理解的,花时间多看几遍,相信你一定可以掌握其中的精髓。

都到这儿了!

点个关注再走呗!!

以上是关于彻底搞懂JavaScript中的作用域和闭包的主要内容,如果未能解决你的问题,请参考以下文章

JavaScript学习总结2--作用域和闭包

JavaScript 作用域和闭包

你不知道的JavaScript(作用域和闭包)

JavaScript作用域和闭包

JavaScript--作用域和闭包

JavaScript之作用域和闭包