一些废话
刚接触js时,都说原型链是js中最难的部分,看完教程,不以为然。直到一年多之后,仍然被问到很多答不上来和答错的问题,终于又感觉到,曾经“我以为”的东西,或许并非“我以为”的样子。经过多次的原来如此的“恍然大悟”之后,终于有了一个相对系统的认识,但即使是多年之后的现在,依然会有一些问题,令我困惑。
要讲清楚原型链的基本原理,其实只需两三句话。但要理解它,首先要对js的对象、函数、数据类型等有深入的理解和认识,对于初学者来说,大部分内容都在短时间内学完,基础并不扎实,所以对于比较抽象的理论,都觉得有些难。另一方面,大部分初级工程师的大部分工作,都是在搭好的架子下填充业务代码,对于他们来说,多背几个API才是提高工作效率的最佳方式,原型继承的设计和应用离他们相去甚远,一些没有实践的抽象理论,注定无法深入理解,也注定被快速遗忘,是以很多人觉得,原型链很难,同时并没有什么实用价值,这是认识上的误区。废话有点多了,言归正传,下面从什么是原型链说起。
一、 什么是原型链
对于一些没有明确定义,又很难一句话全面概况的诸如“xxx是什么”这样的问题其实是挺蛋疼的,就像遇到外星人问你“什么是筷子”一样,你可以说那是一种用来吃饭的工具,但这种定义是不准确,不全面的。如果遇到一些2B属性的面试官,也经常会被问到“什么是原型链”这样的问题。曾经为了防备,也曾对原型链的概念进行过归纳定义:原型链是指JS中由各级对象的__ proto__属性连续继承实现的链式结构,保存了对象的共有属性和方法,控制着对象属性的使用顺序。这概念显然也不怎么严谨和全面,不懂的人看了会觉得蛋疼,懂的人看了更加蛋疼。
为了大概解释清楚原型链是怎么一回事,还是引用一下《JavaScript高级程序设计》里面的解释吧。
ECMAScript将原型链作为实现继承的主要方法。其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓原型链的基本概念。
本文适合有一定基础但离“牛逼”这个形容词还相去甚远的屌丝阅读,如果你连上面的解释都没看懂或者下面的所有例子不假思索就看出结果,下面的内容都不值得浪费时间。关于原型链的基本用法和解释,例子就不列举了,关于构造函数、constructor属性、prototype属性、__ proto__属性、实例对象,它们之间的逻辑关系,也不画图了(因为随便百度下都能查到海量的内容,大同小异,甚而千篇一律)。下面主要通过一些例子,总结下初级前端普遍存在的认识偏差问题,还有一些我也不懂怎么解释的问题。
二、一些例子
var a = 300;
function Fn1(){
this.a = 100;
this.b = 200;
return function(){
console.log(this.a); // ?
}.call(arguments[0]);
}
function Fn2(){
this.a = new Fn1();
this.name = "Cindy";
}
function Fn3(){
this.age =16;
}
/******* 第1类 ********/
var a = new Fn1().b; // 问题1 输出?
var v = new Fn1(Fn2()); // 问题2 输出?
v.constructor === Fn1; // 问题3 ? // true
Fn1.prototype.constructor === Fn1; // 问题4 ?
Fn1.prototype instanceof Fn1; // 问题5 ?
Fn1.prototype.constructor = null;
new Fn1().constructor === null; // 问题6 ?
/******* 第2类 ********/
Fn3.age = 22;
Fn3.name = "Andy";
Fn3.prototype.age = 22;
Fn3.prototype.name = "Andy";
var f3 = new Fn3();
f3.age === 22; // 问题7 ?
f3.name === "Andy"; // 问题8 ?
f3 instanceof Fn3 === f3 instanceof Object; // 问题9 ?
Fn3.prototype = new Fn2();
f3.name === Fn3.name; // 问题10 ?
f3 instanceof Fn3; // 问题11 ?
/******* 第3类 ********/
Date.__proto__ === Function.prototype; // 问题12 ?
Date.constructor == Function; // 问题13 ?
Function.__proto__ === Function.prototype; // 问题14 ?
Function.constructor === Function; // 问题15 ?
typeof Date.prototype; // 问题16 ?
typeof Function.prototype; // 问题17 ?
typeof Object.prototype; // 问题18 ?
Object.__proto__ === Function.prototype; 问题19 ?
Object.constructor == Function; // 问题20 ?
Function.prototype.__proto__ === Object.prototype; // 问题21 ?
Object.prototype === Object.__proto__.__proto__; // 问题22 ?
Object.prototype.__proto__; // 问题23 ?
Function.prototype.prototype; // 问题24 ?
typeof Object.prototype.toString; // 问题25 ?
Object.prototype.toString.call(Object.prototype.toString); // 问题26 ?
Object.prototype.toString.prototype; // 问题27 ?
Object.prototype.toString.__proto__.prototype; // 问题28 ?
第1类
问题1、问题2比较简单,但涉及的知识点很多,如下:
实例化一个对象发生的事情:大概可以分为三步,第一创建一个空对象obj,第二将这个空对象的__ proto__属性指向构造函数对象的prototype成员对象,第三将构造函数的作用域赋给新对象并调用构造函数。
构造函数中如果返回一个应用类型的对象(普通对象、函数、数组),则不再创建新对象,直接返回该对象,例子如下:
function foo(name) {
this.name = name;
return [1,2,3,4]
}
console.log(new foo('cindy')); // 'Array(4) [1, 2, 3, 4]'
- 第2种情况,如果返回的是一个立即执行的匿名函数,则仍然会创建新对象。匿名函数的this指向是谁调用它,它指向谁。下面代码匿名函数指向window,alert的是 Andy。
var name = 'Andy';
function foo(name) {
this.name = name;
return (function(){alert(this.name)})()
}
console.log(new foo('cindy')); // 'Andy' '{name: "cindy"}'
- call方法中,如果不传参数或者第一个参数是 null/undefined 时,this 的指向为全局对象,在浏览器宿主环境指 window。 构造器 Fn1 中返回的函数加了call方法,相当于一个立即执行的匿名函数,所以new Fn1() 时还是会创建新对象。问题1、问题2中Fn1的arguments[0]是undefined,返回的方法执行时, this指向 window,问题1、问题2打印的都是window.a,问题1中值是300,问题2中值是200。
var a = new Fn1().b; // 问题1 输出 300
var v = new Fn1(Fn2()); // 问题2 输出 200 {a: 100, b: 200}
问题3-6,都是 constructor 的指向问题。问题3,实例的 constructor 指向构造函数,没毛病,true;问题4,构造函数的原型对象的 constructor 属性指向当前构造函数,true;问题5,构造函数的原型对象并非当前构造函数的实例,false;问题6,实例中自身并没有 constructor 属性,实例对象的 constructor都是通过继承而来的,改变了原型中的 constructor 指向,实例中 constructor 属性会动态改变,false。
v.constructor === Fn1; // 问题3 true
Fn1.prototype.constructor === Fn1; // 问题4 true
Fn1.prototype instanceof Fn1; // 问题5 false
Fn1.prototype.constructor = null;
new Fn1().constructor === null; // 问题6 {a: 100, b: 200} true
知识点误区:
1. 构造函数的原型对象 (如Fn1.prototype指向的对象)是当前构造函数的实例
网上很多文章这样说,有的可能是为了便于读者理解其他问题,有的可能是没有深入去理解。其实除了 constructor 属性指向当前构造函数,Fn1.prototype不具备Fn1实例的一切特点,连 instanceof 检测都通不过。按我的理解,构造函数的原型对象是 Object 的实例(Fn1.prototype.__ proto__指向Object.prototype),是一个普通对象。但如果是Object 的实例,Fn1.prototype.constructor应该指向 Object,我的理解是(不知道实际是不是)在创建 Fn1 的时候,预定义了 Fn1.prototype 并改变了其 constructor 的指向,目的是便于Fn1 的实例能够沿着原型通过 constructor 找到 Fn1。
2. 每个对象都有一个预定义属性 constructor,指向构造函数
为了便于理解,很多文章和教材都这样说,但通过问题6可以很明显的看出,普通对象的 constructor 属性是继承而来的,并非自身属性。这个认识的偏差,有时候也会产生很多问题。
第2类
问题7-11,主要说明以下几个问题。
js中,函数也是对象,可以往里面添加/修改属性和方法,但这对它的实例没有直接影响(修改了构造函数的 prototype 属性除外)。在继承中,永远是自有属性的优先级大于继承属性,f3有自有属性age,也有继承于原型对象的age,直接读取f3.age,输出自有属性。Fn3.age = 22语句对实例 f3 没有影响,Fn3.prototype.age = 22读取优先级较低。所以,f3.age依然是16,问题7为false。
根据1可以总结出,继承存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。问题8中方f3.name继承了 Fn3.prototype.name,true。
instanceof 操作符主要用于判断默认情况下(未修改原型对象或改变构造函数prototype的指向),一个对象是否是某个构造函数的实例。判断的方法是检测构造函数的prototype属性是否出现在实例对象的原型链中的任何位置。问题9中Fn3和Object的prototype属性都存在f3中,true。问题11,改变Fn3的prototype属性指向后,f3和Fn3的 instanceof 关系不复存在。因此,这种检测方法是很不严谨的。
问题10中,f3.name 依然是 Andy,Fn3.prototype只是一个指针,指向构造函数的原型对象,这个对象在f3实例化的时候已经绑定了,通过Fn3.prototype修改原型对象的属性,可以让实例实现动态继承,但直接修改 Fn3.prototype 的指向,并不会改变已经实例化的对象的__ proto__属性的指向,但之后实例化的对象会指向新的原型。函数对象中的name属性为保留属性,不可修改,所以问题10中 "Andy" === "Fn1",false。
Fn3.age = 22;
Fn3.name = "Andy";
Fn3.prototype.age = 22;
Fn3.prototype.name = "Andy";
var f3 = new Fn3();
f3.age === 22; // 问题7 false
f3.name === "Andy"; // 问题8 true
f3 instanceof Fn3 === f3 instanceof Object; // 问题9 true
Fn3.prototype = new Fn2();
f3.name === Fn3.name; // 问题10 false
f3 instanceof Fn3; // 问题11 false
有一种说法,对象主要通过__ proto__属性而非prototype实现继承的,有一定的合理性,但也忽视了prototype属性在原型链中的作用,__ proto__是一个通道,但是它最初是通过prototype才找到原型对象的,并且prototype一直拥有修改原型对象的权利。我的理解,__ proto__是承上,prototype是启下,少了任何一环,都形成不了原型链。
第3类
第3类主要想弄清楚JS中一堆大佬的资历和伦理问题。在ES6标准中,JS有12大内置对象,分别是String、Number、Boolean 、Array、Date、RegExp、Math、Error、Function 、Object、Global (在浏览器中被替换为Window)、JSON。这其中,Global不可访问,Window不是ES标准,暂不讨论。余下的11大内置对象,除了Math,JSON是以 object 类型存在外,其他都是 function 类型的内置构造器,意味着可以通过new操作符实例化,比如 Object 是一切对象的祖宗(proto指针的顶端),Function是一切函数的祖宗(constructor 的顶端),它们在js中,是骨灰级的存在。
问题12-15结果都为true,一句话总结:所有的构造器都是Function的实例,包括根构造器 Object 及 Function 自身。所有构造器都继承了 Function.prototype 的属性及方法。如length、call、apply、bind等(这里仅举例其中两个,其它的可以自己测试)。
Date.__proto__ === Function.prototype; // 问题12 true
Date.constructor == Function; // 问题13 true
Function.__proto__ === Function.prototype; // 问题14 true
Function.constructor === Function; // 问题15 true
问题16-18,结果为"object","function","object",一句话总结:除了Function.prototype,其他所有构造函数的原型对象皆为"object"类型,可以自己试下。按常规理解,原型对象都应该是普通对象(object类型),不应该是函数对象。具体为什么 Function.prototype 是函数对象,我也不理解,只能先记着了,如果你知道,一定要告诉我。
typeof Date.prototype; // 问题16 object
typeof Function.prototype; // 问题17 function
typeof Object.prototype; // 问题18 object
问题19-22,问题19、问题20说明 Object 是 Function 的实例,继承了 Function 的原型对象的方法;问题21说明 Function 的原型对象是 Object 的实例,继承了 Object 的原型对象的方法,问题22是前面几个的等式替换,继承到最后,Object 只能继承它自己的原型对象。
Object.__proto__ === Function.prototype; // 问题19 true
Object.constructor == Function; // 问题20 true
Function.prototype.__proto__ === Object.prototype; // 问题21 true
Object.prototype === Object.__proto__.__proto__; // 问题22 true
问题23-24,终极问题,Function 的原型对象没有原型对象,Object 的原型对象的proto属性指向null。原型链至此到了最顶端。
Object.prototype.__proto__; // 问题23 null
Function.prototype.prototype; // 问题24 undefined
问题25-28是自己思考的一些问题和疑惑:很多文章和教材都说过,任何函数都可以作为构造函数使用,函数对象都有 prototype 属性。《JavaScript高级程序设计》和《JavaScript权威指南》也有相同或类似的表述。对于自定义的函数来说,这个结论没有毛病,但对于所有预定义的API函数,也包括上面讨论的 Function.prototype 这种类型的函数,都没有prototype属性,也无法作为构造器使用。
typeof Object.prototype.toString; // 问题25 function
Object.prototype.toString.call(Object.prototype.toString); // 问题26 "[object Function]"
Object.prototype.toString.prototype; // 问题27 undefined
Object.prototype.toString.__proto__.prototype; // 问题28 undefined
三、 总结和思考:另一些废话
JS的特点
JS是一门很松散的语言,很多东西可以这样写,也可以那样写。同样的功能,有的人写出来是水,有的人是冰,有的人是雪花,拘谨的人嫌弃它的随意,随意的人觉得它灵活。也正由于它的不严谨,弄出好些诸如 typeof null 为 object 这样的问题。永远不要说自己很精通JS(面试的时候除外),很多问题或许连设计者都未曾预见。随着学习的深入,或许有一天,你会推翻自己总结的所有结论,因为总有特例。诸如上面任何函数都可以作为构造函数使用,函数对象都有 prototype 属性这个结论,可以覆盖几乎所有我们可能用作构造函数的函数,但作为命题,只要找出一个特例就可以推翻。
关于学习
关于这个问题,从设计者的角度来说,预定义的API也的确不应该允许当做构造函数使用,实际应用中,应该也没有人会做 var obj = new Function.prototype.slice() 这样的操作,总结的时候也很难想到这种情况。即使会被推翻,我觉得仍然应该总结,不总结就很难有提高,总结而不纠结,并保持思考和钻研是我认为学习者应有的态度。
上面的这些例子,从常规思维来说,很多人会觉得有病才会思考这样的问题。但如果你去腾讯、阿里等大公司面试,你会发现问的全部都是这一类问题,不管哪个模块的知识点,问题要么偏,要么深,要么很特例。或许在他们眼里,只会答常规问题的人,根本没资格面试。
JS中的矛盾
任何问题,追根究底,都会有矛盾,如宇宙的起源,时间的始终,哲学上说矛盾贯穿一切事物的始终。JS中也有很有意思的矛盾问题:
对象是怎么来的?
由构造函数实例化出来的。
构造函数哪来的?
由更高级的构造函数实例化出来的。
最高级的构造函数是?
Function。
Function哪来的?
它自己把自己构造出来的。(笑哭)
还有:
Object的本质是什么?
是一个构造函数。
构造函数的祖先是谁?
Function。
Function是不是一个对象?
是。
对象的祖先是不是Object?
是。
让我们来想一下,天地混沌之中,不知道如何就产生了一个叫 Function 的东西,生了一个很厉害的儿子叫 Object,他们共同创造了众神、众生。最后 Function 发现, Object 是它的祖先。以前看过一个讲原型链的视频教程,老师把原型对象比作爹,把构造函数比作妈,最后又说实例的爹是实例它妈生的,所以JS是乱伦的,哈哈哈。
++++++++++++++++++++++++++ 完整答案 ++++++++++++++++++++++++++++++++++++
var a = 300;
function Fn1(){
this.a = 100;
this.b = 200;
return function(){
console.log(this.a);
}.call(arguments[0]);
}
function Fn2(){
this.a = new Fn1();
this.name = "Cindy";
}
function Fn3(){
this.age =16;
}
/******* 第1类 ********/
var a = new Fn1().b; // 问题1 输出 300
var v = new Fn1(Fn2()); // 问题2 输出 200 {a: 100, b: 200}
v.constructor === Fn1; // 问题3 // true
Fn1.prototype.constructor === Fn1; // 问题4 ? true
Fn1.prototype instanceof Fn1; // 问题5 false
Fn1.prototype.constructor = null;
new Fn1().constructor === null; // 问题6 {a: 100, b: 200} true
/******* 第2类 ********/
Fn3.age = 22;
Fn3.name = "Andy";
Fn3.prototype.age = 22;
Fn3.prototype.name = "Andy";
var f3 = new Fn3();
f3.age === 22; // 问题7 ?false
f3.name === "Andy"; // 问题8 true
f3 instanceof Fn3 === f3 instanceof Object; // 问题9 true
Fn3.prototype = new Fn2();
f3.name === Fn3.name; // 问题10 false
f3 instanceof Fn3; // 问题11 false
/******* 第3类 ********/
Date.__proto__ === Function.prototype; // 问题12 true
Date.constructor == Function; // 问题13 true
Function.__proto__ === Function.prototype; // 问题14 true
Function.constructor === Function; // 问题15 true
typeof Date.prototype; // 问题16 object
typeof Function.prototype; // 问题17 function
typeof Object.prototype; // 问题18 object
Object.__proto__ === Function.prototype; 问题19 true
Object.constructor == Function; // 问题20 true
Function.prototype.__proto__ === Object.prototype; // 问题21 true
Object.prototype === Object.__proto__.__proto__; // 问题22 true
Object.prototype.__proto__; // 问题23 null
Function.prototype.prototype; // 问题24 undefined
typeof Object.prototype.toString; // 问题25 function
Object.prototype.toString.call(Object.prototype.toString); // 问题26 "[object Function]"
Object.prototype.toString.prototype; // 问题27 undefined
Object.prototype.toString.__proto__.prototype; // 问题28 undefined