Skip to content

原型链

继承与原型链

我们都知道原型链是前端必须学会的 JavaScript 基础,也是面试非常经常考的一个知识点,所以现在就出篇文章专门介绍一下,在细致讲之前,先上两张神图。

基于原型链的继承

继承属性

JavaScript 对象是动态的属性(指其自有属性)“包”。JavaScript 对象有一个指向一个原型对象的链。当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。

举个例子:

js
const obj = {
  a: 1,
};

const obj2 = {
  b: 2,
  c: 4,
};

Object.setPrototypeOf(obj2, obj);

const obj3 = {
  c: 3,
  __proto__: obj2,
};

console.log(obj3.a); // 1

console.log(obj3.b); // 2

console.log(obj3.c); // 3
// obj3上有自有属性“c”吗?有,且其值为 3。
// 原型也有“c”属性,但其没有被访问。
// 这被称为属性遮蔽(Property Shadowing)

根据上面代码中,obj3 是没有定义 a 和 b 的属性,但是我们却输出了 a 和 b 的值,这就说明了原型链继承属性的作用,当他找不到时,会一直向上寻找;除此之外,我们可以看到 obj3 和其原型都有 c 属性,但是值不同,此时却输出了自己的属性值,这就是我们上面说的,找不到就会往上面找,找得到就不找了。

继承方法

JavaScript 并没有其他基于类的语言所定义的“方法”。在 JavaScript 中,任何函数都被可以添加到对象上作为其属性。函数的继承与其他属性的继承没有差别,包括上面的“属性遮蔽”(这种情况相当于其他语言的方法重写)。

当继承的函数被调用时,this 值指向的是当前继承的对象,而不是拥有该函数属性的原型对象,举个例子:

js
const parent = {
  value: 2,
  method() {
    return this.value + 1;
  },
};

console.log(parent.method()); // 3
// 当调用 parent.method 时,“this”指向了 parent

// child 是一个继承了 parent 的对象
const child = {
  __proto__: parent,
};
console.log(child.method()); // 3
// 调用 child.method 时,“this”指向了 child。
// 又因为 child 继承的是 parent 的方法,
// 首先在 child 上寻找“value”属性。但由于 child 本身没有名为“value”的自有属性
// 该属性会在[[Prototype]] 上被找到,即 parent.value。

child.value = 4; // 在 child,将“value”属性赋值为 4。
// 这会遮蔽 parent 上的“value”属性。
// child 对象现在看起来是这样的:
// { value: 4, __proto__: { value: 2, method: [Function] } }
console.log(child.method()); // 5
// 因为 child 现在拥有“value”属性,“this.value”现在表示
// child.value

构造函数

接下来我们来讲一下构造函数是什么。

假设我们要创建多个盒子,其中每一个盒子都是一个对象,包含一个可以通过 getValue 函数访问的值,一个简单的实现可能是:

js
const boxes = [
  {
    value: 1,
    getValue() {
      return this.value;
    },
  },
  {
    value: 2,
    getValue() {
      return this.value;
    },
  },
  {
    value: 3,
    getValue() {
      return this.value;
    },
  },
];

这是不够好的,因为每一个实例都有自己的,做相同事情的函数属性,这是冗余且不必要的。相反,我们可以将 getValue 移动到所有盒子的 [[Prototype]] 上:

js
const boxPrototype = {
  getValue() {
    return this.value;
  },
};

const boxes = [
  { value: 1, __proto__: boxPrototype },
  { value: 2, __proto__: boxPrototype },
  { value: 3, __proto__: boxPrototype },
];

这样,所有盒子的 getValue 方法都会引用相同的函数,降低了内存使用率。但是,手动绑定每个对象创建的 proto 仍旧非常不方便。这时,我们就可以使用构造函数,它会自动为每个构造的对象设置 [[Prototype]]。构造函数是使用 new 调用的函数。

js
// 一个构造函数
function Box(value) {
  this.value = value;
}

// 使用 Box() 构造函数创建的所有盒子都将具有的属性
Box.prototype.getValue = function () {
  return this.value;
};

const boxes = [new Box(1), new Box(2), new Box(3)];

因为 Box.prototype 引用了(作为所有实例的 [[Prototype]] 的)相同的对象,所以我们可以通过改变 Box.prototype 来改变所有实例的行为。

js
function Box(value) {
  this.value = value;
}
Box.prototype.getValue = function () {
  return this.value;
};
const box = new Box(1);

// 在创建实例后修改 Box.prototype
Box.prototype.getValue = function () {
  return this.value + 1;
};
box.getValue(); // 2

所以构造函数就是这样的,用于我们构造一个对象,当然 ES6 有了类的语法糖,上面的代码就可以写成这样:

js
class Box {
  constructor(value) {
    this.value = value;
  }

  // 在 Box.prototype 上创建方法
  getValue() {
    return this.value;
  }
}

最后,需要提醒的是:

  1. 重新赋值 Constructor.prototype(Constructor.prototype = ...)是一个不好的主意,原因有两点:

    • 在重新赋值之前创建的实例的 [[Prototype]] 现在引用的是与重新赋值之后创建的实例的 [[Prototype]] 不同的对象——改变一个的 [[Prototype]] 不再改变另一个的 [[Prototype]]。
    • 除非你手动重新设置 constructor 属性,否则无法再通过 instance.constructor 追踪到构造函数,这可能会破坏用户期望的行为。一些内置操作也会读取 constructor 属性,如果没有设置,它们可能无法按预期工作。
    • 说白了就是,改变之后,之前你通过这个构造函数建立的对象并不会修改,那么之前建立的对象和之后建立的其实就不是一个东西了,然后就是修改这个之后会导致一些期待的行为无法发生。
  2. 有一个常见的错误实践(misfeature):扩展 Object.prototype 或其它内置原型。这种不良特性例子是,定义 Array.prototype.myMethod = function () {...},然后在所有数组实例上使用 myMethod。

    • 这种错误实践被称为猴子修补(monkey patching)。使用猴子修补存在向前兼容的风险,因为如果语言在未来添加了此方法但具有不同的签名,你的代码将会出错。它已经导致了类似于 SmooshGate 这样的事件,并且由于 JavaScript 致力于“不破坏 web”,因此这可能会对语言的发展造成极大的麻烦。
  3. 箭头函数不能用来当做构造函数哦

__proto__和 prototype

经过上面大致知道了原型链的作用和构造函数,接下来我们来讲一下最重要的两个东西。

是啥

这两个东西到底是啥呢?

  • prototype: 显式原型
  • ** proto**: 隐式原型

有什么关系

那么这两个都叫原型,那他们两到底啥关系呢?

一般,构造函数的 prototype 和其实例的 __proto__ 是指向同一个地方的,这个地方就叫做原型对象

举个例子:

js
function Person(name, age) {
  this.name = name;
  this.age = age;
}
Person.prototype.sayName = function () {
  console.log(this.name);
};
console.log(Person.prototype); // { sayName: [Function] }

const person1 = new Person("小明", 20);
console.log(person1.__proto__); // { sayName: [Function] }

const person2 = new Person("小红", 30);
console.log(person2.__proto__); // { sayName: [Function] }

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

所以就有了下面这张图

函数

我们平时定义函数,本质都是用 new Function 来的,举个例子

js
// 最常见的三种构造函数的方式
function fn1(name, age) {
  console.log(`我是${name}, 我今年${age}岁`);
}
fn1("test", 10); // 我是test, 我今年10岁

const fn2 = function (name, age) {
  console.log(`我是${name}, 我今年${age}岁`);
};
fn2("test", 10); // 我是test, 我今年10岁

const arrowFn = (name, age) => {
  console.log(`我是${name}, 我今年${age}岁`);
};
arrowFn("test", 10); // 我是test, 我今年10岁

// 转化成 new Function
const fn1 = new Function(
  "name",
  "age",
  "console.log(`我是${name}, 我今年${age}岁`)"
);
fn1("test", 10); // 我是test, 我今年10岁

const fn2 = new Function(
  "name",
  "age",
  "console.log(`我是${name}, 我今年${age}岁`)"
);
fn2("test", 10); // 我是test, 我今年10岁

const arrowFn = new Function(
  "name",
  "age",
  "console.log(`我是${name}, 我今年${age}岁`)"
);
arrowFn("test", 10); // 我是test, 我今年10岁

我们之前说过,构造函数的 prototype 和其实例的__proto__是指向同一个地方的,这里的 fn1,fn2,arrowFn 其实也都是 Function 构造函数的实例,那我们来验证一下吧

js
function fn1(name, age) {
  console.log(`我是${name}, 我今年${age}岁`);
}

const fn2 = function (name, age) {
  console.log(`我是${name}, 我今年${age}岁`);
};

const arrowFn = (name, age) => {
  console.log(`我是${name}, 我今年${age}岁`);
};

console.log(Function.prototype === fn1.__proto__); // true
console.log(Function.prototype === fn2.__proto__); // true
console.log(Function.prototype === arrowFn.__proto__); // true

所以就有了下这张图:

对象

我们平时定义对象,本质都是用 new Object 来的,举个例子

js
// 第一种:构造函数创建对象
function Person(name, age) {
  this.name = name;
  this.age = age;
}
const person1 = new Person("test", 10);
console.log(person1); // Person { name: 'test', age: 10 }

// 第二种:字面量创建对象
const person2 = { name: "test", age: 10 };
console.log(person2); // { name: 'test', age: 10 }

// 第三种:new Object创建对象
const person3 = new Object();
person3.name = "test";
person3.age = 10;
console.log(person3); // { name: 'test', age: 10 }

// 第四种:Object.create创建对象
const person4 = Object.create({});
person4.name = "test";
person4.age = 10;
console.log(person4); // { name: 'test', age: 10 }

// 本质是new Object创建对象
const person = new Object();
person.name = "test";
person.age = 10;
console.log(person); // { name: 'test', age: 10 }

我们之前说过,构造函数的 prototype 和其实例的 __proto__ 是指向同一个地方的,这里的 person2,person3 其实也都是 Object 构造函数的实例,那我们来验证一下吧

js
const person2 = { name: "test", age: 10 };

const person3 = new Object();
person3.name = "test";
person3.age = 10;

console.log(Object.prototype === person2.__proto__); // true
console.log(Object.prototype === person3.__proto__); // true

所以就有了下这张图:

blog-25

Function 和 Object

上面咱们常说

  • 函数是 Function 构造函数的实例
  • 对象是 Object 构造函数的实例

那 Function 构造函数和 Object 构造函数他们两个又是谁的实例呢?

  • function Object() 其实也是个函数,所以他是 Function 构造函数的实例
  • function Function() 其实也是个函数,所以他也是 Function 构造函数的实例,没错,他是他自己本身的实例

咱们可以试验一下就知道了

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

就有了下面这张图

blog-26

constructor

constructor 和 prototype 是成对的,你指向我,我指向你。

js
function fn() {}

console.log(fn.prototype); // {constructor: fn}
console.log(fn.prototype.constructor === fn); // true

就有了下面这张图

blog-27

原型链

Person.prototype 和 Function.prototype

讨论原型链之前,咱们先来聊聊这两个东西

  • Person.prototype,它是 构造函数 Person 的原型对象
  • Function.prototype,他是 构造函数 Function 的原型对象

都说了原型对象,原型对象,可以知道其实这两个本质都是对象

那既然是对象,本质肯定都是通过 new Object() 来创建的。既然是通过 new Object() 创建的,那就说明 Person.prototype 和 Function.prototype 都是构造函数 Object 的实例。也就说明了 Person.prototype 和 Function.prototype 他们两的 __proto__ 都指向 Object.prototype

咱们可以验证一下:

js
function Person() {}

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

就有了下面这张图

blog-28

原型链终点

上面咱们看到,三条原型链结尾都是 Object.prototype,那是不是说明了 Object.prototype 就是原型链的终点呢?其实不是的, Object.prototype 其实也有 __proto__,指向 null,那才是原型链的终点

至此,整个原型示意图就画完啦!!!。

blog-29

使用不同的方法来创建对象和改变原型链

最后介绍一下如何使用不同的方法来创建对象和改变原型链。

使用语法结构创建对象

js
const o = { a: 1 };
// 新创建的对象 o 以 Object.prototype 作为它的 [[Prototype]]
// Object.prototype 的原型为 null。
// o ---> Object.prototype ---> null

const b = ["yo", "whadup", "?"];
// 数组继承了 Array.prototype(具有 indexOf、forEach 等方法)
// 其原型链如下所示:
// b ---> Array.prototype ---> Object.prototype ---> null

function f() {
  return 2;
}
// 函数继承了 Function.prototype(具有 call、bind 等方法)
// f ---> Function.prototype ---> Object.prototype ---> null

const p = { b: 2, __proto__: o };
// 可以通过 __proto__ 字面量属性将新创建对象的
// [[Prototype]] 指向另一个对象。
// (不要与 Object.prototype.__proto__ 访问器混淆)
// p ---> o ---> Object.prototype ---> null
优点被所有的现代引擎所支持。将 __proto__ 属性指向非对象的值只会被忽略,而非抛出异常。与 Object.prototype.__proto__ setter 相反,对象字面量初始化器中的 __proto__ 是标准化,被优化的。甚至可以比 Object.create 更高效。在创建对象时声明额外的自有属性比 Object.create 更符合习惯。
缺点不支持 IE10 及以下的版本。

使用构造函数

js
function Graph() {
  this.vertices = [];
  this.edges = [];
}

Graph.prototype.addVertex = function (v) {
  this.vertices.push(v);
};

const g = new Graph();
// g 是一个带有自有属性“vertices”和“edges”的对象。
// 在执行 new Graph() 时,g.[[Prototype]] 是 Graph.prototype 的值。
优点所有引擎都支持——一直到 IE 5.5。此外,其速度很快、非常标准,且极易被 JIT 优化。
缺点要使用这个方法,必须初始化该函数。在初始化过程中,构造函数可能会存储每一个对象都必须生成的唯一信息。这些唯一信息只会生成一次,可能会导致问题。 构造函数的初始化过程可能会将不需要的方法放到对象上。

使用 Object.create()

js
const a = { a: 1 };
// a ---> Object.prototype ---> null

const b = Object.create(a);
// b ---> a ---> Object.prototype ---> null
console.log(b.a); // 1 (inherited)

const c = Object.create(b);
// c ---> b ---> a ---> Object.prototype ---> null

const d = Object.create(null);
// d ---> null(d 是一个直接以 null 为原型的对象)
console.log(d.hasOwnProperty);
// undefined,因为 d 没有继承 Object.prototype
优点被所有现代引擎所支持。允许在创建时直接设置对象的 [[Prototype]] ,这允许运行时进一步优化对象。还允许使用 Object.create(null) 创建没有原型的对象。
缺点不支持 IE8 及以下版本。但是,由于微软已经停止了对运行 IE8 及以下版本的系统的扩展支持,这对大多数应用程序而言应该不是问题。此外,如果使用了第二个参数,慢对象的初始化可能会成为性能瓶颈,因为每个对象描述符属性都有自己单独的描述符对象。当处理上万个对象描述符时,这种延时可能会成为一个严重的问题。

有兴趣可以细致了解下这个函数,挺有意思,第一个参数是原型,第二个参数是为新创建的对象添加具有对应属性名称的属性描述符。

使用类

js
class Polygon {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
}

class Square extends Polygon {
  constructor(sideLength) {
    super(sideLength, sideLength);
  }

  get area() {
    return this.height * this.width;
  }

  set sideLength(newLength) {
    this.height = newLength;
    this.width = newLength;
  }
}

const square = new Square(2);
// square ---> Square.prototype ---> Polygon.prototype ---> Object.prototype ---> null
优点被所有现代引擎所支持。非常高的可读性和可维护性。私有属性是原型继承中没有简单替代方案的特性。
缺点类,尤其是带有私有属性的类,比传统的类的性能要差(尽管引擎实现者正在努力改进这一点)。不支持旧环境,通常需要转译器才能在生产中使用类。

使用 Object.setPrototypeOf()

虽然上面的所有方法都会在对象创建时设置原型链,但是 Object.setPrototypeOf(), 允许修改现有对象的 [[Prototype]] 内部属性。

js
const obj = { a: 1 };
const anotherObj = { b: 2 };
Object.setPrototypeOf(obj, anotherObj);
// obj ---> anotherObj ---> Object.prototype ---> null
优点被所有现代引擎所支持。允许动态地修改对象的原型,甚至可以强制为使用 Object.create(null) 创建的无原型对象设置原型。
缺点性能不佳。如果可以在创建对象时设置原型,则应避免此方法。许多引擎会优化原型,并在调用实例时会尝试提前猜测方法在内存中的位置;但是动态设置原型会破坏这些优化。它可能会导致某些引擎重新编译你的代码以进行反优化,以使其按照规范工作。不支持 IE8 及以下版本。

使用 __proto__ 访问器

所有对象都继承了 Object.prototype.__proto__ 访问器,它可以用来设置现有对象的 [[Prototype]] (如果对象没有覆盖 __proto__ 属性)。

js
const obj = {};
// 请不要使用该方法:仅作为示例。
// 因为Object.prototype.__proto__ 访问器是非标准的,且已被弃用。应该使用 Object.setPrototypeOf 来代替。
obj.__proto__ = { barProp: "bar val" };
obj.__proto__.__proto__ = { fooProp: "foo val" };
console.log(obj.fooProp);
console.log(obj.barProp);
优点被所有现代引擎所支持。将 __proto__ 设置为非对象的值只会被忽略,而非抛出异常。
缺点性能不佳且已被弃用。

参考文献

  1. 继承与原型链 - JavaScript | MDN (mozilla.org)

  2. 这可能是掘金讲「原型链」,讲的最好最通俗易懂的了,附练习题! - 掘金 (juejin.cn)

备案号:闽ICP备2024028309号-1