JavaScript 中的作用域与闭包
作者: 大力yy
前言:
前几天面试中,面试官抛出一道题,问我输出结果是啥:
var arr = [] for (var i = 0; i < 3; i++) { arr[i] = function() { console.log(i); } } arr[0]() arr[1]() arr[2]()
无知的我脱口而出:“三个2”,面试官眉头一皱:“你再仔细看看”。哦豁,大事不妙,赶紧仔细看看,不对是三个3。面试官点了点头,心想应该是答对了。接着面试官又问我,怎么修改呢。心想这不就是闭包吗,var改let吗。接着面试官又问我其他方法呢,我说用立即执行函数。结果到了手写的时候突然懵了,背的八股文忘了,然后就尴尬了。。。
看来只背背八股文还是不行,所以今天针对这个问题,仔细学习了一下前因后果。
一、JavaScript 是一门编译语言
通常 JavaScript 被归类于“解释性语言”或“脚本语言”等,作为开发Web 页面的脚本语言而出名。但是事实上,它是一门编译语言。
MDN对JavaScript的定义如下:
JavaScript (JS) 是一种具有函数优先的轻量级,解释型或即时编译型的编程语言。
JavaScript 是一种基于原型编程、多范式的动态脚本语言,并且支持面向对象、命令式和声明式(如函数式编程)风格。
—— MDN
1.1 传统编译语言的编译步骤
(1)分词/词法分析(Tokenizing/Lexing)将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。
(2)解析/语法分析(Prsing)将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
(3)代码生成将 AST 转换为可执行代码。这个过程与语言、目标平台等息息相关;例如window下C语言编译最终得到.exe文件。
1.2 JavaScript 与传统编译语言的区别
(1)JavaScript 与传统编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中移植。 (2)JavaScript引擎负责整个JavaScript程序的编译及执行过程,编译器负责语法分析及代码生成等,相对于传统编译语言的编译器更加复杂(例如:在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化)
(3)大部分情况下JavaScript 编译发生在代码执行前的几微秒(甚至更短)时间内。
二、作用域(Scope)
作用域:负责收集并维护由所有标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。—— 《你不知道的JavaScript 上卷》
在了解什么是作用域前,首先来看看var a = 2;
是如何进行处理的。可能大部分和我一样认为这就是一句声明,但是JavaScript认为这里面又两个完全不同的声明,一个由编译器在编译时处理,另一个由引擎在运行时处理。
- 首先编译器会将这段程序分解成词法单元,然后将词法单元解析成一个树结构
- 紧接进行代码生成,编译器会进行如下处理:
- 遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a。
- 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量(即在作用域链上查找)如果引擎最终找到了 a 变量,就会将 2 赋值给它。否则引擎就会举手示意并抛出一个异常!
2.1 LHS查询 和 RHS查询
如上例子,编译器为引擎生成了为引擎生成了运行时所需的代码后,引擎执行它时,是如何查找变量a的呢?这里就要引入LHS查询和RHS查询两个术语了。
(1)LHS 查询:试图找到变量的容器本身,从而可以对其赋值
(2)RHS 查询:查找某个变量的值
以下面程序为例,对LHS 和 RHS 做更深一步的解释:
function foo(a) { console.log( a ); // 2 } foo( 2 );
- 首先:foo() 函数的调用,需要对foo进行RHS查询,即查找 foo 的值
- 紧接着执行foo(2)时,这里传递参数时,隐式进行了 a = 2,那么这里需要对 a 进行LHS查询,找到a后再将2赋值给a。
- 进入foo函数内部,然后对console进行RHS查询,然后对a进行RHS查询,传递进log()。
再看个例子:
function foo(a) { var b = a; return a + b; } var c = foo( 2 );
其中有:
3次 LHS 查询
- c = ..
- a = 2
- b = ..
4次 RHS 查询
- foo(..)
- = a
- a ..
- .. b
2.2 作用域嵌套
作用域简单来说就是根据名称查找变量的一套规则,但实际情况中,上述的查询的可能不仅限于一个作用域。
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。
因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(即:全局作用域)为止。
function foo(a) { console.log( a + b ); } var b = 2; foo( 2 ); // 4
例如上述代码中,对b进行RHS查询时,无法在当前函数foo的作用域中完成,需要向上一级作用域查找,即在全局作用域中完成了。
LHS查询和RHS查询都会在当前执行的作用域开始查找,如果没有找到,则会向上一级查找,直到查找成功或者达到全局作用域。达到全局作用域,无论是否找到,都会停止查询过程。
2.3 ReferenceError 和 TypeError
若在任何作用域中都无法查找到变量,那么引擎就会抛出异常。但是针对LHS查询失败和RHS查询失败抛出的异常是不同的。
(1)ReferenceError
console.log(a);
上述代码在执行时,会抛出 ReferenceError 。这是因为在对 a 进行RHS查询时,是无法查找到改变量的。这是因为变量 a ”未声明“,不存在于任何作用域中。
所以,RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError异常。
相比之下,LHS查询在所有嵌套的作用域中查询不到目标变量时,全局作用域会创建一个具有该名称的变量,并将其返还给引擎。但是,如果是在”严格模式“下,引擎也会抛出 ReferenceError.
// 严格模式下 "use strict" a = 2; // ReferenceError // 非严格模式下 a = 2 // 执行成功
即:
- RHS查询失败时:引擎会抛出 ReferenceError
- LHS查询失败时:
- 严格模式: 引擎会抛出 ReferenceError;
- 非严格模式:全局作用域会创建一个具有该名称的变量,并将其返还给引擎
(2)TypeError
如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError。
// 对非函数类型的值进行调用 let a = 0; a(); // 引用undefined类型的值的属性 let b; b.name;
(3)ReferenceError 和 TypeError 的区别
ReferenceError 表示RHS查询失败,或严格模式下的LHS查询失败
TypeError 则代表RHS查询成功了,但是对结果的操作是非法或不合理的。
小结
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。
LHS 和 RHS 查询都会在当前执行作用域中开始,如果没找到,就会向上级作用域继续查找目标标识符,这样每次上升一级作用域,最后抵达全局作用域(顶层),无论找到或没找到都将停止。
不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)。
三、词法作用域
第二节中提到作用域可以定义为一套规则,但是这套规则又是如何去定义的呢?
作用域主要有两种主要的工作模型:词法作用域 和 动态作用域,其中 JavaScript 采用的是词法作用域
3.1 词法阶段
如第一节中介绍的,大部分标准语言编译器的第一个工作阶段叫作词法分析。即对源代码中的字符进行检查,识别出每个单词。
简单来说,词法作用域就是定义在词法阶段的作用域。即由代码中变量的书写位置来决定的。
3.2 词法作用域 查找规则
(1)作用域查找是找从运行时所处的最内部作用域开始,逐级向外,直到遇见第一个匹配的标识符为止。
(2)遮蔽效应:在多层嵌套的作用域中可以定义同名的标识符,但是内部的标识符会”遮蔽“外部的标识符。
(3)全局变量会自动成为全局对象的属性。所以可以通过全局对象的引用来间接访问全局变量。
// a是全局变量 var a = 1; // 浏览器中全局对象一般为window console.log(window.a) // 1
所以,当全局变量在内部作用域被同名变量“遮蔽”时,可通过该方法访问到全局变量,例如:
// a是全局变量 var a = 1; funcion foo() { let a = 2; console.log(a); // 2 console.log(window.a); // 1 }
但是,对于非全局变量来说,如果被遮蔽了,就无法访问到。
(4)无论函数何时、何处以及如何被调用,它的词法作用域都只由被声明时所处的位置决定。(即与代码中书写的位置保持一致)
(5)词法作用域的查找只会查找\color{red}{一级标识符}一级标识符。
例如:针对foo.a.b
,词法作用域只会试图查找 foo 标识符,找到 foo 这个变量后,对象属性访问规则会接管对 a 和 b 属性的访问。
这也解释了前面当引擎遇到console.log();
时,只会对 console 进行一次RHS查询,不会接着对 log 进行RHS查询。
3.3 欺骗词法 —— eval、with
3.2中说到,词法作用域是由书写代码期间函数所声明的位置来定义。但是JavaScript中有两个机制会在运行时“修改”词法作用域——eval、with。但是很多地方都建议不使用这两种机制,因为欺骗词法作用域会导致性能下降。
(1)eval
eval()
是全局对象的一个函数属性。eval()
的参数是一个字符串。如果字符串表示的是表达式,eval()
会对表达式进行求值。如果参数表示一个或多个 JavaScript 语句,那么eval()
就会执行这些语句。 —— MDN
换个说法,eval可以在书写的代码中用程序生成代码并运行,就好像代码是写在那个位置一样。
在执行 eval(..) 之后的代码时,引擎并不“知道”或“在意”前面的代码是以动态形式插入进来,并对词法作用域的环境进行修改的。引擎只会如往常地进行词法作用域查找。
function foo(str, a) { eval( str ); // 欺骗! console.log( a, b ); } var b = 2; foo( "var b = 3;", 1 ); // 1, 3 foo( "", 1 ); // 1, 2
从上述代码可以看出,书写的代码中 foo 函数的词法作用域并没有声明变量 b。但是eval(..) 调用中的 var b = 3;
这段代码会被当作本来就在那里一样来处理。因此对foo函数的词法作用域进行了修改,在foo函数内部创建了一个变量b,遮蔽了全局变量b,所以输出 1, 3。
eval(..) 可以在运行期修改书写期的词法作用域。但个人觉得其实并没有破坏词法作用域的查找规则,即把 eval() 的参数在eval书写的位置替换eval()。然后再按词法作用域规则去查找。
\color{red}{注意:}注意:在严格模式下,eval()在运行时有自己的词法作用域,所以其中的声明无法修改所在的作用域。
function foo(str, a) { "use strict" eval( str ); // 欺骗! console.log( a, b ); } var b = 2; foo( "var b = 3;console.log(b)", 1 ); //3 1, 2
从上述代码可以看出,严格模式下,在eval()函数内部输出b,值为3,但是在eval()函数外部输出b,值为2.
不推荐使用与 eval() 以及与 eval() 类似的函数setTimeout(..) 和setInterval(..) 的第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的函数代码。这些功能已经过时且并不被提倡。(目前一般是传递回调函数)
new Function(..) 最后一个参数可以接受代码字符串,并将其转化为动态生成的函数(前面的参数是这个新生成的函数的形参)。这种构建函数的语法比eval(..) 略微安全一些,但也要尽量避免使用。
(2)with
'with'语句将某个对象添加到作用域链的顶部,如果在statement中有某个未使用命名空间的变量,跟作用域链中的某个属性同名,则这个变量将指向这个属性值。如果沒有同名的属性,则将拋出ReferenceError异常。—— MDN
with (expression) { statement }
换种说法,with 可以将一个没有或有多个属性的对象处理为一个完全隔离的全新的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。
var c = 3; let obj = { a: 1, b: 2 } with(obj) { console.log(a); // 1 var b = 5; console.log(b); // 5 console.log(c); // 3 console.log(d); // ReferenceError }
对于上述代码,我们可以这样理解,with 语句创建了一个全新的词法作用域,并把 obj 放在该词法作用域的顶层(若把该词法作用域类比为全局作用域,那么obj就是一个全局对象)。在该全新的作用域中,obj的所有属性都可以直接访问。console.log(a)
输出1:当前词法作用域未声明变量a,所以向上一级查找,obj中包含属性a,所以输出1console.log(b)
输出5:当前词法作用域中声明了变量b,该变量b”遮蔽“了obj中的属性b,所以输出5console.log(c)
输出3:当前词法作用域未定义变量c,obj中也没有属性c,则继续向全局作用域查找,所以输出3console.log(d)
抛出异常ReferenceError:因为在当前词法作用域以及其嵌套的所有词法作用域中都未声明变量d,RHS查询失败,所以抛出ReferenceError
\color{red}{注意:}注意:在 ECMAScript 5严格模式下,with标签已经被禁用。
(3)为什么不推荐使用 eval() 和 with
1. eval() 和 with 对性能的影响JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
但是当引擎在代码中遇见了 eval() 或者 with,无法直到eval()中的字符串参数如何对作用域进行修改,也不知道 with 用来创建新词法作用域的对象的内容到底是什么。因为eval() 和 with 是在运行时修改或创建新的词法作用域,所以这会影响引擎在编译阶段的性能优化,会导致程序运行变慢。
2. 严格模式下严格模式下,eval()在运行时有自己的词法作用域,而with则被禁用了。
3. eval() 函数不安全如果你用 eval()
运行的字符串代码被恶意方(不怀好意的人)修改,您最终可能会在您的网页/扩展程序的权限下,在用户计算机上运行恶意代码
4. with的弊端
with
使用'with'可以减少不必要的指针路径解析运算。(但是很多情況下,也可以不使用with语句,而是使用一个临时变量来保存指针,来达到同样的效果)with
语句使得程序在查找变量值时,都是先在指定的对象中查找。所以那些本来不是这个对象的属性的变量,查找起来将会很慢with
语句使得代码不易阅读,同时使得JavaScript编译器难以在作用域链上查找某个变量,难以决定应该在哪个对象上来取值
四、函数作用域和块作用域
第三节中指出,词法作用域是书写代码时的位置来决定的。但是这些词法作用域时基于什么的位置来确定的呢?JavaScript中主要具有函数作用域和块作用域两种。
4.1 函数作用域
简单来说,函数作用域就是指,属于这个函数的全部变量都可以在整个函数范围内使用及复用(在嵌套的作用域中也可以使用)。但是外部作用域无法访问函数内部的任何内容。
4.2 块作用域
块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指 { .. } 内部) (1)用 with 从对象中创建出的块作用域仅在 with 声明中而非外部作用域中有效。
(2)JavaScript 的 ES3 规范中规定 try/catch 的 catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。
(3)let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说,let为其声明的变量隐式地了所在的块作用域。
for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值
(4)const 同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误
五、函数提升和变量提升
在介绍闭包之前,我还要啰嗦几句,以便后续更好解释例题。
5.1 变量声明提升
对于一段JavaScript代码。我们可能会认为时从上到下一行一行地去执行的,但实际上并不完全是这样的。
console.log(a); // 1 var a = 1;
如果程序是从上到下执行的话,那么第一行代码应该会抛出ReferenceError,因为并没有在这之前并没有声明变量a。但实际上会输出 undefined ,这是为啥呢?
这要从编译开始说起了,引擎在解释JavaScript代码前会先对其进行编译,编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。所以,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
所以上述代码实际的执行顺序是:
var a; console.log(a); a = 2;
\color{red}{注意}注意:
- 只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。所以上述代码
var a = 2
中只有var a
提升了。 - ES6中新加入的let 和 const 关键字声明变量时,并不会进行变量提升。
5.2 函数声明提升
除了变量声明会提升,函数声明也会提升。
foo(); function foo() { console.log( 1 ); // 1 }
如上代码,实际的执行顺序如下:
function foo() { console.log( 1 ); // 1 } foo();
此外,需要注意的是,只有函数声明会提升,函数表达式并不会提升。
foo(); // TypeError bar(); // ReferenceError var foo = function bar() { // ... };
5.3 声明提升注意点
函数声明先提升,然后再变量声明提升
foo(); // 1 var foo; function foo() { console.log( 1 ); } foo = function() { console.log( 2 );
一个普通块内部的函数声明通常会被提升到所在作用域的顶部
foo(); // "b" var a = true; if (a) { function foo() { console.log("a"); } } else { function foo() { console.log("b"); } }
var 声明的是函数作用域,所以在一个普通块内部,var的变量声明也会提升
console.log(a) // undefined if(false) { var a = 1; } console.log(a) // ReferenceError function f() { var a = 1; }
六、闭包
介绍完前面的知识后,终于可以引出主角闭包了,首先看看MDN中对闭包的定义:
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。 —— MDN
好像有点晦涩难懂,再来看看《你不知道的JavaScript上卷》中对闭包的定义:
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。 —— 《你不知道的JavaScript上卷》
还是先来看两段代码吧:
function foo() { var a = 2; function bar() { console.log( a ); // 2 } bar(); } foo();
基于词法作用域的查找规则,函数bar()可以访问外部作用域中的变量a。这是闭包吗?反正我之前认为这就是。但是确切来说,这并不是闭包。
function foo() { var a = 2; function bar() { console.log( a ); } return bar; } var baz = foo(); baz();
这段代码就很清晰地展示了闭包,当foo()执行完毕后,通常会销毁foo的内部作用域,但是闭包阻止了这一行为。bar()它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。
这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域。
所以,无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
第一段代码中,bar() 就是在其词法作用域内执行的,所以严格来说并不能称为闭包,因为并不需要“记住”词法作用域。
6.1 例题
既然了解了什么闭包,我们来看看文章开头的面试题:
var arr = [] for (var i = 0; i < 3; i++) { arr[i] = function() { console.log(i); } } arr[0]() // 3 arr[1]() // 3 arr[2]() // 3
三个函数调用的结果都是3,为什么会这样呢?
首先看for循环中的var i = 0;
,其中声明的变量 i 是全局作用域的一个变量,所以在执行arr[0]() 、arr[1]()、arr[2]()
的时候,在作用域链上查找变量 i 时,最终找到都是全局作用域中的同一个变量 i。因为经历了三次循环,所以 i 的值变成了3。故调用三个函数输出的值都是3。
那么,怎么去改进使得程序由正确的输出呢?
1. for循环中使用 let 声明i
前面提到,for 循环头部的 let 将 i 绑定到了 for 循环的块中,指出变量 i 在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
var arr = [] for (let i = 0; i < 3; i++) { arr[i] = function() { console.log(i); } } arr[0]() // 0 arr[1]() // 1 arr[2]() // 2
2. 立即执行函数(IIFE)
var arr = [] for (var i = 0; i < 3; i++) { (function IIFE(i) { arr[i] = function() { console.log(i); } })(i) } arr[0]() // 0 arr[1]() // 1 arr[2]() // 2
此外这里再分析一种错误的写法:
var arr = [] for (var i = 0; i < 3; i++) { var j = i; arr[i] = function() { console.log(j); } } arr[0]() // 2 arr[1]() // 2 arr[2]() // 2
这也是我改进的最初答案,想着使用一个变量记录当前的i值不就行了。但是结果并不像我想的那样,翻阅书籍后,发现var声明的作用域是函数作用域,所以在for循环块中的var j = i
也会声明提升。相当于j也是一个全局变量了。最后三个函数中查找到的j也相同。
到此这篇关于JavaScript 中的作用域与闭包的文章就介绍到这了,更多相关JS作用域与闭包内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!