JavaScript 学习笔记(四)模式学习

这篇博客的学习来源是《JavaScript 模式》这本书。我在看这本书的过程中学习到了很多很有用的技巧,在这里记录下来以备以后查阅。另外,如果读者对 JavaScript 已经有一定的了解和应用经验,推荐看一下这本书,或许你也能够从这本书中学习到一些很有用的知识和技巧。

模式

模式是针对普通问题的解决方案,是解决一类特定问题的模板。

基本技巧

尽量少用全局变量

全局变量在整个 JavaScript 应用或者页面中都是可见且可修改的,这边带来一些问题,最常见的便是命名冲突问题,例如当一个应用程序中两个独立的、相互之间没有联系的模块分别定义了同名的全局变量,这个时候便产生了命名冲突问题,这里问题在实际开发过程中是不好排查的,好的解决办法就是尽量少用全局变量。

鉴于 JavaScript 中的变量可以不经声明便可以赋值,在实际过程中经常会在不知不觉间创建全局变量,例如下面这段代码:

1
2
3
4
5
6
7
8
function add(x, y) {
result = x + y
return result
}

var sum = add(1, 2)

console.log(result)

上面代码的执行结果是3,表面上看变量 result 是在函数内被赋值的,其作用域应该就只是在函数内,但实际情况是在函数外仍然可以获取到。原因是 result 没有经过声明就执行了赋值操作,这样的没有通过 var 关键词声明的变量便是一个隐式的全局变量,尽管它是在函数内部被创建的,因此在函数外仍然可以获取到。

显式全局变量与隐式全局变量

使用 var 关键字创建的全局变量可以称之为显式全局变量。

不使用 var 关键字创建的全局变量可以称之为隐式全局变量。

这两种全局变量实际上是不一样的,前者不可以通过 delete 关键字删除,而后者可以。通过这个特性,我们就可以知道后者,也就是隐式全局变量实际上并不算是真正意义上的全局变量,它实际上是挂载在 window 对象下的属性,因为只有对象的属性才可以通过 delete 关键字删除。例如下面这段代码:

1
2
3
4
5
6
7
8
9
var a = {}

b = {}

/* 如果是在 Nodejs 中运行则需要将 window 改为 global */
console.log(window.a)
console.log(window.a === a)
console.log(window.b)
console.log(window.b === b)

上面代码的执行结果是undefined false {} true,这便有力地证明了前面所述内容。


注意:经过我的实践发现,这一条在不同的运行环境下结果是不一样的,在 Nodejs 中的运行结果与上面所说的运行结果相同,在浏览器(Google Chrome 61)中结果显示{} true {} true,很明显在浏览器中不管是通过哪种形式声明的全局变量,它们最后都挂载在了全局对象(浏览器环境是 window)下。另外需要说明的一点是,在 ES6 中通过 let const 关键字声明的全局变量不会挂载在全局对象下。


获取全局对象

在浏览器中全局对象叫做 window,不使用 var 关键字声明的隐式全局变量全部作为属性挂载在 window 下面,但是在其他的运行环境中全局对象的名字可能不叫做 window,例如在 Nodejs 中全局对象叫做 global,下面介绍一种获取当前运行环境的全局对象的方法。

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

上面代码的原理与 this 有关,在 JavaScript 中 this 一直指向调用它的对象,在上面的代码中,由于是一个立即执行函数,所以调用 this 的就是全局对象,自然 this 就指向了全局对象。

变量提升

JavaScript 允许在函数的任意地方声明多个变量,无论在哪里,效果都等同于在函数的顶部声明,这就是所谓的变量提升。当先使用变量再在函数后面声明变量的时候可能会导致逻辑错误。对 JavaScript 而言,只要变量是在同一个作用域内,就视为已经声明,哪怕是变量在被声明之前使用也不会报错,而是其值为 undefined。例如下面这段代码:

1
2
3
4
5
6
7
8
9
var test = '123'

function foo() {
console.log(test)
var test = '456'
console.log(test)
}

foo()

上面代码的执行结果是undefined 456,有同学预想的执行结果可能是123 456。这里第一个打印语句输出 undefined 的原因就是在 foo 函数体中已经有了关于变量的声明,虽然被写在打印语句的后面,但因为存在变量提升的缘故,导致其声明被提前到函数的最开始部分,而赋值则位于打印语句之后,因此第一个打印语句打印的是没有经过赋值的局部变量 test 而非全局变量 test,第二个打印打印的是经过了赋值的局部变量 test 而非全局变量 test。实际上上面的代码与下面的代码是等效的。

1
2
3
4
5
6
7
8
9
10
var test = '123'

function foo() {
var test
console.log(test)
test = '456'
console.log(test)
}

foo()

正是为了避免出现上面代码中这种较为隐晦的错误,我们推荐在编写代码时将需要用到的所有变量全部在程序开始部分声明,另外这种做法也方便以后查找程序需要用到的所有变量。

尽量使用 let const 声明变量

在 ES6 之前,我们推荐(或许应该强制要求)变量在使用之前都应该通过 var 关键字声明,在 ES6 出现以后,我们有了更加方便安全的变量声明方法,那就是 let 和 const,let 声明的变量存在块作用域,const 声明的变量不可以被更改,其他详细的介绍请翻阅阮一峰老师的《ES6 标准入门》一书。相对于 let,我更推荐使用 const 关键字,其带来的不可变特性可以在很大程度上减少因为变量的不经意改动而带来的 BUG;使用 let 和 const 关键字声明变量的另一个好处是,这两者声明的变量不存在变量提升,也就是说不可以在变量声明之前使用该变量,这其实是在强调前面所说的尽量在函数开始时声明变量这一条规则。例如下面这段代码:

1
2
3
4
5
6
7
8
9
var test = '123'

function foo() {
console.log(test)
const test = '456'
console.log(test)
}

foo()

运行上面的代码会报错,错误出现在第一个打印语句,原因是ReferenceError: test is not defined。这就是因为通过 const 声明的变量不会自动提升到函数的最开始部分,因此打印一个没有声明的变量是非法的。看到这里或许有同学会问,既然不存在变量提升,那第一个打印语句中的变量 test 应该就是全局变量 test 而不是没有定义。这样的疑问是正常的,原因是通过 let 和 const 声明的变量虽然不会有变量提升,但仍然会绑定作用域,上面的代码中存在全局变量 test,但是在 foo 函数的作用域中又声明了一个局部变量 test,这就导致后者而非前者绑定在这个作用域。这个特性在 ES6 中被称为“暂时性死区”,关于这一特性的更多知识,请翻阅阮一峰老师的《ES6 标准入门》一书。

函数的提升

我们已经讨论了关于变量提升的问题,其实在 JavaScript 中函数也存在提升现象,与变量不同的是,函数提升不仅把函数的声明提升到顶部,而且也将函数的定义提升到了顶部,例如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function foo1() {
console.log(1)
}

function foo2() {
console.log(2)
}

function test() {
console.log(typeof foo1)
console.log(typeof foo2)

foo1()

function foo1() {
console.log(3)
}

var foo2 = function () {
console.log(4)
}
}

test()

上面代码的执行结果是function undefined 3,首先 foo1 是函数这是没有问题的,但是其执行以后却打印出了 3 而不是 1,这是因为在函数 test 中另外定义了一个局部作用域的函数 foo1,虽然其定义的位置处于打印操作和执行操作的后面,但是因为存在函数的声明与定义提升,导致该局部作用域的函数 foo1 被提升到了函数 test 的最前面,而 foo2 为 undefined 的原因是变量提升只提升声明而不提升定义,因此在执行打印操作的时候,变量 foo2 只是经过了声明但值为 undefined。

循环时暂存总长度

使用 for 循环是暂存总长度是很有帮助的,作用相当于数据缓存机制。例如下面这段代码:

1
2
3
4
5
6
var i = 0
var length = myArray.length

for (; i < length; i++) {
/* do something */
}

上面的代码与将 i 与 length 的声明与赋值操作写到 for 循环后面的小括号中,效果是一样的,因为通过 var 声明的变量不存在块作用域,因此即使将变量放在 for 循环后面的小括号中声明,浏览器执行代码的时候仍然是会先通过变量提升的方式转变成上面这种形式,然后再执行代码。需要注意的是,通过 let 和 const 声明的变量因为存在块作用域,所以声明与赋值语句是否放在 for 循环后面的小括号中,效果是不一样的。

推荐逐步递减至 0 的循环方式

在 JavaScript 中同 0 比较比同其他数值比较速度更快一些,配合 while 语句可以写成下面这种很简单的循环形式:

1
2
3
4
5
var i = myArray.length

while (i--) {
/* do something */
}

自增自减运算符与 if、for、while 语句共用时需要注意的问题

我们知道自增和自减运算符放在变量前和放在变量后,效果是不同的,放在变量前表示先执行自增或自减操作然后再执行其他操作,放在变量后则与之相反。例如下面这段代码:

1
2
3
4
5
6
7
var i

i = 10
console.log(i--)

i = 10
console.log(--i)

上面代码的执行结果是10 9,原因是执行第一个打印操作时自减运算符放在变量的后面,表示先执行打印操作,在执行自减操作,因此结果是 10,第二个与之相反,因此结果是 9。这是很简单的用法,我想强调的是与 for、while 这种语句联合起来使用的时候,可能就不是很好分析了,例如下面这段代码:

1
2
3
4
5
var i = 5

for (; i > 0; i--) {
console.log(i)
}

上面代码的执行结果是5 4 3 2 1,先执行打印语句,再执行自减操作。

1
2
3
4
5
var i = 5

for (; i > 0; --i) {
console.log(i)
}

上面代码的执行结果同样是5 4 3 2 1,可能有部分同学的预想结果是4 3 2 1,思路是先执行自减操作,再执行打印操作,其实这样理解是有问题的,主要是没有弄清楚 for 循环后面的小括号中最后一个语句的执行时机,按照 for 循环的定义,这条语句实际上是在 for 循环内的语句已经执行了一遍以后再执行的,因此上面的写法等价于:

1
2
3
4
5
6
var i = 5

for (; i > 0;) {
console.log(i)
--i
}

这样就很明显了,无论最后的语句自减运算符在前还是在后,都要先执行一遍 for 循环内的语句,因此在这里,自增或自减运算符无论是在前还是在后,最后的执行效果是相同的。

与 for 循环比较类似的就是 while 循环,请看下面这段代码:

1
2
3
4
5
var i = 5

while (i--) {
console.log(i)
}

上面代码的执行结果是4 3 2 1 0,可能有部分同学的预想结果是5 4 3 2 1,思路是先执行打印操作,再执行自减操作,这样理解也是有问题的,主要是没有弄清楚 while 循环后面的小括号中语句的执行时机,按照 while 语句的定义,这条语句的执行时机实际上是在 while 循环内的语句之前,因此上面的写法等价于:

1
2
3
4
5
6
7
8
var i = 5

while (true) {
if (!i--) {
break
}
console.log(i)
}

这样就很明显了,在执行最后的打印操作之前,先执行了自增或自减操作,因此第一个输出的值是经过了自减操作的变量 i 的值,也就是 4,最后一个值在打印之前先做判断,因为自减运算符在后面,执行判断操作的时候变量 i 的值还是 1,所以没有跳出循环,执行完判断操作以后变量 i 自减,最后打印出 i 的值,也就是 0。上面是自增或自减运算符放在变量后面的情况,如果放在前面呢?请看下面这段代码:

1
2
3
4
5
var i = 5

while (--i) {
console.log(i)
}

上面代码的执行结果是4 3 2 1,它与下面的写法是等价的:

1
2
3
4
5
6
7
8
var i = 5

while (true) {
if (!--i) {
break
}
console.log(i)
}

最后一个值是 1 不是 0 的原因就在于,当 i 等于 1 时,因为自减运算符在前面,执行判断操作的时候变量 i 先执行了自减操作,变为 0,这时再执行判断操作,就跳出循环了,因此变量为 0 时没有被打印出来。

正是因为自增自减运算符与 if、for、while 等语句共同使用的时候有很多需要注意的地方,稍有疏忽就可能导致程序出现错误,所以部分编码规范不推荐使用自增自减运算符。但如果你觉得自己对这些需要注意的地方都了解了,那大可不必拘泥于这些条条框框,我们从上面的示例也可以看到,使用自增自减运算符可以在一定程度上简化代码。

避免使用隐式类型转换

JavaScript 程序中有很多地方都有隐式转换存在,例如比较语句==false == 0; '' == 0这类比较判断都会返回 true,这其实是存在风险的,因为隐式类型转换的规则很复杂,要确保代码运行正确就要了解众多隐式类型转换规则,而这件事我感觉是件出力不讨好的事情,所以推荐在程序的任何地方都使用严格相等 === 或严格不等 !==。