前言
最近在复习js基础,因为工作以后基本上没用过,天天都是拿起框架加油干,确实大部分都忘了。到了原型和原型链这一部分,觉得自己理解的比较模糊。又翻阅了《你不知道的javascript》、阮一峰老师的还有网络上的各种文章,收获满满(感谢各位作者大佬)。所以整理成这篇文章,加深自己的印象,也希望对大家有所帮助。
文章收录在作者代码库,主要是个人学习的代码以及文章,觉得有帮助可以点个小星星,会持续更新。
另外也希望大家可以支持一下我的开源作品,这是。感谢!
思维导图
不太了解原型链的同学可能会觉得有点乱,没关系,看完文章再回过头来看,就很清晰了。
Prototype
众所周知,在JavaScript中,可以通过关键字new调用构造函数来创建一个实例对象。
function Person(name){ this.name = name; this.say = function () { console.log(this.name); } } let lisi = new Person('lisi'); let liwu = new Person('liwu'); lisi.say() // lisi liwu.say() // liwu console.log(lisi.say === liwu.say); // false复制代码
可以看出,lisi和liwu都有say
这个方法,但是这两个方法并不是同一个。也就是说在创建对象的时候,每个实例对象都会有一套自己的属性和方法。很显然,这样造成了资源浪费。
这时候我们想,如果可以让实例对象引用同一个属性或方法就好了。所以JavaScript的作者引入了原型对象[Prototype]来解决这个问题。原型对象上有两个默认属性,constructor
和 __proto__
(下文会详细讲)。
function Person(name){ this.name = name; } Person.prototype.say = function () { console.log(this.name); } let lisi = new Person('lisi'); let liwu = new Person('liwu'); console.log(lisi.say === liwu.say); // true console.log(lisi.hasOwnProperty('say'), liwu.hasOwnProperty('say')); // false false复制代码
这个时候可以看到,构造的新的实例对象都有say
方法,但是hasOwnProperty('say')
返回的结果却是false。这说明实例对象自身是没有say
方法的,之所以可以使用.say
的方式来调用,是因为在使用.
语法调用对象方法的时候会触发对象自身的[get]操作。
[get]操作会优先查找自身的属性,没有找到则会通过原型链来逐级查找上级的原型对象,直到js顶层的Object对象。所以此处可以说明实例对象会继承构造函数的原型对象上的属性和方法。
但是正因为如此,我们需要注意的是:因为原型对象的属性和方法是会被所有实例对象继承的,所以使用的时候要慎重考虑该属性或方法是否适合放在原型对象上。比如Person有一个age属性:
Person.prototype.age = 18; console.log(lisi.age, liwu.age); // 18 18 Person.prototype.age = 20; console.log(lisi.age, liwu.age); // 20 20复制代码
因为age属性是引用的Person的原型对象上的,所以原型对象上的属性值改了,所有的实例对象相应的属性值都会改动。这时候我们就不得不考虑,是否有必要将age属性放在原型对象了,毕竟鲁迅曾经说过:‘每个人都是都一无二的’。
强行插图,哈哈哈!我们再来看下面这种情况:
lisi.say = function() { console.log('oh nanana'); }; lisi.say(); // oh nanana liwu.say(); // liwu console.log(lisi.hasOwnProperty('say'), liwu.hasOwnProperty('say')); // true false复制代码
这是为什么呢,其实和之前类似,是因为.
语法在赋值的时候触发了对象的[set]方法,所以会给lisi自身加上一个say
方法。而在调用方法时,最先找到自身的say方法调用,输出oh nanana
。因为操作都是在lisi这个对象本身,所以对liwu
没有影响。
constructor
constructor 即为 构造函数,构造函数其实和普通的函数没有什么区别,对构造函数使用new运算符,就能生成实例,并且this变量会绑定在实例对象上。
对于Person来讲,会有prototype属性指向它的原型对象,而在Person.prototype上又有constructor属性指向它对应的构造函数,所以这是一个循环的引用。大概是这样:Person -> Person.prototype —> Person.prototype.constructor -> Person
。
console.log(Person.constructor === Person.prototype.constructor) // true console.log(Person.prototype.constructor === Person) // true console.log(Person.hasOwnProperty('constructor')) // false复制代码
从这里可以看出,Person自身是没有constructor属性的,之所以可以使用,是因为从它的原型对象上继承了constructor属性。
延用上面的栗子,我们在加点东西:
function Chinese() { this.country = '中国'; } Person.prototype = new Chinese(); let lisisi = new Person('lisisi'); console.log(lisi.country, lisisi.country); // undefined 中国复制代码
在这个栗子中,我们将Person.prototype整体赋值成了Chinese的实例对象。注意,是赋值的实例对象,不是构造函数。上面打印结果是lisisi
有country属性,这个我们好理解,因为lisisi
继承了Person.prototype,而Person.prototype被我们赋值成了Chinese的实例对象,自然会继承Chinese实例对象的country属性。
但是lisi
为什么没有country属性呢,之前改得say
方法明明受影响啊。我们打印出lisi和lisisi的完整结构来看一下:
可以看到,其实是因为我们将Person.prototype整体替换成了Chinese实例对象,相当于改变了Person.prototype的地址,但是lisi在实例化的时候,引用的是之前的Person.prototype地址,这两者之间没有联系,自然不会有影响。而之前的say
方法是用Person.prototype.say的形式改的,lisi
继承的依旧是同一地址上的say
方法,所以会受影响。
这个例子之所以放在这里讲,而不是prototype那里,是因为这个方法会有一点副作用,将Person.prototype整体赋值成了Chinese的实例对象,会导致原来的constructor属性也被覆盖掉。
console.log(lisisi instanceof Person); // true console.log(Person.prototype.isPrototypeOf(lisisi)); // true console.log(Object.getPrototypeOf(lisisi)); // Chinese {country: "中国"} // instanceof做的事是判断在`lisisi`的整条[Prototype]链中是否有指向 Person.prototype 的对象。 // isPrototypeOf做的事是判断在`lisisi`的整条[Prototype]链中是否出现过 Person.prototype。 // 它们的区别在于前者要访问构造函数,后者直接访问原型对象。 console.log(lisisi.__proto__ === Person.prototype); // true // __proto__指向实例对象对应的原型对象,但不一定是其构造函数的原型对象,因为prototype可以修改 console.log(lisisi.constructor === Chinese); // true 复制代码
从上可以看出,虽然lisisi
继承的依然是的Person.prototype
,但是由于Person.prototype指向了Chinese的实例对象。所以,这个时候lisisi
的constructor已经不是Person了,而是继承了Chinese实例对象的constructor,也就是构造函数Chinese。为了解决这个问题,我们需要手动修正constructor的指向。
Person.prototype = new Chinese(); Person.prototype.constructor = Person; let lisisi = new Person('lisisi'); console.log(lisisi.constructor === Person); // true复制代码
从这个栗子也可以说明,使用引用类型的constructor是并不安全的,因为他们可以修改。不过基础类型的constructor都是只读的,都指向对应基础类型构造函数。
let a = 'oh nanana', b = 0, c = true; console.log(a.constructor, b.constructor, c.constructor); // ƒ String() { [native code] } ƒ Number() { [native code] } ƒ Boolean() { [native code] } a.constructor = {}; b.constructor = {}; c.constructor = {}; console.log(a.constructor, b.constructor, c.constructor); // ƒ String() { [native code] } ƒ Number() { [native code] } ƒ Boolean() { [native code] }复制代码
proto
实例对象有__proto__ 属性,指向实例对象对应的原型对象,即lisi.__proto__ === Person.prototype
。但是直接用.__proto__
的写法来设置原型对象的写法是不被赞同的,因为这样还会有除了性能消耗以外的问题。MDN中这样说到:
由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,更改对象的 [[Prototype]]在各个浏览器和 JavaScript 引擎上都是一个很慢的操作。其在更改继承的性能上的影响是微妙而又广泛的,这不仅仅限于 obj.proto = ... 语句上的时间花费,而且可能会延伸到任何代码,那些可以访问任何[[Prototype]]已被更改的对象的代码。如果你关心性能,你应该避免设置一个对象的 [[Prototype]]。相反,你应该使用 Object.create()来创建带有你想要的[[Prototype]]的新对象。
在《你不知道的JavaScript》中说到,__proto__
的本质其实更像是getter/setter
,大致实现为:
Object.defineProperty( Object.prototype, "__proto__", { get: function() { return Object.getPrototypeOf( this ); }, set: function(o) { // ES6 中的 setPrototypeOf(obj, prototype) 设置原型对象 Object.setPrototypeOf(this, o ); return o; } } );复制代码
何为原型链
现在我们知道,实例对象的__proto__
属性指向其对应的原型对象。而在原型对象prototype
上又有constructor
和__proto__
属性,此时的__proto__
又指向上级对应的原型对象,最终指向Object.prototype
, 而Object.prototype.__proto__ === null
。这就构成了原型链,而原型链最终都是指向null。
还是来看个栗子:
function Person(name){ this.name = name; } let lisi = new Person('lisi');复制代码
在这个栗子中可以找到两条原型链,我们逐一来看。
- 第一条:首先,
lisi.__proto__ === Person.prototype
,而原型对象也是对象,所以Person.prototype.__proto__ === Object.prototype
,最后,Object.prototype.__proto__ === null
。即:
lisi.__proto__.__proto__.__proto__ === null;复制代码
- 第二条:Person这个函数对象的
__proto__
指向的应该是它的构造函数对应的原型对象,Person.__proto__ === Funcion.prototype
,然后Funcion.prototype.__proto__ === Object.prototype
,最后一样都回到null。即:
Person.__proto__.__proto__.__proto__ === null;复制代码
到这里,相信你已经可以理解文章开头的那张图了。
new方法做了什么
文章中创建实例对象是通过new 运算符。new命令的作用,就是执行构造函数,返回一个实例对象。
那么在执行new操作的过程中到底做了哪些事呢?我们可以看到,new 操作返回的实例对象具有两个特征:
- 具有构造函数中定义的this指针的属性和方法
- 具有构造函数原型上的属性和方法
于是我们大概可以知道,使用new 命令时它所执行的几个步骤:
- 创建一个空对象,并将这个空对象的
__proto__
,指向构造函数的原型对象[prototype],使其继承构造函数原型上的属性。 - 改变构造函数内部this指针为这个空对象(如果有传参,需要将参数也导入构造函数)
- 执行构造函数中的代码,使其具有构造函数this指针的属性。
所以我们可以简单模拟实现一个具有new命令功能的函数。
function newObj() { let o, f = [].shift.call(arguments); // 取出参数的第一个成员,即构造函数 o = Object.create(f.prototype); // 创建一个继承了构造函数原型的新对象 f.call(o, ...arguments); // 执行构造函数使得新对象获取相应属性 return o; } let zs = newObj(Person, 'zs'); console.log(zs instanceof Person); // true复制代码
我们打印一下zs实例对象:
可以看出zs是继承了Person的原型的,但是还有一个需要注意的点:假如构造函数return了一个对象的话,new 命令会优先返回构造函数return的对象。如果是其他类型的数据,则会忽略,和没有返回值(函数默认返回undefined)是一样的。这里就不再举例,感兴趣的伙伴可以自己实践一下,也有助于理解。
总结
- 原型链,其中又包括
prototype
、constructor
、__proto__
这几个知识点,比如原型的继承、__proto__
的原理、constructor的指向这些都是面试题中的熟面孔。 - new命令所做的几件事、new实例化的对象具有的特点以及模拟实现new命令的函数。
相关文章
由衷感谢这些文章的作者。
- 《你不知道的JavaScript》
交流群(960807765)
前端交流群,欢迎各种技术交流,期待你的加入
后记
如果你看到了这里,且本文对你有一点帮助的话,希望你可以动动小手支持一下作者,感谢?。文中如有不对之处,也欢迎大家指出,共勉。
- 文章代码库
- 作者开源作品