原型与原型继承

之前一直不理解 JavaScript 中的原型链机制,最近读过《你不知道的 JavaScript(上卷)》后终于有点理解了,在这里记录一下。

什么是原型

JavaScript 中所有对象都有一个[[Prototype]]内置属性,Python 中的对象中也有类似的默认内置属性(通过print(dir(Object))可以查看)。

JS 与 Java 在 OOP 上的区别就在于:ES5 中没有类(ES6 和 TS 支持 class),只有对象,所有的对象都是对象与对象的关系,而 OOP 中强调的是通过类的模板语法实现类与类的关系。

因此从语言设计上来说,为了实现 OOP 原本属于 class 的职责在 JS 中就交给 Object 来做,这就是对象原型(Prototype)。

原型调用的示例

var myObject = { a: 2 };
console.log(myObject.a); // 2

当访问对象属性时会触发[[Get]]操作,首先判断该对象是否有此属性,有的话就使用。

但当 a 不在myObject中,仍要使用时就会调用原型链。

var anotherObject = { a: 2 };
var myObject = Object.create(myObject);

console.log(myObject.a); // 2

该代码表明,当myObject没有 a 属性时,会沿其原型链往上查找,直到有这个属性并返回;若没有这个属性,会查找整条原型链并返回 Undefined。原型链顶端是 Object.prototype。

属性设置与屏蔽

myObject.foo = "bar";
  • 如果 foo 是 myObject 的属性,这句可以直接赋值
  • 如果 foo 不是 myObject 的属性,这句会触发原型链的遍历。原型链上如果不存在 foo,这个属性会添加到 myObject 上
  • 如果 foo 是 myObject 的属性,且原型链上层也有对象包含 foo 属性,那么会触发属性屏蔽,bar 赋值只会影响到 myObject,不影响原型链上层所有含 foo 属性的对象。
  • 如果 foo 不是 myObject 的属性,但原型链上层含有 foo 属性,则可能产生以下几种情况:
    • Prototype链上层存在 foo 并且该属性未被标记为只读("writable:true")则会直接在 myObject 上添加 foo 的新属性
    • Prototype链上层存在 foo 并且该属性被标记为只读("writable:false"),那么无法修改已有属性或在 myObject 上创建屏蔽属性。正常模式下会忽略该句,在严格模式下会抛出错误。也就是说不会发生屏蔽。
    • 如果在[[Prototype]]上层存在 foo 且为一个 setter,那就一定会调用这个 setter,foo 不会被添加到 myObject 上,也不会重定义这个 setter。
var anotherObject = { a: 2 };
var myObject = Object.create(anotherObject);

anotherObject.a; // 2
myObject.a; // 2

anotherObject.hasOwnProperty("a"); // true
myObject.hasOwnProperty("a"); // false

myObject.a += 1; // 属性屏蔽

anotherObject.a; // 2
myObject.a; // 3

myObject.hasOwnProperty("a"); // true

new 关键字与原型继承

function Foo() {
  /* ... */
}
var a = new Foo();

这个语句做了什么?

a 对象是在调用new Foo()时创建的,它的 prototype 会被关联到 Foo.prototype 上。

Object.getPrototypeof(a) === Foo.prototype; // true

也就是说,与 Java 在调用 new 时会产生 class 的实例不同,JavaScript 调用 new 直接产生相关联的对象,这些对象的 prototype 全部指向同一个对象。

但以上原型关联只是 new 的一个副作用,new 的本意并不是直接创建关联。直接实现该效果的函数为Object.create(obj)

这个机制被称为原型继承,但与 Java OOP 的继承不同的是,Java 中继承就是将 Class 中的属性、方法复制到实例中;而 JS 原型继承没有复制,只有与原型链的关联。所以从意义上来说原型继承和继承实际没有任何关系。。

构造函数

以下所有示例代码为一整段拆分

function Foo() {}
console.log(Foo.prototype.constructor === Foo); // true

var a = new Foo();
console.log(a.constructor === Foo); // true
console.log(a.constructor); // [Function: Foo]

new Foo()语句调用了函数中的Foo.prototype.constructor方法

但 a 没有自己的 constructor

var b = new a(); // Uncaught TypeError: a is not a constructor

var c = Object.create(a);
console.log(c.constructor); // function Foo(){ }

这个示例就是 new 调用 a 报错因为 a 没有构造函数,而 b 企图把原型绑定到 a 上。

但 c 是从 a 创建来的对象,它的原型也被绑定到 Foo 上而不是 a 上,所以正确运行。

小结: 在普通函数前面使用 new,会把该函数变成一个“构造函数调用”,new 会劫持所有普通函数并用构造函数的形式来调用它。

Foo.prototype = { a: 2 }; // 此处更改了Foo.prototype的默认实现
var b = new Foo();
// Foo原型对象更改
console.log(b.constructor === Foo); // false
// b.constructor被指向Object而不再是Foo
console.log(b.constructor); // [Function: Object]

/* 此处将constructor重新指向Foo.prototype */
Object.defineProperty(Foo.prototype, "constructor", {
  enumerable: true,
  writable: true,
  configurable: true,
  value: Foo,
});

var c = new Foo();
console.log(c.constructor === Foo); // true
console.log(c.constructor); // [Function: Foo]

因此,a.construtor是不可靠且不安全的引用,实际使用中应该避免这种写法。

call 与 apply

定义

apply 和 call 都是 JS 函数对象的原型方法, 因此我们可以在任何的函数调用这两个方法, 主要作用就是使得对象能够调用到本来不属于它的方法(就是对象的本领)

举例:

/* cat有run()方法,dog有eat()方法 */
const cat = {
  name: "cat",
  run() {
    console.log(this.name + " can run");
  },
};
const dog = {
  name: "dog",
  eat() {
    console.log(this.name + " eat bones");
  },
  count(a, b) {
    console.log(`${this.name}:${a} + ${b} = ${a + b}`);
  },
};

cat.run(); // cat can run

// 此时dog没有run方法,但dog也想调用run怎么办呢
cat.run.apply(dog); // dog can run
// 同理
dog.eat.apply(cat); // cat eat bones

由这个例子可以得出,apply 函数(还有后面的 call 函数)是将一个对象已有的函数给另一个对象调用,同时改变了this的指向

联系与区别

  • 共同之处:都可以用来代替另一个对象调用一个方法,将一个函数的对象上下文从初始的上下文改变为由 thisObj 指定的新对象。
  • 不同之处:
    • apply:最多只能有两个参数——新 this 对象和一个数组 argArray。如果给该方法传递多个参数,则把参数都写进这个数组里面,当然,即使只有一个参数,也要写进数组里。如果 argArray 不是一个有效的数组或 arguments 对象,那么将导致一个 TypeError。如果没有提供 argArray 和 thisObj 任何一个参数,那么 Global 对象将被用作 thisObj,并且无法被传递任何参数。
    • call:它可以接受多个参数,第一个参数与 apply 一样,后面则是一串参数列表。这个方法主要用在 js 对象各方法相互调用的时候,使当前 this 实例指针保持一致,或者在特殊情况下需要改变 this 指针。如果没有提供 thisObj 参数,那么 Global 对象被用作 thisObj。

    实际上,apply 和 call 的功能是一样的,只是传入的参数列表形式不同。
/* apply()方法 */
function.apply(thisObj[, argArray]);

/* call()方法 */
function.call(thisObj[, arg1[, arg2[, [,...argN]]]]);

在设计上,call 本质是 apply 的语法糖

dog.count(1, 3); // dog:1 + 3 = 4
dog.count.apply(cat); // cat:undefined + undefined = NaN

dog.count.apply(cat, [2, 4]); // cat:2 + 4 = 6
// apply接收一个调用对象和参数表对象,即apply的参数表有两个对象
dog.count.call(cat, 2, 4); // cat:2 + 4 = 6
// call接收一个调用对象和其全部参数,即call的参数表可以有不定多个

call 与 apply 的应用

let arr = [1, 2, 19, 6];
//例子:求数组中的最值
console.log(Math.max.call(null, 1, 2, 19, 6)); // 19
console.log(Math.max.apply(null, arr)); //  19 直接可以用arr1传递进去

相关阅读:

《你不知道的 JavaScript》整理(四)——原型 - 咖啡机(K.F.J)

原型继承 - 廖雪峰的官方网站

从原型聊到原型继承,深入理解 JavaScript 面向对象精髓 - 掘金

彻底搞懂 call、apply 和 bind - 掘金