原文链接:blog.xqopen.com/about-oojs
前言
作为 JavaScript 7大数据类型之一的对象object,它是我们经常使用的无序键值对的集合。虽然在我们的代码中无处不在,但谈及JavaScript的面向对象设计,却往往是知其然不知其所以然。数据类型的定义对于理解对象来说,大概也只是冰山一角。
1. 开门见山
1.1 什么是面向对象
面向对象是一种对现实世界理解和抽象的方法:为了编程的目标,利用现实事物的一些重要特性将复杂的事物简单化,抽象为一个模型。
如下图中,将现实中的人,抽象为一个Person对象,拥有名字、年龄、性别的状态,和打招呼的行为。
而对象,是一个包含其相关数据和方法的集合,用于描述我们构建的模型的状态和行为,通常由一些变量和函数组成,C++ 中称它们为成员变量和成员函数,Java 中则称它们为属性和方法。
在JavaScript中,状态和行为都抽象为对象的属性。
1.2 面向对象的程序设计
The basic idea of Object-oriented programming is that we use objects to model real world things that we want to represent inside our programs, and/or provide a simple way to access functionality that would otherwise be hard or impossible to make use of.
使用对象来构建现实世界的模型,将原本很难或不能被使用的功能简单化描述出来,以供访问。
OOP的基本思想,简单来就是将事物抽象为对象,定义对象的属性和方法,封装形成一个模板。根据这个模板创建的对象,就有了相应的状态和行为。如下图,通过上文抽象的Person
,实例化了两个对象来表示具体的人。
除了通过封装对象来实例化具体的事物,传统的OOP还有继承、多态的特征。
在不同的编程语言中,设计者利用不同的语言特性来抽象描述对象,如 C++、Java 语言使用类的方式、JavaScript使用原型的方式来达到面向对象编程的设计。
2. 基于类的OOP
2.1 “传统”类
基于类的编程中,可以创建基于其它类的新类,这些新的子类可以继承它们父类的数据和功能,并且定义专用的特征。而子类中重写父类的行为则表现为类的多态。
如下图,类Adult
和Baby
继承了父类Person
,添加了各自专用的行为walk
和climb
;并且Baby
重写了sayHi
的行为。
类意味着复制。类被实例化时,它的行为会被复制到实例中。类被继承时,行为也会被复制到子类中。
而JavaScript 中只有对象, 并不存在可以被实例化的“类”。
2.2 模拟类
为了模拟类的行为,JavaScript开发者设计了混入模式。类似通过在“子类”的构造函数或方法中调用“父类”call(this)
的方式,把一个对象的属性复制到另一个对象中:
var Something = {
cool: function () {
this.greeting = "Hello World";
this.count = this.count ? this.count + 1 : 1;
}
};
var Another = {
cool: function() {
// 隐式把 Something 混入 Another
Something.cool.call( this );
}
};
这通常会产生“丑陋”并且脆弱的语法,且无法完全模拟类的复制行为。比如对象的属性也是对象时(函数也是对象),复制的是对象的引用而不是对象本身。
模拟类的行为会让JS代码更加难懂并且难以维护。
3. 基于原型的OOP
JavaScript通过原型(prototype)来实现面向对象编程。
对象在被创建时有一个特殊的 **[[Prototype]]**内置属性(x.__proto__
,__proto__
属性非标准),指向它的原型对象(X.prototype
)。对象以其原型为模板,从原型“继承”属性和方法。
引用对象的属性时会触发[[Get]] 操作,对象本身没有这个属性时,会继续访问对象的 [[Prototype]] 链,直到找到匹配的属性名或者查找完整条原型链:
// 定义一个构造器函数
function Person(name) {
this.name = name;
this.sayHi = function() {
console.log('Hi, I\'m ' + this.name);
}
}
// 创建一个对象实例
var person1 = new Person('Jack');
person1.sayHi() // Hi, I'm Jack
新对象、原型对象、构造函数之间却并不如上图所示直接关联。而 **[[Prototype]]**对象实际是对象实例和它的构造器之间的链接。
3.1 原型链
函数对象有一个名为 prototype
的属性(只有函数对象有),指向另一个对象。调用new Person(...)
时创建person1
,并将person1.__proto__
关联到Person.prototype
指向的对象,这其实是间接完成了新对象与构造函数之间的关联。
每个对象有一个原型,原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为**[[Prototype]] 链(原型链)**。
以person1
和Person
为例的原型链关系如下:
如图,构造函数的原型对象Person.prototype的constructor
属性引用的是Person.prototype关联的函数,即Person。
person1.__proto__ === Person.prototype // true
Person.prototype.constructor === Person; // true
person1.constructor === Person // true
这里,对象 person1 的 constructor 属性指向了 Person,但其实person1并没有constructor属性,而是通过原型链上溯,找到了 Person.prototype 的 constructor。
上文示例有个问题是,当我们调用new Person(...)
时来创建person1
和person2
时,1和2拥有各自的name,但也拥有的各自的sayHi
函数,虽然函数名称和代码都是相同的。从节省内存的角度考虑,这里实际上共用一个函数就可以了:
function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function () {
console.log('Hi, I\'m ' + this.name);
}
将sayHi
函数定义在对象共同的 prototype 原型上,实例对象不再创建此函数对应的属性,而是通过原型链进行访问。
3.2 原型继承
通过原型链将对象之间关联起来的机制通常被称为原型继承。
需要注意的是这并不像类继承一样可以完全创建副本。对于对象属性,JavaScript 会在两个对象之间创建一个关联,从而让一个对象可以通过委托访问另一个对象的属性和函数。所以也有文档中称这种机制为委托。
可见,如果要继承Person
来定义更具体的人Adult
,则需要将Adult
的与Person
的关联起来。由Adult
构造的对象访问属性时,会顺着原型链先访问Adult
的原型,再访问Person
的原型,从而达到继承Person
状态和行为的效果。
那么如何让Adult
的原型与Person
的原型关联呢?以下是两种关联的形式,虽然存在一些问题:
// Adult.prototype 直接引用了 Person.prototype 对象
// 对 Adult.prototype 进行赋值操作时,会直接修改 Person.prototype 本身
Adult.prototype = Person.prototype;
// 创建了一个关联到 Person.prototype 的新对象,Adult.prototype共享了Person的属性
Adult.prototype = new Person();
如果要对 Adult 定义自己的行为,那就不能直接将 Adult.prototype 指向 Person.prototype。若使用Person构造一个新对象,Adult.prototype 会添加 Person 的数据属性,这是不必要的,也可能会影响 Adult 的“后代”。
我们需要创建一个合适的关联对象,这个对象是Adult.prototype的原型,指向 Person.prototype,而不产生一些多余的信息:
对象的另一种创建方式Object.create(..)
,会创建一个新对象并把这个对象的 [[Prototype]] 关联到指定的对象,而且没有构造函数方式的副作用。
以下就是“原型风格”的继承:
function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function () {
console.log('Hi, I\'m ' + this.name);
}
function Adult(name) {
Person.call(this, name);
}
// 创建了一个新的 Bar.prototype 对象并关联到 Person.prototype
Adult.prototype = Object.create(Person.prototype);
// 手动修复 constructor
Adult.prototype.constructor = Adult;
Adult.prototype.walk = function () {
console.log('I can walk well');
};
var adult1 = new Adult("Bob");
adult1.sayHi(); // Hi, I'm Bob
adult1.walk(); // I can walk well
这种方法有个缺点是需要创建一个新对象然后把旧对象抛弃掉,有轻微的性能损失。另外Adult.prototype指向了一个新对象,会丢失constructor,如果需要这个属性的话需要手动修复,否则会查找到 Person.prototype.constructor,也就是 Person。
若不想创建新对象,还有一种方式是直接修改 Adult.prototype 的原型:
// ES6 添加的辅助函数
Object.setPrototypeOf( Adult.prototype, Person.prototype );
ES6之前只能通过设置 __proto__
属性来实现,但是这个方法并不是标准并且无法兼容所有浏览器。
3.3 class语法糖
原型风格的继承,显而易见增加了代码的阅读难度和维护难度。
ES6引入了class
关键字来简化编码方式,避免代码出错,上例可以修改如下:
class Person {
constructor(name) {
this.name = name;
}
sayHi() {
console.log('Hi, I\'m ' + this.name);
}
}
class Adult extends Person {
constructor(name) {
super(name)
}
walk() {
console.log('I can walk well');
}
}
代码风格优美很多,也解决了很多原型风格代码中的问题和缺点,比如不再引用杂乱的.prototype
、不需要手动设置原型关联等。但这并不代表JavaScript引入了类的机制。
传统面向类的语言在声明时静态复制所有行为,而JS中的 class 实际还是使用基于原型的实时委托,如果修改或者替换了父“类”中的一个方法,那子“类”和所有实例都会受到影响。所以说 class 基本上只是现有原型委托机制的一种语法糖。
4. 小结
日常编码中我们也一直在使用继承和原型链,比如调用字符串、数组等内置对象的方法和属性。
arr ----> Array.prototype ----> Object.prototype ----> null
原型和继承的机制虽然很复杂,但JavaScript的强大和灵活正是来自于此,理解其原理有助于我们从点到面理解JavaScript。
相比于基于类的OOP建议父类和子类使用相同的方法名来表示特定的行为,基于原型的OOP会尽量避免在 原型链的不同级别中使用相同的命名,这样会降低代码的可读性和健壮性。
类的继承像是按照父类到子类的关系垂直组织,而原型的继承则是通过任意方向的委托关联并排组织的,更像是兄弟关系。
由于没有直接提供对多态的支持,实现继承的方式也有很大的差异,不符合面向对象的特征,很多人支持JavaScript不是面向对象而是一门基于对象的语言的说法。
然而OOJS的设计符合OOP的基本思想,而且JavaScript可以不通过类,直接创建对象,似乎JavaScript 才是真正应该被称为“面向对象”的语言?