JavaScript中的作用域和闭包
该篇博客参考《你不知道的JavaScript(上)》总结,主要是对作用域和闭包进行了深入的理解,并结合书进行了概括。
目录
- 词法作用域
- 作用域
- 函数作用域
- 函数内容私有化
- IIFE(立即执行函数表达式)
- 块作用域
- with
- try/catch
- let
- const
- 闭包
- 无处不在的闭包
- 模块化
词法作用域
* 作用域
function foo(a){
var b = a * 2;
function bar(c){
console.log(a,b,c);
}
bar(b * 3);
}
foo(2);//2,4,12
上述代码包含三个作用域:
- 全局作用域:包含所有的代码的最外层为全局作用域,在这里全局作用域只有foo标识符。
- foo函数创建的作用域,里面包含a、b和bar三个标识符。
- bar函数创建的作用域,里面包含c标识符。
从上述分析看来,作用域一层一层向内嵌套的。可以总结成:作用域是由代码所写位置决定的。
标识符查找方式:在查找一个标识符时,引擎会先在当前作用域中寻找,若未找到则向父级作用域中寻找,直到找到该标识符或找到全局作用域为止。作用域查找会在找到第一个匹配的标识符时停止。
* 函数作用域
可以这么说,每声明一个函数都会为其自身创建一个作用域。函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数范围内使用及复用(包括这个函数所包含的子作用域)。
- 函数内容私有化
上面说每声明一个函数都会为其自身创建一个作用域,反过来看,也可以说函数的作用域将这个函数包装了起来,结合标识符查找方式,在函数作用域外是不能访问到函数中的变量及函数的。因此我们在写代码的过程中,应该思考如何选择作用域来包含变量和函数,不能为了方便而将所有的变量都在全局作用域中声明,这样便不能体现对变量和函数的使用权限。对比以下两种写法:
function doSomething(b){
a = 2 * b;
doSomethingElse(a);
}
function doSomethingElse(b){
//代码
}
var a;
function doSomething(b){
var a = 2 * b;
function doSomethingElse(b){
//代码
}
doSomethingElse(a);
}
虽说第一种写法没有错误,但是相比于第二种,无论从代码美观度还是设计的合理度来看,使用第二种写法更正确。第二种写法将只在函数doSomething中使用的变量和函数在该函数内部,严格限制了使用权限。当代码量过大时,js文件中的变量及函数很多,你可能根本不记得是否使用过某个名字,而这种包装模式也就在一定程度上避免了标识名的冲突。
- IIFE(立即执行函数表达式)
var a = 2;
(function foo(){
var a = 3;
console.log(a);//3
})()
console.log(a);//2
上面的代码给foo函数加了两个括号,第一个()将函数变成了表达式,第二个()执行了这个函数。还有另外一个改进的形式:(function(){…}()),这两种形式在功能上是一致的,使用看个人喜好。
为什么要用IIFE?
- 传统的方法定义和执行分开写,而IIFE一步到位;
- 传统的方法直接污染全局命名空间(浏览器里的 global 对象,如 window);
不污染全局命名空间是因为IIFE创建了一个新的函数作用域,真正的业务代码被封装在其中,便不会污染全局。如果需要传入参数,甚至是全局对象,可以这样写:
var a = 2;
(function foo(global){
var a = 3;
console.log(a);//3
console.log(global.a);//2
})(window)//在这个括号传入
console.log(a);//2
* 块级作用域
- with
with通常被当做重复引用同一个对象中的多个属性的快捷方式,而用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。
var obj = { a:1, b:2, c:3 }; with(obj){ a = 3; b = 4; c = 5; }//更改obj对象各个属性的属性值
* try/catch
try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。
try{ undefined();//执行一个非法操作来强制制造一个异常 } catch(err){ console.log(err);//能够正常执行 } console.log(err);//ReferenceError:err not found
- let
let是在ES6中引入的除var以外的另一种变量声明方式,let关键字可以将变量绑定到所在的任意作用域中,一般都是在{…}内部。
var foo = true; if(foo){ {// 显示的块,如果去掉{}就是隐式绑定了 let bar = foo * 2; bar = something(bar); console.log(bar); } } console.log(bar);//ReferenceError
还有一点需要注意,使用let进行的声明不会在块级作用域中进行提升,声明的代码被运行之前,声明并不会存在。
{ console.log(bar);//ReferenceError let bar = 2; }
- const
ES6引入的const也可以用来创建块作用域变量,但其值是常量,不能修改。
闭包
无处不在的闭包
我们初学时可能没有发现,其实我们写的代码中很多都用到了闭包,我看了很多对闭包的定义,大都有些晦涩,有一句话总结的挺好的:闭包就是内部函数总是可以访问其所在的外部函数中声明的参数和变量,即使在其外部函数被返回(寿命终结)之后。
function foo(){
var a = 2;
function bar(){
console.log(a);
}
return bar;
}
var baz = foo();
baz();//2
观察上述代码,foo函数内定义了一个变量a和一个函数bar,bar()的内部又引用了其外部函数中的变量a,然后foo函数返回bar,在全局作用域中,调用foo(),将返回值给了全局变量baz,而此时foo函数已经执行完毕;我们知道,一般情况下,在foo执行后,引擎的垃圾回收器会将foo的内部作用域销毁,但baz执行以后,依然输出了a的值。其原因在于,当foo函数执行完毕后,其内部函数bar被返回给了baz,baz离不开bar,而bar又离不开a,因此foo()中的作用域存在着bar的引用,也就不会被垃圾回收器回收。
这里有一个我反复琢磨了一次的例子:
for(var i = 1; i <= 5;i ++)
{
setTimeout(function timer(){
console.log(i);
},i*1000)
}
在这里简单说一下setTimeout()的异步机制,JavaScript引擎为单线程,setTimeout()会在延迟时间结束后,并不会立即执行,它会将timer()加入任务队列,等到前面处于等待状态的事件全部处理完,再调用timer()。在上面的代码中,for循环和i*1000是同步的,而timer函数是异步的,所以当timer输出时,for循环已经终止,而此时的i为6,在控制台会以每秒一次的频率输出五次6
那么如何以每秒一次的频率输出1~5呢?
- 而根据作用域的工作原理,尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i。所以,在循环的过程中每个迭代都需要一个闭包作用域。
for(var i = 1;i <= 5;i ++)
{
(function(j){
setTimeout(function timer(){
console.log(j);
},j*1000);
})(i);
}
这里使用了IFEE在每次迭代中创建的作用域封闭起来,并且每次往IFEE中传递当次循环的i,用j来保存每次传进来的i。
模块化
我们可以使用闭包将变量和函数包装起来,再返回到外部调用,这样实现了变量私有化,又可以在外部访问,
function CoolModule(){
var something = "cool";
var another = [1,2,3];
function doSomething(){
console.log(something);
}
function doAnother(){
console.log(another.join("!"));
}
return {
doSomething:doSomething,
doAnother:doAnother
}
}
var foo = coolModule();
foo.doSomething();//cool
foo.doAnother();//1!2!3
模块模式需要具备两个必要条件:
- 必须有外部的封装函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例);
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
除此之外,也可以使用IFEE写法,省略var foo = coolModule()这一步。