减少全局变量

JavaScript使用函数来管理作用域,在一个函数内定义的变量称作“本地变量”,本地变量在函数外部是不能被访问的。与之相对,“全局变量”是不在任何函数体内部声明的变量,或者是直接使用而未声明的变量。

每一个JavaScript运行环境都有一个“全局对象”,不在任何函数体内使用this就可以获得对这个全局对象的引用。你所创建的每一个全局变量都是这个全局对象的属性。为了方便起见,浏览器会额外提供一个全局对象的属性window,(一般)指向全局对象本身。下面的示例代码展示了如何在浏览器中创建或访问全局变量:

  1. myglobal = "hello"; // 反模式
  2. console.log(myglobal); // "hello"
  3. console.log(window.myglobal); // "hello"
  4. console.log(window["myglobal"]); // "hello"
  5. console.log(this.myglobal); // "hello"

全局变量的问题

全局变量的问题是,它们在整个JavaScript应用或者是整个web页面中是始终被所有代码共享的。它们存在于同一个命名空间中,因此命名冲突的情况会时有发生,毕竟在应用程序的不同模块中,经常会出于某种目的定义相同的全局变量。

同样,在网页中嵌入不是页面开发者编写的代码是很常见的,比如:

  • 网页中使用了第三方的JavaScript库
  • 网页中使用了广告代码
  • 网页中使用了用以分析流量和点击率的第三方统计代码
  • 网页中使用了很多组件、挂件和按钮等等

假设某一段第三方提供的脚本定义了一个全局变量result。随后你在自己写的某个函数中也定义了一个全局变量result。这时,第二个变量就会覆盖第一个,会导致第三方脚本工作不正常。

因此,为了让你的脚本和这个页面中的其他脚本和谐相处,要尽量少使用全局变量,这一点非常重要。本书随后的章节中会讲到一些减少全局变量的技巧和策略,比如使用命名空间或者即时函数等,但减少全局变量最有效的方法还是坚持使用var来声明变量。

在JavaScript中有意无意地创建全局变量是件很容易的事,因为它有两个特性:首先,你可以不声明而直接使用变量,其次,JavaScirpt中具有“隐式全局对象”的概念,也就是说任何不通过var声明的变量都会成为全局对象的一个属性(可以把它们当作全局变量)。(译注:在ES6中可以通过let来声明块级作用域变量。)看一下下面这段代码:

  1. function sum(x, y) {
  2. // 反模式:隐式全局变量
  3. result = x + y;
  4. return result;
  5. }

这段代码中,我们直接使用了result而没有事先声明它。这段代码的确是可以正常工作,但被调用后会产生一个全局变量result,这可能会导致其他问题。

解决办法是,总是使用var来声明变量,下面代码就是改进了的sum()函数:

  1. function sum(x, y) {
  2. var result = x + y;
  3. return result;
  4. }

另一种创建全局变量的反模式,就是在var声明中使用链式赋值的方法。在下面这个代码片段中,a是局部变量,但b是全局变量,而作者的意图显然不是这样:

  1. // 反模式
  2. function foo() {
  3. var a = b = 0;
  4. // ...
  5. }

为什么会这样呢?因为这里的计算顺序是从右至左的:首先计算表达式b=0,这里的b是未声明的;这个表达式的结果是0,然后通过var创建了本地变量a,并赋值为0。换言之,可以将代码写成这样:

  1. var a = (b = 0);

如果变量b已经被声明,这种链式赋值的写法是可以使用的,不会意外地创建全局变量,比如:

  1. function foo() {
  2. var a, b;
  3. // ...
  4. a = b = 0; // 两个都是本地变量
  5. }

避免使用全局变量的另一个原因是出于可移植性考虑,如果你希望将你的代码运行于不同的平台环境(宿主),那么使用全局变量就非常危险。因为很有可能你无意间创建的某个全局变量在当前的平台环境中是不存在的,你以为可以安全地使用,而在另一个环境中却是本来就存在的。

忘记var时的副作用

隐式创建的全局变量和显式定义的全局变量之间有着细微的差别,就是通过delete来删除它们的时候表现不一致。

  • 通过var创建的全局变量(在任何函数体之外创建的变量)不能被删除。
  • 没有用var创建的隐式全局变量(不考虑函数内的情况)可以被删除。

也就是说,隐式全局变量并不算是真正的变量,但它们却是全局对象的属性。属性是可以通过delete运算符删除的,而变量不可以被删除:

  1. // 定义三个全局变量
  2. var global_var = 1;
  3. global_novar = 2; // 反模式
  4. (function () {
  5. global_fromfunc = 3; // 反模式
  6. }());
  7. // 尝试删除
  8. delete global_var; // false
  9. delete global_novar; // true
  10. delete global_fromfunc; // true
  11. // 测试删除结果
  12. typeof global_var; // "number"
  13. typeof global_novar; // "undefined"
  14. typeof global_fromfunc; // "undefined"

在ES5严格模式中,给未声明的变量赋值会报错(比如这段代码中提到的两个反模式)。

访问全局对象

在浏览器中,我们可以随时随地通过window属性来访问全局对象(除非你定义了一个名叫window的局部变量)。但换一个运行环境这个window可能就换成了别的名字(甚至根本就被禁止访问全局对象了)。如果不想通过这种写死window的方式来访问全局变量,那么你可以在任意函数作用域内执行:

  1. var global = (function () {
  2. return this;
  3. }());

这种方式总是可以访问到全局对象,因为在被当作函数(而不是构造函数)执行的函数体内,this总是指向全局对象。但这种情况在ECMAScript5的严格模式中行不通,因此在严格模式中你不得不寻求其他的替代方案。比如,如果你在开发一个库,你会将你的代码包装在一个即时函数中(在第四章会讲到),然后从全局作用域给这个匿名函数传入一个指向this的参数。

单var模式

在函数的顶部使用唯一一个var语句是非常推荐的一种模式,它有如下一些好处:

  • 可以在同一个位置找到函数所需的所有变量
  • 避免在变量声明之前使用这个变量时产生的逻辑错误(参考下一小节“声明提前:分散的var带来的问题”)
  • 提醒你不要忘记声明变量,顺便减少潜在的全局变量
  • 代码量更少(输入代码更少且更易做代码优化)

var模式看起来像这样:

  1. function func() {
  2. var a = 1,
  3. b = 2,
  4. sum = a + b,
  5. myobject = {},
  6. i,
  7. j;
  8. // 函数体…
  9. }

你可以使用一个var语句来声明多个变量,变量之间用逗号分隔,也可以在这个语句中加入变量初始化的部分。这是一种非常好的实践方式,可以避免逻辑错误(所有未初始化的变量都被声明了,且值为undefined),并增加了代码的可读性。过段时间后再看这段代码,你可以从初始化的值中大概知道这个变量的用法,比如你一眼就可看出某个变量是对象还是整数。

你可以在声明变量时做一些额外的工作,比如在这个例子中就写了sum=a+b这种代码。另一个例子就是当代码中用到对DOM元素时,你可以把DOM引用赋值的操作也放在这个变量声明语句中,比如下面这段代码:

  1. function updateElement() {
  2. var el = document.getElementById("result"),
  3. style = el.style;
  4. // 使用el和style…
  5. }

声明提前:分散的var带来的问题

JavaScript允许在函数的任意地方写任意多个var语句,但它们的行为会像在函数体顶部声明变量一样,这种现象被称为“声明提前”,当你在声明语句之前使用这个变量时,可能会造成逻辑错误。对于JavaScript来说,一旦在某个作用域(同一个函数内)里声明了一个变量,那么这个变量在整个作用域内都是存在的,包括在var声明语句之前的位置。看一下这个例子:

  1. // 反模式
  2. myname = "global"; // 全局变量
  3. function func() {
  4. alert(myname); // "undefined"
  5. var myname = "local";
  6. alert(myname); // "local"
  7. }
  8. func();

这个例子中,你可能会期望第一个alert()弹出“global”,第二个alert()弹出“local”。这种结果看起来是合乎常理的,因为在第一个alert()执行时,myname还没有被声明,这时就应该“寻找”全局变量myname。但实际情况并不是这样,第一个alert()弹出“undefined”,因为myname已经在函数内被声明了(尽管声明语句在后面)。所有的变量声明都会被提前到函数的顶部,因此,为了避免类似带有“歧义”的程序逻辑,最好在使用之前一起声明它们。

上一个代码片段等价于下面这个代码片段:

  1. myname = "global"; // 全局变量
  2. function func() {
  3. var myname; // 等价于 -> var myname = undefined;
  4. alert(myname); // "undefined"
  5. myname = "local";
  6. alert(myname); // "local"
  7. }
  8. func();

这里有必要对“变量提前”做进一步补充,实际上从JavaScript引擎的工作机制上看,这个过程稍微有点复杂。代码处理经过了两个阶段:第一阶段是创建变量、函数和形参,也就是预编译的过程,它会扫描整段代码的上下文;第二阶段是在代码的运行时(runtime),这一阶段将创建函数表达式和一些非法的标识符(未声明的变量)。(译注:这两个阶段并没有包含代码的执行,是在执行前的处理过程。)从实用性角度来讲,我们更愿意将这两个阶段归成一个概念“变量提前”,尽管这个概念并没有在ECMAScript标准中定义,但我们常常用它来解释预编译的行为过程。