关于 Object-oriented JavaScript

1,157 阅读9分钟

原文链接: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 “传统”类

基于类的编程中,可以创建基于其它类的新类,这些新的子类可以继承它们父类的数据和功能,并且定义专用的特征。而子类中重写父类的行为则表现为类的多态

如下图,类AdultBaby继承了父类Person,添加了各自专用的行为walkclimb;并且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]] 链(原型链)**。

person1Person为例的原型链关系如下:

如图,构造函数的原型对象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(...)时来创建person1person2时,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 才是真正应该被称为“面向对象”的语言?