JavaScript 中的原型与原型链

Date:

为 JavaScript 的面向对象设计与传统类驱动语言(如 Java、C#)有着本质的不同。 这种差异不仅塑造了 JS 独特的语法风格和语言特性,更影响了开发者对对象、继承和复用的理解。 本文将深入探讨 JavaScript 中的原型与原型链机制,解析其核心特性与常见问题。


  • JS 是否是面向对象的语言?
  • 原型、原型链是什么?
  • 原型继承和类继承有什么区别?
  • JS 是如何基于原型链实现继承的?

遇到这些问题时, 我们常常会感到困惑,因为 JavaScript 的面向对象设计与传统类驱动语言(如 Java、C#)有着本质的不同, 文章从JS的设计哲学、原型与原型链、从ES5的继承理解原型链三个方面进行阐述。

目录


一、JS 的设计哲学:无类而有序

早期(ES6 之前)的 JavaScript 都没有Class关键字,只有对象和函数的概念。
相比于 Java,C#, JS 对象为什么可以动态甚至随意地添加属性和方法?

简而言之地说:JavaScript 是基于对象而非传统类驱动的面向对象语言

什么是基于对象?为何如此设计?

博客对 JS 的设计理念进行了详细讲述,这里进行简单概括:

📑 JavaScript 的创建史


1994 年,Netscape Navigator网络浏览器的出现轰动一时,但当时的浏览器功能单一,真就是名义上的“浏览器”,不具备交互性。
因此,迫切需要一种轻量级的脚本语言来增强浏览器的交互能力。


当时,面向对象编程(OOP)已经成为一种流行的编程范式,JS 的设计者 Brendan Eich 也受其影响,希望借鉴 OOP 的优点,但又不想让 JS 变得过于复杂。


C++ 用 new 关键字创建对象, JS 同样采用 new 关键字基于构造函数创造对象。

但抛弃了类的概念后, 两个实例无法共享属性和方法, 变成了两个独立的对象。

function DOG(name) {
  this.name = name;
  this.species = '犬科';
}
let dogA = new DOG('大毛');
let dogB = new DOG('二毛');
// 两两独立的对象,无法共享属性和方法, 更改一个对象的属性,另一个对象不会受到影响。

dogA.species = '猫科';
log(dogB.species); // 显示"犬科",不受dogA的影响

因此,JS 设计者决定引入原型 prototype来实现对象属性和方法的复用。

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

DOG.prototype = { species: '犬科' };

var dogA = new DOG('大毛');
var dogB = new DOG('二毛');

DOG.prototype.species = '猫科';

log(dogA.species); // 猫科
log(dogB.species); // 猫科

最终,JS 支持封装、继承、多态, ES6 也带来了 Class 关键字,核心机制依然是原型链。例如:

// 构造函数方式创建对象
function Person(name) {
  this.name = name;
}
Person.prototype.say = function () {
  console.log(this.name);
};
const p1 = new Person('Alice');

// es6
class Person {
  constructor(name) {
    this.name = name;
  }
  say() {
    console.log(this.name);
  }
}
const p2 = new Person('Alice');

二、原型与原型链:JS 的继承基石

1. 原型的本质

在面向对象编程中,继承是指将特性从父代传递给子代,以便新代码可以重用并基于现有代码的特性进行构建。
JavaScript 使用原型链机制实现继承,每个对象都有一条链接到另原型对象的内部链。通过__proto__Object.getPrototypeOf()访问。
该原型对象有自己的原型,依此类推,直到原型为 null 的对象,null 没有原型,是原型链中最后的一环。

const arr = [1, 2, 3];
console.log(arr.map); // 继承自Array.prototype 的 map方法

const x = 'hello world';
console.log(x.split(' ')); // 继承自String.prototype 的 split方法

2. 函数的原型 VS 对象的原型

函数的原型对象的原型 是原型链机制的核心。
上文提到,JS 用 new 创建对象时,基于构造函数而不是类,JS 中的函数具有prototype属性 直白地说,当采用一个函数创造对象时,就是把该函数的 prototype 属性给挂到对象的proto


01. 函数的原型(Function.prototypefn.prototype

  • 每个函数(包括构造函数)都有一个 prototype 属性

  • 这个 prototype 是一个对象,会被用作由该函数创建的对象的原型。
  • 即:

    function Person() {}
    let p = new Person();
    console.log(p.__proto__ === Person.prototype); // true
    
  • Function.prototype 是所有函数的原型对象

  • 所有函数本身(包括你自定义的)都是 Function 的实例:

    console.log(Person.__proto__ === Function.prototype); // true
    

02. 对象的原型(__proto__

Object.getPrototypeOf(person) === person.__proto__ // true

  • 每个对象都有一个隐藏属性 __proto__(标准叫 [[Prototype]]

  • 它指向创建该对象的构造函数的 prototype 属性:

    let obj = {};
    console.log(obj.__proto__ === Object.prototype); // true
    
  • 原型链查找机制

  • JS 在查找属性或方法时会沿 __proto__ 一层层往上找,直到 null 为止。
  • 这构成了原型链,例如:

    function Person() {
      this.sayhi = function () {
        console.log('hi');
      };
    }
    
    const p = new Person();
    
    console.log(p.__proto__ === Person.prototype, p.__proto__); // true {}
    
    console.log(
      p.__proto__.__proto__ === Object.prototype,
      Person.prototype.__proto__
    ); // true [Object: null prototype] {}
    
    console.log(
      p.__proto__.__proto__.__proto__ === null,
      Person.prototype.__proto__.__proto__
    ); // true null
    
    console.log(Person.__proto__ === Function.prototype, Person.__proto__); // true {}
    
    console.log(
      Person.__proto__.__proto__ === Object.prototype,
      Person.__proto__.__proto__
    ); // true [Object: null prototype] {}
    
    console.log(
      Person.__proto__.__proto__.__proto__ === null,
      Person.__proto__.__proto__.__proto__
    ); // true null
    
  • 如果我们简单模拟一下 js 引擎查找对象的属性的过程:

    function getProperty(obj, propName) {
      // 在对象本身查找
      if (obj.hasOwnProperty(propName)) {
        return obj[propName];
      } else if (obj.__proto__ !== null) {
        // 如果对象有原型,则在原型上!递归!查找
        return getProperty(obj.__proto__, propName);
      } else {
        // 直到找到Object.prototype,Object.prototype.__proto__为null,返回undefined
        return undefined;
      }
    }
    

03. 区别与联系

对象/函数.prototype.__proto__
普通对象指向其构造函数的 prototype
函数指向 Function.prototype
构造函数实例指向构造函数的 prototype

三、原型链驱动的继承机制

在ES6之前,JS 没有 class 关键字,实现继承的方式经过了几代演变,最终通过寄生组合式继承实现。


1 原型链继承

  • 原理:让子类的原型对象指向父类的实例。这样,子类实例就能访问到父类原型上的属性和方法。
  • 优点:子类实例可以访问父类原型上的方法。
  • 缺点:所有子类实例共享父类实例属性(如数组、对象等引用类型), 创建子类实例时,无法向父类构造函数传参

  • 说白了,子类永远持有父类的同一个实例,导致父类属性被错误共享。

  function Parent() {
    this.name = 'parent';
  }

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

  function Child() {
    this.type = 'child';
  }

  Child.prototype = new Parent();

  var child = new Child();
  child.sayName(); // 'parent'

2 借用构造函数继承

这种方式的初衷在于解决原型链继承的引用类型被错误共享的问题,通过在子类构造函数中调用父类构造函数来实现。

  • 原理:在子类构造函数中调用父类构造函数,这样每个子类实例都有自己的父类实例属性,互不干扰。
  • 优点:每个子类实例都有自己的父类属性,互不影响。可以向父类构造函数传参。
  • 缺点:父类原型上的方法无法被子类继承。每个实例都单独拷贝一份父类属性,方法无法复用。

function Parent() {
  this.name = 'parent';
}
Parent.prototype.sayName = function() {
  console.log(this.name);
}

function Child() {
  Parent.call(this);
  this.type = 'child';
}

var child = new Child();
console.log(child.name); // 'parent'

3 组合继承

  • 原理:将原型链继承和借用构造函数继承结合起来,既可以继承父类原型上的方法,又可以避免属性共享的问题。
  • 优点:每个子类实例有自己的父类属性,互不影响父类原型上的方法可以复用
  • 缺点:父类原型上的方法被调用两次,一次在原型链继承时,一次在借用构造函数继承时。

function Parent() {
  this.name = 'parent';
}

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

function Child() {
  Parent.call(this);
  this.type = 'child';
}

Child.prototype = new Parent();
Child.prototype.constructor = Child;

var child = new Child();
child.sayName(); // 'parent'
console.log(child.name); // 'parent'

4. 原型式继承(这不像是继承,像是直接梭哈的js对象)

Douglas Crockford 提出的一种继承方式,基于一个现有对象创建新对象。

  • 原理:使用 Object.create() 方法(或者模拟实现)来创建一个新对象,并将新对象的原型指向一个已存在的对象。
  • 优点:简单灵活
  • 缺点:和原型链继承一样,引用类型共享问题
function createObject(obj) {
  function F() {}
  F.prototype = obj;
  return new F();
}

var parent = {
  name: 'parent',
  sayName: function() {
    console.log(this.name);
  }
};

var child = createObject(parent);
child.name = 'child';
child.sayName(); // 'child'

5. 寄生式继承(搞笑呢,属于凑数的)

在原型式继承的基础上,增强对象。

  • 原理:创建一个函数,该函数接收一个对象作为参数,然后增强该对象,并返回该对象。
  • 优点:在原型式继承的基础上增强子对象,加个方法属性啥的,WTF这也能成为一类吗?
  • 缺点:和原型式继承一样,存在引用类型共享问题,隔靴搔痒。

function createObject(obj) {
  function F() {}
  F.prototype = obj;
  return new F();
}

function createChild(parent) {
  var child = createObject(parent);
  child.name = 'child';
  child.sayName = function() {
    console.log(this.name);
  };
  return child;
}

var parent = {
  name: 'parent',
  sayName: function() {
    console.log(this.name);
  }
};

var child = createChild(parent);
child.sayName(); // 'child'

6. 寄生组合式继承

这是最理想的继承方式,使用Object.create()方法来创建父类原型的副本,避免了调用父类构造函数时创建不必要的实例。

  • 原理:创建父类原型的一个副本,并将其赋值给子类原型,避免了调用两次父类构造函数。
  • 优点:解决了所有组合继承的缺点,引用类型共享父类构造函数被调用两次的问题。
function inherit(child, parent) {
  var prototype = Object.create(parent.prototype); // 创建父类原型的一个副本
  prototype.constructor = child;
  child.prototype = prototype;
}

function Parent() {
  this.name = 'parent';
}

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

function Child() {
  Parent.call(this);// 只调用1次父类构造函数
  this.type = 'child';
}

inherit(Child, Parent);

var child = new Child();
child.sayName(); // 'parent'
console.log(child.name); // 'parent'

7. ES6 Class 继承

ES6 引入了 class 关键字,但它本质上仍然是基于原型链和寄生组合式继承的语法糖。它并没有引入新的继承模型,只是让 JavaScript 的继承写法更接近传统面向对象语言。

  • 核心:extends 关键字和 super 关键字封装了寄生组合式继承的底层逻辑。
      class Parent {
          constructor(name) {
              this.name = name;
              this.hobbies = ['reading', 'coding'];
          }
          sayHello() {
              console.log('Hello, I am ' + this.name);
          }
      }
    
      class Child extends Parent {
          constructor(name, age) {
              super(name); // 相当于 Parent.call(this, name),调用父类构造函数
              this.age = age;
          }
          sayAge() {
              console.log('My age is ' + this.age);
          }
      }
    

总结

继承方式解决引用类型共享解决父类传参父类方法复用父类构造函数调用次数
原型链1
借用构造函数1
组合继承2
原型式0
寄生式0
寄生组合式1
ES6 Class1

在现代 JavaScript 开发中,ES6 的 class 关键字 是实现继承的首选和标准方式,因为它在底层已经为你处理了寄生组合式继承的所有细节。理解其他继承方式有助于深入理解 JavaScript 的原型本质,并在阅读旧代码时派上用场。