JavaScript 中没有类

Posted by jiananshi on 2019-04-22

是的,JavaScript 中既不存在类(Class)也没有继承,JavaScript 是一门基于原型(Prototype)的语言,在 JS 中使用类的概念的万恶之源应该是 Douglas Crockford:

js-good-part

把 JS 当作基于类的语言本来就是容易误导的一个前提,书里写的组合、寄生、组合寄生继承等方式让整个问题更加难以描述清楚了。接下来我们看一下常见的 JS 模拟类机制。

模拟类继承

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
function Human(name) {
this.name = name;
}

Human.prototype.getName = function() {
return this.name;
}

function kid(name, age) {
var self = this;
var getName = this.getName;

Human.call(this, name);

this.age = age;
this.getName = function() {
return 'Mr. ' + getName.call(self);
}
}

kid.prototype = new Human();
kid.prototype.constructor = kid;
kid.prototype.getAge = function() {
return this.age;
}

const xiaoming = new kid('xiaoming', 12);
xiaoming.getName(); // 'Mr. xiaoming'
xiaoming.getAge(); // 12

上面这段代码中我们的基类是 Human,我们定义了一个新的类 kid 模拟了继承 Human 的操作,作为 kid 的实例,xiaoming 理所应当的可以调用 getNamegetAge 方法,而且我们用构造函数模拟了多态,你可以创建一个有不同名字和年龄的别的什么人。

但是为什么我要说 JS 是在模拟类呢,因为传统的类机制可以比作一个工厂,我们从工厂中生产相同的模具,他们有同样的外观和功能。而这里,JS 不同的有两点:

  1. JS 的实例从未「继承」某个父类的方法,或者称作方法代理更好理解
  2. 基于 $1,JS 实例是共享父类的方法的,父类的方法改变,所有实例都会受到影响(这点是可以避免的,不过本质上还是在模拟类)

前面我们提到了父类代理这个概念,这里我想明确的阐述这个机制,首先并没有什么父类,只有原型链上更靠「上」的对象,代理也就是我们调用的是它的方法。这里假设你对 prototype 和 [[prototype]] 的区别和作用非常了解,就不做赘述了。相对于继承,在一个对象和另一个对象中建立 [[prototype]] 的连接才是 JS 科学的打开方式。

其实建立连接也不是必须是函数,用函数仅仅是因为需要用到构造函数的时候,构造函数在创建函数的时候默认会写在函数的 prototype 属性上,你可以看到上面的代码中我们覆盖了 kid 的 prototype 属性,所以后面又追加了一个 kid.prototpye.constructor = kid。在 es6 中可以用 Object.create 创建连接:

1
2
3
4
5
6
7
8
9
10
var b = { 
name: 'b',
getName: function() {
return this.name;
}
}

var a = Object.create(b);

a.getName(); // b

这不是比 new 操作直观多了嘛,不需要将方法写到 prototype 上,不过这个例子不是特别好,因为 a 的没有 name 属性所以通过原型链返回了 b 的 name 属性,Object.create 的作用就是将返回对象的 [[prototype]] 指向传入的对象。

ES6 中模拟类继承语法

本来类继承就已经和原型 - 连接这个概念相去甚远,ES6 部分语法又补了一刀:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Human {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
}

class Kid extends Human {
constructor(name, age) {
super(name);
this.age = age;
}
getAge() {
return this.age;
}
}

这里的关键词 extendssuper 在第一节的代码实例中都有对应的 es5 实现,常写 React 的同学应该很熟悉这套语法了,anyway 这依然只是对类的模拟而已。

原型和委托

为了文章的完整性我们还是复习一下 JS 的原型机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Human(name) {
this.name = name;
}

Human.sayName = function() {
console.log(this.name);
}

Human.prototype.getName = function() {
return this.name;
}

Human.prototype.constructor === Human // true

var xiaoming = new Human('xiaoming');

xiaoming.getName(); // xiaoming
xiaoming.sayName(); // Uncaught TypeError: xiaoming.sayName is not a function

首先我们创建一个构造函数 Human,之所以叫他构造函数是因为在 new 操作的时候会将执行函数体的内容并且将 this 指向新创建的对象上,接下来我们分别在 HumanHuman.prototpye 上定义了两个方法,然后我们通过 new 操作符创建一个叫做 xiaoming 的实例对象,试着调用之前定义的两个方法,可以看到 getName 运行正常,而调用 sayName 则报错。

对于有基于类语言基础的人来说方便理解的解释是 sayName 是 static 方法,而 getName 是 public 方法,sayName 是 static 方法(细节跟类语言略有出入,比如 Java),而在 JS 里我想换一种表达方式来讲,其实 xiaoming 和 Human 的关系不是特别大, 相比较于 xiaoming 和 Human.prototype,new 操作其实是将 xiaoming[[prototype]] 指到了 Human.prototype,所以 xiaoming 对象上面找不到的方法会沿着 [[prototype]] 找到 Human.prototype,自然没有 Human 什么事了,他更多的是充当构造函数做一个初始化的事情,下面一节我会介绍 ES6 中 new 操作符之外创建连接的方式,整个流程显得更直观。

js-prototype

ES6 中获取和设置原型的方式

过去想要获取一个对象的 [[prototype]] 通常我们只能使用 Chrome 中提供的非标准属性 __proto__,ES6 提供了以下几种操作:

  1. Object.setPrototypeOf(a, b) 直接把 a 的 [[prototype]] 指向 b
  2. Object.getPrototypeOf(a) 获取 a 的 [[prototype]]
  3. Object.create(a) 返回一个 [[prototype]] 指向 a 的对象

之所以有这个观点主要是来自于阅读 You Don’t Know JavaScript 之后,我自己尽可能在相关场合使用原型、连接的概念来解释,之所以这类问题是面试常见问题我认为和设计的不好是有关的,而 es6 class 相关的语法又将这个问题愈演愈烈,希望你以后也可以以原型的角度来思考 JS 而非模拟类的机制。