一起来明明白白的了解作用域和闭包吧

103 阅读12分钟

本文来源于《你不知道的JavaScript》上卷,第一部分:作用域和闭包。有兴趣,有时间的同学,强烈建议直接去看书,书里的知识才是最全面最系统的,本文仅为个人书后总结。

一 作用域是什么?

变量的存储查找的这一套规则被称为作用域

1.1 编译原理

js通常被认为是动态(解释执行)语言,但事实上它也是一门编译语言,但并不是提前编译的。 传统语言的编译分为三部分:

  • 分词/词法分析(Tokenizing/Lexing):var a = 2;分解成为、词法单元: var、 a、 =、 2
  • 解析/语法分析(Parsing)::将词法单元流转化为抽象语法树(AST)
  • 代码生成: 将 AST 转换为可执行代码的过程称被称为代码生成。

而对于js来讲,通常会在代码片段(通常以script标签为片段)执行前进行编译。

1.2 理解作用域

var a = 2 为例。该段代码执行需要有

  • 引擎:负责js的编译和执行
  • 编译器:负责语法分析及代码生成
  • 作用域:负责收集并维护由所有变量的引用及使用规则。

执行过程

  1. 遇到var a,编译器会查询作用域是否存在变量a,若存在,忽视该声明,若不存在,在该作用域声明一个变量a。
  2. 接下来编译器生成了用来执行a = 2这个操作的代码,由引擎进行执行,引擎执行时,会在该作用域下进行查询,本例中,引擎会在本作用域下找到a,然后进行赋值。

1.3 作用域嵌套

上面我们所举的例子是在单作用域下进行的,引擎很顺利的在本作用域下找到了变量a,执行了赋值语句。那么如果该作用域下找不到变量a该怎么办呢? 比如:

function foo() {
	a = 2;
}
foo()

这种情况下,我们没有在函数foo中声明a,那么编译器也不会去执行对a的声明,而是直接生成a=2的执行语句。那当引擎执行到 a = 2 时会发生什么?

1.3.1 LHS 和 RHS

在引擎执行的过程中会对变量进行查询,在a = 2中我们涉及到了LHS查询,因为a出现在赋值操作的左边。那么另外一种RHS的使用,很显然就是变量出现在赋值操作右边的时候。

  • 相同点:
    • 变量遮蔽:通常LHS和RHS都会从当前作用域开始层级一层一层往上找(最远到全局作用域),会在第一次查找到目标值之后返回,不会再继续往下查找,所以我们不会再获取到上层的同名变量。在这里插入图片描述
  • 不同点:
    • LHS是查找变量的容器,也可以理解为指针,因为我们需要为它赋值。RHS查找则是简单的查找该变量中存储的
    • RHS没有查找到时,会抛出ReferenceError 异常;当LHS查找到全局作用域仍不成功时,会隐式的在全局作用域创建一个同名变量供LHS使用。(严格模式下则不会,直接报ReferenceError

我们回过头来看刚才的例子

function foo() {
	a = 2;
}
foo()
  1. 函数作用域总是包含在全局作用域中的,所以天然有一种嵌套的关系。
  2. 首先,引擎执行foo(),发现语句 a = 2 对a进行LHS查找。
  3. LHS发现本层级作用域中没有a,接着去上一层(本例为全局作用域)中去找,同样的发现也没有。
  4. 由于目前已经到达了全局作用域,仍然没找到,那么LHS就会隐式的声明一个全局变量a作为目标值,去进行赋值操作。

我们最后来看一个例子,来看一下其中一共有哪些LHS/RHS查询。

	function foo(a) {
		var b = a;
		return a + b;
	}
	var c = foo( 2 );
  • 此段代码中,涉及到的LHS查询有 foo的形参 a = ...; b = ... ; c = ... ;(形参的赋值也是一次LHS查询。)
  • 涉及到的RHS查询有:... = a; ... + b; a + ...; ... = foo(2)

小结

  • 作用域是变量存储查找的一套规则。
  • 当引擎开始执行语句时,会对变量进行LHS/RHS查找,若变量出现在赋值操作左边,执行LHS查找,否则执行RHS查找。LHS查找变量的容器(指针)RHS查找变量的
  • 两种方式都是从本作用域依次向上查找,第次查到即返回,否则到全局作用域以后终止。若查找失败,非严格模式下,LHS会声明一个全局同名变量并引用。否则同RHS一样报ReferenceError

二 词法作用域

词法作用域就是词法阶段的作用域,是一种静态作用域,我由JavaScript书写的位置来决定的。

你可以将其理解为一个“对象”,你在函数(全局)内同层级所声明的变量都是它的属性。

以下面代码为例

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

以上代码存在三层的作用域嵌套,如下图所示。 在这里插入图片描述

  • 1 代表全局作用域,全局作用域中只有foo函数。
  • 2 代表foo函数中的作用域,包含了形参a, 变量b, 和函数bar
  • 3 代表bar函数中的作用域,包含了形参c

我们在bar中输出了 a, b, c 相当于三个RHS查找。我们的词法作用域和代码执行时的作用域基本一致,书写位置确定了作用域的嵌套结构,加上我们之间所讲的作用域查找规则,我们就可以找到我们所需要的变量了。

结果也很简单:我们在bar中直接找到了c,在 foo作用域中找到了b和a

小结

  • 词法作用域就是词法分析阶段的作用域,是一种静态作用域,意味着作用域是根据js书写的位置决定,与执行位置无关。它帮助我们确定了变量声明位置及其声明方式,从而确定js在执行阶段如何对变量进行查找

谨记词法作用域是由书写位置决定的,是静态的作用域。与之相对应的另外一种是动态,由函数执行的位置决定。


三 作用域的特点

作用域实现了变量的私有化,规避了冲突,体现了软件设计中的最小暴露原则。作用域中的变量只能被内部作用域访问。外部作用域无法访问其内部(除非用闭包)。

3.1 函数作用域

通常我们使用函数来包装一段代码,就形成了一个作用域单元,该作用于单元内的变量和函数声明,都不可被上级所查找,实现了变量私有化。是软件设计中的最小暴露原则的体现。

  1. 规避冲突:
    1. 全局命名空间的冲突 :由于全局变量可以在任意作用域中被访问,所以容易被篡改替换,导致一些意想不到的结果,私有化变量可以解决这点。
    2. 模块管理:利用函数作用域的特点我们可以实现模块机制。

3.2 块级作用域

let 和 const 是es6中新增的声明变量的方式,跟原有的var的声明方式相比会有一些不同的区别

  1. var声明的全局变量会挂载在window上,let/const不会
  2. var具有变量提升,可以在未赋值前使用,值为undefined。let/const则无法使用(事实上let/const也被提升了,但是并没有被初始化赋值)
  3. 在块{..}中,var只会绑定到外部作用域中,let /const会绑定到块级作用域中。

其中最大的区别还是在于let/const可以声明一个块级作用域变量。类似于函数作用域,块级作用域内的变量同样无法被外部查找(let/const声明的变量)。最有名的实践应该就是for循环中嵌套异步函数。

小结

  • 作用域内的命名空间无法被作用域外访问(除闭包)。
  • 作用域单元除了函数包装,还有一种是块级也就是{...}包装。
  • 函数作用域中的变量,let/const/var/function所声明的变量都是函数作用域内的私有化变量,无法被外界访问。
  • 块级作用域中的变量,只有let/const 可以声明块级作用域变量,var声明不会被绑定在块内。(尽量不要在块中声明函数,会很怪异,有兴趣请看我另一篇文章

四 闭包

在上面几节中,我们最后提到了闭包。看过了前面几节,再来理解闭包应该是比较容易的。

4.1 闭包的定义

  • 上面我们了解了什么是词法作用域,作用域是由书写位置所决定的。我们通过词法作用域确定了变量的声明方式和位置。而闭包就是词法作用域的自然结果。
  • 当函数可以记住并访问所在的词法作用域时, 就产生了闭包, 即使函数是在当前词法作用 域之外执行。

我们举个例子可能一下子就清楚了

function foo() {
	var a = 1;
	var b = 2;
	function bar() {
		a++;
		b++;
		console.log(a, b, a + b);
	}
	return bar;
}
let baz = foo();
baz() //2, 3, 5; 这就是闭包的效果。

正常来讲foo()执行过后,内部作用域的变量应该都被回收了,但是我们通过对bar引用,讲foo的内部词法作用域进行了保存。这就形成了一个闭包

  1. bar所在的词法作用域中包含了 a ,b,bar
  2. foo函数通过return bar,在外部用bazbar进行一个接收,使其可以在所在词法作用域外执行。
  3. bar依然可以正常使用其所在词法作用域的内的变量。即对其所在词法作用域进行了引用保存。

4.2 for循环和闭包

我们再看一下其他情况下的闭包使用

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

最终结果大家可能都知道,都为6。

  • 原因是因为setTimeout所在的词法作用域是空的,所以{..}中并没有变量可以用来保存。
  • 但是他们都处在同一个全局作用域下,所以当setTimeout中的函数执行时,查找不到当前所在词法作用域的变量,只能到全局查找 i,由于i是全局变量,此时早已被for循环更改为6,所以最后都输出的是6

那么如果输出我们的预期值呢?当前所在的词法作用域没有变量,那么声明一个对i进行保存不就好了。

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

如上我们对 i 的值进行了一个引用,并将其绑定在{...}块作用域中。

那么通常我们不会这么写,而是直接

for 循环头部的 let 声明还会有一个特殊的行为。 这个行为指出变量在循环过程中不止被声明一次, 每次迭代都会声明。 随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

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

4.3 节流和防抖

我们再来简单的实现一下节流和防抖函数

  1. 节流
function throttle(callback, duration){
	let start = 0;
	function fn(...args) {
		let now = +new Date();
		if (now - start > duration) {
			start = now;
			callback.apply(this, args)
		}
	}
	return fn;
}
  1. 防抖
function debounce (func, wait = 50){
  // 缓存一个定时器id
  let timer = 0;
  function fn(...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(function timerFn() {
      func.apply(this, args)
    }, wait)
  }
  return fn;
}

小结

  • 所以,闭包的概念很简单,就是对词法作用域的引用和保存。
  • 常见的创建方法就是通过引用 作用域内部返回的函数,来实现对作用域内部词法作用域的使用。
  • 另外闭包的最大应用就是模块,我们可以使用闭包轻松实现模块机制。

五 提升

回看我们之前第一部分所讲的, js在执行之前会经过编译. 变量提升就发生在这个阶段.

5.1 变量提升

var a = 2

这条声明实际上会分为两部分var a;a = 2;。 第一个定义声明是在编译阶段进行的。 第二个赋值声明会被留在原地等待执行阶段。

var a;
a = 2;

函数内部作用域, 也同样会有变量提升, 也就是说变量提升是以作用域为范围的.

5.2 函数提升

函数声明同样也会提升, 不同的是函数提升,是整个函数声明包括函数体的提升.

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

函数调用写在函数声明之前,同样有效

5.2.1 函数表达式

函数表达式并不会提升函数体.

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

以上代码会被编译为

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

所以foo的值为undefined, 提前执行会发生类型错误

5.2.2 函数优先

函数声明和变量声明都会提升,但是函数声明会比变量声明的优先级更高.

foo(); // 1
var foo;

function foo() {
	console.log( 1 );
} 

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

以上代码会被编译为

function foo() {
	console.log( 1 );
} 

foo(); // 1

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

变量声明被忽略了, 因为像我们之前讲的, 编译器发现作用域中已经声明了该变量, 就会忽略var声明. 因为函数优先提升在了变量声明之前.

小结

  • var a = 2 会被分解成 var a; 和 a = 2; 一个是编译任务,一个是执行任务. 所以无论声明写在哪里,都会被提升到各自作用域的顶部.
  • 函数声明的提升比变量声明的优先级更高. 并且函数声明提升整个函数声明.
  • 含有声明的赋值表达式并不会提升, 包括函数表达式声明.