JavaScript 原型机制

Author: Guang

在JavaScript语言设计之初,布兰登·艾克就引入了类似于Self语言中的原型机制。基于原型编程(英语:prototype-based programming),是面向对象编程的一种风格和方式。JavaScript的原型机制使它天然支持基于原型编程。原型模式强调对象的动态性和灵活性,允许直接修改对象属性且支持运行时修改,提供访问器属性。当然,在享受这些特性的同时,原型机制也带来了执行的不确定性、安全性、难以预测的问题。在讨论JavaScript使用原型模式创建对象的时候,我们提到过JavaScript是使用原型机制来扩展对象,基于这种扩展机制,我们完善了JavaScript创建对象的方法。这个机制在JavaScript实现继承范式中仍会大展身手,在讨论JavaScript继承之前,我们有必要对JavaScript的原型机制展开讨论。

对象原型

JavaScript中所有的对象都有一个内置属性,称为它的prototype(原型),ECMA-262第5版中管这个指针叫[[Prototype]]。这个原型本身也是一个对象,原型对象也会有自己的原型,由此,对象间的的原型连接构成了原型链。原型链终止于拥有null作为其原型的对象上。当你试图访问一个对象的普通属性时:如果在对象本身中找不到该属性,就会在原型中搜索该属性。如果仍然找不到该属性,那么就搜索原型的原型,以此类推,直到找到该属性,或者到达链的末端,在这种情况下,返回undefined。为行文方便,若无特别强调或说明,下文中的原型单指内置属性prototype(原型)。

看一下之前的代码:

const person = {
    name: 'Guang',
    age: 32,
    job: 'Software Engineer',
    introduce: function() {
        return 'My name is ' + this.name;
    }
};

console.log(person.name); // 输出 "Guang"
console.log(person.toString()); // 输出 "[object Object]"
console.log(person.angle); // 输出 undefined

当访问personname属性时,person本身有这个属性,返回属性值。当person访问toString属性并调用时,person本身没有这个属性,JavaScript程序会沿着原型链寻找这个属性,并且找到了名为toString的属性,完成了属性的访问和调用。当访问personangle属性时,person本身没有这个属性,原型链上也没找到这个属性,得到返回值undefined

注意:我们不能够反过来以属性值为undefined来判断一个对象和它所连接的原型链上没有某个属性,因为可能存在这个属性但是值为undefined,可以使用in操作符来判断一个对象和它所连接的原型链上没有这个属性。如下代码:

console.log('toString' in person); // 输出:true
console.log('unknownThing' in person); // false

in操作符和关键字for可以组合使用遍历对象的所有可枚举[[Enumerable]]属性,包括原型链上的。

ECMAScript标准规定一个对象的原型属于内置属性,原意是说对象的原型不能够直接访问,现实中主流浏览器以属性__proto__实现了对象的原型,所以每个对象都包含一个__proto__属性。不建议直接访问它,即使它可以访问,符合规范的做法是使用Object.getPrototypeOf()方法访问它。

获取并输出person的原型:

console.log(Object.getPrototypeOf(person));

输出结果如下图

person prototype

可以看到输出结果是一个对象,这个对象也有自己的原型,且这个原型值为null

注意:访问对象的属性能够从对象原型中查找,设置对象属性并不能。当使用赋值语句为对象设置属性值时,只会修改对象本身的属性或者添加这个属性。通过为对象属性赋值无法修改原型中的属性。

person.address = 'Beijing'; // 只会设置 person 对象自身的 address 属性

基于原型扩展对象

JavaScript中对象属性能够从原型中查找的机制,同时对象的原型是可更改的。这让我们能够通过扩展现有对象方式创建新的对象,或者扩展现有对象。指定一个对象的原型另一个对象,则这个对象获得了原型的属性和行为,可以直接使用,或者覆盖它们。

1. Object.setPrototypeOf()

我们可以通过Object.setPrototypeOf()动态地设置一个对象的原型。它的作用和通过__proto__属性设置一个对象的原型一样(通过__proto__属性设置原型也是非标准的,你应该总是使用Object.setPrototypeOf())。请看如下代码:

const person = {
    name: 'Guang',
    age: 32,
    job: 'Software Engineer',
    introduce: function() {
        return 'My name is ' + this.name;
    }
};

const engineer = {
    name: 'Greg'
};

Object.setPrototypeOf(engineer, person);

console.log(engineer.introduce()); // 输出 "My name is Greg"

在这个示例中,engineer扩展了person,使用了personintroduce方法,覆盖了personname属性。在person调用introduce方法时,introduce方法中的this指向的是调用者engineer,而非定义者person。这是JavaScript程序运行时确定this指向的机制决定的。

Tips: 不建议频繁地修改对象地原型。JavaScript的原型机制中的属性查找有一个缺点,当原型链比较长的时候,会造成性能问题。V8 JavaScript引擎是支持JIT的,其中包含对属性沿原型查找的优化,而频繁地设置对象原型会破坏JIT的查找优化。

2. Object.create()

Object.create() 方法创建一个新的对象,并允许你指定一个将被用作新对象原型的对象。请看如下代码:

const person = {
    name: 'Guang',
    age: 32,
    job: 'Software Engineer',
    introduce: function() {
        return 'My name is ' + this.name;
    }
};

const engineer = Object.create(person);
engineer.name = 'Greg';

console.log(engineer.introduce()); // 输出 "My name is Greg"

代码执行效果同上。

3. 使用构造函数

所有的函数都有一个名为prototype的属性(下个小节会细讲),这个属性是所有调用该构造函数创建对象的原型对象,当我们修改这个原型对象的属性时,修改会对所有使用该构造函数创建的对象产生影响。

函数对象原型

在JavaScript中,所有的函数都有一个名为prototype的属性,我们在对象的构造函数模式创建一节中有提到。对象的prototype(原型)区别于对象的prototype属性,它没有绑定在命名为prototype的属性上。在JavaScript中函数也是对象,所以函数既有自己的prototype(原型),也有自己的prototype属性。当一个函数作为构造函数调用时,这个函数的prototype属性对象被设置为新构造对象的原型(按照惯例,是名为__proto__的属性)。我们说函数的prototype属性指向的对象可以存放函数作为构造函数创建的所有对象共享的属性和方法,以此来节约内存,原因就是访问对象属性时在原型上的查找机制。标识创建对象的构造方法的constructor属性也是放在对象原型上的,即构造函数的prototype属性对象中(而constructor属性又指向构造函数)。

我们用代码验证:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
}

Person.prototype.introduce = function() {
    return 'My name is ' + this.name;
};

const person = new Person('Guang', 32, 'Software Engineer');

console.log(Object.getPrototypeOf(person) === Person.prototype); // 输出 true
console.log(person.constructor === Person.prototype.constructor); // 输出 true

它们的原型关系图如下所示

person prototype relation

这张图同时表达的信息还有对象person是由构造函数Person创建的。判断一个对象是否由一个构造函数创建可以使用instanceof操作符。instanceof操作符会沿着对象原型指向的原型链一直查找,并逐一判断原型链上的原型的constructor属性值是否是指定的构造函数。当找到constructor属性值为指定构造函数时,返回true,如果在原型链搜索结束也没找到,则返回false。示例代码如下:

console.log(person instanceof Person); // 输出 true
console.log(person instanceof Array); // 输出 false

一切皆对象

在JavaScript中,Object()是基础的内建函数,除了原始类型数据,JavaScript中所有对象都由Object()函数创建,字面量创建对象的底层,仍然是调用new Object()。所以,所有对象的原型链的末端,都是函数Objectprototype属性,除非你手动更改它。而Object.prototype指向null,上面字面量创建的person对象的原型属性(__proto__)指向的就是Object.prototype,然后Object.prototype的值是null。我们也可以看到person对象的原型中constructor属性指向的是Object()。所有对象的原型链的末端都是函数Objectprototype属性,也是说JavaScript中一切皆对象的原因。

person prototype

JavaScript中另一个基础的内建对象时Function(),所有函数均由Function()创建,包括Object()。如果考虑Function()Object(),则使用构造函数创建person对象的原型关系图如下所示。

person prototype relation with all roles

构造函数执行时做了哪些事

结合JavaScript原型机制,当使用new关键字调用函数时发生了那些事情呢?发生了下面四件事。

  1. 创建一个新对象。
  2. 设置构造函数的原型为的新对象的原型
  3. 绑定函数的作用域到这个新对象上,即this指向这个新对象。
  4. 函数返回。一般情况下函数返回第一步创建的新对象,如果返回其他值,如果返回值是对象,则返回这个对象,否则仍返回第一步创建的对象。

仿照new调用改写成普通函数如下:

function myNew(Func, ...args) {
  // 创建一个新的空对象
  const obj = {};
  // 将这个空对象的__proto__指向构造函数的原型
  // obj.__proto__ = Func.prototype;
  Object.setPrototypeOf(obj, Func.prototype);
  // 以obj作为上下文对象对函数进行调用,拿到调用结果
  const res = Func.apply(obj, args);
  // 默认返回执行上下文对象(即obj),除非构造函数单独指定了非原始类型值的返回对象,指定原始类型值无效(构造函数必须返回对象)
  return res instanceof Object ? res : obj;
}

自有属性

访问一个对象的属性,这个属性可能在其自身,也可能在其原型上。有的时候我们需要判断属性是在自身上还是在原型上,这时我们可以使用Object.hasOwn()方法进行判断。代码如下:

console.log(Object.hasOwn(engineer, 'name')); // 输出 true
console.log(Object.hasOwn(engineer, 'introduce')); // 输出 false

ES5中在Object.prototype定义了一个函数hasOwnProperty()方法,但是已经不再推荐使用它,因为他在对象原型链的末端,很容易被覆盖而改变行为。对于原型(__proto__)被设置null的对象来说,调用hasOwnProperty()会直接导致程序崩溃,所以建议使用Object.hasOwn()方法。譬如下面代码:

const nullPrototypeObject = Object.create(null);
nullPrototypeObject.value = 1;

console.log(Object.hasOwn(nullPrototypeObject, 'value')); // 输出 true
nullPrototypeObject.hasOwnProperty('value'); // 运行报错:nullPrototypeObject.hasOwnProperty is not a function

原型机制的问题

原型是 JavaScript 的一个强大且非常灵活的功能,使得重用代码和组合对象成为可能。原型机制带来灵活性的同时,也带来了程序运行时的不确定性。这会导致一些问题。

猴子补丁(Monkey Patching)的命名冲突问题

曾经一种名为猴子补丁(Monkey Patching)的扩展技术在前端特别流行,做法就是利用JavaScript的原型机制,在扩展库中修改原生对象的原型来扩展原生对象暂时不支持的功能。当不同的扩展库应用到同一个项目中时,同样的扩展就会产生命名冲突的问题。另外,Monkey Patching的做法也会影响规范的制定。曾经因为已经广泛应用的第三方库对属性名的占用问题,官方不得不把Array.prototype.contains()更名为Array.prototype.includes()。同样的问题发生在Array.prototype.flatten()更名为Array.prototype.flat()

原型的不可靠性

原型机制是扩展对象的很强大的机制,但是JavaScript没有限制原型的扩展方式,开发人员可随意更改原型属性甚至原型对象本身,有时这么做是危险的,它可能带来对象原型链连接的混乱。下面代码展示了直接通过修改构造函数prototype属性来扩展它所创建对象带来的问题:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
}

// 1. 第一次扩展
Person.prototype.introduce = function() {
    return 'My name is ' + this.name;
};
const person1 = new Person('Guang', 32, 'Software Engineer');

// 2. 第二次扩展
Person.prototype = {
    constructor: Person,
    getJob: function() {
        return this.job;
    }
}
const person2 = new Person('Greg', 29, 'Hardware Engineer');
console.log(Object.getPrototypeOf(person1) === Object.getPrototypeOf(person2)); // 输出 false
person1.getJob(); // 运行报错:person1.getJob is not a function
person2.introduce(); // 运行报错:person2.introduce is not a function

代码中对构造函数Person()第一次扩展是工作良好的,它仅仅是为所有通过Person()创建的对象添加了introduce()方法,没有产生其他副作用。第二次扩展虽然修复了constructor指向问题,但它为构造函数Person()赋值了一个新对象,这样,扩展之前通过Person()创建的对象原型和扩展后通过Person()创建的对象原型便不是同一个对象。通过第二次扩展的方式进行扩展,不能扩展之前通过Person()创建的对象,并且覆盖了第一次扩展。所以person1.getJob()的调用会报错。

上述代码的原型变化如下图所示: prototype chaos issue

发表留言

历史留言

--空--