JavaScript高级程序设计阅读笔记
# 变量声明
# var 声明提升
使用 var 时,下面的代码不会报错。这是因为使用这个关键字声明的变量会自动提升到函数作用域顶部:
function foo() {
console.log(age)
var age = 26
}
foo() // undefined
2
3
4
5
之所以不会报错,是因为 ECMAScript 运行时把它看成等价于如下代码:
function foo() {
var age
console.log(age)
age = 26
}
foo() // undefined
2
3
4
5
6
这就是所谓的“提升”(hoist),也就是把所有变量声明都拉到函数作用域的顶部。
let 声明的范围是块作用域,而 var 声明的范围是函数作用域
if (true) {
var name = 'Matt'
console.log(name) // Matt
}
console.log(name) // Matt
if (true) {
let age = 26
console.log(age) // 26
}
console.log(age) // ReferenceError: age 没有定义
2
3
4
5
6
7
8
9
10
11
在这里,age 变量之所以不能在 if 块外部被引用,是因为它的作用域仅限于该块内部。
块作用域是函数作用域的子集,因此适用于 var 的作用域限制同样也适用于 let。
# 1. 暂时性死区
let 与 var 的另一个重要的区别,就是 let 声明的变量不会在作用域中被提升。
// name 会被提升
console.log(name) // undefined
var name = 'Matt'
// age 不会被提升
console.log(age) // ReferenceError:age 没有定义
let age = 26
2
3
4
5
6
在解析代码时,JavaScript 引擎也会注意出现在块后面的 let 声明,只不过在此之前不能以任何方式来引用未声明的变量。
在 let 声明之前的执行瞬间被称为“暂时性死区”(temporal dead zone),在此阶段引用任何后面才声明的变量都会抛出 ReferenceError。
# 浮点数自动转化为整数
因为存储浮点值使用的内存空间是存储整数值的两倍,所以 ECMAScript 总是想方设法把值转换为 整数。
在小数点后面没有数字的情况下,数值就会变成整数。类似地,如果数值本身就是整数,只是小 数点后面跟着 0(如 1.0),那它也会被转换为整数,如下例所示:
let floatNum1 = 1 // 小数点后面没有数字,当成整数 1 处理
let floatNum2 = 10.0 // 小数点后面是零,当成整数 10 处理
2
3
# parseInt 按进制解析
通过第二个参数,可以极大扩展转换后获得的结果类型。比如:
let num1 = parseInt('10', 2) // 2,按二进制解析
let num2 = parseInt('10', 8) // 8,按八进制解析
let num3 = parseInt('10', 10) // 10,按十进制解析
let num4 = parseInt('10', 16) // 16,按十六进制解析
2
3
4
因为不传底数参数相当于让 parseInt()自己决定如何解析(一般会按照十进制解析),所以为避免解析出错,建议始终传给它第二个参数。
# 函数参数按值传递传递
ECMAScript 中所有函数的参数都是按值传递的。
这意味着函数外的值会被复制到函数内部的参数 中,就像从一个变量复制到另一个变量一样。
如果是原始值,那么就跟原始值变量的复制一样,如果是 引用值,那么就跟引用值变量的复制一样。
对很多开发者来说,这一块可能会不好理解,毕竟变量有按 值和按引用访问,而传参则只有按值传递。
在按值传递参数时,值会被复制到一个局部变量(即一个命名参数,或者用 ECMAScript 的话说, 就是 arguments 对象中的一个槽位)。
在按引用传递参数时,值在内存中的位置会被保存在一个局部变 量,这意味着对本地变量的修改会反映到函数外部。(这在 ECMAScript 中是不可能的。)来看下面这个 例子:
function addTen(num) {
num += 10
return num
}
let count = 20
let result = addTen(count)
console.log(count) // 20,没有变化 console.log(result); // 30
2
3
4
5
6
7
这里,函数 addTen()有一个参数 num,它其实是一个局部变量。在调用时,变量 count 作为参数 传入。count 的值是 20,这个值被复制到参数 num 以便在 addTen()内部使用。在函数内部,参数 num 的值被加上了 10,但这不会影响函数外部的原始变量 count。参数 num 和变量 count 互不干扰,它们 只不过碰巧保存了一样的值。如果 num 是按引用传递的,那么 count 的值也会被修改为 30。这个事实 在使用数值这样的原始值时是非常明显的。但是,如果变量中传递的是对象,就没那么清楚了。比如, 再看这个例子:
function setName(obj) {
obj.name = 'Nicholas'
}
let person = new Object()
setName(person)
console.log(person.name) // "Nicholas"
2
3
4
5
6
这一次,我们创建了一个对象并把它保存在变量 person 中。然后,这个对象被传给 setName() 方法,并被复制到参数 obj 中。在函数内部,obj 和 person 都指向同一个对象。结果就是,即使对象 是按值传进函数的,obj 也会通过引用访问对象。当函数内部给 obj 设置了 name 属性时,函数外部的 对象也会反映这个变化,因为 obj 指向的对象保存在全局作用域的堆内存上。很多开发者 '错误' 地认为, 当在局部作用域中修改对象而变化反映到全局时,就意味着参数是按引用传递的。为证明对象是按值传 递的,我们再来看看下面这个修改后的例子:
function setName(obj) {
obj.name = 'Nicholas'
obj = new Object()
obj.name = 'Greg'
}
let person = new Object()
setName(person)
console.log(person.name) // "Nicholas"
2
3
4
5
6
7
8
这个例子前后唯一的变化就是 setName()中多了两行代码,将 obj 重新定义为一个有着不同 name 的新对象。
当 person 传入 setName()时,其 name 属性被设置为"Nicholas"。
然后变量 obj 被设置 为一个新对象且 name 属性被设置为"Greg"。
如果 person 是按引用传递的,那么 person 应该自动将 指针改为指向 name 为"Greg"的对象。
可是,当我们再次访问 person.name 时,它的值是"Nicholas", 这表明函数中参数的值改变之后,原始的引用仍然没变。
当 obj 在函数内部被重写时,它变成了一个指 向本地对象的指针。而那个本地对象在函数执行结束时就被销毁了。
# 选择 Object 还是 Map
对于多数 Web 开发任务来说,选择 Object 还是 Map 只是个人偏好问题,影响不大。不过,对于 在乎内存和性能的开发者来说,对象和映射之间确实存在显著的差别。
- 内存占用
Object 和 Map 的工程级实现在不同浏览器间存在明显差异,但存储单个键/值对所占用的内存数量
都会随键的数量线性增加。批量添加或删除键/值对则取决于各浏览器对该类型内存分配的工程实现。 不同浏览器的情况不同,但给定固定大小的内存,Map 大约可以比 Object 多存储 50%的键/值对。
- 插入性能
向 Object 和 Map 中插入新键/值对的消耗大致相当,不过插入 Map 在所有浏览器中一般会稍微快 一点儿。对这两个类型来说,插入速度并不会随着键/值对数量而线性增加。如果代码涉及大量插入操 作,那么显然 Map 的性能更佳。
- 查找速度
与插入不同,从大型 Object 和 Map 中查找键/值对的性能差异极小,但如果只包含少量键/值对, 则 Object 有时候速度更快。在把 Object 当成数组使用的情况下(比如使用连续整数作为属性),浏 览器引擎可以进行优化,在内存中使用更高效的布局。这对 Map 来说是不可能的。对这两个类型而言, 查找速度不会随着键/值对数量增加而线性增加。如果代码涉及大量查找操作,那么某些情况下可能选 择 Object 更好一些。
- 删除性能
使用 delete 删除 Object 属性的性能一直以来饱受诟病,目前在很多浏览器中仍然如此。为此, 出现了一些伪删除对象属性的操作,包括把属性值设置为 undefined 或 null。但很多时候,这都是一 种讨厌的或不适宜的折中。而对大多数浏览器引擎来说,Map 的 delete()操作都比插入和查找更快。 如果代码涉及大量删除操作,那么毫无疑问应该选择 Map。
总结: 大部分场景区别不大, 数据量大,增删查频繁场景 map 性能更高
# 对象属性的类型
ECMA-262 使用一些内部特性来描述属性的特征。这些特性是由为 JavaScript 实现引擎的规范定义 的。
因此,开发者不能在 JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用 两个中括号把特性的名称括起来,比如[[Enumerable]]。
属性分两种: 数据属性和访问器属性。
# 1. 数据属性
数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4 个特性描述它们的行为。
[[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特 性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特 性都是 true,如前面的例子所示。
[[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对 象上的属性的这个特性都是 true,如前面的例子所示。
[[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的 这个特性都是 true,如前面的例子所示。
[[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性 的默认值为 undefined。
在像前面例子中那样将属性显式添加到对象之后,[[Configurable]]、[[Enumerable]]和 [[Writable]]都会被设置为 true,而[[Value]]特性会被设置为指定的值。比如:
let person = {
name: 'Nicholas'
}
2
3
这里,我们创建了一个名为 name 的属性,并给它赋予了一个值"Nicholas"。这意味着[[Value]] 特性会被设置为"Nicholas",之后对这个值的任何修改都会保存这个位置。
要修改属性的默认特性,就必须使用 **Object.defineProperty()**方法。
这个方法接收 3 个参数: 要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包 含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。根据要修改 的特性,可以设置其中一个或多个值。比如:
let person = {}
Object.defineProperty(person, 'name', {
writable: false, // 只读
value: 'Nicholas'
})
console.log(person.name) // "Nicholas"
person.name = 'Greg'
console.log(person.name) // "Nicholas"
2
3
4
5
6
7
8
这个例子创建了一个名为 name 的属性并给它赋予了一个只读的值"Nicholas"。这个属性的值就 不能再修改了,在非严格模式下尝试给这个属性重新赋值会被忽略。在严格模式下,尝试修改只读属性 的值会抛出错误。
# 2. 访问器属性
访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不 过这两个函数不是必需的。
在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效 的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。
访 问器属性有 4 个特性描述它们的行为。
[[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特 性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性 都是 true。
[[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对 象上的属性的这个特性都是 true。
[[Get]]:获取函数,在读取属性时调用。默认值为 undefined。
[[Set]]:设置函数,在写入属性时调用。默认值为 undefined。 访问器属性是不能直接定义的,必须使用 Object.defineProperty()。下面是一个例子:
// 定义一个对象,包含伪私有成员year_和公共成员edition
let book = {
year_: 2017,
edition: 1
}
Object.defineProperty(book, 'year', {
get() {
return this.year_
},
set(newValue) {
if (newValue > 2017) {
this.year_ = newValue
this.edition += newValue - 2017
}
}
})
book.year = 2018
console.log(book.edition) // 2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在这个例子中,对象 book 有两个默认属性:year_和 edition。
year_中的下划线常用来表示该 属性并不希望在对象方法的外部被访问。
另一个属性 year 被定义为一个访问器属性,其中获取函数简 单地返回 year_的值,而设置函数会做一些计算以决定正确的版本(edition)。
因此,把 year 属性修改 为 2018 会导致 year_变成 2018,edition 变成 2。
这是访问器属性的典型使用场景,即设置一个属性 值会导致一些其他变化发生。
获取函数和设置函数不一定都要定义。只定义获取函数意味着属性是只读的,尝试修改属性会被忽 略。
在严格模式下,尝试写入只定义了获取函数的属性会抛出错误。类似地,只有一个设置函数的属性 是不能读取的,非严格模式下读取会返回 undefined,严格模式下会抛出错误。
在不支持 Object.defineProperty()的浏览器中没有办法修改[[Configurable]]或[[Enumerable]]。 这也是 vue 不支持 ie8 的原因
# 创建对象
# 工厂模式
工厂模式是一种众所周知的设计模式,广泛应用于软件工程领域,用于抽象创建特定对象的过程。
function createPerson(name, age, job) {
let o = new Object()
o.name = name
o.age = age
o.job = job
o.sayName = function() {
console.log(this.name)
}
return o
}
let person1 = createPerson('Nicholas', 29, 'Software Engineer')
let person2 = createPerson('Greg', 27, 'Doctor')
2
3
4
5
6
7
8
9
10
11
12
这里,函数 createPerson()接收 3 个参数,根据这几个参数构建了一个包含 Person 信息的对象。
可以用不同的参数多次调用这个函数,每次都会返回包含 3 个属性和 1 个方法的对象。
这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。
# 构造函数模式
前面几章提到过,ECMAScript 中的构造函数是用于创建特定类型对象的。像 Object 和 Array 这 样的原生构造函数,运行时可以直接在执行环境中使用。
当然也可以自定义构造函数,以函数的形式为 自己的对象类型定义属性和方法。比如,前面的例子使用构造函数模式可以这样写:
function Person(name, age, job) {
this.name = name
this.age = age
this.job = job
this.sayName = function() {
console.log(this.name)
}
}
let person1 = new Person('Nicholas', 29, 'Software Engineer')
let person2 = new Person('Greg', 27, 'Doctor')
person1.sayName() // Nicholas 10 person2.sayName(); // Greg
2
3
4
5
6
7
8
9
10
11
在这个例子中,Person()构造函数代替了 createPerson()工厂函数。实际上,Person()内部 的代码跟 createPerson()基本是一样的,只是有如下区别。
- 没有显式地创建对象。
- 属性和方法直接赋值给了 this。
- 没有 return。
另外,要注意函数名 Person 的首字母大写了。按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。
这是从面向对象编程语言那里借鉴的,有助于在 ECMAScript 中区分构 造函数和普通函数。
毕竟 ECMAScript 的构造函数就是能创建对象的函数。
要创建 Person 的实例,应使用 new 操作符。以这种方式调用构造函数会执行如下操作。
(1) 在内存中创建一个新对象。
(2) 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性。
(3) 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
(4) 执行构造函数内部的代码(给新对象添加属性)。
(5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
上一个例子的最后,person1 和 person2 分别保存着 Person 的不同实例。这两个对象都有一个 constructor 属性指向 Person,如下所示:
console.log(person1.constructor == Person) // true
console.log(person2.constructor == Person) // true
2
constructor 本来是用于标识对象类型的。
不过,一般认为 instanceof 操作符是确定对象类型 更可靠的方式。
前面例子中的每个对象都是 Object 的实例,同时也是 Person 的实例,如下面调用 instanceof 操作符的结果所示:
console.log(person1 instanceof Object) // true
console.log(person1 instanceof Person) // true
console.log(person2 instanceof Object) // true
console.log(person2 instanceof Person) // true
2
3
4
定义自定义构造函数可以确保实例被标识为特定类型,相比于工厂模式,这是一个很大的好处。
在 这个例子中,person1 和 person2 之所以也被认为是 Object 的实例,是因为所有自定义对象都继承 自 Object(后面再详细讨论这一点)。
构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以表示构造函数:
let Person = function(name, age, job) {
this.name = name
this.age = age
this.job = job
this.sayName = function() {
console.log(this.name)
}
}
let person1 = new Person('Nicholas', 29, 'Software Engineer')
let person2 = new Person('Greg', 27, 'Doctor')
person1.sayName() // Nicholas
person2.sayName() // Greg
console.log(person1 instanceof Object) // true
console.log(person1 instanceof Person) // true
console.log(person2 instanceof Object) // true
console.log(person2 instanceof Person) // true
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在实例化时,如果不想传参数,那么构造函数后面的括号可加可不加。只要有 new 操作符,就可以 调用相应的构造函数:
let person1 = new Person()
let person2 = new Person()
2
# 构造函数也是函数
构造函数与普通函数唯一的区别就是调用方式不同。
除此之外,构造函数也是函数。并没有把某个 函数定义为构造函数的特殊语法。任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操 作符调用的函数就是普通函数。
比如,前面的例子中定义的 Person()可以像下面这样调用:
// 作为构造函数
let person = new Person('Nicholas', 29, 'Software Engineer')
person.sayName() // "Nicholas"
person1.sayName() // Jake
// 作为函数调用
Person('Greg', 27, 'Doctor')
window.sayName() // "Greg"
// 添加到 window 对象
5
6
// 在另一个对象的作用域中调用
let o = new Object()
Person.call(o, 'Kristen', 25, 'Nurse')
o.sayName() // "Kristen"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这个例子一开始展示了典型的构造函数调用方式,即使用 new 操作符创建一个新对象。
然后是普通 函数的调用方式,这时候没有使用 new 操作符调用 Person(),结果会将属性和方法添加到 window 对 象。
这里要记住,在调用一个函数而没有明确设置 this 值的情况下(即没有作为对象的方法调用,或 者没有使用 call()/apply()调用),this 始终指向 Global 对象(在浏览器中就是 window 对象)。
因此在上面的调用之后,window 对象上就有了一个 sayName()方法,调用它会返回"Greg"。
最后展 示的调用方式是通过 call()(或 apply())调用函数,同时将特定对象指定为作用域。这里的调用将 对象 o 指定为 Person()内部的 this 值,因此执行完函数代码后,所有属性和 sayName()方法都会添 9 加到对象 o 上面。
# 构造函数的问题
构造函数虽然有用,但也不是没有问题。构造函数的主要问题在于,其定义的方法会在每个实例上 都创建一遍。
因此对前面的例子而言,person1 和 person2 都有名为 sayName()的方法,但这两个方 法不是同一个 Function 实例。
我们知道,ECMAScript 中的函数是对象,因此每次定义函数时,都会 初始化一个对象。逻辑上讲,这个构造函数实际上是这样的:
function Person(name, age, job) {
this.name = name
this.age = age
this.job = job
this.sayName = new Function('console.log(this.name)') // 逻辑等价
}
2
3
4
5
6
这样理解这个构造函数可以更清楚地知道,每个 Person 实例都会有自己的 Function 实例用于显 示 name 属性。
当然了,以这种方式创建函数会带来不同的作用域链和标识符解析。但创建新 Function 实例的机制是一样的。因此不同实例上的函数虽然同名却不相等,如下所示:
console.log(person1.sayName == person2.sayName) // false
因为都是做一样的事,所以没必要定义两个不同的 Function 实例。况且,this 对象可以把函数 与对象的绑定推迟到运行时。
要解决这个问题,可以把函数定义转移到构造函数外部:
function Person(name, age, job) {
this.name = name
this.age = age
this.job = job
this.sayName = sayName
}
function sayName() {
console.log(this.name)
}
let person1 = new Person('Nicholas', 29, 'Software Engineer')
let person2 = new Person('Greg', 27, 'Doctor')
person1.sayName() // Nicholas
person2.sayName() // Greg
2
3
4
5
6
7
8
9
10
11
12
13
在这里,sayName()被定义在了构造函数外部。在构造函数内部,sayName 属性等于全局 sayName() 函数。
因为这一次 sayName 属性中包含的只是一个指向外部函数的指针,所以 person1 和 person2 共享了定义在全局作用域上的 sayName()函数。
这样虽然解决了相同逻辑的函数重复定义的问题,但 全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法, 那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新 问题可以通过原型模式来解决。
# 原型模式
每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例 共享的属性和方法。
实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处 是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以 直接赋值给它们的原型,如下所示:
function Person() {}
Person.prototype.name = 'Nicholas'
Person.prototype.age = 29
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function() {
console.log(this.name)
}
let person1 = new Person()
person1.sayName() // "Nicholas"
let person2 = new Person()
person2.sayName() // "Nicholas"
console.log(person1.sayName == person2.sayName) // true
2
3
4
5
6
7
8
9
10
11
12
使用函数表达式也可以:
这里,所有属性和 sayName()方法都直接添加到了 Person 的 prototype 属性上,构造函数体中 什么也没有。
但这样定义之后,调用构造函数创建的新对象仍然拥有相应的属性和方法。
与构造函数模 式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。
因此 person1 和 person2 访问的 都是相同的属性和相同的 sayName()函数。要理解这个过程,就必须理解 ECMAScript 中原型的本质。
# 理解原型
无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向 原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构 造函数。
在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自 Object。
每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构 造函数的原型对象。
脚本中没有访问这个[[Prototype]]特性的标准方式,但 Firefox、Safari 和 Chrome 会在每个对象上暴露proto属性,通过这个属性可以访问对象的原型。
在其他实现中,这个特性 完全被隐藏了。关键在于理解这一点: 实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有了
// 构造函数可以是函数表达式
// 也可以是函数声明,因此以下两种形式都可以:
function Person() {}
let Person = function() {}
function Person() {}
/**
* 声明之后,构造函数就有了一个 * 与之关联的原型对象:
*/
console.log(typeof Person.prototype)
console.log(Person.prototype)
// {
// constructor: f Person(),
// __proto__: Object
// }
/**
* 如前所述,构造函数有一个 prototype 属性 * 引用其原型对象,而这个原型对象也有一个 * constructor 属性,引用这个构造函数
* 换句话说,两者循环引用:
*/
console.log(Person.prototype.constructor === Person) // true
/**
* 正常的原型链都会终止于 Object 的原型对象 * Object 原型的原型是 null
*/
console.log(Person.prototype.__proto__ === Object.prototype)
console.log(Person.prototype.__proto__.constructor === Object) // true
console.log(Person.prototype.__proto__.__proto__ === null)
console.log(Person.prototype.__proto__)
// {
// constructor: f Object(),
// toString: ...
// hasOwnProperty: ...
// isPrototypeOf: ...
// ...
// }
/**
* 构造函数、原型对象和实例 * 是 3 个完全不同的对象: */
console.log(person1 !== Person)
console.log(person1 !== Person.prototype) // true
console.log(Person.prototype !== Person) // true
/**
* 实例通过__proto__链接到原型对象,
* 它实际上指向隐藏特性[[Prototype]] *
* 构造函数通过 prototype 属性链接到原型对象 *
* 实例与构造函数没有直接联系,与原型对象有直接联系 */
console.log(person1.__proto__ === Person.prototype)
conosle.log(person1.__proto__.constructor === Person) // true
/**
* 同一个构造函数创建的两个实例 共享同一个原型对象:
*/
console.log(person1.__proto__ === person2.__proto__) // true
/**
* instanceof 检查实例的原型链中
* 是否包含指定构造函数的原型:
*/
console.log(person1 instanceof Person) // true
console.log(person1 instanceof Object) // true
console.log(Person.prototype instanceof Object) // 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
对于前面例子中的 Person 构造函数和 Person.prototype,可以通过下图看出各个对象之间的 关系。
# 总结
工厂模式缺陷: 对象标识问题(即无法标识新创建的对象是什么类型)
构造函数缺陷: 其定义的方法会在每个实例上 都创建一遍,会造成内存和性能浪费
# 类继承 使用 super 时要注意几个问题。
super 只能在派生类构造函数和静态方法中使用。
class Vehicle {
constructor() {
super()
// SyntaxError: 'super' keyword unexpected
}
}
2
3
4
5
6
不能单独引用 super 关键字,要么用它调用构造函数,要么用它引用静态方法。
class Vehicle {}
class Bus extends Vehicle {
constructor() {
console.log(super)
// SyntaxError: 'super' keyword unexpected here
}
}
2
3
4
5
6
7
8
调用 super()会调用父类构造函数,并将返回的实例赋值给 this。
class Vehicle {}
class Bus extends Vehicle {
constructor() {
super()
console.log(this instanceof Vehicle)
}
}
new Bus() // true
2
3
4
5
6
7
8
9
super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。
class Vehicle {
constructor(licensePlate) {
this.licensePlate = licensePlate
}
}
class Bus extends Vehicle {
constructor(licensePlate) {
super(licensePlate)
}
}
console.log(new Bus('1337H4X')) // Bus { licensePlate: '1337H4X' }
2
3
4
5
6
7
8
9
10
11
如果没有定义类构造函数,在实例化派生类时会调用 super(),而且会传入所有传给派生类的 参数。
class Vehicle {
constructor(licensePlate) {
this.licensePlate = licensePlate
}
}
class Bus extends Vehicle {}
console.log(new Bus('1337H4X')) // Bus { licensePlate: '1337H4X' }
2
3
4
5
6
7
在类构造函数中,不能在调用 super()之前引用 this。
class Vehicle {}
class Bus extends Vehicle {
constructor() {
console.log(this)
}
}
new Bus()
// ReferenceError: Must call super constructor in derived class
// before accessing 'this' or returning from derived constructor
2
3
4
5
6
7
8
9
如果在派生类中显式定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回 一个对象。
class Vehicle {}
class Car extends Vehicle {}
class Bus extends Vehicle {
constructor() {
super()
}
}
class Van extends Vehicle {
constructor() {
return {}
}
}
console.log(new Car()) // Car {}
console.log(new Bus()) // Bus {}
console.log(new Van()) // {}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 代理 proxy
ECMAScript 6 新增的代理和反射为开发者提供了拦截并向基本操作嵌入额外行为的能力。
具体地 说,可以给目标对象定义一个关联的代理对象,而这个代理对象可以作为抽象的目标对象来使用。
在对 目标对象的各种操作影响目标对象之前,可以在代理对象中对这些操作加以控制。
Vue3 中弃用了 Object.defineProperty() 转用 proxy, 因为 IE 不支持代理,所以 Vue3 也放弃支持 IE 了
# 定义捕获器
使用代理的主要目的是可以定义捕获器(trap)。捕获器就是在处理程序对象中定义的“基本操作的 拦截器”。
每个处理程序对象可以包含零个或多个捕获器,每个捕获器都对应一种基本操作,可以直接 或间接在代理对象上调用。
每次在代理对象上调用这些基本操作时,代理可以在这些操作传播到目标对 象之前先调用捕获器函数,从而拦截并修改相应的行为
# get()捕获器
例如,可以定义一个 get()捕获器,在 ECMAScript 操作以某种形式调用 get()时触发。下面的例 子定义了一个 get()捕获器:
const target = {
foo: 'bar'
}
const handler = {
// 捕获器在处理程序对象中以方法名为键
get() {
return 'handler override'
}
}
const proxy = new Proxy(target, handler)
2
3
4
5
6
7
8
9
10
这样,当通过代理对象执行 get()操作时,就会触发定义的 get()捕获器。
当然,get()不是 ECMAScript 对象可以调用的方法。这个操作在 JavaScript 代码中可以通过多种形式触发并被 get()捕获 器拦截到。
proxy[property]、proxy.property 或 Object.create(proxy)[property]等操作都 会触发基本的 get()操作以获取属性。
因此所有这些操作只要发生在代理对象上,就会触发 get()捕获 器。注意,只有在代理对象上执行这些操作才会触发捕获器。在目标对象上执行这些操作仍然会产生正 常的行为。
const target = {
foo: 'bar'
}
const handler = {
// 捕获器在处理程序对象中以方法名为键
get() {
return 'handler override'
}
}
const proxy = new Proxy(target, handler)
console.log(target.foo)
console.log(proxy.foo)
console.log(target['foo'])
console.log(proxy['foo'])
// bar
// handler override
// bar
// handler override
console.log(Object.create(target)['foo']) // bar
console.log(Object.create(proxy)['foo']) // handler override
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 捕获器参数和反射 API
所有捕获器都可以访问相应的参数,基于这些参数可以重建被捕获方法的原始行为。比如,get() 捕获器会接收到目标对象、要查询的属性和代理对象三个参数。
const target = {
foo: 'bar'
}
const handler = {
get(trapTarget, property, receiver) {
console.log(trapTarget === target)
console.log(property)
console.log(receiver === proxy)
}
}
const proxy = new Proxy(target, handler)
// proxy.foo;
// true
// foo
// true
// 有了这些参数,就可以重建被捕获方法的原始行为:
const target = {
foo: 'bar'
}
const handler = {
get(trapTarget, property, receiver) {
return trapTarget[property]
}
}
const proxy = new Proxy(target, handler)
console.log(proxy.foo) // bar
console.log(target.foo) // bar
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
所有捕获器都可以基于自己的参数重建原始操作,但并非所有捕获器行为都像 get()那么简单。
因 此,通过手动写码如法炮制的想法是不现实的。实际上,开发者并不需要手动重建原始行为,而是可以 通过调用全局 Reflect 对象上(封装了原始行为)的同名方法来轻松重建
处理程序对象中所有可以捕获的方法都有对应的反射(Reflect)API 方法。
这些方法与捕获器拦截 的方法具有相同的名称和函数签名,而且也具有与被拦截方法相同的行为。因此,使用反射 API 也可以 9 像下面这样定义出空代理对象:
const target = {
foo: 'bar'
}
const handler = {
get() {
return Reflect.get(...arguments)
}
}
const proxy = new Proxy(target, handler)
console.log(proxy.foo) // bar
console.log(target.foo) // bar
// 甚至还可以写得更简洁一些:
const target = {
foo: 'bar'
}
const handler = {
get: Reflect.get
}
const proxy = new Proxy(target, handler)
console.log(proxy.foo) // bar
console.log(target.foo) // bar
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
事实上,如果真想创建一个可以捕获所有方法,然后将每个方法转发给对应反射 API 的空代理,那 么甚至不需要定义处理程序对象:
const target = {
foo: 'bar'
}
const proxy = new Proxy(target, Reflect)
console.log(proxy.foo) // bar
console.log(target.foo) // bar
2
3
4
5
6
反射 API 为开发者准备好了样板代码,在此基础上开发者可以用最少的代码修改捕获的方法。比如, 下面的代码在某个属性被访问时,会对返回的值进行一番修饰:
const target = {
foo: 'bar',
baz: 'qux'
}
const handler = {
get(trapTarget, property, receiver) {
let decoration = ''
if (property === 'foo') {
decoration = '!!!'
}
return Reflect.get(...arguments) + decoration
}
}
const proxy = new Proxy(target, handler)
console.log(proxy.foo) // bar!!!
console.log(target.foo) // bar
console.log(proxy.baz) // qux
console.log(target.baz) // qux
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 常用代理捕获器
- get()
- set()
- has() 返回布尔值,表示属性是否存在。返回非布尔值会被转型为布尔值。
- defineProperty() 返回布尔值,表示属性是否成功定义。返回非布尔值会被转型为布尔值。
- deleteProperty() 返回布尔值,表示属性是否成功删除。返回非布尔值会被转型为布尔值。
代理是 ECMAScript 6 新增的令人兴奋和动态十足的新特性。尽管不支持向后兼容,但它开辟出了 一片前所未有的 JavaScript 元编程及抽象的新天地。
从宏观上看,代理是真实 JavaScript 对象的透明抽象层。代理可以定义包含捕获器的处理程序对象, 而这些捕获器可以拦截绝大部分 JavaScript 的基本操作和方法。在这个捕获器处理程序中,可以修改任 何基本操作的行为,当然前提是遵从捕获器不变式。
与代理如影随形的反射 API,则封装了一整套与捕获器拦截的操作相对应的方法。可以把反射 API 看作一套基本操作,这些操作是绝大部分 JavaScript 对象 API 的基础。
代理的应用场景是不可限量的。开发者使用它可以创建出各种编码模式,比如(但远远不限于)跟 踪属性访问、隐藏属性、阻止修改或删除属性、函数参数验证、构造函数参数验证、数据绑定,以及可 观察对象。
# 函数参数
ECMAScript 函数的参数跟大多数其他语言不同。ECMAScript 函数既不关心传入的参数个数,也不 关心这些参数的数据类型。
定义函数时要接收两个参数,并不意味着调用时就传两个参数。你可以传一 个、三个,甚至一个也不传,解释器都不会报错。
之所以会这样,主要是因为 ECMAScript 函数的参数在内部表现为一个数组。
函数被调用时总会接 收一个数组,但函数并不关心这个数组中包含什么。如果数组中什么也没有,那没问题;如果数组的元 素超出了要求,那也没问题。
事实上,在使用 function 关键字定义(非箭头)函数时,可以在函数内 部访问 arguments 对象,从中取得传进来的每个参数值。
arguments 对象是一个类数组对象(但不是 Array 的实例),因此可以使用中括号语法访问其中的 元素(第一个参数是 arguments[0],第二个参数是 arguments[1])。
而要确定传进来多少个参数, 可以访问 arguments.length 属性。
在下面的例子中,sayHi()函数的第一个参数叫 name:
function sayHi(name, message) {
console.log('Hello ' + name + ', ' + message)
}
2
3
可以通过 arguments[0]取得相同的参数值。因此,把函数重写成不声明参数也可以:
function sayHi() {
console.log('Hello ' + arguments[0] + ', ' + arguments[1])
}
2
3
# 默认参数作用域
默认参数是按顺序初始化的,所以后定义默认值的参数可以引用先定义的参数。看下面这个例子:
function makeKing(name = 'Henry', numerals = name) {
return `King ${name} ${numerals}`
}
console.log(makeKing()) // King Henry Henry
2
3
4
参数初始化顺序遵循“暂时性死区”规则,即前面定义的参数不能引用后面定义的。像这样就会抛 出错误:
// 调用时不传第一个参数会报错
function makeKing(name = numerals, numerals = 'VIII') {
return `King ${name} ${numerals}`
}
2
3
4
参数也存在于自己的作用域中,它们不能引用函数体的作用域:
// 调用时不传第二个参数会报错
function makeKing(name = 'Henry', numerals = defaultNumeral) {
let defaultNumeral = 'VIII'
return `King ${name} ${numerals}`
}
2
3
4
5
# 函数收集参数
在构思函数定义时,可以使用扩展操作符把不同长度的独立参数组合为一个数组。这有点类似 arguments 对象的构造机制,只不过收集参数的结果会得到一个 Array 实例。
function getSum(...values) {
// 顺序累加 values 中的所有值
// 初始值的总和为0
return values.reduce((x, y) => x + y, 0)
}
console.log(getSum(1, 2, 3)) // 6
2
3
4
5
6
收集参数的前面如果还有命名参数,则只会收集其余的参数;如果没有则会得到空数组。因为收集 参数的结果可变,所以只能把它作为最后一个参数:
// 不可以
function getProduct(...values, lastValue) {}
// 可以
function ignoreFirst(firstValue, ...values) {
console.log(values);
}
ignoreFirst();
ignoreFirst(1);
ignoreFirst(1,2);
ignoreFirst(1,2,3); // [2, 3]
2
3
4
5
6
7
8
9
10
箭头函数虽然不支持 arguments 对象,但支持收集参数的定义方式,因此也可以实现与使用 arguments 一样的逻辑:
let getSum = (...values) => {
return values.reduce((x, y) => x + y, 0)
}
console.log(getSum(1, 2, 3)) // 6
2
3
4
另外,使用收集参数并不影响 arguments 对象,它仍然反映调用时传给函数的参数:
function getSum(...values) {
console.log(arguments.length) // 3
console.log(arguments) // [1, 2, 3]
console.log(values) // [1, 2, 3]
}
console.log(getSum(1, 2, 3))
2
3
4
5
6
# 函数声明提升
JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中 生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。
来看 下面的例子:
// 没问题
console.log(sum(10, 10))
function sum(num1, num2) {
return num1 + num2
}
2
3
4
5
以上代码可以正常运行,因为函数声明会在任何代码执行之前先被读取并添加到执行上下文。
这个 过程叫作函数声明提升(function declaration hoisting)。
在执行代码时,JavaScript 引擎会先执行一遍扫描, 把发现的函数声明提升到源代码树的顶部。
因此即使函数定义出现在调用它们的代码之后,引擎也会把 函数声明提升到顶部。如果把前面代码中的函数声明改为等价的函数表达式,那么执行的时候就会出错:
// 会出错
console.log(sum(10, 10))
let sum = function(num1, num2) {
return num1 + num2
}
2
3
4
5
上面的代码之所以会出错,是因为这个函数定义包含在一个变量初始化语句中,而不是函数声明中。
这意味着代码如果没有执行到加粗的那一行,那么执行上下文中就没有函数的定义,所以上面的代码会出错。
这并不是因为使用 let 而导致的,使用 var 关键字也会碰到同样的问题:
console.log(sum(10, 10))
var sum = function(num1, num2) {
return num1 + num2
}
2
3
4
# 尾调用优化
ECMAScript 6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧。 具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。比如:
function outerFunction() {
return innerFunction() // 尾调用
}
2
3
在 ES6 优化之前,执行这个例子会在内存中发生如下操作。
(1) 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
(2) 执行 outerFunction 函数体,到 return 语句。计算返回值必须先计算 innerFunction。
(3) 执行到 innerFunction 函数体,第二个栈帧被推到栈上。
(4) 执行 innerFunction 函数体,计算其返回值。
(5) 将返回值传回 outerFunction,然后 outerFunction 再返回值。
(6) 将栈帧弹出栈外。
在 ES6 优化之后,执行这个例子会在内存中发生如下操作。
(1) 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
(2) 执行 outerFunction 函数体,到达 return 语句。为求值返回语句,必须先求值 innerFunction。 (3) 引擎发现把第一个栈帧弹出栈外也没问题,因为 innerFunction 的返回值也是 outerFunction 的返回值。
(4) 弹出 outerFunction 的栈帧。
(5) 执行到 innerFunction 函数体,栈帧被推到栈上。
(6) 执行 innerFunction 函数体,计算其返回值。
(7) 将 innerFunction 的栈帧弹出栈外。 很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多
少次嵌套函数,都只有一个栈帧。这就是 ES6 尾调用优化的关键:如果函数的逻辑允许基于尾调用将其 销毁,则引擎就会那么做。
注意 现在还没有办法测试尾调用优化是否起作用。不过,因为这是 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();
}
下面是几个符合尾调用优化条件的例子:
"use strict";
// 有优化:栈帧销毁前执行参数计算 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();
}
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
差异化尾调用和递归尾调用是容易让人混淆的地方。无论是递归尾调用还是非递归尾调用,都可以 应用优化。
引擎并不区分尾调用中调用的是函数自身还是其他函数。不过,这个优化在递归场景下的效 果是最明显的,因为递归代码最容易在栈内存中迅速产生大量栈帧。
注意 之所以要求严格模式,主要因为在非严格模式下函数调用中允许使用 f.arguments 和 f.caller,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此 尾调用优化要求必须在严格模式下有效,以防止引用这些属性。
尾递归优化示例:
可以通过把简单的递归函数转换为待优化的代码来加深对尾调用优化的理解。下面是一个通过递归 计算斐波纳契数列的函数:
function fib(n) {
if (n < 2) {
return n
}
return fib(n - 1) + fib(n - 2)
}
console.log(fib(0)) // 0
console.log(fib(1)) // 1
console.log(fib(2)) // 1
console.log(fib(3)) // 2
console.log(fib(4)) // 3
console.log(fib(5)) // 5
console.log(fib(6)) // 8
2
3
4
5
6
7
8
9
10
11
12
13
显然这个函数不符合尾调用优化的条件,因为返回语句中有一个相加的操作。结果,fib(n)的栈 帧数的内存复杂度是 O(2n)。因此,即使这么一个简单的调用也可以给浏览器带来麻烦:
fib(1000);
当然,解决这个问题也有不同的策略,比如把递归改写成迭代循环形式。不过,也可以保持递归实 现,但将其重构为满足优化条件的形式。为此可以使用两个嵌套的函数,外部函数作为基础框架,内部 函数执行递归:
"use strict";
// 基础框架 function fib(n) {
return fibImpl(0, 1, n);
}
// 执行递归
function fibImpl(a, b, n) {
if (n === 0) {
return a
}
return fibImpl(b, a+b, n-1)
}
2
3
4
5
6
7
8
9
10
11
12
这样重构之后,就可以满足尾调用优化的所有条件,再调用 fib(1000)就不会对浏览器造成威胁了。
# 实现 sleep()
很多人在刚开始学习 JavaScript 时,想找到一个类似 Java 中 Thread.sleep()之类的函数,好在程 序中加入非阻塞的暂停。以前,这个需求基本上都通过 setTimeout()利用 JavaScript 运行时的行为来 实现的。
有了异步函数之后,就不一样了。一个简单的箭头函数就可以实现 sleep():
async function sleep(delay) {
return new Promise((resolve) => setTimeout(resolve, delay))
}
async function foo() {
const t0 = Date.now()
await sleep(1500) // 暂停约 1500 毫秒 console.log(Date.now() - t0);
}
foo()
// 1502
2
3
4
5
6
7
8
9