## 减少全局变量
JavaScript使用函数来管理作用域,在一个函数内定义的变量称作“本地变量”,本地变量在函数外部是不能被访问的。与之相对,“全局变量”是不在任何函数体内部声明的变量,或者是直接使用而未声明的变量。
每一个JavaScript运行环境都有一个“全局对象”,不在任何函数体内使用this就可以获得对这个全局对象的引用。你所创建的每一个全局变量都是这个全局对象的属性。为了方便起见,浏览器会额外提供一个全局对象的属性`window`,(一般)指向全局对象本身。下面的示例代码展示了如何在浏览器中创建或访问全局变量:
myglobal = "hello"; // 反模式console.log(myglobal); // "hello"console.log(window.myglobal); // "hello"console.log(window["myglobal"]); // "hello"console.log(this.myglobal); // "hello" |
## 全局变量的问题
全局变量的问题是,它们在整个JavaScript应用或者是整个web页面中是始终被所有代码共享的。它们存在于同一个命名空间中,因此命名冲突的情况会时有发生,毕竟在应用程序的不同模块中,经常会出于某种目的定义相同的全局变量。
同样,在网页中嵌入不是页面开发者编写的代码是很常见的,比如:
- 网页中使用了第三方的JavaScript库
- 网页中使用了广告代码
- 网页中使用了用以分析流量和点击率的第三方统计代码
- 网页中使用了很多组件、挂件和按钮等等
假设某一段第三方提供的脚本定义了一个全局变量result。随后你在自己写的某个函数中也定义了一个全局变量result。这时,第二个变量就会覆盖第一个,会导致第三方脚本工作不正常。
因此,为了让你的脚本和这个页面中的其他脚本和谐相处,要尽量少使用全局变量,这一点非常重要。
在JavaScript中有意无意地创建全局变量是件很容易的事,因为它有两个特性:首先,你可以不声明而直接使用变量,其次,JavaScirpt中具有“隐式全局对象”的概念,也就是说任何不通过`var`声明的变量都会成为全局对象的一个属性(可以把它们当作全局变量)。(译注:在ES6中可以通过`let`来声明块级作用域变量。)看一下下面这段代码:
function sum(x, y) { // 反模式:隐式全局变量 result = x + y; return result;} |
这段代码中,我们直接使用了`result`而没有事先声明它。这段代码的确是可以正常工作,但被调用后会产生一个全局变量`result`,这可能会导致其他问题。
解决办法是,总是使用var来声明变量,下面代码就是改进了的`sum()`函数:
function sum(x, y) { var result = x + y; return result;} |
另一种创建全局变量的反模式,就是在`var`声明中使用链式赋值的方法。在下面这个代码片段中,`a`是局部变量,但`b`是全局变量,而作者的意图显然不是这样:
// 反模式function foo() { var a = b = 0; // ...} |
为什么会这样呢?因为这里的计算顺序是从右至左的:首先计算表达式`b=0`,这里的`b`是未声明的;这个表达式的结果是`0`,然后通过var创建了本地变量`a`,并赋值为`0`。换言之,可以将代码写成这样:
var a = (b = 0); |
如果变量b已经被声明,这种链式赋值的写法是可以使用的,不会意外地创建全局变量,比如:
function foo() { var a, b; // ... a = b = 0; // 两个都是本地变量} |
> 避免使用全局变量的另一个原因是出于可移植性考虑,如果你希望将你的代码运行于不同的平台环境(宿主),那么使用全局变量就非常危险。因为很有可能你无意间创建的某个全局变量在当前的平台环境中是不存在的,你以为可以安全地使用,而在另一个环境中却是本来就存在的。
## 忘记var时的副作用
隐式创建的全局变量和显式定义的全局变量之间有着细微的差别,就是通过`delete`来删除它们的时候表现不一致。
- 通过`var`创建的全局变量(在任何函数体之外创建的变量)不能被删除。
- 没有用`var`创建的隐式全局变量(不考虑函数内的情况)可以被删除。
也就是说,隐式全局变量并不算是真正的变量,但它们却是全局对象的属性。属性是可以通过`delete`运算符删除的,而变量不可以被删除:
// 定义三个全局变量var global_var = 1;global_novar = 2; // 反模式(function () { global_fromfunc = 3; // 反模式}());// 尝试删除delete global_var; // falsedelete global_novar; // truedelete global_fromfunc; // true// 测试删除结果typeof global_var; // "number"typeof global_novar; // "undefined"typeof global_fromfunc; // "undefined" |
在ES5严格模式中,给未声明的变量赋值会报错(比如这段代码中提到的两个反模式)。
## 访问全局对象
在浏览器中,我们可以随时随地通过`window`属性来访问全局对象(除非你定义了一个名叫`window`的局部变量)。但换一个运行环境这个`window`可能就换成了别的名字(甚至根本就被禁止访问全局对象了)。如果不想通过这种写死`window`的方式来访问全局变量,那么你可以在任意函数作用域内执行:
var global = (function () { return this;}()); |
这种方式总是可以访问到全局对象,因为在被当作函数(而不是构造函数)执行的函数体内,`this`总是指向全局对象。但这种情况在ECMAScript5的严格模式中行不通,因此在严格模式中你不得不寻求其他的替代方案。比如,如果你在开发一个库,你会将你的代码包装在一个即时函数中,然后从全局作用域给这个匿名函数传入一个指向`this`的参数。
## 单var模式
在函数的顶部使用唯一一个`var`语句是非常推荐的一种模式,它有如下一些好处:
- 可以在同一个位置找到函数所需的所有变量
- 避免在变量声明之前使用这个变量时产生的逻辑错误
- 提醒你不要忘记声明变量,顺便减少潜在的全局变量
- 代码量更少(输入代码更少且更易做代码优化)
单`var`模式看起来像这样:
function func() { var a = 1, b = 2, sum = a + b, myobject = {}, i, j; // 函数体…} |
你可以使用一个`var`语句来声明多个变量,变量之间用逗号分隔,也可以在这个语句中加入变量初始化的部分。这是一种非常好的实践方式,可以避免逻辑错误(所有未初始化的变量都被声明了,且值为undefined),并增加了代码的可读性。过段时间后再看这段代码,你可以从初始化的值中大概知道这个变量的用法,比如你一眼就可看出某个变量是对象还是整数。
你可以在声明变量时做一些额外的工作,比如在这个例子中就写了`sum=a+b`这种代码。另一个例子就是当代码中用到对DOM元素时,你可以把DOM引用赋值的操作也放在这个变量声明语句中,比如下面这段代码:
function updateElement() { var el = document.getElementById("result"), style = el.style; // 使用el和style…} |
### 声明提前:分散的var带来的问题
JavaScript允许在函数的任意地方写任意多个`var`语句,但它们的行为会像在函数体顶部声明变量一样,这种现象被称为“声明提前”,当你在声明语句之前使用这个变量时,可能会造成逻辑错误。对于JavaScript来说,一旦在某个作用域(同一个函数内)里声明了一个变量,那么这个变量在整个作用域内都是存在的,包括在`var`声明语句之前的位置。看一下这个例子:
// 反模式myname = "global"; // 全局变量function func() { alert(myname); // "undefined" var myname = "local"; alert(myname); // "local"}func(); |
这个例子中,你可能会期望第一个`alert()`弹出“global”,第二个`alert()`弹出“local”。这种结果看起来是合乎常理的,因为在第一个`alert()`执行时,`myname`还没有被声明,这时就应该“寻找”全局变量`myname`。但实际情况并不是这样,第一个`alert()`弹出“undefined”,因为`myname`已经在函数内被声明了(尽管声明语句在后面)。所有的变量声明都会被提前到函数的顶部,因此,为了避免类似带有“歧义”的程序逻辑,最好在使用之前一起声明它们。
上一个代码片段等价于下面这个代码片段:
myname = "global"; // 全局变量function func() { var myname; // 等价于 -> var myname = undefined; alert(myname); // "undefined" myname = "local"; alert(myname); // "local"}func(); |
> 这里有必要对“变量提前”做进一步补充,实际上从JavaScript引擎的工作机制上看,这个过程稍微有点复杂。代码处理经过了两个阶段:第一阶段是创建变量、函数和形参,也就是预编译的过程,它会扫描整段代码的上下文;第二阶段是在代码的运行时(runtime),这一阶段将创建函数表达式和一些非法的标识符(未声明的变量)。(译注:这两个阶段并没有包含代码的执行,是在执行前的处理过程。)从实用性角度来讲,我们更愿意将这两个阶段归成一个概念“变量提前”,尽管这个概念并没有在ECMAScript标准中定义,但我们常常用它来解释预编译的行为过程。
## for循环
在`for`循环中,可以对数组或类似数组的对象(比如`arguments`和`HTMLCollection`对象)进行遍历,通常`for`循环模式形如:
// 非最优的循环方式for (var i = 0; i < myarray.length; i++) { // 访问myarray[i]…} |
这种模式的问题是,每次遍历都会访问数组的length属性,这会降低代码运行效率,特别是当`myarray`不是一个数组而是一个`HTMLCollection`对象的时候。
`HTMLCollection`是由DOM方法返回的对象集合,比如:
- document.getElementsByName()
- document.getElementsByClassName()
- document.getElementsByTagName()
还有一些`HTMLCollection`是在DOM标准诞生之前就已经在用了并且现在仍然可用,包括:
- document.images
页面中所有的IMG元素
- document.links
页面中所有的A元素
- document.forms
页面中所有的表单
- document.forms[0].elements
页面中第一个表单的所有字段
这些对象的问题在于,它们都会实时查询文档(HTML页面)中的对象。也就是说每次通过它们访问集合的`length`属性时,总是都会去查询DOM,而DOM操则是很耗资源的。
更好的办法是在`for`循环中缓存要遍历的数组的长度,比如下面这段代码:
for (var i = 0, max = myarray.length; i < max; i++) {
// 访问myarray[i]…
}
通过这种方法只需要获取`length`一次,然后在整个循环过程中使用它。
不管在什么浏览器中,在遍历`HTMLCollection`时缓存`length`都可以让程序执行的更快,可以提速2倍(Safari3)到190倍(IE7)不等。更多细节可以参照Nicholas Zakas的《高性能JavaScript》,这本书也是由O'Reilly出版。
需要注意的是,当你在循环过程中需要修改这个元素集合(比如增加DOM元素)时,你可能需要更新`length`。
按照“单`var`模式”,你可以将`var`提到循环的外部,比如:
function looper() { var i = 0, max, myarray = []; // … for (i = 0, max = myarray.length; i < max; i++) { // 访问myarray[i]… }} |
当你越来越依赖“单`var`模式”时,带来的好处就是提高了代码的一致性。而缺点则是在重构代码的时候不能直接复制粘贴一个循环体,比如,你正在将某个循环从一个函数复制至另外一个函数中,那么必须确保`i`和`max`也复制到新函数里,并且需要从旧函数中将这些没用的变量删除掉。
最后一个需要对循环做出调整的地方是将i++替换成为下面两者之一:
i = i + 1i += 1 |
JSLint提示你这样做,是因为`++`和`--`实际上降低了代码的可读性,如果你觉得无所谓,可以将JSLint的`plusplus`选项设为`false`(默认为`true`)。稍后,在本书所介绍的最后一个模式中用到了:`i += 1`。
关于这种`for`模式还有两种变化的形式,做了少量改进,原因有二:
- 减少一个变量(没有`max`)
- 减量循环至0,这种方式速度更快,因为和零比较要比和非零数字或数组长度比较要高效的多
第一种变化形式是:var i, myarray = [];for (i = myarray.length; i--;) { // 访问myarray[i]…}第二种变化形式用到了while循环:var myarray = [], i = myarray.length;while (i--) { // 访问myarray[i]…} |
这些小改进只能体现在对性能要求比较苛刻的地方,此外,JSLint不推荐使用`i--`。
## for-in循环
`for-in`循环用于对非数组对象进行遍历。通过`for-in`进行循环也被称作“枚举”(enumeration)。
从技术上讲,`for-in`循环同样可以用于数组(JavaScript中数组也是对象),但不推荐这样做。当数组对象被扩充了自定义函数时,可能会产生逻辑错误。另外,`for-in`循环中属性的遍历顺序是不固定的,所以最好数组使用普通的`for`循环,对象使用`for-in`循环。
可以使用对象的`hasOwnProperty()`方法过滤来自原型链中继承来的属性,这一点非常重要。看一下这段代码:
// 对象var man = { hands: 2, legs: 2, heads: 1};// 在代码的另一个地方给所有的对象添加了一个方法if (typeof Object.prototype.clone === "undefined") { Object.prototype.clone = function () {};} |
在这个例子中,我们使用对象字面量定义了一个名叫`man`的对象。在代码中的某个地方(可以是`man`定义之前也可以是之后),给`Object`的原型增加了一个方法`clone()`。原型链是实时的,这意味着所有的对象都可以访问到这个新方法。要想在枚举`man`的时候避免枚举出`clone()`方法,就需要调用`hasOwnProperty()`来过滤来自原型的属性。如果不做过滤,`clone()`也会被遍历到,这是我们不希望看到的:
// 1.for-in循环for (var i in man) { if (man.hasOwnProperty(i)) { // filter console.log(i, ":", man[i]); }}/*控制台中的结果hands : 2legs : 2heads : 1*/// 2.反模式:// 不使用hasOwnProperty()过滤的for-in循环for (var i in man) { console.log(i, ":", man[i]);}/*控制台中的结果hands : 2legs : 2heads : 1clone: function()*/ |
另外一种调用`hasOwnProperty()`的方法是通过`Object.prototype`来调用,像这样:
for (var i in man) { if (Object.prototype.hasOwnProperty.call(man, i)) { // 过滤 console.log(i, ":", man[i]); }} |
这种做法的好处是,在`man`对象中重新定义了`hasOwnProperty`方法的情况下,可以避免调用时的命名冲突。为了避免查找属性时从`Object`对象一路找到原型的冗长过程,你可以定义一个变量来“缓存”住它:
var i, hasOwn = Object.prototype.hasOwnProperty;for (i in man) { if (hasOwn.call(man, i)) { // 过滤 console.log(i, ":", man[i]); }} |
> 严格说来,省略`hasOwnProperty()`并不是一个错误。根据具体的任务以及你对代码的自信程度,你可以省略掉它以提高一些程序执行效率。但当你对当前要遍历的对象不确定的时候,添加hasOwnProperty()则更加保险些。
这里介绍一种格式上的变种(这种写法无法通过JSLint检查),这种写法在`for`循环所在的行加入了`if`判断条件,他的好处是能让循环语句读起来更完整和通顺(“如果元素包含属性X,则对X做点什么”):
// 警告:无法通过JSLint检查var i, hasOwn = Object.prototype.hasOwnProperty;for (i in man) if (hasOwn.call(man, i)) { // 过滤 console.log(i, ":", man[i]);} |
## (不)扩充内置原型
我们可以扩充构造函数的`prototype`属性来为构造函数增加功能,这个特性非常强大,但有时会强大到超过我们的掌控。
给内置构造函数如`Object()`、`Array()`、`Function()`扩充原型看起来非常诱人,但这种做法会严重降低代码的可维护性,因为它会让你的代码变得难以预测。对于那些基于你的代码来做开发的开发者来说,他们更希望使用原生的JavaScript方法来保持代码的一致性,而不愿意使用你所添加的方法。
另外,如果将属性添加至原型中,很可能导致原型上的属性在那些不使用`hasOwnProperty()`做过滤的循环中被遍历出来,从而造成混乱。
因此,不扩充内置对象的原型是最好的,你也可以自己定义一个规则,仅当下列条件满足时才考虑扩充内置对象的原型:
1. 未来的ECMAScript版本或者JavaScirpt会将你将要实现的方法添加为内置方法。比如,你可以实现ECMAScript5定义的一些方法,直到浏览器升级至支持ES5。这样,你只是提前定义了这些方法。
2. 当某个属性或者方法是你在其它地方实现过的,或者是某个JavaScript引擎或浏览器的一部分,而你检查时又发现它不存在时。
3. 在有充分的文档说明,并且和团队其他成员做了沟通的时候。
如果你遇到这三种情况之一,你可以给内置原型添加自定义方法,写法如下:
if (typeof Object.protoype.myMethod !== "function") { Object.protoype.myMethod = function () { // 实现… };} |
## switch模式
你可以通过下面这种模式来增强`switch`语句的可读性和健壮性:
var inspect_me = 0, result = '';switch (inspect_me) { case 0: result = "zero"; break; case 1: result = "one"; break; default: result = "unknown";} |
这个简单的例子所遵循的风格约定如下:
- 每个`case`和`switch`对齐(这里不考虑花括号相关的缩进规则)。
- 每个`case`中的代码整齐缩进。
- 每个`case`都以`break`作为结束。
- 避免连续执行多个case语句块(省略break时),如果你坚持认为连续执行多个`case`语句块是最好的方法,请务必补充文档说明,对于其他人来说,会觉得这种情况是错误的写法。
- 以`default`结束整个`switch`,以确保即便是在找不到匹配项时也有合理的结果。
## 避免隐式类型转换
在JavaScript对变量进行比较时会有一些隐式的数据类型转换。比如诸如`false == 0`或`"" == 0`之类的比较都返回`true`。
为了避免隐式类型转换对程序造成干扰,推荐使用`===`和`!==`运算符,它们除了比较值还会比较类型:
var zero = 0;if (zero === false) { // 不会执行,因为zero是0,不是false}// 反模式if (zero == false) { // 代码块会执行…} |
有一种观点认为当`==`够用的时候就不必使用`===`。比如,当你知道`typeof`的返回值是一个字符串,就不必使用全等运算符。但JSLint却要求使用全等运算符,这无疑会提高代码风格的一致性,并减少了阅读代码时的思考量(“这里使用`==`是故意的还是无意的?”)。
### 避免使用eval()
当你想使用`eval()`的时候,不要忘了那句话“`eval()` is evil”(`eval()`是魔鬼)。这个函数的参数是一个字符串,它会将传入的字符串作为JavaScript代码执行。如果用来解决问题的代码是事先知道的(在运行之前),则没有理由使用`eval()`。如果需要在运行时动态生成并执行代码,那一般都会有更好的方式达到同样的目的,而非一定要使用`eval()`。例如,访问动态属性时可以使用方括号:
// 反模式var property = "name";alert(eval("obj." + property));// 更好的方式var property = "name";alert(obj[property]); |
`eval()`还有安全隐患,因为你有可能会运行一些被干扰过的代码(比如一段来自于网络的代码)。这是一种在处理Ajax请求所返回的JSON数据时比较常见的反模式。这种情况下最好使用浏览器的内置方法来解析JSON数据,以确保代码的安全性和数据的合法性。如果浏览器不支持`JSON.parse()`,你可以使用JSON.org所提供的库。
值得一提的是,多数情况下,给`setInterval()`、`setTimeout()`和`Function()`构造函数传入字符串的情形和`eval()`类似,这种用法也是应当避免的,因为这些情形中JavaScript最终还是会执行传入的字符串参数:
// 反模式setTimeout("myFunc()", 1000);setTimeout("myFunc(1, 2, 3)", 1000);// 更好的方式setTimeout(myFunc, 1000);setTimeout(function () { myFunc(1, 2, 3);}, 1000); |
`new Function()`的用法和`eval()`非常类似,应当特别注意。这种构造函数的方式很强大,但经常会被误用。如果你不得不使用`eval()`,你可以尝试用`new Function()`来代替。这有一个潜在的好处,在`new Function()`中运行的代码会在一个局部函数作用域内执行,因此源码中所有用`var`定义的变量不会自动变成全局变量。还有一种方法可以避免`eval()`中定义的变量被转换为全局变量,即是将`eval()`包装在一个即时函数内。
看一下这个例子,这里只有`un`成为全局变量污染了全局命名空间:
console.log(typeof un);// "undefined"console.log(typeof deux); // "undefined"console.log(typeof trois); // "undefined"var jsstring = "var un = 1; console.log(un);";eval(jsstring); // 打印出 "1"jsstring = "var deux = 2; console.log(deux);";new Function(jsstring)(); // 打印出 "2"jsstring = "var trois = 3; console.log(trois);";(function () { eval(jsstring);}()); // 打印出 "3"console.log(typeof un); // "number"console.log(typeof deux); // "undefined"console.log(typeof trois); // "undefined" |
`eval()`和`Function()`构造函数还有一个区别,就是`eval()`可以修改作用域链,而`Function`更像是一个沙箱。不管在什么地方执行`Function()`,它都只能看到全局作用域。因此它不会太严重的污染局部变量。在下面的示例代码中,`eval()`可以访问并修改其作用域之外的变量,而`Function()`则不能(注意,使用`Function()`和`new Function()`是完全一样的)。
(function () { var local = 1; eval("local = 3; console.log(local)"); // 打印出 3 console.log(local); // 打印出 3}());(function () { var local = 1; Function("console.log(typeof local);")(); // 打印出 undefined}()); |
## 使用parseInt()进行数字转换
你可以使用`parseInt()`将字符串转换为数字。函数的第二个参数是进制参数,这个参数应该被指定,但却通常被省略。当字符串以0为前缀时转换就会出问题,例如,在表单中输入日期的一个字段。ECMAScript3中以0为前缀的字符串会被当作八进制数处理,这一点在ES5中已经有了改变。为了避免转换类型不一致而导致的意外结果,应当总是指定第二个参数:
var month = "06",year = "09";month = parseInt(month, 10);year = parseInt(year, 10); |
在这个例子中,如果省略掉parseInt的第二个参数,比如`parseInt(year)`,返回的值是0,因为“09”被认为是八进制数(等价于`parseInt(year,8)`),但09是非法的八进制数。
字符串转换为数字还有两种方法:
+"08" // 结果为8Number("08") // 结果为8 |
这两种方法要比`parseInt()`更快一些,因为顾名思义`parseInt()`是一种“解析”而不是简单的“转换”。但当你期望将“08 hello”这类字符串转换为数字,则必须使用`parseInt()`,其他方法都会返回NaN。
## 小结
本章我们讨论了一些最佳实践和模式,它们包括:
- 减少全局对象,最好每个应用只有一个全局对象
- 函数都使用单`var`模式来定义,这样可以将所有的变量放在同一个地方声明,同时可以避免“声明提前”给程序逻辑带来的影响
- `for`循环、`for-in`循环、`switch`语句、“避免使用`eval()`”、不要扩充内置原型
浙公网安备 33010602011771号