深入理解作用域与闭包

一、作用域是什么

1.1、编译原理

在传统编译语言中,程序的一段源代码在执行之前会经历三个步骤,统称为编译

  1. 分词/词法分析

    这个过程会将由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元。例如,var a = 2;,该行代码会被分解为var、a、=、2、;

    分词与词法分析的区别:词法单元的识别是通过有状态还是无状态的方式进行的。如果词法单元生成器在判断a是一个独立的词法单元还是其他的词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就称为词法分析

  2. 解析/语法分析

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

  3. 代码生成

    将AST转换为可执行代码的过程被称为代码生层,简单来说就是有某种方法可以将var a = 2;的AST转换为一组机器指令,用来创建一个叫做a的变量(包括分配内存),并将一个值存储在a中

引擎如何管理系统资源:简单了解引擎可以根据需要创建并储存变量即可

上述为在传统编程语言,程序的源代码在执行之前会经历三个步骤,但JS的引擎要复杂很多。JS引擎不会有大量的时间来进行优化,JS的编译过程不是发生在构建之前的,大部分情况下发生在编译前的几微秒甚至更短的时间内,所以JS引擎会使用各种办法(比如JIT,可以延迟编译甚至实施重编译)来保证性能最佳

1.2、理解作用域

作用域(收集并维护由所有声明的标识符(变量)组成的一系列查询,确定当前执行的代码对这些标识符的访问权限)是根据名称查找变量的一套规则,通常还会涉及到JS引擎(从头到尾负责整个JS程序的编译及执行过程)和编译器(负责语法分析及代码生成)

var a = 2;为例,引擎会将其拆分为两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理

  1. 遇到var a,编译器会询问作用域中是否已经有一个该名称的变量存在于同一个作用域的集合中,如果存在,编译器会忽略该声明继续进行编译,否则会在当前作用于声明一个新的变量,并命名为a
  2. 之后编译器会为引擎生成运行时所需要的代码,用来处理a=2这个赋值操作。在作用域总如果有a变量,就是用a变量,没有就继续查找,没有查到就会抛出异常

编译器在执行第二步时,会查找变量a来判断a是否已经声明过。查找的方式会影响查找的结果,查找的方式分为两种:LHS和RHS(赋值操作的左侧与右侧,变量出现在复制操作的左侧时进行LHS查询,出现在右侧时进行RHS查询)

  1. LHS:视图找到变量的容器本身,从而可以对其赋值,可以理解为取到一个变量的源值(可以理解为赋值操作的目标是谁)
  2. RHS:与简单的查找某个变量的值别无二致(可以理解为谁是复制操作的源头)

1.3、作用域嵌套

在实际开发过程中,通常需要同时顾及几个作用域,当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。

遍历嵌套作用域链的规则:引擎从当前的执行作用域开始查找变量,如果找不到就继续向上一层查找,当抵达最外层的全局作用域时,无论是否找到,查找的过程都会停止

1.4、总结

  1. 作用域是一套规则,用于确定在何处以及如何查找变量 (标识符)。如果查找的目的是对变量进行赋值,那么就会使用LHS查询,如果目的是获取变量的值,就会使用RHS 查询。赋值操作符会导致 LHS 查询。=操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。

  2. JavaScript 引擎首先会在代码执行前对其进行编译,在这个过程中,像 var a = 2这样的声明会被分解成两个独立的步骤

    1. 首先,var a 在其作用域中声明新变量。这会在最开始的阶段,也就是代码执行前进行
    2. 接下来,a=2会查询(LHS 查询)变量 a并对其进行赋值。
  3. LHS和RHS查询都会在当前执行作用域中开始,如果有需要 (也就是说它们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域(一层楼),最后抵达全局作用域 (顶层),无论找到或没找到都将停止。

  4. 不成功的RHS引用会导致抛出 ReferenceError 异常。如果RHS查询到了一个变量,但是对这个变量的值进行不合理的操作,比如试图对一个飞函数类型的值进行函数调用,或者引用null或者underfind类型的值中的属性,那么引擎会抛出异常,即TypeError

    不成功的LHS引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常 (严格模式下)。

二、词法作用域

2.1、定义

作用域有两种主要的工作模型,一种是动态作用域,另一种是比较普遍的词法作用域。

简单的说,词法作用域就是定义在词法阶段(上一节中编译器的第一个工作就是词法话)的作用域,换句话说,就是在写代码时将变量和块作用域写在哪里来决定的,所以词法分析器在处理代码时会保持作用域不变

JS引擎在解析代码时,作用域在查找时,始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止

无论函数在哪里被调用,也无论他如何被调用,它的词法作用域都只由函数被声明时所处的位置决定

2.2、如何修改词法作用域

JS中有两种机制来实现修改(也可以说欺骗)词法作用域的目的,但是普遍认为在代码中使用这两种机制并不是什么好主意。先看一下这两种机制分别是什么原理,再来解释性能问题

1、eval

在JS中eval函数可以接受一个字符串作为参数,并将其中的内容视为好像在书写时就窜在于这个位置的代码。根据这个原理可以理解,在执行eval(…)之后的代码时,引擎并不知道前面的代码是以动态形式插入进来的,并对词法作用域的环境进行修改,引擎会和往常一样进行词法作用域查找,看以下代码:

function foo(str,a){
	eval(str);  // 欺骗!!!
	console.log(a,b)
}
var b = 2;
foo("var b = 3;",1);  // 1,3

eval(…)调用中的“var b = 3;”这段代码会被当作本来就在那里一样来处理。由于那段代码声明了一个新的变量b,因此它对已经存在的 foo(…)的词法作用域进行了修改。事实上,和前面提到的原理一样,这段代码实际上在 foo(…)内部创建了一个变量b,并遮蔽了外部 (全局) 作用域中的同名变量。
当 console.log(…)被执行时,会在 foo(…)的内部同时找到a和b,但是永远也无法找到外部的b。因此会输出“1,3”而不是正常情况下会输出的“1,2”。

2、with

JS中另一个用来“欺骗”词法作用域的是with关键字,在这里以这个角度来理解:它如何同被他所影响的词法作用域进行交互。

with通常被当作重复引用同一个对象中的多个属性的跨界方式,可以不需要重复引用对象本身,例如:

var obj = {
	a:1,
	b:2,
	c:3
}
// 单调乏味的重复“obj”
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with(obj){
	a = 3;
	b = 4;
	c = 5;
}

但实际上这不仅仅是为了方便地访问对象属性,看一下代码:

function foo(obj){
    with(obj){
        a = 2
    }
}

var o1 = {
    a:3
}

var o2 = {
    b:3
}

foo(o1);
console.log(o1.a);  // 2

foo(o2)
console.log(o2.a);  // undefined
console.log(a);  // 2  --全局作用域

可以得出,with可以将一个没有或者有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符,尽管with块可以将一个对象处理为词法作用域,但是这个块内部正常声明并不会限制在这个块的作用域中,而是被添加到with所处的函数作用域中。

eval函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而with声明实际上是根据你传递给他的对象凭空创建了一个全新的词法作用域

3、性能

eval和with会在运行时修改或者创建新的作用域,以此来“欺骗”其他在书写时定义的词法作用域

但是如果它们能实现比较复杂的功能,并且使代码更具有扩展性,这不是更好的功能吗?答案是否定的。JS引擎会在编译阶段进行数项的功能优化,一些优化用于确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符,但是吐过引擎在代码中发现了eval(…)和with,它只能简单地假设关于标识符位置的判断都是无效的。最悲观的就是代码中大量使用eval(…)和with,使得所有的优化都是无意义的,因此对JS引擎来说,最简单的就是不做优化,那么运行起来就一定会变得很慢。

2.3、总结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。
JavaScript中有两个机制可以“欺骗”词法作用域:eval(…)和with。前者可以对一段含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域 (同样是在运行时)。
这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

三、函数作用域和块作用域

3.1、函数作用域定义

JS是具有基于函数的作用域,意味着每声明一个函数都会为其自身创建一个“气泡”,而其他结构都不会创建这种气泡,但是这种说法并不完全正确。函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数范围内使用及复用

3.2、隐藏内部实现

对于函数的传统认知就是先声明一个函数,然后再向里边添加代码。但是反过来,从所写的代码里挑选一个任意片段,然后用函数声明对他进行包装,实际上就是把这些代码“隐藏”起来了(个人感觉类似于闭包)

这样做的原因,大都是从最小特权原则中引申出来的(系统安全中最基本的原则之一),也叫做最小授权或最小暴露原则,即在软件设计中,应该最小限度的暴露必要内容,而将其他的内容“隐藏”起来,看以下代码

function doSomething(a){
	b = a + doSomethingElse(a * 2)
	console.log(b * 3)
}
function doSomethingElse(a){
	return a - 1
}
var b
doSomething(a) // 15

更加“合理”的设计应该将这些私有的具体内容隐藏在doSomething(…)内部

function doSomething(a){
	function doSomethingElse(a){
		return a - 1
	}
	var b
	b = a + doSomethingElse(a * 2)
	console.log(b * 3)
}
doSomething(2)  // 15

这样做的另一个好处就是可以“规避冲突”,两个标识符可能具有相同的名字但是用途却不一样,无意间可能造成命名冲突,会导致变量的值被意外覆盖

3.3、函数作用域

在任意代码片段外部添加包装函数。可以将内部的变量和函数定义“隐藏起来”,外部作用域无法访问包装函数内部的任何内容

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

但是这样写存在两个问题,首先必须声明一个具名函数foo(),意味着foo污染了所在作用域,其次必须显示的调用这个函数才能运行其中的代码,改进(函数不需要函数名,可以自运行)如下:

(function foo(){
	var a = 3
	console.log(a)  // 3
})()
console.log(a)  // 2
1、匿名和具名

看以下代码:

setTimeout(function(){
	console.log("匿名函数")
},1000)

以上就是一个匿名函数虽然写起来简单便捷,但是也有几个缺点:

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得函数调试很困难
  2. 当函数调用的时候,当函数需要引用自身的时候只能使用已经过期的arguments.callee引用

如果需要使用具名函数,只需要简单改动如下即可:

setTimeout(function timeoutHander(){
	console.log("具名函数")
},1000)
2、立即执行函数表达式(IIFE)
var a = 2
(function foo(){
	var a = 3
	console.log(a)  // 3
})();
console.log(a)  // 2

在上述例子中,第一个()将函数变成表达式,第二个()执行了这个函数

3.4、块作用域

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

1、with

with不仅仅在词法作用域中起到作用,同时也是块作用域中的一个例子,用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效

2、try/catch

在try/catch中的catch分句会创建一个块作用域,其声明的变量仅在catch内部有效

3、let

只要声明是有效的,在声明中的任意位置都可以使用{…}括号来为let创建一个用于绑定的块,但是使用let进行的声明不会在块作用域中进行提升

3.5、总结

函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。
但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域也可以属于某个代码块 (通常指[ …}内部)。
从ES3 开始,try/catch 结构在 catch 分中具有块作用域
在ES6中引入了 let 关键字 (var 关键字的表亲),用来在任意代码块中声明变量。if(…){ let a = 2;}会声明一个劫持了 的[ …]块的变量,并且将变量添加到这个块中。
有些人认为块作用域不应该完全作为函数作用域的替代方案。两种功能应该同时存在,开发者可以并且也应该根据需要选择使用何种作用域,创造可读、可维护的优良代码。

四、变量提升

JS在执行代码时,会先进行声明在进行赋值,函数的声明会被提升,但是函数表达式不会被提升

如果同时存在函数声明与变量声明,函数声明会优先被提升,然后才是变量

小节:

我们习惯将 var a = 2;看作一个声明,而实际上 JavaScript引擎并不这么认为。它将var a和a= 2当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务
这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为提升。

声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候,否则会引起很多危险的问题!

五、作用域闭包

根据以上的学习,我们初步了解到闭包是基于词法作用域写代码时所产生的自然结果

5.1、循环和闭包

来看一下在循环中的闭包

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

如果我们想输出1-5,还想继续使用var关键字,那么我们如何解决这个问题呢?

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

在迭代内部使用IIFE会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问,这样问题即可解决

5.2、闭包与模块化

事实上,模块化的思想,也是利用了闭包。最常见的实现模块化的方法通常被称为模块暴露

模块模式需要两个条件:

  1. 必须由外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态

5.3、总结

闭包就好像从 JavaScript 中分离出来的一个充满神秘色彩的未开化世界,只有最勇敢的人才能够到达那里。但实际上它只是一个普通且明显的事实,那就是我们在词法作用域的环境下写代码,而其中的函数也是值,可以随意传来传去。
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。
如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循环中。但同时闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。
模块有两个主要特征

  1. 为创建内部作用域而调用了一个包装函数
  2. 包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。

现在我们会发现代码中到处都有闭包存在,并且我们能够识别闭包然后用它来做一些有用的事

六、拓展

6.1、动态作用域

通过上边的内容,我们得知JS中的作用域就是词法作用域,而且大部分语言都是基于词法作用域的,词法作用域最重要的特征就是它的定义过程发生在代码的书写阶段(假设没有使用eval()或with),动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心他们从何处调用,换句话说,动态作用域的作用域链是基于调用栈的,而不是代码中的作用域嵌套。看以下代码

词法作用域:

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

动态作用域

function foo(){
	console.log(a) // 3(不是2!)
}
function bar(){
	var a = 3
	foo()
}
var a = 2
bar()

可以明确的是JS并不具有动态作用域,他只有词法作用域,但是!this机制某种程度上很像动态作用域

主要区别:

  1. 词法作用域:在写代码或者说定义的时候确定的;关注函数在何处声明
  2. 动态作用域:运行时确定的(this也是!);关注函数从何处调用

你可能感兴趣的:(java,前端,javascript)