JavaScript 解密 —— 理解对象

一、prototypes

Prototype 是一个对象,其中定义的属性和功能可以自动被其他对象访问。Prototype 可以发挥类似于传统的 OO 语言中的作用,事实上 JavaScript 中的 prototype 主要用途就是编写 OO 形式的代码。

在 JavaScript 中,对象表示一系列已命名的属性及其属性值的合集。

1
2
3
4
5
let obj = {
prop1: 1, // 属性值为基本类型
prop2: function () {}, // 属性值为函数
prop3: {} // 属性值为另一个对象
}

关联到某个对象上的属性可以很容易地被修改或删除。

1
2
3
4
5
6
7
8
9
10
11
12
let obj = {
prop1: 1, // 属性值为基本类型
prop2: function () {}, // 属性值为函数
prop3: {} // 属性值为另一个对象
}

obj.prop1 = [] // 将 prop1 属性的值改为其他类型
delete obj.prop2 // 删除某个属性
obj.prop4 = "Hello" // 添加一个新的属性

console.log(obj)
// { prop1: [], prop3: {}, prop4: 'Hello' }

每个对象都可以拥有一个 prototype 的引用,在对象本身不包含某个属性时,可以由 prototype 对象去寻找该属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const yoshi = { skulk: true }
const hattori = { sneak: true }
const kuma = { creep: true }

console.log("skulk" in yoshi) // true
console.log("sneak" in yoshi) // false
console.log("creep" in yoshi) // false

// 通过 setPrototypeOf 方法将 hattori 设置为
// yoshi 对象的 prototype,yoshi 此时可以访问 sneak 属性
Object.setPrototypeOf(yoshi, hattori)
console.log("sneak" in yoshi) // true
console.log("creep" in yoshi) // false

// 将 kuma 设置为 hattori 对象的 prototype,
// yoshi 此时可以访问 creep 属性
Object.setPrototypeOf(hattori, kuma)
console.log("sneak" in yoshi) // true
console.log("creep" in yoshi) // true

prototype1
prototype2

prototype 与对象构建

JavaScript 中,最简单的创建新对象的语法如下:

1
2
3
4
5
6
const warrior = {}
warrior.name = 'Saito'
warrior.occupation = 'marksman'

console.log(warrior)
// { name: 'Saito', occupation: 'marksman' }

对于有面向对象编程背景的人来说,上述方式看上去缺少了很多东西,比如没有一个用来初始化对象的类构造器。
此外如果需要同时创建多个相同类型的对象,手动地一个一个为对象关联属性则工作量太大且容易出问题。

和其他常见的 OO 语言类似,JavaScript 也是使用 new 关键字通过构造器初始化对象。只不过该过程中被未出现类的定义,new 关键字实际上调用的是构造器函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Ninja() {}
// 每个函数都包含一个内置的 prototype 对象,可以被随意修改
Ninja.prototype.swingSword = function () {
return true
}

// 作为函数调用 Ninja(),不会创建任何对象
const ninja1 = Ninja()
console.log(ninja1) // undefined

// 作为构造器调用,一个新的对象被创建且作为构造器函数的上下文(this),
// 构造器函数的 prototype 成为新创建对象的 prototype
const ninja2 = new Ninja()
console.log(ninja2) // Ninja {}
console.log(ninja2.swingSword()) // true

prototype3

当某个函数作为构造器函数使用时,构造器函数的 prototype 将成为新创建对象的 prototype。
在上面的例子中,用 swingSword 方法扩展了 Ninja.prototype,当 ninja2 对象被创建后,其 prototype 就被设置成 Ninja 的 prototype。
因此,当后面我们访问 ninja2 对象的 swingSword 属性时,对于属性值的搜索最终传递给 Ninja 的 prototype 对象。
此外,所有通过 Ninja 构造器创建的对象都可以访问 swingSword 方法。

实例属性

当函数通过 new 关键字作为构造器调用时,其本身即成为新创建对象的上下文。除了通过 prototype 暴露属性外,还可以使用 this 关键字在构造器函数中初始化对象实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Ninja() {
this.swung = false

// instance method
this.swingSword = function () {
return !this.swung
}
}

// prototype method
Ninja.prototype.swingSword = function () {
return this.swung
}

const ninja = new Ninja()
console.log(ninja.swingSword()) // true

上面代码的执行结果表明,实例方法会覆盖同名的 prototype 方法。

instance method
在构造器函数中,this 关键字指向通过 new 创建的对象。因此构造器中添加的属性会直接在新的 ninja 对象中创建。
当我们需要访问 ninja 对象的 swingSword 属性时,本就没有必要再从 prototype 中搜索。ninja 对象本身已经有了通过构造器创建的同名的实例属性。

简单来说,每个对象都包含自己版本的通过构造器创建的属性,同时也都能够访问 prototype 的属性(名字相同时实例属性优先)。

Side effects
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
// 定义一个构造器函数,具有 swung 属性
function Ninja() {
this.swung = true
}

// 通过 new 调用构造器函数,创建一个 Ninja 的实例
const ninja1 = new Ninja()

// 在 ninja1 实例创建之后,为 prototype 添加 swingSword 方法
Ninja.prototype.swingSword = function() {
return this.swung
}

// 即便 swingSword 方法是后面添加到 prototype 的,ninja1 实例仍能访问
console.log(ninja1.swingSword()) // true

// 完全覆盖 Ninja 的 prototype
Ninja.prototype = {
pierce: function() {
return true
}
}

// Ninja 的 prototype 被完全替换后,ninja1 实例仍能访问 swingSword 方法
console.log(ninja1.swingSword()) // true

// 再次新建 ninja2 实例,此时该实例不能访问 swingSword 但可以访问 pierce 方法
const ninja2 = new Ninja()
console.log(ninja2.pierce()) // true
console.log(ninja2.swingSword) // undefined

从上面代码中可以看出,构造器函数的 prototype 可以随意被替换,但之前已经生成的对象实例仍指向旧的 prototype。
prototype

Object typing
1
2
3
4
5
6
7
8
9
function Ninja() {}

const ninja = new Ninja()
console.log(typeof ninja) // object
console.log(ninja instanceof Ninja) // true
console.log(ninja.constructor === Ninja) // true

const ninja2 = new ninja.constructor()
console.log(ninja2 instanceof Ninja) // true

instanceof 操作符可以帮助我们确定某个对象实例是否由某个特定的构造器创建。此外,还可以借助能够被所有实例访问的 constructor 属性,因为该属性一定指向原始的构造器函数。
又因为 constructor 属性是对原始构造器的引用,它因此也可以用来实例化新的对象。

二、继承

通过 prototype 实现继承
1
2
3
4
5
6
7
8
9
10
function Person() {}
Person.prototype.dance = function() {}

function Ninja() {}
Ninja.prototype = new Person()

const ninja = new Ninja()
console.log(ninja instanceof Ninja) // true
console.log(ninja instanceof Person) // true
console.log(typeof ninja.dance) // function

inheritance

当我们通过 ninja 对象访问 dance 方法时,JavaScript 运行时首先试着检查 ninja 对象本身。ninja 本身并不包含 dance 属性,因此 ninja 对象的 prototype(即 person 对象)继续被搜索。person 对象也不包含 dance 属性,最终 person 对象的 prototype 被搜索检查,dance 方法被找到。
这就是 JavaScript 中实现继承的方式。

覆盖 constructor 属性引发的问题

在上面的代码中,通过创建一个新的 Person 对象作为 Ninja 构造器的 prototype,令 Ninja 成为 Person 的子类。这种方式会导致原始的 Ninja prototype 被替换,即丢失 constructor 属性。
而 constructor 属性对于判断对象的起源非常重要。针对这个问题,可以使用 Object.defineProperty 方法为新的 Ninja.prototype 添加 constructor 属性,将其值设置为 Ninja

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person() {}
Person.prototype.dance = function() {}

function Ninja() {}
Ninja.prototype = new Person()

Object.defineProperty(Ninja.prototype, "constructor", {
enumerable: false,
value: Ninja,
writable: true
})

var ninja = new Ninja()
console.log(ninja.constructor === Ninja) // true

三、ES6 中的 class

ES6 中引入了一个新的 class 关键字,为创建对象、实现继承提供了一种更为优雅的方式,不必再自己通过 prototype 手动实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Ninja{
constructor(name) {
this.name = name
}
swingSword() {
return true
}
}

var ninja = new Ninja("Yoshi")
console.log(ninja instanceof Ninja) // true
console.log(ninja.name) // Yoshi
console.log(ninja.swingSword()) // true

class 关键字实际上是一种语法糖,上述代码等同于 ES5 中的如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
function Ninja(name) {
this.name = name
}

Ninja.prototype.swingSword = function() {
return true
}

var ninja = new Ninja('Yoshi')
console.log(ninja instanceof Ninja) // true
console.log(ninja.name) // Yoshi
console.log(ninja.swingSword()) // true

static methods
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Ninja{
constructor(name, level) {
this.name = name
this.level = level
}
swingSword() {
return true
}
static compare(ninja1, ninja2) {
return ninja1.level - ninja2.level
}
}

var ninja1 = new Ninja("Yoshi", 4)
var ninja2 = new Ninja("Hattori", 3)

console.log("compare" in ninja1 || "compare" in ninja2) // false
console.log(Ninja.compare(ninja1, ninja2) > 0) // true
console.log("swingSword" in Ninja) // false

compare 这种 static methods 是类级别的代码,在 ES6 之前的代码中,可以这样实现:

1
2
function Ninja() {}
Ninja.compare = function(ninja1, ninja2) { ... }

继承
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
class Person {
constructor(name) {
this.name = name
}
dance() {
return true
}
}

class Ninja extends Person {
constructor(name, weapon) {
super(name)
this.weapon = weapon
}
wieldWeapon() {
return true
}
}

var person = new Person("Bob")
console.log(person instanceof Person) // true
console.log(person.dance()) // true
console.log(person.name) // Bob
console.log(person instanceof Ninja) // false
console.log(person.wieldWeapon) // undefined

var ninja = new Ninja("Yoshi", "Wakizashi")
console.log(ninja instanceof Ninja) // true
console.log(ninja.wieldWeapon()) // true
console.log(ninja instanceof Person) // true
console.log(ninja.name) // Yoshi
console.log(ninja.dance()) // true

参考资料

Secrets of the JavaScript Ninja, Second Edition