本章首先解释如何使用变量,然后详细介绍它们的工作原理(环境、闭包等)。
在 JavaScript 中,您需要在使用变量之前通过 var
语句声明它:
var
foo
;
foo
=
3
;
// OK, has been declared
bar
=
5
;
// not OK, an undeclared variable
您还可以将声明与赋值结合起来,立即初始化变量
var
foo
=
3
;
未初始化变量的值为 undefined
:
> var x; > x undefined
您可以从两个角度检查程序的工作方式:
您在不运行程序的情况下检查源代码中的程序。给定以下代码,我们可以进行静态断言,即函数 g
嵌套在函数 f
中
function
f
()
{
function
g
()
{
}
}
形容词 词法 与 静态 同义,因为两者都与程序的 词法(单词、源代码)有关。
您检查在执行程序时发生的情况(“在运行时”)。给定以下代码
function
g
()
{
}
function
f
()
{
g
();
}
当我们调用 f()
时,它会调用 g()
。在运行时,g
被 f
调用表示一种动态关系。
变量的作用域是它可以访问的位置。例如
function
foo
()
{
var
x
;
}
在这里,x
的 直接作用域 是函数 foo()
。
如果作用域嵌套在变量的直接作用域内,则该变量在所有这些作用域中都可访问
function
foo
(
arg
)
{
function
bar
()
{
console
.
log
(
'arg: '
+
arg
);
}
bar
();
}
console
.
log
(
foo
(
'hello'
));
// arg: hello
arg
的直接作用域是 foo()
,但它也可以在嵌套作用域 bar()
中访问。关于嵌套,foo()
是 外部作用域,而 bar()
是 内部作用域。
如果一个作用域声明了一个与周围作用域中同名的变量,则在内部作用域及其嵌套的所有作用域中,对外部变量的访问将被阻止。对内部变量的更改不会影响外部变量,在离开内部作用域后可以再次访问外部变量:
var
x
=
"global"
;
function
f
()
{
var
x
=
"local"
;
console
.
log
(
x
);
// local
}
f
();
console
.
log
(
x
);
// global
在函数 f()
内部,全局变量 x
被局部变量 x
遮蔽。
大多数主流语言都是 块级作用域 的:变量“存在于”最里面的代码块中。以下是 Java 中的一个示例:
public
static
void
main
(
String
[]
args
)
{
{
// block starts
int
foo
=
4
;
}
// block ends
System
.
out
.
println
(
foo
);
// Error: cannot find symbol
}
在前面的代码中,变量 foo
只能在其直接包围的块内访问。如果我们尝试在块结束后访问它,则会收到编译错误。
相比之下,JavaScript 的变量是 函数作用域 的:只有函数会引入新的作用域;在作用域方面,块会被忽略。例如:
function
main
()
{
{
// block starts
var
foo
=
4
;
}
// block ends
console
.
log
(
foo
);
// 4
}
换句话说,foo
在 main()
的所有地方都可以访问,而不仅仅是在块内。
JavaScript 会 提升 所有变量声明,将它们移动到其直接作用域的开头。这清楚地说明了如果在声明变量之前访问它会发生什么:
function
f
()
{
console
.
log
(
bar
);
// undefined
var
bar
=
'abc'
;
console
.
log
(
bar
);
// abc
}
我们可以看到变量 bar
已经存在于 f()
的第一行,但它还没有值;也就是说,声明已被提升,但赋值没有。JavaScript 执行 f()
,就好像它的代码是
function
f
()
{
var
bar
;
console
.
log
(
bar
);
// undefined
bar
=
'abc'
;
console
.
log
(
bar
);
// abc
}
如果您声明一个已经声明的变量,则不会发生任何事情(变量的值保持不变)
> var x = 123; > var x; > x 123
每个函数声明也会被提升,但方式略有不同。完整的函数会被提升,而不仅仅是存储它的变量的创建(请参阅提升)。
一些 JavaScript 样式指南建议您只将变量声明放在函数的开头,以避免被提升所迷惑。如果您的函数相对较小(无论如何都应该是这样),那么您可以稍微放宽该规则,并在变量使用位置附近声明它们(例如,在 for
循环内)。这样可以更好地封装代码段。显然,您应该意识到这种封装只是概念上的,因为函数范围的提升仍然会发生。
您通常会引入一个新的作用域来限制变量的生命周期。您可能希望这样做的一个例子是 if
语句的“then”部分:它仅在条件成立时执行;如果它只使用辅助变量,我们不希望它们“泄漏”到周围的作用域中:
function
f
()
{
if
(
condition
)
{
var
tmp
=
...;
...
}
// tmp still exists here
// => not what we want
}
如果要为 then
块引入新的作用域,可以定义一个函数并立即调用它。这是一种解决方法,是对块级作用域的模拟:
function
f
()
{
if
(
condition
)
{
(
function
()
{
// open block
var
tmp
=
...;
...
}());
// close block
}
}
这是 JavaScript 中的一种常见模式。Ben Alman 建议将其称为立即调用函数表达式(IIFE,发音为“iffy”)。通常,IIFE 如下所示
(
function
()
{
// open IIFE
// inside IIFE
}());
// close IIFE
以下是关于 IIFE 的一些注意事项
function
开头,则解析器会将其视为函数声明(请参阅表达式与语句)。但是函数声明不能立即调用。因此,我们通过以左括号开始语句来告诉解析器关键字 function
是函数表达式的开头。在括号内,只能有表达式。如果您在两个 IIFE 之间忘记了它,那么您的代码将无法再工作:
(
function
()
{
...
}())
// no semicolon
(
function
()
{
...
}());
前面的代码被解释为函数调用——第一个 IIFE(包括括号)是要调用的函数,第二个 IIFE 是参数。
IIFE 会产生成本(认知和性能方面),因此在 if
语句中使用它很少有意义。选择前面的示例是为了便于说明。
您还可以通过前缀运算符强制执行表达式上下文。例如,您可以通过逻辑非运算符来实现:
!
function
()
{
// open IIFE
// inside IIFE
}();
// close IIFE
或者通过 void
运算符(请参阅void 运算符)
void
function
()
{
// open IIFE
// inside IIFE
}();
// close IIFE
使用前缀运算符的优点是忘记终止分号不会导致问题。
请注意,如果您已经在表达式上下文中,则无需为 IIFE 强制执行表达式上下文。那么您不需要括号或前缀运算符。例如:
var
File
=
function
()
{
// open IIFE
var
UNTITLED
=
'Untitled'
;
function
File
(
name
)
{
this
.
name
=
name
||
UNTITLED
;
}
return
File
;
}();
// close IIFE
在前面的示例中,有两个不同的变量都名为 File
。一方面,该函数只能在 IIFE 内部直接访问。另一方面,该变量在第一行声明。它被赋予 IIFE 中返回的值。
您可以使用参数为IIFE 内部定义变量:
var
x
=
23
;
(
function
(
twice
)
{
console
.
log
(
twice
);
}(
x
*
2
));
这类似于
var
x
=
23
;
(
function
()
{
var
twice
=
x
*
2
;
console
.
log
(
twice
);
}());
IIFE 使您能够将私有数据附加到函数。然后您不必声明全局变量,并且可以将函数与其状态紧密打包。您可以避免污染全局命名空间:
var
setValue
=
function
()
{
var
prevValue
;
return
function
(
value
)
{
// define setValue
if
(
value
!==
prevValue
)
{
console
.
log
(
'Changed: '
+
value
);
prevValue
=
value
;
}
};
}();
IIFE 的其他应用在本的其他地方也有提及
包含程序所有内容的作用域称为 全局作用域 或 程序作用域。这是您在输入脚本(无论是网页中的 <script>
标签还是 .js 文件)时所处的作用域。在全局作用域内,您可以通过定义函数来创建嵌套作用域。在这样的函数内部,您可以再次嵌套作用域。每个作用域都可以访问自己的变量以及周围作用域中的变量。由于全局作用域包含所有其他作用域,因此它的变量可以在任何地方访问:
// here we are in global scope
var
globalVariable
=
'xyz'
;
function
f
()
{
var
localVariable
=
true
;
function
g
()
{
var
anotherLocalVariable
=
123
;
// All variables of surround scopes are accessible
localVariable
=
false
;
globalVariable
=
'abc'
;
}
}
// here we are again in global scope
全局变量有两个缺点。首先,依赖全局变量的软件容易受到副作用的影响;它们的健壮性较差,行为可预测性较低,并且可重用性较差。
其次,网页上的所有 JavaScript 代码都共享相同的全局变量:您的代码、内置代码、分析代码、社交媒体按钮等等。这意味着名称冲突可能会成为一个问题。这就是为什么最好尽可能多地隐藏全局作用域中的变量。例如,不要这样做
<!-- Don’t do this -->
<script>
// Global scope
var
tmp
=
generateData
();
processData
(
tmp
);
persistData
(
tmp
);
</script>
变量 tmp
变为全局变量,因为它的声明是在全局作用域中执行的。但它只在本地使用。因此,我们可以使用 IIFE(请参阅通过 IIFE 引入新的作用域)将其隐藏在嵌套作用域中
<script>
(
function
()
{
// open IIFE
// Local scope
var
tmp
=
generateData
();
processData
(
tmp
);
persistData
(
tmp
);
}());
// close IIFE
</script>
值得庆幸的是,模块系统(请参阅模块系统)在很大程度上消除了全局变量的问题,因为模块不通过全局作用域进行交互,并且每个模块都有自己的模块全局变量作用域。
ECMAScript 规范使用内部数据结构环境来存储变量(请参阅环境:管理变量)。该语言有一个不同寻常的特性,即可以通过一个对象(即所谓的全局对象)访问全局变量的环境。全局对象可用于创建、读取和更改全局变量。在全局作用域中,this
指向它:
> var foo = 'hello'; > this.foo // read global variable 'hello' > this.bar = 'world'; // create global variable > bar 'world'
请注意,全局对象具有原型。如果要列出其所有(自身和继承的)属性,则需要使用列出所有属性键中的getAllPropertyNames()
等函数。
> getAllPropertyNames(window).sort().slice(0, 5) [ 'AnalyserNode', 'Array', 'ArrayBuffer', 'Attr', 'Audio' ]
JavaScript 创建者 Brendan Eich 认为全局对象是他“最后悔的事情之一”。它会对性能产生负面影响,使变量作用域的实现更加复杂,并导致代码模块化程度降低。
浏览器和 Node.js 具有用于引用全局对象的全局变量。不幸的是,它们是不同的:
window
,它是文档对象模型 (DOM) 的标准化部分,而不是 ECMAScript 5 的标准化部分。每个框架或窗口都有一个全局对象。global
,这是一个特定于 Node.js 的变量。每个模块都有自己的作用域,其中this
指向一个包含该作用域变量的对象。因此,在模块内部,this
和global
是不同的。在这两个平台上,this
都引用全局对象,但前提是您位于全局作用域中。在 Node.js 上,这几乎从未发生过。如果要以跨平台的方式访问全局对象,可以使用以下模式:
(
function
(
glob
)
{
// glob points to global object
}(
typeof
window
!==
'undefined'
?
window
:
global
));
从现在开始,我将使用window
来引用全局对象,但在跨平台代码中,您应该使用前面的模式和glob
。
本节介绍通过window
访问全局变量的用例。但一般规则是:尽可能避免这样做。
前缀window
是一个视觉提示,表明代码引用的是全局变量,而不是局部变量:
var
foo
=
123
;
(
function
()
{
console
.
log
(
window
.
foo
);
// 123
}());
但是,这会使您的代码变得脆弱。一旦您将foo
从全局作用域移至另一个周围作用域,它就会停止工作。
(
function
()
{
var
foo
=
123
;
console
.
log
(
window
.
foo
);
// undefined
}());
因此,最好将foo
引用为变量,而不是window
的属性。如果要明确表示foo
是全局变量或类似全局变量,可以添加名称前缀,例如g_
。
var
g_foo
=
123
;
(
function
()
{
console
.
log
(
g_foo
);
}());
我更喜欢不通过window
引用内置全局变量。它们是众所周知的名称,因此您无法从指示它们是全局变量中获得多少好处。并且带前缀的window
会增加混乱:
window
.
isNaN
(...)
// no
isNaN
(...)
// yes
当您使用 JSLint 和 JSHint 等代码风格检查工具时,使用window
意味着在引用当前文件中未声明的全局变量时不会收到错误。但是,这两种工具都提供了告知此类变量并防止此类错误的方法(在其文档中搜索“全局变量”)。
这不是一个常见的用例,但尤其是垫片和 polyfill(请参阅垫片与 Polyfill)需要检查全局变量someVariable
是否存在。在这种情况下,window
很有用:
if
(
window
.
someVariable
)
{
...
}
这是一种执行此检查的安全方法。如果未声明someVariable
,则以下语句将引发异常:
// Don’t do this
if
(
someVariable
)
{
...
}
您可以通过window
进行检查的另外两种方法大致相同,但更明确一些:
if
(
window
.
someVariable
!==
undefined
)
{
...
}
if
(
'someVariable'
in
window
)
{
...
}
检查变量是否存在(并具有值)的通用方法是通过typeof
(请参阅typeof:对基元进行分类)
if
(
typeof
someVariable
!==
'undefined'
)
{
...
}
window
允许您将事物添加到全局作用域(即使您位于嵌套作用域中),并且允许您有条件地执行此操作:
if
(
!
window
.
someApiFunction
)
{
window
.
someApiFunction
=
...;
}
通常最好在全局作用域中使用var
将事物添加到全局作用域。但是,window
提供了一种有条件地进行添加的简洁方法。
当程序执行进入其作用域时,变量就会出现。然后它们需要存储空间。在 JavaScript 中,提供该存储空间的数据结构称为环境。它将变量名映射到值。其结构与 JavaScript 对象的结构非常相似。环境有时会在您离开其作用域后继续存在。因此,它们存储在堆上,而不是堆栈上。
变量以两种方式传递。如果您愿意,它们有两个维度:
每次调用函数时,它都需要为其参数和变量提供新的存储空间。完成后,通常可以回收该存储空间。例如,以下阶乘函数的实现。它会递归调用自身多次,并且每次都需要为n
提供新的存储空间:
function
fac
(
n
)
{
if
(
n
<=
1
)
{
return
1
;
}
return
n
*
fac
(
n
-
1
);
}
无论调用函数多少次,它始终需要访问其自身(新的)局部变量和周围作用域的变量。例如,以下函数doNTimes
在其内部有一个辅助函数doNTimesRec
。当doNTimesRec
多次调用自身时,每次都会创建一个新的环境。但是,在这些调用期间,doNTimesRec
也会保持与doNTimes
的单个环境的连接(类似于所有函数共享一个全局环境)。doNTimesRec
需要该连接才能访问第 (1) 行中的action
:
function
doNTimes
(
n
,
action
)
{
function
doNTimesRec
(
x
)
{
if
(
x
>=
1
)
{
action
();
// (1)
doNTimesRec
(
x
-
1
);
}
}
doNTimesRec
(
n
);
}
这两个维度处理如下:
为了解析标识符,将遍历完整的环境链,从活动环境开始。
让我们看一个例子:
function
myFunction
(
myParam
)
{
var
myVar
=
123
;
return
myFloat
;
}
var
myFloat
=
1.3
;
// Step 1
myFunction
(
'abc'
);
// Step 2
图 16-1 说明了执行前面的代码时会发生什么:
myFunction
和myFloat
已存储在全局环境 (#0) 中。请注意,myFunction
引用的function
对象通过内部属性[[Scope]]
指向其作用域(全局作用域)。myFunction('abc')
,会创建一个新的环境 (#1),用于保存参数和局部变量。它通过outer
(从myFunction.[[Scope]]
初始化)引用其外部环境。借助外部环境,myFunction
可以访问myFloat
。如果函数离开创建它的作用域,它会保持与该作用域(以及周围作用域)的变量的连接。例如:
function
createInc
(
startValue
)
{
return
function
(
step
)
{
startValue
+=
step
;
return
startValue
;
};
}
createInc()
返回的函数不会丢失与其startValue
的连接,该变量为函数提供了在函数调用之间持久化的状态。
> var inc = createInc(5); > inc(1) 6 > inc(2) 8
闭包是一个函数加上与其创建时作用域的连接。该名称源于闭包“封闭”了函数的自由变量。如果变量未在函数中声明(即,如果它来自“外部”),则该变量是自由的。
这是一个高级部分,将更深入地介绍闭包的工作原理。您应该熟悉环境(请回顾环境:管理变量)。
闭包是执行离开其作用域后环境仍然存在的示例。为了说明闭包的工作原理,让我们检查之前与createInc()
的交互,并将其分为四个步骤(在每个步骤中,活动执行上下文及其环境都突出显示;如果函数处于活动状态,则也会突出显示):
此步骤发生在交互之前,以及在评估createInc
的函数声明之后。已将createInc
的条目添加到全局环境 (#0) 中,并指向一个函数对象。
此步骤发生在函数调用createInc(5)
执行期间。为createInc
创建了一个新的环境 (#1),并将其推送到堆栈上。其外部环境是全局环境(与createInc.[[Scope]]
相同)。该环境保存参数startValue
。
这一步发生在赋值给 inc
之后。在我们从 createInc
返回后,指向其环境的执行上下文将从堆栈中移除,但环境仍然存在于堆中,因为 inc.[[Scope]]
引用了它。inc
是一个闭包(函数加上诞生环境)。
这一步发生在执行 inc(1)
期间。一个新的环境(#1)已经被创建,并且一个指向它的执行上下文已经被推送到堆栈上。它的外部环境是 inc
的 [[Scope]]
。外部环境使 inc
可以访问 startValue
。
这一步发生在执行 inc(1)
之后。没有引用(执行上下文、outer
字段或 [[Scope]]
)指向 inc
的环境。因此,它不再需要,可以从堆中移除。
有时,您创建的函数的行为会受到当前作用域中变量的影响。在 JavaScript 中,这可能会有问题,因为每个函数都应该使用该变量在函数创建时的值。但是,由于函数是闭包,因此该函数将始终使用该变量的当前值。在 for
循环中,这可能会阻止事情正常工作。一个例子将使事情更清楚:
function
f
()
{
var
result
=
[];
for
(
var
i
=
0
;
i
<
3
;
i
++
)
{
var
func
=
function
()
{
return
i
;
};
result
.
push
(
func
);
}
return
result
;
}
console
.
log
(
f
()[
1
]());
// 3
f
返回一个包含三个函数的数组。所有这些函数仍然可以访问 f
的环境,因此也可以访问 i
。实际上,它们共享相同的环境。唉,在循环结束后,i
在该环境中的值为 3。因此,所有函数都返回 3
。
这不是我们想要的。为了解决这个问题,我们需要在创建一个使用索引 i
的函数之前对其进行快照。换句话说,我们希望将每个函数与函数创建时 i
的值打包在一起。因此,我们采取以下步骤
只有函数才能创建环境,因此我们使用 IIFE(参见 通过 IIFE 引入新的作用域)来完成步骤 1
function
f
()
{
var
result
=
[];
for
(
var
i
=
0
;
i
<
3
;
i
++
)
{
(
function
()
{
// step 1: IIFE
var
pos
=
i
;
// step 2: copy
var
func
=
function
()
{
return
pos
;
};
result
.
push
(
func
);
}());
}
return
result
;
}
console
.
log
(
f
()[
1
]());
// 1