javaScript深度理解 — 作用域、闭包

在这里插入图片描述
上一篇文章学习了JavaScript的变量以及es2015的块作用域中的变量、常量。在提及作用域的时候对作用域也不是特别懂,我便欲罢不能,苦苦追寻。于是乎,便有了对作用域更透彻的理解以及更深层次的认识(透彻和深层仅形容在本小白的知识储备范围内)。本文将详细介绍作用域基础知识词法作用域函数作用域块作用域作用域提升作用域闭包。话不多说,直接上代码:

function(){var name = '别秃头'};
console.log(name);// => ReferenceError

上面的代码中在函数里面定义了一个变量name,在全局中访问了该变量,但是编译时却报错ReferenceError,为什么我们命名定义了变量却又访问不到,这就是因为定义变量的位置和引用变量的位置根本就不是一个作用域,在全局作用域中没有name这个变量,所以编译时会报错ReferenceError。那么作用域到底是什么呢?
以下内容参照 《你不知道的JavaScript》

一、什么是作用域

我们在程序中定义了一个变量,这个存在哪儿?我又怎么找到他?这就需要一套规则来存储并且用的时候很快就可以找到,这套规则被称为作用域。从字面上理解就是作用到的区域举个栗子:平头是鹅城的县长,那么我的权利作用的区域就是鹅城以及鹅城下面的乡镇及各个村落。作用域的概念已经了解,但是我们应该怎么设置作用域的规则呢?这就需要了解javaScript编译原理。

1、编译原理

传统的编译语言程序中一段源代码在执行前经历三个步骤,统称为编译。简单理解为代码转换为计算机可以执行的语言的过称就叫做编译.

①分词/词法分析

第一个步骤是将字符组成的字符串分解成有意义的代码块(对编程语言有意义)。 这些代码块被称为词法单元。例如var a = 2;最终会被编译成var、a、=、2、;

②解析/语法分析

第二个步骤是将词法单元流(数组)转换成一个由元素逐渐嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(AST)。

③代码生成

第三个步骤是将抽象语法树(AST)转化成可执行代码的过程。简单来说就是有某种方法可以将var a = 2;的AST转换为一组机器指令,用来创建一个叫做a的变量(包括分配内存等),并将一个值存在a中。实际上JavaScript引擎不止上面的三个步骤,它的步骤要复杂得多。

2、理解作用域

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

3、编译角色

一段代码实际上会有两个完全不同的声明,一个是编译器,另一个则是JavaScript引擎。比如一条语句var a = 2;编译器会首先执行词法分析(上文中的第一步),然后执行语法分析(上文中的第二步),最后将语法树转换为可执行代码的过程中第二个角色引擎便参与进来。事实上遇到var a ,编译器会询问作用域在同一个作用域集合中有没有变量a,如果有,编译器会忽略该声明,继续进行编译,如果没有,编译器会要求作用域在当前作用域集合中声明一个新变量,并命名为a,接下来编译器会为引擎生成运行时所需的代码,这些代码用来处理a = 2的赋值操作,引擎运行时会询问作用域当前作用域集合中有没有a这个变量,有就赋值为2,没有就就会继续查找(查找作用域链,后面会写到)。如果彻底找不到,引擎就会举手示意并抛出一个异常。

4、LHS查询、RHS查询

引擎查找变量,作用域会提供帮助,但是具体怎么查询呢。这就要用到LHS和RHS,通俗左查询和右查询,也可以理解为赋值运算符左侧用左查询,赋值运算符右侧用右查询,(实际上可能不完全是这样)。最好是理解是LHS查询接收赋值的变量容器,RHS查询是取到它的源值,也就是取值。上代码:

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

上面这段代码,最后一行foo()函数调用,需要对foo进行RHS查询,在作用域中找到编译器编译好的函数后执行函数foo,此时作为实参的2需要给形参a赋值(函数与的行参是函数中的变量),需要对a进行LHS查询并将2赋值给它,最后在console.log中对a又进行了一次RHS引用。console.log()本身也需要一个引用才能执行,因此又对console对象进行了RHS查询,查询有没有log这个方法。上文的代码中执行的查询:
在这里插入图片描述

5、作用域嵌套

当一个块或函数在另一个块或函数中,就发生了作用域的嵌套。当在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量或者抵达最外层的作用域(全局作用域)。这么一个查找过程形成的链条就叫做作用域链

6、抛出异常

如果RHS查询在所有的嵌套作用域中都找不到所需的变量,引擎就会抛出ReferenceError异常。
如果LHS查询在所有的嵌套作用域中都找不到所需的变量,则会在全局作用域中创建一个具有该名称的变量(因为a = 5找不到a时,就相当于不用var 声明了一个变量,也就是全局变量),并将其返回给引擎。(程序在非严格模式下)。严格模式抛出ReferenceError异常
如果RHS查询找到了一个变量,但是你对其进行不合理的操作,比如:对一个非函数类型的值进行函数调用;引用null或undefined类型中的属性,引擎会抛出TypeError的异常。
通俗的理解就是RHS查询ReferenceError肯定是RHS查询没找到,而TypeError是作用域中找到了,但是你没用好。LHS查询严格模式找不到也是ReferenceError

JavaScript和其他编译语言不同之处是它编译的过程不是发生在构建之前,大部分情况是编译发生在代码执行前的几微秒,然后马上就会执行。
动态作用域词法作用域

二、词法作用域

词法作用域也就是定义在词法阶段的作用域。换句话说,词法作用域就是你在写代码的时候就已经决定了变量的作用域。因此在词法分析器处理代码时会保持作用域不变(大部分情况是这样)。下面这段代码在全局和局部分别定义了变量a,并且foo执行的位置是在另一个全局函数box的内部,但是foo的作用域还是在全局,不会因为调用位置而改变。

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

还有一种作用域就是动态作用域,使用它的语言很少,它的作用域取决于函数调用的位置。还是上面的代码, 如果是动态作用域的话, foo函数的执行结果就是局部变量3而不是全局变量2。

1、作用域查找

作用域查找会在找到第一个匹配的标识符时停止。在多层嵌套的作用域中可以重复定义同名变量。这叫做遮蔽效应里面的变量遮蔽了外面的变量。抛开遮蔽效应,作用域查找肯定从最近的作用域开始,本作用域找到了就停止,找不到才会向上查找,直到找到或者到顶层作用域为止。

2、欺骗词法eval()

eval(…)函数可以接受一个字符串作用参数,并将其中内容好像在书写代码时就存在于程序中的这个位置。
示例:

function foo(str, a) {
    eval(str) //欺骗的目的
    console.log(a, b);
}
var b = 2;
foo("var b = 3", 10); //执行结果10,3  

上面的代码中b不会输出全局作用域中的2。因为传进去的是一个var b = 3;在执行到eval函数时,eval里面的代码会被执行,从而在foo函数作用域中创建了一个变量b。从而遮蔽了全局作用域中的b(查找就近原则)。
注意:
在严格模式中,eval(…)函数在运行时有其自己的词法作用域,所以此时无法修改所在的作用域。

3、欺骗词法with()

with通常被当作重复引用同一个对象的多个属性的快捷方式,可以不需要重复引用对象本身。
示例:

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
上述代码在执行13行时,将对象o1传进了函数foo,此时进行了with操作,里面的a=2,会在全局作用域中创建一个变量a,并把2赋值给它。

注意:
使用欺骗词法会影响性能,最好不要用,而且严格模式完全禁止with。

三、函数中的作用域

JavaScript中在ES2015前使用的是函数作用域(变量在声明它们的函数体以及这个函数体嵌套的任意函数体都是有定义的)。
思考下面代码:

var a = 1;
function foo(){
	var b = 2;
	bar();//调用了bar();
	function bar(){
		var c = 3;
		console.log(a);//调用作用域链顶级定义的变量
	}
}
bar();//ReferenceError
foo();// => 1

1、作用域链

上面的代码a是全局变量,在foo下的bar()中也可访问到,而却不能在foo外部调用内部的bar(),举个栗子:平头还是前面说到的鹅城县长,下面管辖着乡镇及村庄。如某一天某村有饥荒,民不聊生。那么村里没了粮食,肯定要去镇里要,镇里有就会下拨到村里,如果没有,手就伸向了我这里,平头看在眼里痛在心里,怎么能至百姓与水火之中而不顾,如我县里有,就拨下去,如果没有,毕竟县不是最大的地区,我还要找到我的上司,直至中央。。。这么一套索要的链条就是作用域链。也就是说作用域查找是由下至上,而值是由上向下传递。。见下图。
在这里插入图片描述

2、隐藏内部实现

刚才的例子说是向上查找,那么回到刚才的例子,我有物资要向下发放,一个好干部是不能向下索取的,所以,我作为县长钱可以下放,我的镇长们钱也要下放,但是都不可搜刮民之膏脂。于是乎。本作用域取不到内部作用域内的变量。内部作用域取不到内部的内部的变量, 这样可以规避冲突实行最小暴露原则,如果所有变量对外部都可见,全局空间会非常混乱,换句话说也就就不会有全局空间的说法了。

3、函数作用域

函数声明:函数声明以function为第一个词开始的

var a = 1;
function(){
	var a = 2;
	console.log(a);//=> 2
};//立即执行函数
foo();//=> 2
console.log(a);//=> 1

函数表达式:函数声明不是以function为第一个词开始的(函数表达式不仅仅为下面代码中的立即执行和函数还有var foo = function(){ })

var a = 1;
(function(){
	var a = 2;
	console.log(a);//=> 2
})();//立即执行函数
console.log(a);//=> 1

第一段代码foo被绑定在所在作用域中,可以直接调用foo访问,而第二段代码的foo被绑定在函数表达式自身的函数中,而不是所在作用域,意味着foo只有在他的代码所代表的位置中可以访问,之外则不行。这样的好处是不会污染全局环境。

4、匿名函数

函数声明不可以匿名,但是函数声明表达式可以。缺点:匿名函数可读性差,维护又很费劲。引用自身又很费劲。

四、块作用域

JavaScript在ES2015后新增块级作用域(变量只在定义它的代码块内可见)。在ES2015前可以用with语句,try/catch模仿块作用域。

1. with

用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。

2. try/catch

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

3、ES6的let、const声明

let 定义块级变量,const定义的是块级常量(常量不可修改)。因前文有总结,这里不再赘述,点击查看详细讲解

五、作用域提升

我们习惯将var a = 2;看做一条声明,实际上引擎将var a 和 a = 2看做两条不同的声明。第一阶段是编译,第二阶段才是执行。也就是说,不管声明变量或者声明函数在什么位置,都将在代码本身被执行前首先进行处理,可以理解这个过程将所有声明移动到相关作用域的最顶端。也就是说的变量声明提前函数提升。上代码:

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

上段代码本以为会输出undefined,因为会认为先声明一个全局变量a = 2,随后又声明了一遍没有赋值,默认就会赋值undefined。而实际中的执行顺序是第二行,第一行,第三行。结果自然是2。那么再看下面代码:

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

变量提升了,但是赋值不会提升,它的值默认是undefined,结果也就是undefined。
函数优先原则
函数声明和变量声明都会被提升。二者都存在时,是函数首先会被提升,然后才是变量

foo(); //1
var foo; //重复的声明(被忽略)
function foo(){
    console.log(1);
}
foo = function(){
    console.log(2);
}

会输出1,而不是2。
上述代码被引擎理解为:

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

尽管重复的var声明会被忽略,但是后面的函数还是会覆盖前面的:

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

六、作用域闭包

在写这篇博文之前我以为我理解了闭包,(本来想着就是一个两个函数套在一起,内部函数引用了嵌套他的函数的变量。然后在返回内部函数,调用外部函数的时候,自然就可以获得该函数内的变量)。但实际上提笔不知怎么写,看了好多书搜集了好多资料,才真正懂了点闭包,以防自己定义出错。所以下面的介绍大量引用书中以及博文中的内容.文中留有原文链接

定义

了解一个事物,最直接方式是看其定义。给一个事物下定义是一件非常困难的事情。下面看一下闭包在编程语言中的定义。在计算机科学中,闭包(Closure)是词法闭包(Lexical Closure)的简称,指定义在函数内部引用了函数内部变量的函数。以下引用自weixin_34204722的博客

《JavaScript权威指南》中的概念

函数对象可以通过作用域链互相关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学中成为闭包

《JavaScript权威指南》中的概念

闭包是指有权访问另一个函数作用域中的变量的函数。

《你不知道的JavaScript》中的概念

闭包是基于词法作用域书写代码时所产生的自然结果。当函数记住并访问所在的词法作用域,闭包就产生了。

个人理解
首先闭包是一个函数,他可以访问并操作其他函数内部变量的函数。也就是说他定义在所访问的函数内部。前文提到JavaScript采用词法作用域,而闭包的本质是静态作用域(静态作用域规则查找一个变量声明时依赖的是源程序中块之间的静态关系),所以函数访问的是我们定义时候的作用域,也就是词法作用域,闭包就实现了。emmm。这段文字初次理解闭包比较晦涩,继续向下看代码片段以及注解,一定会搞明白闭包是怎么回事。

我们常见的闭包形式就是a 函数套 b 函数,然后 a 函数返回 b 函数,这样 b 函数在 a 函数以外的地方执行时,依然能访问 a 函数的作用域。其中“b 函数在 a 函数以外的地方执行时”这一点,才体现了闭包的真正的强大之处。思考下面代码是不是闭包。

function outer() {
  var a = 2;
  function inner() {
    console.log(a);//2
  }
  inner();
}
outer();

基于词法作用域和查找规则,inner函数是可以访问到outer内部定义的变量a的。从技术上讲,这就是闭包。但是也可以说不是,因为用来解释inner对a的引用方法是词法作用域的查找规则,而这些规则只是闭包中的一部分而已。

下面我们将上面的代码修改下,让我们能够清晰的看到闭包


function outer() {
  var a = 2;
  function inner() {
    console.log(a);
  }
  return inner;
}
var neal = outer();
neal();//2

可能是所有讲解闭包的博客中都用烂了的例子了。这里inner函数被正常调用执行,并且可以访问到outer函数里定义的变量a。讲道理,在outer函数运行后,通常函数整个内部作用域都会被销毁。

而闭包的神奇之处正是如此可以阻止垃圾回收这种事情的发生,事实上,内部作用域已然存在且引用着a变量,所以没有被回收。inner函数拥有outer函数内部作用域的闭包,使得该作用域能够一直存活,以供inner函数在之后的任何时间可以访问。

inner()已然持有对该作用域的引用,而这个引用就被叫做闭包。

函数在定义时的词法作用域以外的地方被调用,闭包使得函数可以继续访问定义时的词法作用域。

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包

var fn;
function foo() {
  var a = 2;
  function baz() {
    console.log(a);
  }
  fn = baz;
}
 
function bar() {
  fn();
}
foo();
bar();
复制代码

代码处处有闭包

function wait(message) {
     setTimeout( function timer() {
         console.log( message ); }, 1000 ); }
wait( "Hello, closure!" );

如上的代码,一个很常见的定时器,但是timer函数具有涵盖wait作用域的闭包,因为此还保留对变量Message的引用。

wait执行1s后,他的内部作用域并不会消失,timer函数依然保持有wait作用域的闭包。

深入到引擎内部原理中,内置的g工具函数setTimeout持有对一个参数的引用,引擎调用这个函数,在例子中就是内部的timer函数,而词法作用域在这个过程中保持完整。这就是闭包。

无论何时何地,如果将函数作为第一级值类型并到处传递,你就会看到闭包在这些函数中的使用。在定时器、事件监听、Ajax请求、跨窗口通信或者其他异步任务中,只要使用回调函数,就在使用闭包。

在经典的for循环中使用闭包

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

如上for循环,大家都知道输出6,毕竟这个作用域中,我们只有一个i,所有的回调函数都是在这个for循环结束以后才执行的。

如果我们试图假设循环中的每一个迭代在运行时都会给自己捕获一个i的副本,但是根据作用域的工作原理,尽管循环中五个函数是在各个迭代中分别定义,但是他们都被封闭在共享的作用域中,因此还是只有一个i。

所以回到正题,我们需要使用闭包,在每一个循环中每一个迭代都让他产生一个闭包作用域。

所以我们代码修改如下:

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

but!!!你也发现了,这样并不姓,不是IIFE会产生一个闭包的么?是的没错,但是如果这个IIFE产生的闭包作用域是可空的,那么将它封装起来又有什么意义呢?所以它需要点实质性的东西,让我们去使用。

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

当然,如上问题我们可以使用es6中的let来解决。但是这里就不做过多说明了。

坚持学习,每天进步,It’s going to be okey.

在这里插入图片描述
如果您觉得博文对你有帮助欢迎评论点赞收藏~

  • 4
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值