前端核心知识系列:面向对象和原型

对象——这个概念在编程中非常重要,任何语言和领域的开发者都应该具有面向对象思维,能够有效运用对象。良好的面向对象系统设计将是应用强健性、可维护性和可扩展性的关键;反之,如果面向对象环节有失误,那么将是项目的灾难

说到 JavaScript 面向对象,它实质是基于原型的对象系统,而不是基于类的。这是由设计之初所决定的,是基因层面的。随着 ES Next 标准的进化和新特性的添加,使得 JavaScript 面向对象更加贴近其他传统面向对象型语言。有幸目睹语言的发展和变迁,伴随着某个语言的成长,我认为是开发者之幸。

首先实现一个new

说起 JavaScript 当中的 new 关键字,有一段很有趣的历史。其实 JavaScript 创造者 Brendan Eich 实现 new 是为了获得更高的流行度,它是强行学习 Java 的一个残留产出,他想让 JavaScript 成为 Java 的小弟。很多人认为这个设计掩盖了 JavaScript 中真正的原型继承,只是表面上看,更像是基于类的继承。
这样的误会使得很多传统 Java 开发者并不能很好理解 JavaScript。实际上,我们前端工程师应该明白,new 关键字到底做了什么事情。
  • step1:首先创建一个空对象,这个对象将会作为执行 new 构造函数() 之后,返回的对象实例
  • step2:将上面创建的空对象的原型(proto),指向构造函数的 prototype 属性
  • step3:将这个空对象赋值给构造函数内部的 this,并执行构造函数逻辑
  • step4:根据构造函数执行逻辑,返回第一步创建的对象或者构造函数的显式返回值
因为 new 是 JavaScript 的关键字,我们不能直接覆盖,实现一个 newFunc 来进行模拟,预计使用方式:
function Person(name) { this.name = name}
const person = new newFunc(Person, 'lucas')
console.log(person)
// {name: "lucas"}
实现:
function newFunc(...args) { // 取出 args 数组第一个参数,即目标构造函数 const constructor = args.shift()
// 创建一个空对象,且这个空对象继承构造函数的 prototype 属性 // 即实现 obj.__proto__ === constructor.prototype const obj = Object.create(constructor.prototype)
// 执行构造函数,得到构造函数返回结果 // 注意这里我们使用 apply,将构造函数内的 this 指向为 obj const result = constructor.apply(obj, args)
// 如果造函数执行后,返回结果是对象类型,就直接返回,否则返回 obj 对象 return (typeof result === 'object' && result != null) ? result : obj}
上述代码并不复杂,几个关键点:
  • 使用 Object.create 将 obj 的 proto 指向为构造函数的原型
  • 使用 apply 方法,将构造函数内的 this 指向为 obj
  • 在 newFunc 返回时,使用三目运算符决定返回结果
我们知道,构造函数如果有显式返回值,且返回值为对象类型,那么构造函数返回结果不再是目标实例。如下代码:


function Person(name) { this.name = name return {1: 1}}
const person = new Person(Person, 'lucas')
console.log(person)
// {1: 1}
了解这些注意点,对于理解 newFunc 的实现就不再困难。

如何优雅地实现继承

实现继承式是面向对象的一个重点概念。我们前面提到过 JavaScript 的面向对象系统是基于原型的,它的继承不同于其他大多数语言。
社区上对于 JavaScript 继承讲解的资料不在少数,这里我不再赘述每一种继承方式的实现过程,还需要开发者事先进行了解。

ES5 相对可用的继承方案

我们仅总结以下 JavaScript 中实现继承的关键点。
如果想使 Child 继承 Parent,那么
  • 原型链实现继承最关键的要点是:
Child.prototype = new Parent()
这样的实现,不同的 Child 实例的 proto 会引用同一 Parent 的实例。
  • 构造函数实现继承的要点是:
function Child (args) {   // ...   Parent.call(this, args)}


这样的实现问题也比较大,其实只是实现了实例属性继承,Parent 原型的方法在 Child 实例中并不可用。
  • 组合继承的实现才基本可用,其要点是:
function Child (args1, args2) {   // ...   this.args2 = args2   Parent.call(this, args1)}Child.prototype = new Parent()Child.prototype.constrcutor = Child
它的问题在于 Child 实例会存在 Parent 的实例属性。因为我们在 Child 构造函数中执行了 Parent 构造函数。同时,Child.proto 也会存在同样的 Parent 的实例属性,且所有 Child 实例的 proto 指向同一内存地址。
  • 同时上述实现也都没有对静态属性的继承
    还有一些其他不完美的继承方式,我们这里不再过多介绍。
一个比较完整的实现为:
function inherit(Child, Parent) {    // 继承原型上的属性   Child.prototype = Object.create(Parent.prototype)
// 修复 constructor Child.prototype.constructor = Child
// 存储超类 Child.super = Parent
// 静态属性继承 if (Object.setPrototypeOf) { // setPrototypeOf es6 Object.setPrototypeOf(Child, Parent) } else if (Child.__proto__) { // __proto__ es6 引入,但是部分浏览器早已支持 Child.__proto__ = Parent } else { // 兼容 IE10 等陈旧浏览器 // 将 Parent 上的静态属性和方法拷贝一份到 Child 上,不会覆盖 Child 上的方法 for (var k in Parent) { if (Parent.hasOwnProperty(k) && !(k in Child)) { Child[k] = Parent[k] } } }
}
上面静态属性继承存在一个问题:在陈旧浏览器中,属性和方法的继承我们是静态拷贝的,继承完后续父类的改动不会自动同步到子类。这是不同于正常面向对象思想的。但是这种组合式继承,已经相对完美、优雅。

ES6实现集成剖析

在 ES6 时代,我们可以使用 class extends 进行继承。但是我们都知道 ES6 的 class 其实也就是 ES5 原型的语法糖。我们通过研究 Babel 编译结果,来深入了解一下。
首先,我们定义一个父类:


class Person {   constructor(){       this.type = 'person'   }}


这个类包含了一个实例属性。
然后,实现一个 Student 类,这个「学生」类继承「人」类:
class Student extends Person {   constructor(){       super()   }}
从简出发,我们定义的 Person 类只包含了 type 为 person 的这一个属性,不含有方法。我们 Student 类也继承了同样的属性。
如下
var student1 = new Student()student1.type // "person"


那么,经过 Babel 编译,我们的代码是什么样呢?
一步一步来看:

class Person {   constructor(){       this.type = 'person'   }}
被编译为:

var Person = function Person() {   _classCallCheck(this, Person);   this.type = 'person';};
我们看到其实还是构造函数那一套。
class Student extends Person {   constructor(){       super()   }}
编译结果:
// 实现定义 Student 构造函数,它是一个自执行函数,接受父类构造函数为参数var Student = (function(_Person) {   // 实现对父类原型链属性的继承   _inherits(Student, _Person);
// 将会返回这个函数作为完整的 Student 构造函数 function Student() { // 使用检测 _classCallCheck(this, Student); // _get 的返回值可以先理解为父类构造函数 _get(Object.getPrototypeOf(Student.prototype), 'constructor', this).call(this); }
return Student;})(Person);
// _x 为 Student.prototype.__proto__// _x2 为'constructor'// _x3 为 thisvar _get = function get(_x, _x2, _x3) { var _again = true; _function: while (_again) { var object = _x, property = _x2, receiver = _x3; _again = false; // Student.prototype.__proto__ 为 null 的处理 if (object === null) object = Function.prototype; // 以下是为了完整复制父类原型链上的属性,包括属性特性的描述符 var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x = parent; _x2 = property; _x3 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } }};
function _inherits(subClass, superClass) { // superClass 需要为函数类型,否则会报错 if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } // Object.create 第二个参数是为了修复子类的 constructor subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); // Object.setPrototypeOf 是否存在做了一个判断,否则使用 __proto__ if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;}

我们看到 Babel 将 class extends 编译成了 ES5 组合模式的继承,这才是 JavaScript 面向对象的实质。