之前一直不理解 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)