--摘自《You Don‘t Know JS- Scope, Closures》
对于所有的编程语言,作用域是一个基础的概念。深入了解JavaScript中的作用域,对正确的使用这个语言有重要的作用。
什么是作用域
作用域是一组变量如何存储和读取的规则,存在两类模型:
- 静态作用域(也称作字面作用域、词法作用域)。
- 动态作用域。
作用域的操作
对作用域有两类操作:读操作,写操作。
在编译原理中被读取的操作数叫右操作数(RHS),被修改的操作数叫做操作数(LHS)。
这种命名来自赋值表达式: a = b;
a在左为LHS,b在右为RHS。
静态作用域
JavaScript中变量的作用域为静态作用域。
静态作用域在代码书写时决定,并且是可嵌套的。
如下所示,代码片段中存在三个作用域,作用域间存在严格的嵌套关系:
作用域3是作用域2的子集,作用域2是作用域1的子集。
function foo(a) { var b = a * 2; function bar(c) { console.log( a, b, c ); } bar(b * 3); } foo( 2 ); // 2 4 12
变量的查找
JavaScript执行引擎从当前作用域开始查询操作数(LHS,RHS),若操作数不在当前作用域,则向上一级作用查找,直至全局作用域。
若全局作用域仍未找到操作数,则查询失败。
因此上例代码的输出为 2 4 12。
操作数查询失败的行为
RHS查询失败,抛出ReferenceError异常。
LHS查询失败,则在全局作用域中创建同名变量。(strict模式中,抛出ReferenceError异常)。
HACK作用域
Hack作用域是不推荐的行为,但这有助于深入了解JavaScript的作用域。通过eval或with关键字,可以修改执行代码的作用域。eval修改已存在的作用域
eval中的代码运行在当前作用域中,并修改或访问当前作用域中可查询到的操作数。
如下所示:
function foo(str, a) { eval( str ); // cheating! console.log( a, b ); } var b = 2; foo( "var b = 3;", 1 ); // 1, 3"var b = 3;"运行在 function foo() 的作用域中,并在此作用域中声明了变量b。
function foo()作用域中的变量b覆盖了全局作用域中的b,因此程序输出为1,3。
strict模式中,eval中的代码运行在新的作用域中,此作用域为当前作用域的子集。
如下所示:
function foo(str) { "use strict"; eval( str ); console.log( a ); // ReferenceError: a is not defined } foo( "var a = 2" );"var a = 2;"运行在独立的作用域中,此作用域为function foo()作用域的子集,因此console.log(a)在查询右操作数a时,抛出ReferenceError异常。
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 -- Oops, leaked global!foo(o1)在o1作用域中查找做操作数a并修改其值,因此console.log(o1.a)输出为2。
foo(o2)在o2作用域中查找做操作数a, 查询失败。继续向上级作用域function foo()中查找,同样失败。全局作用域同样没有做操左数a的定义。
跟据左操作数查询规则,JavaScript执行引擎在全局作用域中创建新的变量a,并赋值为2,出现了作用域泄漏的现象。
函数作用域
ES3之前,函数是创建作用域的唯一途径。每个函数都会创建一个作用域,并嵌套在当前作用域中。
函数作用域中定义的操作数可在此函数任何位置使用,也可在其子作用域中使用,但上级作用域中不可使用。
因此函数作用域可作为命名空间使用,防止多模块的命名冲突。
如下所示:
function doSomething(a) { function doSomethingElse(a) { return a - 1; } var b = a + doSomethingElse( a * 2 ); console.log( (b * 3) ); } doSomething( 2 ); // 15function doSomethingElse()仅在function doSomething()作用域可访问,函数作用域起到了封装的作用。
ES3之前函数是作用域的最小单位。函数中任何位置创建的操作数,其作用域都为此函数。
如下所示:
function foo() { function bar(a) { i = 3; // changing the `i` in the enclosing scope‘s for-loop console.log( a + i ); } for (var i=0; i<10; i++) { bar( i * 2 ); // oops, infinite loop ahead! } }这段代码将陷入死循环。虽然"var i = 0"是在for循环中声明,因为函数是作用域的最小单位,i的作用域为function foo()。
function bar()将i修改为3,导致了死循环的发生。
函数表达式
函数创建的作用域对其内部起到了封装作用,防止了命名冲突。但因此函数名便成了命名冲突的主要来源。
函数表达式解决了函数名冲突的问题。
如何区分函数定义和函数表达式
整个语句以function关键字开始,则此语句为函数定义。
反之,function标识的为函数表达式。
如下所示:
function foo1() {....} //函数定义 (function foo2(){....}) //函数表达式函数表达式中函数名的作用域
函数表达式创建了一个新的作用域,此作用域为当前作用域的子集。
以(function foo(){ … })为例, 此函数表达式中的函数名foo,仅在"…"所示位置中可被访问。
块作用域
ES3中规定,try/catch中catch语句定义的变量,作用域为此catch语句。
如下所示:
try { undefined(); // illegal operation to force an exception! } catch (err) { console.log( err ); // works! } console.log( err ); // ReferenceError: `err` not foundeerr的作用域仅为catch语句,因此console.log(err)抛出ReferenceError异常。
let关键字
ES6中引入了let关键字。let与var都用于定义变量。
区别是:
- var定义的变量作用域为函数,let定义的变量作用域为代码块。
- var定义出现的位置不影响作用域中对其引用,let定义的变量仅在定义后才可引用。
如下所示:
var foo = true; if (foo) { { // <-- explicit block let bar = foo * 2; bar = something( bar ); console.log( bar ); } } console.log( bar ); // ReferenceError{ console.log( bar ); // ReferenceError! let bar = 2; }
let循环
let在for循环中定义变量时,此变量作用域为for循环。
如下所示:
for (let i=0; i<10; i++) { console.log( i ); } console.log( i ); // ReferenceErrorlet将i的作用域绑定到for循环,并且每次循环绑定一次,可以用如下代码说明:
{ let j; for (j=0; j<10; j++) { let i = j; // re-bound for each iteration! console.log( i ); } }
const
ES6中引入的const关键字所定义的变量,作用域为代码块。
与let关键字不同的是,const定义的是常量,不可修改。
如下所示:
var foo = true; if (foo) { var a = 2; const b = 3; // block-scoped to the containing `if` a = 3; // just fine! b = 4; // error! } console.log( a ); // 3 console.log( b ); // ReferenceError!
闭包
什么是闭包
闭包是函数在其静态作用域外被执行时,仍能访问其上级作用域的能力。
如下所示:
function foo() { var a = 2; function bar() { console.log( a ); } return bar; }var baz = foo(); baz(); // 2 -- Whoa, closure was just observed, man.foo()函数返回了其内部函数bar()。
函数bar()在其静态作用域之外被调用(baz()),仍能访问其上级作用域中的变量a。
这种行为能力称为闭包。
可以说:bar()的闭包包含了foo()的作用域。原文为:function bar() has a closure over the scope of foo()).
闭包和循环
当闭包和循环纠结到一起时,情况就变的有意思了。
现在实现一段代码输出1 2 3 4 5,每次输出的时间间隔为1秒。
请看如下代码:
for (var i=1; i<=5; i++) { setTimeout( function timer(){ console.log( i ); }, i*1000 ); }函数timer()访问了其上级作用域中的变量i,timer()由计时器调用,调用处在其静态作用域外,因此构成了闭包。
代码的输出为五个6。
原因是计时器最早在1秒后运行,此时循环已经执行结束,i的值变为6。由于i的作用域为for循环所在的函数,因此timer()五次执行都获取到了i的最新值。
立即调用函数表达式(IIFE)能解决这个问题吗
函数表达式会创建一个新的作用域,但能解决这个问题吗?
请看如下代码:
for (var i=1; i<=5; i++) { (function(){ setTimeout( function timer(){ console.log( i ); }, i*1000 ); })(); }执行后结果仍为五个6。
原因是此函数表达式虽然创建了一个新的作用域,但作用域为空,最终timer()函数还是引用了更上一级作用域中的i。
那如果在函数表达式作用域内部存储i的当前值呢?
请看如下代码:
for (var i=1; i<=5; i++) { (function(){ var j = i; setTimeout( function timer(){ console.log( j ); }, j*1000 ); })(); }执行后结果为1 2 3 4 5
因为函数表达式创建了一个新的作用域,五次循环产生了这个作用域的5个副本,每个副本里j存储了i的不同值。
timer()函数在作用域的不同副本中执行,并打印出不同值。
上述代码更简洁的写法为:
for (var i=1; i<=5; i++) { (function(j){ setTimeout( function timer(){ console.log( j ); }, j*1000 ); })( i ); }let关键字能解决这个问题吗
let的作用域是块,可以解决这个问题吗?
请看如下代码:
for (var i=1; i<=5; i++) { let j = i; // yay, block-scope for closure! setTimeout( function timer(){ console.log( j ); }, j*1000 ); }执行结果为1 2 3 4 5。
因为做域是块,所以五次循环的五个副本中j的值不同,因此输出值不同。
当用let定义循环变量时,此变量的作用域为for循环,并且每次循环都会绑定一次作用域(产生一个副本),因此上述代码可进一步精简为:
for (let i=1; i<=5; i++) { setTimeout( function timer(){ console.log( i ); }, i*1000 ); }