理解JS中的原型(Prototypes)

2,624 阅读7分钟

该文章是直接翻译国外一篇文章,关于JS原型。
都是基于原文处理的,其他的都是直接进行翻译可能有些生硬,所以为了行文方便,就做了一些简单的本地化处理。
如果想直接根据原文学习,可以忽略此文。

全新对象

在JS中,对象是有很多keyvalue构成的一种数据存储结构。例如,如果想描述一个人的基本信息,可以构建一个拥有firstNamelastName的对象,并且分别被赋值为北宸。在JS对象中的key的值是String类型的。

在JS中,可以用Object.create创建一下全新的对象:

//构建了一个空对象
var person = Obeject.create(null);

此时有些开发会说,为什么不用var person ={} 来构建一个空对象。其实之所以能用这种处理方式,只是JS引擎给你做了处理。为了能够用最原始的代码去了解原型,我们也需要循序渐进的去接触这些东西。

如果通过一个key遍历对象,但是对象中没有对应的key能进行匹配,JS就会返回一个 undefined

person["name"] //undefined

如果key是一个合法的标识符,也可以用如下的语法进行对象数据的访问:

person.name //undefined

合法的标识符格式:

in general, an identifier starts with a unicode letter, $, _, followed by any of the starting characters or numbers. A valid identifier must also not be a reserved word. There are other allowed characters, such as unicode combining marks, unicode connecting punctuation, and unicode escape sequences.

给对象赋值

现在你已经有了一个空对象,但是好像并没有啥卵用。在我们为对象新增自定义属性之前,我们还需要对对象的额外属性(named data property)有一个更深的了解。

一般而言,对象的自定义属性有一个name属性和与name属性相对应的value属性。但是自定义属性还可以被enumerableconfigurablewritable这些隐藏属性控制,来在不同的场景中表现出不同的行为。

如果一个自定义属性是enumerable的,通过for(prop in obj)来操作该自定义属性的宿主对象,这个自定义属性会被循环操作捕获。 如果是writable的,你可以对这个自定义属性进行赋值。如果是configurable的,可以对这个自定义属性进行删除或者是改变其他的隐藏属性。

一般在我们创建一个新的自定义属性,我们总是希望这个属性是enumerablewritableconfigurable的。

我们可以利用Object.defineProperty来向对象新增一个自定义属性。

var person = Object.create(null);
Object.defineProperty(person, 'firstName', {
  value: "Yehuda",
  writable: true,
  enumerable: true,
  configurable: true
});

Object.defineProperty(person, 'lastName', {
  value: "Katz",
  writable: true,
  enumerable: true,
  configurable: true
});

这样新增属性代码有些冗余,我们可以将配置信息提出来:

var config = {
  writable: true,
  enumerable: true,
  configurable: true
};

var defineProperty = function(obj, name, value) {
  config.value = value;
  Object.defineProperty(obj, name, config);
}

var person = Object.create(null);
defineProperty(person, 'firstName', "Yehuda");
defineProperty(person, 'lastName',   "Katz");

虽然代码有一些精简了,但是还是看起来很别扭。所以我们需要一个更加合理或者是更加简洁的方式来定义属性。

Prototypes

从上面我们得知,JS中的对象就是一系列key和对应value组成的数据格式。但是在JS中还存在一个属性(一个指向其他对象的指针)。我们称这个指针为对象的原型(prototype)

如果你在某个对象中,查找一个key,但是在该对象范围内没有找到。JS会继续在prototype所指向的对象中继续寻找。以此类推,直到prototype所指向的对象是一个null,查询结束。并且返回undefined

让我们来回顾一下,在调用Object.create(null)的时候,会发生些什么。 方法中接收一个null为参数。其实也就是说,在利用Object.create(paramsObj)构建出的对象,他的prototype指向了paramsObj

可以通过Object.getPrototyOf来查询指定对象的prototype.

var man = Object.create(null);
defineProperty(man, 'sex', "male");

var yehuda = Object.create(man);
defineProperty(yehuda, 'firstName', "Yehuda");
defineProperty(yehuda, 'lastName', "Katz");

yehuda.sex       // "male"
yehuda.firstName // "Yehuda"
yehuda.lastName  // "Katz"

Object.getPrototypeOf(yehuda) // returns the man object

我们可以通过这种方式来新增函数,这样这个函数就会被多处使用:

var person = Object.create(null);
defineProperty(person, 'fullName', function() {
  return this.firstName + ' ' + this.lastName;
});

//将man的prototype指向person ,这样,在man对象还有已man为prototype的对象都可以访问这个函数
var man = Object.create(person);
defineProperty(man, 'sex', "male");

var yehuda = Object.create(man);
defineProperty(yehuda, 'firstName', "Yehuda");
defineProperty(yehuda, 'lastName', "Katz");

yehuda.sex        // "male"
yehuda.fullName() // "Yehuda Katz"

设置属性

由于构建一个具有writableconfigurableenumerable属性的对象很常见。所以JS为了让代码看起来简洁,采用另外一种给对象新增属性的方式。

通过简化方式,让代码看起来很短,也不需要额外的处理writableconfigurableenumerable等属性,同时这些属性的值都是true

var person = Object.create(null);

//在此处我们可以直接定义想要给对象新增的属性,而writable,
// configurable, 和 enumerable这些属性由JS统一处理
person['fullName'] = function() {
  return this.firstName + ' ' + this.lastName;
};

//将man的prototype指向person ,这样,在man对象还有已man为prototype的对象都可以访问这个函数
var man = Object.create(person);
man['sex'] = "male";

var yehuda = Object.create(man);
yehuda['firstName'] = "Yehuda";
yehuda['lastName'] = "Katz";

yehuda.sex        // "male"
yehuda.fullName() // "Yehuda Katz"

对象字面量

JS提供了一个种字面量语法来构建一个对象。同时可以一次性将所有需要新增的属性都指出并赋初值

var person  ={ firstName: "北宸", lastName: "范" }

其实上面的实现是如下代码的"语法糖":

var person = Object.create(Object.prototype);
person.firstName = "北宸";
person.lastName  = "范";

person的原型链为Object.prototype。这个可以在控制台中实践一下

Object.prototype对象中包含了很多我们希望在定义的对象中可以通过原型链访问的方法和属性。通过上面的分析可以得知,我们通过字面量构建的对象,它的原型就是Object.prototype

当然,我们也有机会在定义的对象中对存储在Object.prototype中的方法进行重写。

var alex = { firstName: "Alex", lastName: "Russell" };

alex.toString() // "[object Object]"

var brendan = {
  firstName: "北宸",
  lastName: "范",
  toString: function() { return "范北宸"; }
};

brendan.toString() // "范北宸"

但是基于字面量构建对象的原型是无法进行指定的。也就是说,字面量构建的对象的原型永远都是Object.prototype。这样做的话,就无法利用原型来分享共有属性和方法。所以,我们需要对这种模式进行改进。

var fromPrototype = function(prototype, object) {
//用于将自定义的原型和目标对象进行关联,这样在新的对象中就可以访问原型中的方法和属性(原型搭建)
  var newObject = Object.create(prototype);
    //遍历目标对象,将属于目标对象中的属性都复制到新对象中,(属性迁移)
  for (var prop in object) {
    if (object.hasOwnProperty(prop)) {
      newObject[prop] = object[prop];      
    }
  }

  return newObject;
};

var person = {
  toString: function() {
    return this.firstName + ' ' + this.lastName;
  }
};

var man = fromPrototype(person, {
  sex: "male"
});

var jeremy = fromPrototype(man, {
  firstName: "北宸",
  lastName:  "范"
});

jeremy.sex        // "male"
jeremy.toString() // "范北宸"

如上的对象构建的对象结构如下:

基于原型的面向对象编程

有一点很明确,原型(prototype)可以用于继承(继承是OOP语言最明显的特点之一)。为了利用原型来实现继承,JS提供了new操作符。

为了实现面向对象编程,JS允许你使用一个函数对象将prototypeconstructor封装起来。

//Person的constructor
var Person = function(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
}
//Person的prototype
Person.prototype = {
  toString: function() { return this.firstName + ' ' + this.lastName; }
}

自此,我们就实现了一个用于作为constructor函数对象还有作为新对象的prototype的对象。

让我们通过构建一个函数来模拟new的操作流程。它的主要目的就是为了新建指定的构造函数的实例.


function newObject(func) {
  // 构建函数的参数list
  var args = Array.prototype.slice.call(arguments, 1);

  // 基于构造函数的原型构建一个对象
  var object = Object.create(func.prototype);

  // 由于构造函数中存在this的值,所以在构建实例的时候,需要将this的指向实例对象
  func.apply(object, args);

  // 返回基于指定构造函数构建的新对象
  return object;
}

var brendan = newObject(Person, "范", "北宸");
brendan.toString() // "范北宸"

Note:这里func.apply(object, args)的操作有一个this指向问题。可以参考理解JS函数调用和"this"

在JS实际构建对象中,用的是new

var mark = new Person("范", "北宸");
mark.toString() // "范北宸"

Note:关于new的运行机制,大致如下:

  1. 创建一个空对象,作为将要返回的对象实例。
  2. 将这个空对象的原型,指向构造函数的prototype属性。
  3. 将这个空对象赋值给函数内部的this关键字。
  4. 开始执行构造函数内部的代码。

本质上,JS函数中的"class"其实就是一个函数对象作为构造函数同时附带上一个prototype对象。