JavaScript中的作用域与闭包
JavaScript由于设计的原因和历史遗留的问题,经常被开发人员所诟病。经过不断的发展和优化,最新的ES6版本已经向主流编程语言靠齐。但还是有一些公司在面试中,喜欢考察变量提升的概念、变量先使用再声明的输出顺序、闭包、还有老生常谈的循环体内定时器问题。本文将总结你不知道的JavaScript中的关于作用域和闭包的章节,结合实际开发,解释上述问题。
1、编译执行
很多资料上写道JavaScript是一门纯解释型语言,由解释器解释源码执行。实际上JavaScript是“编译型”语言,参考MDN 上概念,JavaScript是一种具有函数优先的轻量级,解释型或即时编译型的编程语言。因此在执行之前,确有编译的过程。理解这个概念,就很好理解变量提升的合理性。
2、作用域
2.1、什么是作用域
作用域顾名思义,指的是一块区域,这块区域储存着对应变量的集合,保证程序对变量的有序访问。你肯定不希望将所有的变量都放在一个区域,这样会带来诸多的问题。就像班级里的学生一样,按照年级和教室进行划分区域。可以大大减少重名的概率。 不同的作用域之间相互隔离的,用来保证数据的有序访问。但不同的作用域也是可以串联的,用来保证在找不到变量的时候通过作用域链访问上层作用域变量。JavaScript就是这样设计的。
2.2、作用域的类型
JavaScript中的有不同类型的作用域。按照语言的实现,作用域可以分为词法作用域、和动态作用域。JavaScript中的实现使用词法作用域。另外按照JavaScript的语法实现上,作用域还可区分为全局作用域、函数作用域和块作用域。
词法作用域
词法作用域顾名思义,就是定义在词法阶段的作用域,即写代码时候写在哪里,变量就在当前所处的作用域中生效。
function fun1() { function fun2(a) { //pass } }
上述代码在全局作用域中定义,显然可以看到,全局作用域中定义着函数fun1,在函数fun1作用域中定义着函数fun2,函数fun2中定义局部变量a,是的,形式参数a也是定义在作用域中的。
动态作用域
与词法作用域相对应的是动态作用域,使用是个案例便可理解这一概念。
function foo() { console.log(a); } function bar() { var a = 3; foo(); } var a = 2; bar()
运行bar()函数,控制台打印的结果为2。运行bar函数 将会调用foo函数,foo函数执行并打印a变量。由于foo函数作用域中没有定义a变量,根据作用域链查找到全局作用域中的a变量,打印2;这是执行的逻辑。也是词法作用域的概念,即与定义位置有关,与调用位置无关。
2.3、函数作用域
函数作用域指的是这个函数内部定义的全部变量都可以在整个函数的范围内使用,外部作用域无法访问包装函数内部的任何内容。即函数隐藏了内部实现,对外只暴露输入(形参)和输出(return)接口。函数作用域有一下几种形势:
声明式:
var a = 2; function foo() { var a = 3; console.log(a); // 3 } foo(); console.log(a);
就像平时定义一个普通函数一样。很明显,foo函数内部作用域的变量a,与全局作用域中的a变量,互不干扰。但这种写法带来的问题是foo函数会出现在全局作用域中,并且需要调用这个函数foo(),才能运行其中的代码。而我们的初衷,只是想通过创建一个函数作用域,将内部变量包裹起来,使之与外部变量互不干扰。JavaScript提供了立即执行函数表达式(IIFE)。
立即执行函数表达式:(IIFE)
var a = 2; (function foo() { var a = 3; console.log(a); // 3 })() console.log(a);
将函数用()包裹起来,并直接使用()调用,就是立即执行函数表达式。另一种立即执行函数表达式形式:
var a = 2; (function foo() { var a = 3; console.log(a); // 3 }()) console.log(a);
两种形式在功能上是一致的。另一种IIEF是将其作为函数调用,并传入参数。
var a = 2; (function foo(global) { var a = 3; console.log(a); // 3 console.log(global.a); // 2 }(window)) var a = 2; (function foo(global) { var a = 3; console.log(a); // 3 console.log(global.a); // 2 })(window)
还有一种形式,是倒置代码的运行顺序,将需要运行的函数放在第二位。
var a = 2; (function foo(fun) { fun(window); })(function fun(global) { var a = 3; console.log(a); console.log(global.a) })
咋一看不好理解,但拆开看就一目了然。主要利用了JavaScript中函数是一等公民的实现,即JavaScript中的函数可以像变量一样传递给函数参数。
//拆开后的形势 var a = 2; function fun(global) { var a = 3; console.log(a); console.log(global.a) } (function foo(fun) { fun(window); })(fun)
2.4、块级作用域
所谓的块级作用域指的是使用 { } 包裹的代码块,内部拥有独立作用域。很显然,我们使用的 if 语句和 for 循环,都是有 {} 的, 但ES6之前的版本无块级作用域,也就是说ES6之前的版本,大括号内都没有独立作用域的问题,比如:
var a = 1; function f() { console.log(a); if (true) { var a = 2; } } f(); // undefined
按理来说,通过作用域链,可以访问到全局作用域中的a变量;但由于在 f 函数内部存在变量提升(后面会讲到)又由于if语句没有块级作用域,内部声明的变量a 遮蔽了全局作用域中的a变量,因此是undefined。(将var 改成let 可以得到正确结果)
在for循环中没有块级作用域会带来变量泄露的问题,比如:
for (var i = 1; i <= 1; i++) { console.log(i); } console.log(i); // 2
i 现在是全局变量了。同理使用 let 可以得到预计的结果。(记得刷新浏览器)
值得注意的是,使用let在一个已经存在的块作用域上的行为是隐式的。简单来说 let 会复用所在的代码块的 {}。
var a = 1; function f() { console.log(a); if (true) { let a = 2; // 隐式 } } f(); var a = 1; function f() { console.log(a); if (true) { { let a = 2; // 显式 } } } f();
3、变量提升
所谓的变量提升,指的是JavaScript代码在执行之前,会有一个预编译的阶段,在这一阶段,会将使用 var 声明的变量名、完整的函数声明,提升到当前所在作用域的顶部。(let、const声明的变量不会提升!)这也很好理解,就像在旅行之前,你肯定会检查背包里面的物品,不至于在途中落了东西而终止旅行。观察代码:
var a = 1;
简单的声明赋值语句,实际上变量的声明式在编译阶段执行的,赋值只是执行阶段执行的。让我们来解决文章开头提到的“变量先使用再声明的输出顺序”问题:
var a; console.log(a); a = 2;
遇到这样问题,只需要只要代码中的所有使用var的变量都会提升到当前作用域的顶部,这里当然是全局作用域,然后从上到下执行,这里的a,显然是undefined。无论题有多复杂,按照编译器执行的思维去思考,答案是显而易见的。函数也是一样。
fun(); function fun() { console.log(a); // undefinded var a = 2; }
这里的函数整体都会被提升,所以可以正常执行。函数内部作用域执行和在全局作用域中的执行没有什么不同。注意:函数声明可以提升,但函数表达式不存在提升。
提升优先级
如果函数声明和变量声明式同名的,函数会首先被提升,变量提升在函数声明提升之后。
foo(); function foo() { console.log(2); // 2 } var foo = 1;
由于函数的声明会被提升到普通变量之前,上述的 var foo = 1; 无论在函数声明位置之前 还是之后,都是重复的声明。因此会被忽略。
相同的函数声明会被覆盖:
foo(); // 2 function foo() { console.log(1); } function foo() { console.log(2) }
后面的函数声明覆盖了前面的函数声明。
实际上这些都是二流面试过程中会被问到的问题。在实际开发中,遵循变量先声明后使用,规范命名,完全使用let取代var;就不会遇到这么多费力烧脑的问题;实际上也是完全没有必要的。生命很短,做一些有意义的事情,不必纠结语言过去的缺陷。
4、闭包
闭包,准确来说叫作用域闭包,之前的作用域写了很多,显然是为闭包作铺垫的。
4.1、闭包的定义
当函数可以记住并访问所在的词法作用域, 即使函数是在当前词法作用域之外执行, 这时就产生了闭包。--你不知道的JavaScript。
function foo() { var a = 2; function bar() { console.log(a) } return bar; } var baz = foo(); baz(); //2
显然函数bar所在的词法作用域位于foo函数的内部,但执行却在全局作用域中。这样便形成了一个闭包。这是书中的定义,如何用通俗的语言去解释闭包这一概念?
1、在JavaScript中,函数是一等公民,意味着函数可以像变量一样作为函数的参数传入,也可以像变量一样作为函数的return 内容。
2、函数的执行不仅仅需要函数的定义,还需对应的执行环境。观察bar函数,不仅处于foo的词法环境中,内部还通过作用域链访问foo函数的内部变量。
3、JavaScript执行后自动运行垃圾回收,释放内存。
当函数被return出去的时候,意味着可能需要在外部的词法作用域中执行。拿bar函数来说,被return出去之后,baz获取到了其引用,并在全局作用域中执行。但对于垃圾回收程序来说,foo函数已经执行完成了,需要将内部的变量全部清理掉。问题来了,如果内部的变量被清理了,bar函数执行以依赖的a变量不存在,就会报错。函数return出去不能执行有什么用。显然在这种情况下,foo函数的词法环境不会被完全清理,至少bar函数依赖的词法环境不会被清理。也就是,虽然只有函数被return,但函数依赖的执行环境也需要保存在内存中,以便随时调用,这样就形成了一个闭包。所以闭包就是由一个待执行的函数和所依赖词法作用域构成的。
4.2、闭包的其他形式
除了通过return一个函数实现闭包以外,闭包还有其他形式。
定时器:
function wait(mes) { setTimeout(() => { console.log(mes) }, 1000) } wait(1)
这个定时器代码显然也是一个闭包。套用定义来说,函数的定义词法作用域与执行的词法作用域显然是不同的,这里产生了一个闭包。通俗点来说,wait函数执行完成后,应该启动垃圾回收程序。但内部的定时器函数在将来会被执行,所依赖的词法作用域不可以被回收,变量应存在内存中,以便将来调用。除此之外,事件监听器、Ajax通信中,只要使用了回调函数,实际上都是使用闭包。
4.3、循环定时器问题
循环,每秒打印数字:
for (var i = 1; i <= 5; i++) { setTimeout(function timer() { console.log(i) }, i * 1000) }
for循环结束后, timer函数所依赖的词法环境不会被释放,并且timer函数会在将来的时刻执行,这里显然也是一个闭包。不同的是,我们定义了5次timer函数,每个timer函数会引用变量i。(重复函数声明不会被覆盖,而是被setTimeout函数推入异步任务队列)。这里的问题是异步任务执行时,所引用到的变量都是i,而此时的i变量已经是循环结束后的值6。因此会打印5次6。为了达到预期的效果,需要通过闭包,让回调函数的执行都有独立的词法作用域。
for (var i = 1; i <= 6; i++) { (function(j) { setTimeout(function timer() { console.log(j) }, j * 1000) })(i) }
通过立即执行表达式形成一个独立的函数作用域,每个独立的函数作用域中有待执行的函数和函数依赖执行的词法环境。不会相互干扰。或者通过let解决。
for (let i = 1; i <= 5; i++) { setTimeout(function timer() { console.log(i) }, i * 1000) }
显然let帮助我们创建了独立的作用域,并且每次自动将变量i绑定到当前作用域中。