[核心概念] 一文说透JS中的原型和继承(上)

517 阅读10分钟

系列开篇

为进入前端的你建立清晰、准确、必要概念和这些概念的之间清晰、准确、必要关联, 让你不管在什么面试中都能淡定从容。没有目录,而是通过概念关联形成了一张知识网络,往下看你就明白了。当你遇到【关联概念】时,可先从括号中的(强/弱)判断简单这个关联是对你正在理解的概念是强相关(得先理解你才能继续往下)还是弱相关(知识拓展)从而提高你的阅读效率。我也会定期更新相关关联概念。

面试题

  • 什么是原型,原型链
  • js中是如何实现继承关系的(基于原型链的继承)
  • [[Prototype]] 、__proto__ 、 prototype属性、constructor 等等都是干啥的
  • 我们平时能用这些个知识做些什么事
  • js 的面向对象思想

这是干什么的?

我们先给个MDN上的定义,帮助我们初步了解这些概念,以及这些概念的关联。

MDN: JavaScript 常被描述为一种基于原型的语言 (prototype-based language)——每个对象拥有一个原型对象对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链 (prototype chain),它解释了为何一个对象会拥有定义在其他对象中的属性和方法

在传统的面向对象编程 (OOP) 中,首先定义“” (Class),此后创建对象实例时,类中定义的所有属性和方法都被复制到实例中。在 JavaScript 中并不如此复制——而是在对象实例和它的构造器之间建立一个链接(它是__proto__属性,是从构造函数的prototype属性派生的),之后通过上溯原型链,在构造器中找到这些属性和方法

ok 我们现在先留下一个大致印象。模糊的了解了什么是原型和原型链

原型继承

在编程中,我们经常会想获取并扩展一些东西,这非常常见,比如你有 people 这个对象及其属性和方法,并希望将 studentworker 作为基于 people 稍加修改的变体。我们想重用 people 中的内容,而不是复制/重新实现它的方法,而只是在其之上构建一个新的对象。原型继承(Prototypal inheritance) 这个语言特性能够帮助我们实现这一需求。

[[Prototype]]

在 JavaScript 中,对象有一个特殊的隐藏属性 [[Prototype]],它要么为 null,要么就是对另一个对象的引用。该对象被称为“原型”, 也可以理解为这个属性是指向原型对象的指针

就像这样,当我们从 object 中读取一个缺失的属性时,JavaScript 会自动从原型对象中获取该属性。

__proto__

我们可以用 __proto__来设置 [[Prototype]]这个隐藏属性。不过现在有新的访问方式。 __proto__[[Prototype]]属性由于历史原因而留下来的 getter/setter

MDN: 遵循ECMAScript标准,someObject.[[Prototype]] 符号是用于指向 someObject 的原型。从 ECMAScript 6 开始,[[Prototype]] 可以通过 Object.getPrototypeOf()Object.setPrototypeOf() 访问器来访问。这个等同于 JavaScript 的非标准但许多浏览器实现的属性 __proto__

首先明确这不是一个东西。再强调__proto__[[Prototype]]getter/setter。 但由于 __proto__ 标记在观感上更加明显,所以我们在下面的示例中将使用它。

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};
// 设置 rabbit.[[Prototype]] = animal
rabbit.__proto__ = animal; 

可以说 animalrabbit原型,或者说 rabbit原型是从 animal 继承而来的。

现在,如果我们从 rabbit 中读取一个它没有的属性,JavaScript 会自动从 animal 中获取。

alert( rabbit.eats ); // true

当 alert 试图读取 rabbit.eats 时,因为它不存在于 rabbit 中,所以 JavaScript 会顺着 [[Prototype]] 引用,在 animal 中查找(自下而上)

因此,如果 animal 中有许多有用的属性和方法,那么它们将自动地变为在 rabbit 中可用。这种属性被称为“继承”。

例如如果我们在 animal 中有一个方法,它也可以在 rabbit 中被调用:

let animal = {
  eats: true,
  walk() {
    alert("Animal walk");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// walk 方法是从原型中获得的
rabbit.walk(); // Animal walk

原型链可以很长,原型还能有原型,但不能有闭环,如果我们试图在一个闭环中分配 __proto__,JavaScript 会抛出错误。并且只能有一个 [[Prototype]]。一个对象不能从其他两个对象获得继承。这跟OOP的多重继承 [核心概念] 不同 。

现在我们对原型和原型链有了更进一步的认识。当读取实例的属性时,如果找不到,就会查找与对象关联的原型中的属性,如果还查不到,就去找原型的原型,一直找到最顶层为止

F.prototype

我们现在知道,一个对象可能是另一个对象的原型,那么平时我们如何创建对象的?

// 字面量,没毛病
let a = {}

// new 关键字
let b = new Fun(n,m,..)

// 又或者 Object.create 等等

我们来讨论下 new F 这样的用构造函数来创建一个新对象。

如果 F.prototype 是一个对象,那么 new 操作符会使用它为新对象设置 [[Prototype]]

也就是说,F.prototype 这个属性指向的是一个对象(假设是A),那么 用 let b = new F() 创建的实例(b)的[[Prototype]] 就指向这个对象(A),也就是作为这个新实例的原型对象(A)

注意,这里的 F.prototype 指的是 F 的一个名为 "prototype" 的常规属性

看个具体例子

let animal = {
  eats: true
};
// 构造函数
function Rabbit(name) {
  this.name = name;
}
// 构造函数的 prototype 属性设置为 animal
Rabbit.prototype = animal;

let rabbit = new Rabbit("White Rabbit"); //  rabbit.__proto__ == animal

alert( rabbit.eats ); // true

设置 Rabbit.prototype = animal 的字面意思是:“当创建了一个 new Rabbit 时,把它的 [[Prototype]] 赋值为 animal”。

image.png

在上图中,"prototype" 是一个水平箭头,表示一个常规属性[[Prototype]] 是垂直的,表示 rabbit 继承animal, 或者说 animalrabbit原型

所以结论是:F.prototype 属性仅在 new F 被调用时使用,它为新对象[[Prototype]] 赋值

另外 F.prototype 的值要么是一个对象,要么就是 null其他值都不起作用

但我们平时貌似没手动给 new F加过 prototype 这个属性啊,因为 js 给我们加了个默认选项,也就是 默认的 F.prototype构造器属性

每个函数都有 "prototype" 属性,即使我们没有提供它。

默认的 "prototype" 是一个只有属性 constructor 的对象,属性 constructor 指向函数自身

function Rabbit() {}

/* default prototype
Rabbit.prototype = { constructor: Rabbit };
*/

image.png

我们可以检查一下:

function Rabbit() {}
// by default:
// Rabbit.prototype = { constructor: Rabbit }

alert( Rabbit.prototype.constructor == Rabbit ); // true

// 而且 constructor 属性可以通过 [[Prototype]] 给所有实例 rabbits 使用:
let rabbit = new Rabbit(); // inherits from {constructor: Rabbit}

alert(rabbit.constructor == Rabbit); // true (from prototype)

image.png

所以我们可以使用 constructor 属性来创建一个新对象,该对象使用与现有对象相同的构造器。类似 new rabbit.constructor(); 这样。

这里有个点一定要注意:JavaScript 自身并不能确保正确的 "constructor" 函数值。如果我们将整个默认 prototype 替换掉,那么其中就不会有 "constructor" 了。

function Rabbit() {}
Rabbit.prototype = {
  jumps: true
};

let rabbit = new Rabbit();
alert(rabbit.constructor === Rabbit); // false

所以在操作原型对象时,切忌直接替换,我们可以选择添加/删除属性到默认 "prototype"。

function Rabbit() {}

// 不要将 Rabbit.prototype 整个覆盖
// 可以向其中添加内容
Rabbit.prototype.jumps = true
// 默认的 Rabbit.prototype.constructor 被保留了下来

// 或者,也可以手动重新创建 constructor 属性
Rabbit.prototype = {
  jumps: true,
  constructor: Rabbit
};
// 这样的 constructor 也是正确的,因为我们手动添加了它

原生的原型

prototype 属性在 JavaScript 自身的核心部分中被广泛地应用。所有的内置构造函数都用到了它。

Object.prototype

假如我们输出一个空对象:

let obj = {};
alert( obj ); // "[object Object]"

生成字符串 "[object Object]" 的代码在哪里?

obj = {}obj = new Object() 是一个意思,其中 Object 就是一个内建的对象构造函数,其自身的 prototype 指向一个带有 toString 和其他方法的一个巨大的对象。

image.png

所以, 当 obj.toString() 被调用时,这个方法是从 Object.prototype 中获取的。

let obj = {};

alert(obj.__proto__ === Object.prototype); // true

alert(obj.toString === obj.__proto__.toString); //true
alert(obj.toString === Object.prototype.toString); //true

// 请注意在 Object.prototype 上方的链中没有更多的 [[Prototype]]
alert(Object.prototype.__proto__); // null

其他内建对象,像 Array、Date、Function 及其他,都在 prototype 上挂载了方法。我们看个完整点的

image.png

基本数据类型的特殊性

我们看个例子

console.log("123".split(''))
// ["1", "2", "3"]

"123" 不是个字符串基本类型吗,并不是对象,为什么能调用 split 方法?

答案是 临时包装器对象

字符串数字布尔值虽然是基本类型但是如果我们试图访问它们的属性,那么临时包装器对象将会通过内建的构造器 String、Number 和 Boolean 被创建。那么这些方法存储在包装器对象的 prototype 中:String.prototype、Number.prototype 和 Boolean.prototype。它们提供给我们操作字符串、数字和布尔值的方法然后消失

注意原始数据类型只有 undefined 和 null 没有包装器对象。所以它们没有方法和属性。并且它们也没有相应的原型

另外内建原型可以被修改或被用新的方法填充。但是不建议更改它们

原型链图解析

image.png

现在理解原型相关概念后,我们来看下这个图,简单分三部分

  • 上面绿色箭头相关部分
// Foo 是个构造方法
function Foo() {}
// f1 f2 是 Foo 这个构造函数 创建的对象实例
let f1 = new Foo()
let f2 = new Foo()
// 它们的 [[Prototype]] 或者说 __proto__ 指向 Foo.prototype 这个原型对象
console.log(f1.__proto__ === Foo.prototype) // true

// 默认的 prototype 是一个只有属性constructor 的对象,属性 constructor 指向函数自身。
console.log(Foo === Foo.prototype.constructor)
  • 中间蓝色箭头相关部分
// o1 o2 是普通对象创建方式可以这样,实际一个效果
let o1 = new Object()
let o2 = {}
// 其中 Object 就是一个 内建的对象构造函数
// 其自身的 prototype 指向一个带有 toString 和其他方法的一个巨大的对象。

//  类似的 他们的原型 就是 Object.prototype
console.log(o1.__proto__ === Object.prototype) // true
console.log(o2.__proto__ === Object.prototype) // true

// Object.prototype 上方的链中没有更多的 [[Prototype]]
console.log(Object.prototype.__proto__ === null) // true

// 原型对象的 constructor 属性还是指向构造函数自身。
console.log(o2.__proto__.constructor === Object) // true
  • 下方红色箭头相关部分
// 只要是个函数 他的__proto__都是指向 Function.prototype
// 可以理解为函数是 new Function() 出来的对象实例
console.log(Foo.__proto__ === Function.prototype) // true
console.log(Object.__proto__ === Function.prototype) // true
console.log(Function.__proto__ === Function.prototype) // true

相信这张图,你现在看起来会觉得异常清晰。

另外面试中常考的相关手写方案请看这篇 前端面试必刷手写题系列[4],是关于 instanceofnew 关键字手写实现

理解原型之后,我们下篇再说Javascript中的继承[核心概念]


继续下去,你总会有收获。 上面这句话给你们,同样也给我自己前进的动力。

我是摩尔,数学专业,做过互联网研发,测试,产品
致力用技术改变别人的生活,用梦想改变自己的生活
关注我,找到自己的互联网思路,踏实地打牢固自己的技术体系
点赞、关注、评论、谢谢
有问题求助可私信 1602111431@qq.com 我会尽可能帮助你,也可以交个朋友

参考