关于 Object-oriented JavaScript

Code 代码
2020年8月20日 ~

前言

作为 JavaScript 7大数据类型之一的对象object,它是我们经常使用的无序键值对的集合。虽然在我们的代码中无处不在,但谈及JavaScript的面向对象设计,却往往是知其然不知其所以然。数据类型的定义对于理解对象来说,大概也只是冰山一角。

1. 开门见山

1.1 什么是面向对象

面向对象是一种对现实世界理解和抽象的方法:为了编程的目标,利用现实事物的一些重要特性将复杂的事物简单化,抽象为一个模型。

如下图中,将现实中的,抽象为一个Person对象,拥有名字、年龄、性别的状态,和打招呼的行为。

OOJS.0017lokxubr

而对象,是一个包含其相关数据和方法的集合,用于描述我们构建的模型的状态和行为,通常由一些变量和函数组成,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,实例化了两个对象来表示具体的人。

OOJS.002usldtnr8

除了通过封装对象来实例化具体的事物,传统的OOP还有继承多态的特征。

在不同的编程语言中,设计者利用不同的语言特性来抽象描述对象,如 C++、Java 语言使用的方式、JavaScript使用原型的方式来达到面向对象编程的设计。

2. 基于类的OOP

2.1 “传统”类

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

如下图,类AdultBaby继承了父类Person,添加了各自专用的行为walkclimb;并且Baby重写了sayHi的行为。

OOJS.003z4lcntoj

类意味着复制。类被实例化时,它的行为会被复制到实例中。类被继承时,行为也会被复制到子类中。

而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

OOJS.004nvvmutxn

新对象、原型对象、构造函数之间却并不如上图所示直接关联。而 **[[Prototype]]**对象实际是对象实例和它的构造器之间的链接。

3.1 原型链

函数对象有一个名为 prototype 的属性(只有函数对象有),指向另一个对象。调用new Person(...)时创建person1,并将person1.__proto__关联到Person.prototype指向的对象,这其实是间接完成了新对象与构造函数之间的关联。

每个对象有一个原型,原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为**[[Prototype]] 链(原型链)**。

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

OOJS.005723z3nro

如图,构造函数的原型对象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);
}

OOJS.006vda50ye5

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,而不产生一些多余的信息:

OOJS.0077ermxzbf

对象的另一种创建方式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 对象并关联到 Foo.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 才是真正应该被称为“面向对象”的语言?

标签

Livia

人生没有对错,都是选择

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.