创建对象的正确姿势

无类

对于熟悉面向对象的同学,比如 java 开发者,一开始接触到 JavaScript 会非常懊恼,因为在 JavaScript 中,是没有类的概念的,即便 ES6 引入了 classextends 等关键字,那也只是语法糖(syntax sugar),而不能让我们真正创建一个类。我们知道,类的作用就在于 继承派生。作为面向对象的三大特征之一的继承,其优劣在此不再赘述,下面我们看一下如何在缺乏类支持的 JavaScript 实现继承。

is-a

我们说 A 继承子 B,实际上可以转义为 is-a(什么是什么) 关系: A 是 B,比如 Student 继承自 Person,Student 是一个 Person,只不过比 Person 更加具体。

换言之,继承描述了一种层次关系,或者说是一种递进关系,一种更加具体化的递进过程。所以,继承也不真正需要 “类” 来支撑,他只需要这个递进关系。

JavaScript 中虽然没有类,但是是有对象的概念,我们仍然可以借用对象来描述这个递进关系,只不过 JavaScript 换了一种描述方式,叫做 原型(prototype)。顾名思义,原型描述了一个对象的来由:

原型 ----> 对象

显然,二者就构成了上面我们提到的层次递进关系,在js中,原型和对象间的联系链条是通过对象的 __proto__ 属性来完成的。举个更具体的例子,学生对象(student)的原型是人(person),因为学生源于人,在 JavaScript 中我们可以这样实现二者的递进关系:

var person = {
  name: '',
  eat: function() {
    console.log("吃饭");
  }
};

var student = {
  name: 'wxj',
  learn: function() {
    console.log("学习");
  }
};
student.__proto__ = person;
// 由于student is a person,所以他也能够eat
student.eat();

但是上面的代码片是存在问题的,他描述的是“某个学生是某个人”。你只需要通过上面的代码片了解如何在 JavaScript 中通过 __proto__ 实现一种层次递进关系,完成功能的扩展和复用。

原型继承

上面例子的继承虽然达到了目的,但是还不是我们熟悉的传统的面向对象的继承的写法,面向对象的继承的应当是 “Class extends Class”, 而不是上面代码片体现的 “object extends object”。在 JavaScript 中,借助于 构造函数(constructor)new 运算符和构造函数的 prototype 属性,我们能够模拟一个类似 “Class extends Class” 的继承(比如在上例中,我们想要实现 “Student extends Person”),这种方式称之为原型继承:

// 声明一个叫Person的构造函数,为了让他更像是一个类,我们将其大写
function Person(name) {
  this.name = name;
}

// Student '类'
function Student(name) {
  this.name = name;
}

// 通过函数的prototype属性,我们声明了Person的原型,并且可以在该原型上挂载我们想要的属性或者方法
Person.prototype.eat = function() {
  console.log(this.name+"在吃饭");
}

// 现在让Student来继承Person
Student.prototype = new Person();
// 扩展Student
Student.prototype.learn = function() {
  console.log(this.name+"在学习");
}

// 实例化一个Student
var student = new Student("wxj");

student.eat(); // "wxj在吃饭"
student.learn(); // "wxj在学习"

new Person() 实际上是自动为我们解决了如下几件事:

  • 创建一个对象,并设置其指向的原型:
var obj = {'__proto__': Person.prototype};
  • 调用 Person() 构造方法,并且将上下文(this)绑定到 obj 上, 即通过 Person 构造 obj
Person.apply(obj, arguments);
  • 返回创建的对象:
return obj;

所以,Student.prototype = new Person(); //...; var student = new Student("wxj");等效过程如下:

// 继承
Student.prototype = {'__proto__': Person.prototype};
Person.apply(Student.prototype, arguments);
Student.prototype.constructor = Parent;

//...

// 实例化Student
var student = {'__proto__': Student.prototype};
Student.apply(student, "wxj");
student.constructor = Student;

那么,我们在调用 student.eat() 时,沿着 __proto__ 提供的线索,最终在 Person.prototype 这个原型上找到该方法。

有了这些知识,我们也不难模拟出一个 new 来实现对象的创建:

function newObj(constructor) {
  var obj = {
    '__proto__': constructor.prototype
  };

  return function() {
    constructor.apply(obj, arguments);
    return obj;
  }
}
// 测试
function Person(name) {
  this.name = name;
}

// Student '类'
function Student(name) {
  this.name = name;
}

Person.prototype.eat = function() {
  console.log(this.name+"在吃饭");
}

// 继承
Student.prototype = newObj(Person)();
// 扩展Student
Student.prototype.learn = function() {
  console.log(this.name+"在学习");
}

// 实例化
var student = newObj(Student)("wxj");
student.eat(); // =>"wxj在吃饭"
student.learn(); // => "wxj在学习"

Object.create

另外,ES5 更为我们提供了新的对象创建方式:

Object.create(proto, [ propertiesObject ])

现在,我们可以这样创建一个继承自 proto 的对象:

function Person(name) {
  this.name = name;
}

Person.prototype.eat = function() {
  console.log(this.name+"在吃饭");
}

var student = Object.create(Person.prototype);
student.name = 'wxj';
student.eat(); // "wxj在吃饭"

在构造对象上,Object.create(proto) 的过程如下:

  • 创建一个临时的构造函数,并将其原型指向 proto
var Temp = function() {}; // 一般会通过闭包将Temp常驻内存,避免每次create时都创建空的构造函数
Temp.prototype = proto;
  • 通过 new 新建对象,该对象由这个临时的构造函数构造,注意,不会像构造函数传递任何参数:
var obj = new Temp();
  • 清空临时构造函数的原型,并返回创建的对象
Temp.prototype = null; // 防止内存泄漏
return obj;

完整的 Object.create 参看 MDN

为什么要用Object.create()

如此看来,Object.create 似乎也只是 new 的一次包裹,并无任何优势可言。但是,正式这次包裹,使我们新建对象更加灵活。使用 new 运算符最大的限制条件是:被 new 运算的只能是一个 构造函数,如果你想由一个普通对象构造新的对象,使用 new 就将会报错:

var person = {
  name:'',
  eat: function() {
    console.log(this.name+"在吃饭");
  }
};

var student  = new person;
// =>"Uncaught TypeError: person is not a constructor(…)"

但是 Object.create 就不依赖构造函数,因为在上面对其工作流程的介绍中,我们知道,Object.create 内部已经维护了一个构造函数,并将该构造函数的 prototype 属性指向传入的对象,因此,他比 new 更加灵活:

var student = Object.create(person);
student.name = "wxj";
student.eat(); // 'wxj在吃饭'

另外,Object.create 还能传递第二参数,该参数是一个属性列表,能够初始化或者添加新对象的属性,则更加丰富了创建的对象时的灵活性和扩展性,也正是由此功能,Object.create 的内部实现不需要向临时构造函数传递参数:

var student = Object.create(person,{
  name: {value:'wxj',writable: false}
});
student.name = "yoyoyo";
student.eat(); // "wxj在吃饭"

更多用例参看 MDN

underscore 是如何创建对象的

下面我们来看看 underscore 是怎样创建对象的:

var nativeCreate = Object.create;

// ...

// Ctor: 亦即constructor的缩写,这个空的构造函数将在之后广泛用于对象创建,
// 这个做法是出于性能上的考虑,避免每次调用`baseCreate`都要创建空的构造函数
var Ctor = function () {};

// ....

/**
 * 创建一个对象,该对象继承自prototype
 * 并且保证该对象在其原型上挂载属性不会影响所继承的prototype
 * @param {object} prototype
 */
var baseCreate = function (prototype) {
    if (!_.isObject(prototype)) return {};
    // 如果存在原生的创建方法(Object.create),则用原生的进行创建
    if (nativeCreate) return nativeCreate(prototype);
    // 利用Ctor这个空函数,临时设置对象原型
    Ctor.prototype = prototype;
    // 创建对象,result.__proto__ === prototype
    var result = new Ctor;
    // 还原Ctor原型
    Ctor.prototype = null;
    return result;
};

我们可以看到,underscore 利用 baseCreate 创建对象的时候会先检查当前环境是否已经支持了 Object.create,如果不支持,会创建一个简易的 polyfill:

// 利用Ctor这个空函数,临时设置对象原型
Ctor.prototype = prototype;
// 创建对象,result.__proto__ === prototype
var result = new Ctor;
// 防止内存泄漏,因为闭包的原因,Ctor常驻内存
Ctor.prototype = null;

而之所以叫 baseCreate,也是因为其只做了原型继承,而不像 Object.create 那样还支持传递属性列表。

ES6 中的 classextends 语法糖

在 ES6 中,支持了 classextends 关键字,让我们在撰写类和继承的时候更加靠近 java 等语言的写法:

class Person {
  constructor(name){
    this.name=name;
  }

  eat() {
    console.log(this.name+'在吃饭');
  }
}

class Student extends Person{
  constructor(name){
    super(name);
  }

  learn(){
    console.log(this.name+"在学习");
  }
}
// 测试
var student = new Student("wxj");
student.eat(); // "wxj在吃饭"
student.learn(); // "wxj在学习"

但要注意,这只是语法糖,ES6 并没有真正实现类的概念。我们看下 Babel(一款流行的 ES6 编译器)对上面程序的编译结果,当中我们能看到如下语句:

Object.defineProperty(target, descriptor.key, descriptor);
Object.create();

可见,class 的实现还是依赖于 ES5 提供的 Object.definePropertyObject.create 方法。

参考资料

results matching ""

    No results matching ""