详解JS的作用域和闭包

序言

尽管通常将JavaScript归类为“动态”或“解释执行”语言,但实际上它是一门编程语言。但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。尽管如此,JavaScript引擎进行编译的步骤和传统的编译语言非常相似,在某些环节可能比预想的复杂。

传统的编译语言通常会在一段源代码执行之前经历三个步骤,统称为“编译”。这三个步骤分别是分词/词法分析(Tokenizing/Lexing)、解析/语法分析(Parsing)、代码生成。但对于JavaScript来说,大部分情况下编译发生在代码执行前的几微秒,所以JavaScript引擎不会有大量的时间用来进行优化,他只会在这几微秒内用尽各种办法(比如JIT,可以延迟编译甚至实施重编译)来保证性能最佳。

一、理解作用域

首先要知道以下三个概念:

  1. 引擎:从头到尾负责整个JavaScript程序的编译及其执行过程
  2. 编译器:负责语法分析及代码生成
  3. 作用域:负责收集并维护所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

以 var a = 2 为例,首先这段代码是分为两个步骤执行的,第一补是声明var = a,编译器会在当前作用域中查找是否存在一个名为 a 的变量,若存在则忽略该声明,若不存在,则会在当前作用域进行声明。第二步是赋值,在运行时引擎会在作用域中查找该变量,然后将 2 赋值给 a

如果查找的目的是对变量进行赋值,那么就会使用LHS查询,如果目的是获取变量的值,就会使用RHS查询。赋值操作符会导致LHS查询。 = 等号操作符或调用函数是传入参数的操作都会导致关联作用域的赋值操作。

思考一下下面一段代码有几处LHS和RHS查询:

function foo(a) {
     
	var b = a;
	return a + b;
}

var c = foo(2);

答案是LHS查询有三处,分别是(1) c = …;c被赋值。(2)a = 2;隐式变量分配,也就是foo(2)传入foo(a),相当于a = 2。(3)b = …;b被赋值。
LRH查询有四处,分别是(1)函数foo(a)获取a的值。(2)b = a 获取a的值。(3)(4)return a + b 获取一次a获取一次b

二、词法作用域

大部分标准语言编译器的第一个工作阶段叫做词法化。简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

三、函数作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。

1.匿名函数和具名函数

顾名思义,匿名函数是没有名称标识符的函数(比如回调函数),具名函数是有名称标识符的函数。

//匿名函数
setTimeout(function(){
     
	console.log("I wait 1000ms")
},1000)
//具名函数
function hello(){
     
	console.log("Hello World")
}

2. 立即执行函数(IIFE)

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

console.log(a); //2

上面这个函数就是立即执行函数,函数别包含在一对( )括号内部,因此成了一个表达式,通过在末尾加上另外一个( )可以立即执行这个函数。第一个( )将函数变成了表达式,第二个( )执行了这个函数。并且第二个( )可以对第一个( ) 里的函数进行传参。

四、块作用域

定义:块作用域是一个用来对之前最小授权原则进行拓展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。

对于大家常用的for循环来说,为什么要把一个只在for循环内部使用的变量 i,污染到整个函数作用域中呢?

除了for循环之外,ES3规范中规定 try/catch 的 catch 分句也会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

1. let

ES6引入了新的关键字let,提供了var以外的另一种变量声明方式。

let关键字可以将变量绑定到所在的任意作用域中(通常是{。。。}内部)。换句话说,let为其声明的变量隐式得劫持了所在的块作用域。

2. 垃圾收集

考虑以下代码:

function process(data) {
     
	//do something
}
var someData = {
      .. }
process( someData )

var btn = document.getElementById("my_button")
btn.addEventListeer("click", function click(evt){
     
	console.log("button clicked")
}, false)

click 函数的点击回调并不需要 someData 变量。理论上这意味着当process()执行完后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于click函数形成了一个覆盖整个作用域,JavaScript引擎极有可能依然保存着这个结构(取决于具体实现)。

块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存someData 了。

function process(data) {
     
	//do something
}
//在这个块中定义的内容完事可以销毁!
{
     
    let someData = {
      .. }
	process( someData )
}

var btn = document.getElementById("my_button")
btn.addEventListeer("click", function click(evt){
     
	console.log("button clicked")
}, false)

3. let循环

for循环头部的let不仅将 i 绑定到了for循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

每个迭代进行重新绑定会在后面讨论闭包时进行说明。

4. const

const也是ES6和let一块引进来用来创建作用域变量的,与其他两个不同的是它声明的值都是固定的,也就是只能用来声明常量。声明之后任何试图修改值的操作都会引起错误。

五、提升

思考下面两段代码

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

我们先来说一下两段代码的输出结果,第一段输出 2,第二段输出的是undefined。

为什么会是这样的呢?正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。也就是也就是引擎会在解释JavaScript代码之前首先对其进行编译编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将他们关联起来,也就是之前说的词法作用域的机制。

也就导致了var a永远是在前面的,所以第一段代码被成功赋值,第二段代码为赋值而抛出undefined而非未声明的referenceError。

函数的声明与函数表达式

函数的声明是以function foo () { … }的方式声明的函数

函数表达式是 var foo = function () { … }

foo() //函数的声明会被提升
function foo(){
     
	console.log(a) //undefined
	var a = 2
}
foo() // 不是referenceError,而是TypeError,函数表达式不会被提升
var foo = function(){
      .. }

1. 函数优先

函数声明和变量声明都会被提升。但是函数会被首先提升,然后才是变量。重复的var声明会被忽略掉,出现在后面的函数声明会覆盖前面的。

foo() //3

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

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

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

无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为提升。

六、作用域闭包

1. 什么是闭包

函数在当前语法作用域之外执行,也可以函数记住并访问所在的词法作用域

我们来看一段代码,清晰地展示了闭包:

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

var baz = foo()

baz() //2 --朋友,这就是闭包

函数bar()的词法作用域能够访问foo()的内部作用域。然后我们将bar()函数本身当作一个值类型进行传递。在这个例子中,我们将bar所引用的函数对象本身当作返回值。

在foo()执行后,其返回值(也就是内部的bar()函数)赋值给变量baz并调用baz(),实际上只是通过不同的标识符引用调用了内部的函数bar()。

bar() 显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域以外的地方执行。

在foo()执行后,通常会期待fo()的整个内部作用城都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去foo()的内容不会再被使用,所以很自然地会考虑对其进行回收。

而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。谁在使用这个内部作用域?原来是bar()本身在使用。

拜bar()所声明的位置所赐,它拥有涵盖foo() 内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。

bar()依然持有对该作用域的引用,而这个引用就叫作闭包。

2. 循环和闭包

要说明闭包,for循环是最常见的例子。

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

相信大家的预期效果都是分别输出数字1-5,每秒一次,每次一个。

但实际上,这段代码在运行时会以每秒一次的频率输出五次6。

这是为什么呢?

很显然,当for循环5次后,i = 6,然后跳出循环。这时候延迟函数的回调才会开始执行,所以才会输出五个6来。

缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被粉笔仔一个共享的全局作用域中,因此实际上只有一个 i。

那么我们要怎么解决呢?可以使用前面说到过的IIFE立即执行一个函数来创建作用域。我们来试一下

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

你以为这样就可以了吗?其实不然,显然我们拥有了更多的词法作用域,但是这个作用域没有起到对 i 的封闭作用,很显然 i 的赋值是在作用域外的。那么我们再改进一下

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

这样!他就能正常地工作了!也许还能再改进一下

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

还记得我们前面说过的块作用域吗?除了创建一个新的作用域外,我们还可以通过let劫持块作用域,并且在这个块作用域中声明一个变量。本质上是将一个块转换成一个可以被关闭的作用域。

代码如下:

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

或许我们还能再进行优化

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

这样我们就大功告成了!

3. 模块

相信写过一点项目的同学都会知道吧,就是我们的js文件不会都写在一块,而是在一个模块对其暴露再在另一个需要的模块进行导入,最后将主要模块集合在一个js文件进行管理,这样会使我们的代码脉络更加清晰以及更好地分工合作。

4. 未来的模块机制

ES6中为模块增加了一级语法支持。在通过模块系统进行加载时,ES6会将文件当作独立的模块来处理。每个模块都可以导入其他模块或特定的API成员,同样也可以导出自己的API成员。

ES6的模块没有“行内”格式,必须被定义在独立的文件中(一个文件一个模块)。浏览器或者引擎有一个默认的“模块加载器”(可以被重载,但这远超出我们的讨论范围)可以在导入模块时同步地加载模块文件。

考虑以下代码:

bar.js
	function hello(who) {
     
		return "Let me intriduce:" + who
	}
	
	export hello
	
foo.js
	//仅从"bar"模块导入hello()
	import hello from "bar"
	
	var hungry = "hippo"
	
	function awesome() {
     
		console.log(
			hello( hungry ).toUpperCase()
		)
	}
	
	export awesome
	
baz.js
	//导入完整的"foo"和"bar"模块
	module foo from "foo"
	module bar from "bar"
	
	console.log(
		bar.hello("rhino")
	)	//Let me intriduce: rhino
	
	foo.awecome() //Let me intriduce: hippo

import 可以讲一个模块中的一个或多个API导入到当前作用域中,并分别绑定在一个变量上(在我们的例子里是hello)。module会将整个模块的API导入并绑定到一个变量上(在我们的例子里是foo 和 bar)。export会将当前模块的一个标识符(变量,函数)导出为公共API。这些操作可以在模块定义中根据需要使用任意多次。

本文所有用例,部分话语引用自 Scope and Closures, Kyle Simpson 著(O’Reilly,2014)。版权所有,978-1-491-33558-8。

你可能感兴趣的:(JavaScript)