JavaScript 高级程序设计(第四版)
# 1. HTML 中的 JavaScript
要包含外部 JavaScript 文件,必须将 src 属性设置为要包含文件的 URL。文件可以跟网页在同一台服务器上,也可以位于完全不同的域。
所有
<script>
元素会依照它们在网页中出现的次序被解释。在不使用 defer 和 async 属性的情况下,包含在<script>
元素中的代码必须严格按次序解释。对不推迟执行的脚本,浏览器必须解释完位于
<script>
元素中的代码,然后才能继续渲染页面的剩余部分。为此,通常应该把<script>
元素放到页面末尾,介于主内容之后及<body>
标签之前。可以使用 defer 属性把脚本推迟到文档渲染完毕后再执行。推迟的脚本原则上按照它们被列出的次序执行。
可以使用 async 属性表示脚本不需要等待其他脚本,同时也不阻塞文档渲染,即异步加载。异步脚本不能保证按照它们在页面中出现的次序执行。
通过使用
<noscript>
元素,可以指定在浏览器不支持脚本时显示的内容。如果浏览器支持并启用脚本,则<noscript>
元素中的任何内容都不会被渲染。
# 2. 垃圾回收
标记清理。
- JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep)。当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。
- 垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。
引用计数。
另一种没那么常用的垃圾回收策略是引用计数(reference counting)。其思路是对每个值都记录它被引用的次数,声明变量并给它赋一个引用值时,这个值的引用数为 1。如果同一个值又被赋给另一个变量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存。
function problem() { let objectA = new Object() let objectB = new Object() objectA.someOtherObject = objectB objectB.anotherObject = objectA }
1
2
3
4
5
6在这个例子中, objectA 和 objectB 通过各自的属性相互引用,意味着它们的引用数都是 2。在标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域中。而在引用计数策略下,objectA 和 objectB 在函数结束后还会存在,因为它们的引用数远不会变成 0。如果函数被多次调用,则会导致大量内存永远不会被释放。为此,Netscape 在 4.0 版放弃了引用计数,转而采用标记清理。事实上,引用计数策略的问题还不止于此。
# 3. 内存管理
通过 const 和 let 声明提升性能。
ES6 增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为 const 和 let 都以块(而非函数)为作用域,所以相比于使用 var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。
隐藏类和删除操作。
动态删除属性与动态添加属性导致的后果一样。最佳实践是把不想要的属性设置为 null。这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果。
function Article() { this.title = 'Inauguration Ceremony Features Kazoo Band' this.author = 'Jake' } let a1 = new Article() let a2 = new Article() a1.author = null // 继续共享隐藏类 // a1.attr = 777; // 不再共享隐藏类 // delete a1.author; // 不再共享隐藏类
1
2
3
4
5
6
7
8
9
10
11内存泄漏。
意外声明全局变量是最常见但也最容易修复的内存泄漏问题:解释器会把变量 name 当作 window 的属性来创建。
function setName() { name = 'Jake' }
1
2
3定时器也可能会悄悄地导致内存泄漏:只要定时器一直运行,回调函数中引用的 name 就会一直占用内存。
let name = 'Jake' setInterval(() => { console.log(name) }, 100)
1
2
3
4使用 JavaScript 闭包很容易在不知不觉间造成内存泄漏:调用 outer() 会导致分配给 name 的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理 name,因为闭包一直在引用着它。
let outer = function() { let name = 'Jake' return function() { return name } }
1
2
3
4
5
6静态分配与对象池。
- 为了提升 JavaScript 性能,最后要考虑的一点往往就是压榨浏览器了。此时,一个关键问题就是如何减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。
- 静态分配是优化的一种极端形式。如果你的应用程序被垃圾回收严重地拖了后腿,可以利用它提升性能。但这种情况并不多见,大多数情况下,这都属于过早优化,因此不用考虑。
# 4. 原始值包装类型
装箱与拆箱(在以读模式访问字符串值的任何时候,后台都会执行以下 3 步)。
- 创建一个 String 类型的实例。
- 调用实例上的特定方法。
- 销毁实例。
可以把这 3 步想象成执行了如下 3 行 ECMAScript 代码。
let s1 = 'some text' let s2 = s1.substring(2) // 想象的装箱与拆箱 let s1 = new String('some text') let s2 = s1.substring(2) s1 = null
1
2
3
4
5
6
7- 引用类型与原始值包装类型的主要区别在于对象的生命周期。在通过 new 实例化引用类型后,得到的实例会在离开作用域时被销毁,而自动创建的原始值包装对象则只存在于访问它的那行代码执行期间。这意味着不能在运行时给原始值添加属性和方法。
- 这里的第二行代码尝试给字符串 s1 添加了一个 color 属性。可是,第三行代码访问 color 属性时,它却不见了。原因就是第二行代码运行时会临时创建一个 String 对象,而当第三行代码执行时,这个对象已经被销毁了。实际上,第三行代码在这里创建了自己的 String 对象,但这个对象没有 color 属性。
let s1 = 'some text' s1.color = 'red' console.log(s1.color) // undefined
1
2
3
# 5. 单例内置对象
Global 对象。
Global 对象是 ECMAScript 中最特别的对象,因为代码不会显式地访问它。ECMA-262 规定 Global 对象为一种兜底对象,它所针对的是不属于任何对象的属性和方法。事实上,不存在全局变量或全局函数这种东西。在全局作用域中定义的变量和函数都会变成 Global 对象的属性。本书前面介绍的函数,包括 isNaN()、isFinite()、parseInt() 和 parseFloat(),实际上都是 Global 对象的方法。除了这些,Global 对象上还有另外一些方法。
window 对象。
虽然 ECMA-262 没有规定直接访问 Global 对象的方式,但浏览器将 window 对象实现为 Global 对象的代理。因此,所有全局作用域中声明的变量和函数都变成了 window 的属性。
Math。
Math 对象上提供的计算要比直接在 JavaScript 实现的快得多,因为 Math 对象上的计算使用了 JavaScript 引擎中更高效的实现和处理器指令。但使用 Math 计算的问题是精度会因浏览器、操作系统、指令集和硬件而异。
# 6. Object
- 在使用对象字面量表示法定义对象时,并不会实际调用 Object 构造函数。
# 7. Array
与对象一样,在使用数组字面量表示法创建数组不会调用 Array 构造函数。
数组空位。
- 使用数组字面量初始化数组时,可以使用一串逗号来创建空位(hole)。ECMAScript 会将逗号之间相应索引位置的值当成空位,ES6 规范重新定义了该如何处理这些空位。
- ES6 新增的方法和迭代器与早期 ECMAScript 版本中存在的方法行为不同。ES6 新增方法普遍将这些空位当成存在的元素,只不过值为 undefined。ES6 之前的方法则会忽略这个空位,但具体的行为也会因方法而异。
- 由于行为不一致和存在性能隐患,因此实践中要避免使用数组空位。如果确实需要空位,则可以显式地用 undefined 值代替。
const options = [1, , , , 5] // ES6 for (const option of options) { console.log(option === undefined) } // false // true // true // true // false // ES5 // map()会跳过空位置 console.log(options.map(() => 6)) // [6, undefined, undefined, undefined, 6] // join()视空位置为空字符串 console.log(options.join('-')) // "1----5"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17数组索引。
数组最多可以包含 4 294 967 295 个元素(),这对于大多数编程任务应该足够了。如果尝试添加更多项,则会导致抛出错误。以这个最大值作为初始值创建数组,可能导致脚本运行时间过长的错误。
转换方法。
toLocaleString() 方法也可能返回跟 toString() 和 valueOf() 相同的结果,但也不一定。在调用数组的 toLocaleString() 方法时,会得到一个逗号分隔的数组值的字符串。它与另外两个方法唯一的区别是,为了得到最终的字符串,会调用数组每个值的 toLocaleString() 方法,而不是 toString() 方法。
let person1 = { toLocaleString() { return 'Nikolaos' }, toString() { return 'Nicholas' }, } let person2 = { toLocaleString() { return 'Grigorios' }, toString() { return 'Greg' }, } let people = [person1, person2] alert(people) // Nicholas,Greg alert(people.toString()) // Nicholas,Greg alert(people.toLocaleString()) // Nikolaos,Grigorios
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22如果数组中某一项是 null 或 undefined,则在 join() 、toLocaleString() 、toString() 和 valueOf() 返回的结果中会以空字符串表示。
搜索和位置方法。
如果 slice() 的参数有负值,那么就以数值长度加上这个负值的结果确定位置。比如,在包含 5 个元素的数组上调用 slice(-2,-1) ,就相当于调用 slice(3,4) 。如果结束位置小于开始位置,则返回空数组。
# 8. Map
与 Object 类型的一个主要差异是, Map 实例会维护键值对的插入顺序,因此可以根据插入顺序执行迭代操作(Set 具有相同性质)。
对于多数 Web 开发任务来说,选择 Object 还是 Map 只是个人偏好问题,影响不大。不过,对于在乎内存和性能的开发者来说,对象和映射之间确实存在显著的差别。
- 内存占用:批量添加或删除键/值对则取决于各浏览器对该类型内存分配的工程实现。不同浏览器的情况不同,但给定固定大小的内存, Map 大约可以比 Object 多存储 50%的键/值对。
- 插入性能:如果代码涉及大量插入操作,那么显然 Map 的性能更佳。
- 查找速度:如果代码涉及大量查找操作,那么某些情况下可能选择 Object 更好一些。
- 删除性能:对大多数浏览器引擎来说, Map 的 delete() 操作都比插入和查找更快。如果代码涉及大量删除操作,那么毫无疑问应该选择 Map。
# 9. 迭代器与生成器
迭代器维护着一个指向可迭代对象的引用,因此迭代器会阻止垃圾回收程序回收可迭代对象。
箭头函数不能用来定义生成器函数。
# 10. 增强的对象语法
属性值简写。
在这里,即使参数标识符只限定于函数作用域,编译器也会保留初始的 name 标识符。如果使用 Google Closure 编译器压缩,那么函数参数会被缩短,而属性名不变。
// 属性简写 function makePerson(name) { return { name, } } let person = makePerson('Matt') console.log(person.name) // Matt // 压缩示意 function makePerson(a) { return { name: a, } } var person = makePerson('Matt') console.log(person.name) // Matt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 11. 对象解构
- 解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,则赋值表达式必须包含在一对括号中。
let personName, personAge
let person = {
name: 'Matt',
age: 27,
}
;({ name: personName, age: personAge } = person)
console.log(personName, personAge) // Matt, 27
2
3
4
5
6
7
8
9
嵌套解构。
解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性。
let person = { name: 'Matt', age: 27, job: { title: 'Software engineer', }, } let personCopy = {} ;({ name: personCopy.name, age: personCopy.age, job: personCopy.job, } = person) // 因为一个对象的引用被赋值给 personCopy,所以修改 // person.job 对象的属性也会影响 personCopy person.job.title = 'Hacker' console.log(person) // { name: 'Matt', age: 27, job: { title: 'Hacker' } } console.log(personCopy) // { name: 'Matt', age: 27, job: { title: 'Hacker' } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 12. 原型模式
Object.setPrototypeOf() 可能会严重影响代码性能。Mozilla 文档说得很清楚:“在所有浏览器和 JavaScript 引擎中,修改继承关系的影响都是微妙且深远的。这种影响并不仅是执行 Object.setPrototypeOf() 语句那么简单,而是会涉及所有访问了那些修改过 [[Prototype]] 的对象的代码”
为避免使用 Object.setPrototypeOf() 可能造成的性能下降,可以通过 Object.create() 来创建一个新对象,同时为其指定原型。
// Object.setPrototypeOf()
let biped = {
numLegs: 2,
}
let person = {
name: 'Matt',
}
Object.setPrototypeOf(person, biped)
console.log(person.name) // Matt
console.log(person.numLegs) // 2
console.log(Object.getPrototypeOf(person) === biped) // true
// Object.create()
let biped = {
numLegs: 2,
}
let person = Object.create(biped)
person.name = 'Matt'
console.log(person.name) // Matt
console.log(person.numLegs) // 2
console.log(Object.getPrototypeOf(person) === biped) // true
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- for-in 循环和 Object.keys()的枚举顺序是不确定的,取决于 JavaScript 引擎,可能因浏览器而异。
# 13. 对象迭代
- 重写构造函数上的原型之后再创建的实例才会引用新的原型,而在此之前创建的实例仍然会引用最初的原型。
function Person() {}
let friend = new Person()
Person.prototype = {
constructor: Person,
name: 'Nicholas',
age: 29,
job: 'Software Engineer',
sayName() {
console.log(this.name)
},
}
friend.sayName() // 错误
2
3
4
5
6
7
8
9
10
11
12
13
14
# 14. 继承
原型链。
- 原型链虽然是实现继承的强大工具,但它也有问题。主要问题出现在原型中包含引用值的时候。
- 原型链的第二个问题是,子类型在实例化时不能给父类型的构造函数传参。事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用。
function SuperType() { this.colors = ['red', 'blue', 'green'] } function SubType() {} // 继承 SuperType SubType.prototype = new SuperType() let instance1 = new SubType() instance1.colors.push('black') console.log(instance1.colors) // "red,blue,green,black" let instance2 = new SubType() console.log(instance2.colors) // "red,blue,green,black"
1
2
3
4
5
6
7
8
9
10
11
12
13盗用构造函数。
- 在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用 apply() 和 call() 方法以新创建的对象为上下文执行构造函数。
- 盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。由于存在这些问题,盗用构造函数基本上也不能单独使用
function SuperType() { this.colors = ['red', 'blue', 'green'] } function SubType() { // 继承 SuperType SuperType.call(this) } let instance1 = new SubType() instance1.colors.push('black') console.log(instance1.colors) // "red,blue,green,black" let instance2 = new SubType() console.log(instance2.colors) // "red,blue,green"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15组合继承。
- 组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。
- 组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。而且组合继承也保留了 instanceof 操作符和 isPrototypeOf() 方法识别合成对象的能力。
function SuperType(name) { this.name = name this.colors = ['red', 'blue', 'green'] } SuperType.prototype.sayName = function() { console.log(this.name) } function SubType(name, age) { // 继承属性 SuperType.call(this, name) this.age = age } // 继承方法 SubType.prototype = new SuperType() SubType.prototype.sayAge = function() { console.log(this.age) } let instance1 = new SubType('Nicholas', 29) instance1.colors.push('black') console.log(instance1.colors) // "red,blue,green,black" instance1.sayName() // "Nicholas"; instance1.sayAge() // 29 let instance2 = new SubType('Greg', 27) console.log(instance2.colors) // "red,blue,green" instance2.sayName() // "Greg"; instance2.sayAge() // 27
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31原型式继承。
- 2006 年,Douglas Crockford 写了一篇文章:《JavaScript 中的原型式继承》 (opens new window)(“Prototypal Inheritance inJavaScript”)。这篇文章介绍了一种不涉及严格意义上构造函数的继承方法。他的出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。
- 这个 object() 函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时类型的一个实例。本质上, object() 是对传入的对象执行了一次浅复制。
function object(o) { function F() {} F.prototype = o return new F() } let person = { name: 'Nicholas', friends: ['Shelby', 'Court', 'Van'], } let anotherPerson = object(person) anotherPerson.name = 'Greg' anotherPerson.friends.push('Rob') let yetAnotherPerson = object(person) yetAnotherPerson.name = 'Linda' yetAnotherPerson.friends.push('Barbie') console.log(person.friends) // "Shelby,Court,Van,Rob,Barbie"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20ECMAScript 5 通过增加 Object.create() 方法将原型式继承的概念规范化了。
let person = { name: 'Nicholas', friends: ['Shelby', 'Court', 'Van'], } let anotherPerson = Object.create(person) anotherPerson.name = 'Greg' anotherPerson.friends.push('Rob') let yetAnotherPerson = Object.create(person) yetAnotherPerson.name = 'Linda' yetAnotherPerson.friends.push('Barbie') console.log(person.friends) // "Shelby,Court,Van,Rob,Barbie"
1
2
3
4
5
6
7
8
9
10
11
12
13
14寄生式继承。
- 与原型式继承比较接近的一种继承方式是寄生式继承(parasitic inheritance),也是 Crockford 首倡的一种模式。寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。
- 寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object() 函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。
- 通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。
function object(o) { function F() {} F.prototype = o return new F() } function createAnother(original) { let clone = object(original) // 通过调用函数创建一个新对象 // 以某种方式增强这个对象 clone.sayHi = function() { console.log('hi') } return clone // 返回这个对象 } let person = { name: 'Nicholas', friends: ['Shelby', 'Court', 'Van'], } let anotherPerson = createAnother(person) anotherPerson.sayHi() // "hi"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22寄生式组合继承。
- 组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。
- 寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。
function inheritPrototype(subType, superType) { let prototype = object(superType.prototype) // 创建对象 prototype.constructor = subType // 增强对象 subType.prototype = prototype // 赋值对象 }
1
2
3
4
5- 这个 inheritPrototype() 函数实现了寄生式组合继承的核心逻辑。这个函数接收两个参数:子类构造函数和父类构造函数。在这个函数内部,第一步是创建父类原型的一个副本。然后,给返回的 prototype 对象设置 constructor 属性,解决由于重写原型导致默认 constructor 丢失的问题。最后将新创建的对象赋值给子类型的原型。
- 这里只调用了一次 SuperType 构造函数,避免了 SubType.prototype 上不必要也用不到的属性,因此可以说这个例子的效率更高。而且,原型链仍然保持不变,因此 instanceof 操作符和 isPrototypeOf() 方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式。
function object(o) { function F() {} F.prototype = o return new F() } function inheritPrototype(subType, superType) { let prototype = object(superType.prototype) // 创建对象 prototype.constructor = subType // 增强对象 subType.prototype = prototype // 赋值对象 } function SuperType(name) { this.name = name this.colors = ['red', 'blue', 'green'] } SuperType.prototype.sayName = function() { console.log(this.name) } function SubType(name, age) { SuperType.call(this, name) this.age = age } inheritPrototype(SubType, SuperType) SubType.prototype.sayAge = function() { console.log(this.age) }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 15. 类
类构造函数。
类构造函数与构造函数的主要区别是,调用类构造函数必须使用 new 操作符。而普通构造函数如果不使用 new 调用,那么就会以全局的 this (通常是 window )作为内部对象。调用类构造函数时如果忘了使用 new 则会抛出错误。
function Person() {} class Animal {} // 把 window 作为 this 来构建实例 let p = Person() let a = Animal() // TypeError: class constructor Animal cannot be invoked without 'new'
1
2
3
4
5
6
7
8与立即调用函数表达式相似,类也可以立即实例化。
// 因为是一个类表达式,所以类名是可选的 let p = new (class Foo { constructor(x) { console.log(x) } })('bar') // bar console.log(p) // Foo {}
1
2
3
4
5
6
7
8继承。
很多 JavaScript 框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承(composition over inheritance)。”这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性。
# 16. 尾调用优化
- ECMAScript 6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧。具体来说,这项优化非常适合 “尾调用”,即外部函数的返回值是一个内部函数的返回值。
function outerFunction() {
return innerFunction() // 尾调用
}
2
3
在 ES6 优化之前,执行这个例子会在内存中发生如下操作。
- 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
- 执行 outerFunction 函数体,到 return 语句。计算返回值必须先计算 innerFunction。
- 执行到 innerFunction 函数体,第二个栈帧被推到栈上。
- 执行 innerFunction 函数体,计算其返回值。
- 将返回值传回 outerFunction ,然后 outerFunction 再返回值。
- 将栈帧弹出栈外。
在 ES6 优化之后,执行这个例子会在内存中发生如下操作。
- 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
- 执行 outerFunction 函数体,到达 return 语句。为求值返回语句,必须先求值 innerFunction。
- 引擎发现把第一个栈帧弹出栈外也没问题,因为 innerFunction 的返回值也是 outerFunction 的返回值。
- 弹出 outerFunction 的栈帧。
- 执行到 innerFunction 函数体,栈帧被推到栈上。
- 执行 innerFunction 函数体,计算其返回值。
- 将 innerFunction 的栈帧弹出栈外。
现在还没有办法测试尾调用优化是否起作用。不过,因为这是 ES6 规范所规定的,兼容的浏览器实现都能保证在代码满足条件的情况下应用这个优化。
尾调用优化的条件。
- 代码在严格模式下执行。
- 外部函数的返回值是对尾调用函数的调用。
- 尾调用函数返回后不需要执行额外的逻辑。
- 尾调用函数不是引用外部函数作用域中自由变量的闭包。
'use strict' // 无优化:尾调用没有返回 function outerFunction() { innerFunction() } // 无优化:尾调用没有直接返回 function outerFunction() { let innerFunctionResult = innerFunction() return innerFunctionResult } // 无优化:尾调用返回后必须转型为字符串 function outerFunction() { return innerFunction().toString() } // 无优化:尾调用是一个闭包 function outerFunction() { let foo = 'bar' function innerFunction() { return foo } return innerFunction() } // 有优化:栈帧销毁前执行参数计算 function outerFunction(a, b) { return innerFunction(a + b) } // 有优化:初始返回值不涉及栈帧 function outerFunction(a, b) { if (a < b) { return a } return innerFunction(a + b) } // 有优化:两个内部函数都在尾部 function outerFunction(condition) { return condition ? innerFunctionA() : innerFunctionB() }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38之所以要求严格模式,主要因为在非严格模式下函数调用中允许使用 f.arguments 和 f.caller ,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。
# 17. window 对象
窗口关系。
- top 对象始终指向最上层(最外层)窗口,即浏览器窗口本身。而 parent 对象则始终指向当前窗口的父窗口。如果当前窗口是最上层窗口,则 parent 等于 top (都等于 window )。最上层的 window 如果不是通过 window.open() 打开的,那么其 name 属性就不会包含值。
- 还有一个 self 对象,它是终极 window 属性,始终会指向 window。实际上, self 和 window 就是同一个对象。之所以还要暴露 self ,就是为了和 top 、 parent 保持一致。
窗口位置与像素比。
window.devicePixelRatio 实际上与每英寸像素数(DPI,dots per inch)是对应的。DPI 表示单位像素密度,而 window.devicePixelRatio 表示物理像素与逻辑像素之间的缩放系数。
视口位置。
scroll()、scrollTo() 和 scrollBy() 都接收一个 ScrollToOptions 字典,除了提供偏移值,还可以通过 behavior 属性告诉浏览器是否平滑滚动。
// 正常滚动 window.scrollTo({ left: 100, top: 100, behavior: 'auto', }) // 平滑滚动 window.scrollTo({ left: 100, top: 100, behavior: 'smooth', })
1
2
3
4
5
6
7
8
9
10
11
12
13导航与打开新窗口。
弹出窗口。
- window.open() 方法可以用于导航到指定 URL,也可以用于打开新浏览器窗口。这个方法接收 4 个参数:要加载的 URL、目标窗口、特性字符串和表示新窗口在浏览器历史记录中是否替代当前加载页面的布尔值。通常,调用这个方法时只传前 3 个参数,最后一个参数只有在不打开新窗口时才会使用。
- 如果 window.open() 的第二个参数是一个已经存在的窗口或窗格(frame)的名字,则会在对应的窗口或窗格中打开 URL。第二个参数也可以是一个特殊的窗口名,比如 _self、_parent、_top 或 _blank。
- 如果 window.open() 的第二个参数不是已有窗口,则会打开一个新窗口或标签页。第三个参数,即特性字符串,用于指定新窗口的配置。如果没有传第三个参数,则新窗口(或标签页)会带有所有默认的浏览器特性(工具栏、地址栏、状态栏等都是默认配置)。如果打开的不是新窗口,则忽略第三个参数。Features (opens new window)
window.open( 'https://www.coderljw.ga/', 'matrix', 'height=777,width=777,top=77,left=77,resizable=yes' )
1
2
3
4
5- 还可以使用 close() 方法像这样关闭新打开的窗口。这个方法只能用于 window.open() 创建的弹出窗口。虽然不可能不经用户确认就关闭主窗口,但弹出窗口可以调用 top.close() 来关闭自己。关闭窗口以后,窗口的引用虽然还在,但只能用于检查其 closed 属性了。
- 新创建窗口的 window 对象有一个属性 opener,指向打开它的窗口。这个属性只在弹出窗口的最上层 window 对象( top )有定义,是指向调用 window.open() 打开它的窗口或窗格的指针。
let matrix = window.open( 'https://www.coderljw.ga/', 'matrix', 'height=400,width=400,top=10,left=10,resizable=yes' ) console.log(matrix.opener === window) // true matrix.close() console.log(matrix.closed) // true
1
2
3
4
5
6
7
8
9
10在某些浏览器中,每个标签页会运行在独立的进程中。如果一个标签页打开了另一个,而 window 对象需要跟另一个标签页通信,那么标签便不能运行在独立的进程中。在这些浏览器中,可以将新打开的标签页的 opener 属性设置为 null,表示新打开的标签页可以运行在独立的进程中。把 opener 设置为 null 表示新打开的标签页不需要与打开它的标签页通信,因此可以在独立进程中运行。这个连接一旦切断,就无法恢复了。
安全限制。
- 弹出窗口有段时间被在线广告用滥了。很多在线广告会把弹出窗口伪装成系统对话框,诱导用户点击。因为长得像系统对话框,所以用户很难分清这些弹窗的来源。为了让用户能够区分清楚,浏览器开始对弹窗施加限制。
- 此外,浏览器会在用户操作下才允许创建弹窗。在网页加载过程中调用 window.open() 没有效果,而且还可能导致向用户显示错误。弹窗通常可能在鼠标点击或按下键盘中某个键的情况下才能打开。
弹窗屏蔽程序。
- 所有现代浏览器都内置了屏蔽弹窗的程序,因此大多数意料之外的弹窗都会被屏蔽。在浏览器屏蔽弹窗时,可能会发生一些事。如果浏览器内置的弹窗屏蔽程序阻止了弹窗,那么 window.open() 很可能会返回 null。此时,只要检查这个方法的返回值就可以知道弹窗是否被屏蔽了。
- 在浏览器扩展或其他程序屏蔽弹窗时, window.open() 通常会抛出错误。因此要准确检测弹窗是否被屏蔽,除了检测 window.open() 的返回值,还要把它用 try / catch 包装起来。
try { let matrix = window.open('https://www.coderljw.ga/', '_blank') } catch (err) { console.log('The popup was blocked!') } if (matrix == null) console.log('The popup was blocked!')
1
2
3
4
5
6
7
定时器。
- 调用 setTimeout() 时,会返回一个表示该超时排期的数值 ID。这个超时 ID 是被排期执行代码的唯一标识符,可用于取消该任务。要取消等待中的排期任务,可以调用 clearTimeout() 方法并传入超时 ID。
- 所有超时执行的代码(函数)都会在全局作用域中的一个匿名函数中运行,因此函数中的 this 值在非严格模式下始终指向 window,而在严格模式下是 undefined。如果给 setTimeout() 提供了一个箭头函数,那么 this 会保留为定义它时所在的词汇作用域。
setInterval(() => alert("Hello world!"), 10000)
这里的关键点是,第二个参数,也就是间隔时间,指的是向队列添加新任务之前等待的时间。比如,调用 setInterval() 的时间为 01:00:00,间隔时间为 3000 毫秒。这意味着 01:00:03 时,浏览器会把任务添加到执行队列。浏览器不关心这个任务什么时候执行或者执行要花多长时间。因此,到了 01:00:06,它会再向队列中添加一个任务。由此可看出,执行时间短、非阻塞的回调函数比较适合 setInterval()。
let num = 0 let max = 10 let incrementNumber = function() { num++ // 如果还没有达到最大值,再设置一个超时任务 if (num < max) { setTimeout(incrementNumber, 500) } else { alert('Done') } } setTimeout(incrementNumber, 500)
1
2
3
4
5
6
7
8
9
10
11
12
13注意在使用 setTimeout() 时,不一定要记录超时 ID,因为它会在条件满足时自动停止,否则会自动设置另一个超时任务。这个模式是设置循环任务的推荐做法。 setIntervale() 在实践中很少会在生产环境下使用,因为一个任务结束和下一个任务开始之间的时间间隔是无法保证的,有些循环定时任务可能会因此而被跳过。而像前面这个例子中一样使用 setTimeout() 则能确保不会出现这种情况。一般来说,最好不要使用 setInterval()。
# 18. location 对象
location 是最有用的 BOM 对象之一,提供了当前窗口中加载文档的信息,以及通常的导航功能。这个对象独特的地方在于,它既是 window 的属性,也是 document 的属性。也就是说,window.location 和 document.location 指向同一个对象。location 对象不仅保存着当前加载文档的信息,也保存着把 URL 解析为离散片段后能够通过属性访问的信息。
查询字符串。
URLSearchParams 提供了一组标准 API 方法,通过它们可以检查和修改查询字符串。给 URLSearchParams 构造函数传入一个查询字符串,就可以创建一个实例。这个实例上暴露了 get()、set() 和 delete() 等方法,可以对查询字符串执行相应操作。大多数支持 URLSearchParams 的浏览器也支持将 URLSearchParams 的实例用作可迭代对象。
let qs = '?q=javascript&num=10' let searchParams = new URLSearchParams(qs) alert(searchParams.toString()) // " q=javascript&num=10" searchParams.has('num') // true searchParams.get('num') // 10 searchParams.set('page', '3') alert(searchParams.toString()) // " q=javascript&num=10&page=3" searchParams.delete('q') alert(searchParams.toString()) // " num=10&page=3" for (let param of searchParams) { console.log(param) } // ["q", "javascript"] // ["num", "10"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18操作地址。
最后一个修改地址的方法是 reload() ,它能重新加载当前显示的页面。调用 reload() 而不传参数,页面会以最有效的方式重新加载。也就是说,如果页面自上次请求以来没有修改过,浏览器可能会从缓存中加载页面。如果想强制从服务器重新加载,可以像下面这样给 reload() 传个 true。
location.reload() // 重新加载,可能是从缓存加载 location.reload(true) // 重新加载,从服务器加载
1
2脚本中位于 reload() 调用之后的代码可能执行也可能不执行,这取决于网络延迟和系统资源等因素。为此,最好把 reload() 作为最后一行代码。
# 19. Document 类型
Document 类型是 JavaScript 中表示文档节点的类型。在浏览器中,文档对象 document 是 HTMLDocument 的实例( HTMLDocument 继承 Document ),表示整个 HTML 页面。 document 是 window 对象的属性,因此是一个全局对象。
document 作为 HTMLDocument 的实例,还有一些标准 Document 对象上所没有的属性。这些属性提供浏览器所加载网页的信息。其中第一个属性是 title,包含
<title>
元素中的文本,通常显示在浏览器窗口或标签页的标题栏。通过这个属性可以读写页面的标题,修改后的标题也会反映在浏览器标题栏上。不过,修改 title 属性并不会改变<title>
元素。特殊集合。
- document.anchors 包含文档中所有带 name 属性的
<a>
元素。 - document.forms 包含文档中所有
<form>
元素(与 document.getElementsByTagName ("form")返回的结果相同)。 - document.images 包含文档中所有
<img>
元素(与 document.getElementsByTagName ("img")返回的结果相同)。 - document.links 包含文档中所有带 href 属性的
<a>
元素。
- document.anchors 包含文档中所有带 name 属性的