1:JavaScript基础
变量与类型
JavaScript规定了几种语言?
- 基本类型:string、undefined、null、boolean、number、Symbol、bigint
- 引用(复杂)类型:object
什么变量保存在堆/栈中?
JavaScript使用的底层数据结构是堆和栈,引用数据类型放堆,基本数据类型放栈
JavaScript内存空间分为栈(stack)空间、堆(heap)空间、代码空间。其中代码空间用于存放可执行代码
栈
栈是内存中一块用于存储局部变量和函数参数的线性结构,遵循着先进后出(LIFO/Last In First Out)的原则。栈由内存中占据一片连续的存储空间,出栈与入栈仅仅是指针在内存中的上下移动而已。
JS的栈空间就是我们所说的调用栈,是用来存储执行上下文的,包含变量空间与词法环境,var、function保存在变量环境,let、const声明的变量保存在词法环境中。
var a = 1;
function add(a){
var b = 2;
let c = 3;
return a + b + c;
}
// 函数调用
add(a)
这段代码很简单,就是创建了一个add函数,然后调用了它。 下面我们就一步步的介绍整个函数调用执行的过程。 在执行这段代码之前,JavaScript引擎会首先创建一个全局执行上下文,包含所有已声明的函数与变量:
从图中可以看出,代码中的全局变量a及函数add保存在变量环境中。
执行上下文准备好后,开始执行全局代码,首先执行a = 1的赋值操作
赋值完成后a的值由undefined变为1,然后执行add函数,JavaScript判断出这是一个函数调用,然后执行以下操作
- 首先,从全局执行上下文中,取出add函数代码
- 其次,对add函数的这段代码进行编译,并创建该函数的执行上下文和可执行代码,并将执行上下文压入栈中
- 然后,执行代码,返回结果,并且add的执行上下文也会从栈顶部弹出,此时调用栈中就只剩下全局上下文了。
至此,整个函数调用执行结束了。
上面需要注意的是:函数(add)中存放在栈区的数据,在函数调用结束后,就已经自动的出栈,换句话说:栈中的变量在函数调用结束后,就会自动回收。 所以通常栈空间都不会设置太大,而基本数据类型在内存中占有固定大小的空间,所以它们的值保存在栈空间,我们通过按值访问。它们也不需要手动管理,函数调用时创建,结束调用则消失。
堆
堆数据结构是一种树状结构。它的存取数据的方式与书架和书非常相似。我们只需要知道书的名字就可以直接取出书了,并不需要把上面的书取出来。 在栈中存储不了的数据(比如对象)就会被存储在堆中,在栈中只是保留了对象在堆中的地址,也就是对象的引用,对于这种,我们把它叫做按引用访问。 举个例子帮助理解一下:
var a = 1
function foo() {
var b = 2
var c = { name: 'an' } // 引用类型
}
// 函数调用
foo()
所以,堆空间通常很大,能存放很多大的数据,不过缺点就是分配内存和回收内存都会占用一定的时间
面试题
来两道关于堆栈的面试题拓展与加深
function foo(i){
if(i < 0){
return
}
console.log('begin' + i)
foo(i - 1)
console.log('end' + i)
}
foo(3) // 打印输出什么?
// begin3
// begin2
// begin1
// begin0
// end0
// end1
// end2
// end3
输出上述的原因是:当foo执行时,创建一个foo执行上下文放入调用栈,然后判断i是否小于0,如果不小于,则继续往下走输出begin3,这时候再次调用foo函数,再创建一个新的foo执行上下文放入调用栈。一直到最后i小于0 return之后,此时i小于0的foo执行上下文会从栈顶弹出,下一个foo执行上下文开始执行输出end(先进后出原则)
var a = { n: 1 };
var b = a;
a.x = a = { n: 2 };
console.log(a.x); // undefined
console.log(b.x); // {n:2}
易错点(错误理解):
等号运算符如果从右往左,那么不是应该先a赋值到新地址,然后a.x追加到a的新地址里吗?
纠正:
不是把a.x追加进a的新地址,而是把a的新地址赋值给a.x,而a.x是存放在a原地址中的,因为a.x的.(点操作符)优先级大于=(赋值运算符)被执行
分析(正确理解):
- 首先对a开辟一块内存空间,然后存入{n:1},然后对b赋值a的地址,此时a和b指向同一块地址
- .(点操作符)运算符优先级大于=(赋值运算符),此时先执行.(点操作符),即在a的地址内放入x属性(此处不同人有不同理解,我暂时理解为属性名不是变量,没有开辟新的空间)
- 此时才进行赋值运算,赋值从右往左,一次赋值,即把{n:2}赋值给a及a.x,此时a指向新地址{n:2},而a.x也指向新地址{n:2}
- 也就是a.x被追加到了原地址(即b),a指向了新地址
Symbol类型在实际开发中的应用、可手动实现一个简单的Symbol
- 避免命名冲突,确保对象属性使用唯一标识符,不会发生属性冲突的危险(概括:变量唯一性)
- 提供给可升级的基础模组(js原生语法层面,基础框架,各类封装的npm包)来使用,便于向下兼容。
(function(){
const root = this;
const generateName = (function(){
let postfix = 0;
return function(descString){
console.log('generateName-descString',descString);
postfix++;
return `@@${descString}_${postfix}`;
}
})();
const SymbolPolyfill = function Symbol(description){
console.log('SymbolPolyfill-description',description);
if(this instanceof SymbolPolyfill) throw new TypeError('Symbol is not a constructor');
const descString = description === undefined ? undefined : String(description);
const symbol = Object.create({
toString:function(){
// console.log('toString');
return this.__Name__
},
valueOf:function(){
return this
}
})
Object.defineProperties(symbol,{
'__Description__':{
value:descString,
writable:false,
enumerable:false,
configurable:false
},
'__Name__':{
value:generateName(descString),
wirtable:false,
enumerable:false,
configurable:false
}
});
return symbol
}
const forMap = {};
Object.defineProperties(SymbolPolyfill,{
'for':{
value:function(description){
console.log('key-description',description);
const descString = description === undefined ? undefined : String(description);
return forMap[descString] ? forMap[descString] : forMap[descString] = SymbolPolyfill(descString)
},
writable:true,
enumerable:false,
configurable:true,
},
'keyFor':{
value:function(symbol){
console.log('keyof-symbol',symbol);
for(const key in forMap){
if(forMap[key] === symbol) return key
}
},
wirtable:true,
enumerable:false,
configurable:true
}
})
root.SymbolPolyfill = SymbolPolyfill; // 在window中添加SymbolPolyfill属性,并将SymbolPolyfill方法挂载到该属性下
})()
基本类型对应的内置对象,以及他们之间的装箱拆箱操作
JS内置对象
- JS共有17个内置对象,经常使用的内置对象有以下几个
- Math对象
- Date对象
- Array对象
- String对象
- 基本类型对象有String、Number、Boolean
- 为了便于操作操作基本类型值,ECMAScript还提供了几个特殊的引用类型,他们是基本类型的包装类型
- Boolean
- Number
- String
首先需要注意包装类型与原始类型的区别
true === new Boolean(true) // false
123 === new Number(123) // false
'ICC-EddiePeng' === new String('ICC-EddiePeng') // false
console.log(typeof new String('ICC-EddiePeng')) // object
console.log(typeof 'ICC-EddiePeng') // string
引用类型和包装类型的主要区别就是对象的生存期,使用new操作符创建的引用类型的实例,在执行流离开当前作用域之前一直都保存在内存中;而基本类型则只存在于一行代码的执行瞬间,然后立即销毁,这意味着我们不能在运行时为基本类型添加属性和方法。
const name = 'ICC-EddiePeng';
// 不报错的原因是因为执行到这的时候,程序会隐式的生成一个new String('ICC-EddiePeng').color = 'red'
// 执行完成之后便执行了delete操作
name.color = 'red';
// 到了打印这一步,程序会再次隐式的生成一个new String('ICC-EddiePeng').color
// 这样看是不是就能很好的理解为什么不报错,但是打印出来的值是undefined
console.log(name.color) // undefined
装箱与拆箱
- 装箱转换:把基本类型转换为对应的包装类型
- 拆箱操作:把引用类型转换为基本类型
既然原始类型不能拓展属性和方法,那么我们是如何使用原始类型调用方法的呢?
每当我们操作一个基础数据类型时,后台就会自动创建一个包装类型的对象,从而让我们能够调用一些方法和属性,例如下面的代码:
const name = 'ICC-EddiePeng';
const name2 = name.substring(2)
实际上发生了以下几个过程:
- 创建一个String的包装类型实例
- 在实例上调用substring方法
- 销毁实例
也就是说,我们使用基本类型调用方法,就会自动进行装箱和拆箱操作,相同的,我们使用Number和Boolean类型时,就会发生这个过程。
toPeimitive方法
从引用类型到基本类型的转换,也就是拆箱的过程中,会遵循ECMAScript规范规定的toPeimitive原则,一般会调用引用类型的valueOf和toString方法,你也可以直接重写toPeimitive方法。一般转换成不同类型的值遵循的原则不同,例如:
- 引用类型转换为Number类型,先调用valueOf,再调用toString
- 引用类型转换为String类型,先调用toString,再调用valueOf
- 若valueOf和toString都不存在,或者没有返回基本类型,则抛出TypeError异常
/*
*基本调用
*/
const obj = {
valueOf: () => { console.log('valueOf'); return 123; },
toString: () => { console.log('toString'); return 'ConardLi'; },
};
console.log(obj - 1); // valueOf 122
console.log(`${obj}ConardLi`); // toString ConardLiConardLi
/*
*重写toPeimitive方法
*/
const obj2 = {
[Symbol.toPrimitive]: () => { console.log('toPrimitive'); return 123; },
};
console.log(obj2 - 1); // toPrimitive 122
/*
*无法转换成基本类型,抛出异常
*/
const obj3 = {
valueOf: () => { console.log('valueOf'); return {}; },
toString: () => { console.log('toString'); return {}; },
};
console.log(obj3 - 1);
// valueOf
// toString
// TypeError
除了程序中的自动拆箱和自动装箱,我们还可以手动进行拆箱和装箱操作。我们可以直接调用包装类型的valueOf或toString,实现拆箱操作
const num = new Number('123')
console.log( typeof num.valueOf() ); //number
console.log( typeof num.toString() ); //string
null和undefined的区别
在基本数据类型中,有两个类型null和undefined,他们有且只有一个值,并且他们代表空和无;
null
表示被赋值过的对象,刻意把一个对象赋值为null,故意表示其为空,不应该有值;
所以对象的某个属性值为null是正常的,null转换为数值时为0
undefined
表示“缺少值”,即此处应有一个值,但还没有定义,当使用var或let声明了变量但没有初始化时,就相当于给变量赋予了undefined值
如果一个对象的某个属性值为undefined,这是不正常的,如obj.name = undefined,我们不应该这样写,应该直接delete obj.name
undefined转为数值时为NaN(非数字值的特殊值)
JavaScript是一门动态类型的语言,成员除了表示存在的空值外,还有可能根本就不存在(因为存不存在只有在运行期才知道),这就是undefined的意义所在。对于Java这种强类型语言,如果有undefined这种情况,就会直接编译失败,所以Java不需要一个这样的类型。
至少说出三种判断JavaScript数据类型的方式,以及他们的优缺点,如何准确的判断数组类型
1:typeof
适用场景
typeof操作符可以准确判断一个变量是否为下面几个原始类型:
typeof 'ICC-EddiePeng' // string
typeof 123 // number
typeof true // boolean
typeof Symbol() // symbol
type undefined // undefined
你还可以用它来判断函数类型
typeof function(){} // function
不适用场景(缺点)
当你用typeof来判断引用类型时,似乎显得有些乏力了:
typeof [] // object
typeof {} // object
typeof new Date() // object
typeof /^\d*$/ // object
除函数外的所有引用类型都会被判定为object
另外typeof null === 'object' // true也会让人感到头疼,这是JavaScript初版就流传下来的bug,后面由于修改会造成大量的兼容问题就一直没有被修复。
2:instanceof
instanceof操作符可以帮助我们判断引用类型具体是什么类型的对象:
[] instanceof Array // true
new Date() instanceof Date // true
new RegExp instanceof RegExp // true
先来回顾下原型链的几条规则:
- 所有引用类型都具有对象特性,即可以自由扩展属性
- 所有引用类型都具有一个
__proto__(隐式原型)属性,是一个普通对象 - 所有的函数都具有
prototype(显示原型)属性,也是一个普通对象 - 所有引用类型
__proto__值指向它构造函数的prototype - 当试图得到一个对象的属性时,如果变量本身没有这个属性,则会去它的
__proto__中找
缺点
[] instanceof Array实际上是判断Array.prototype是否在[]的原型链上
所以,使用instanceof来检测数据类型,不会很准确,这不是它的设计初衷:
[] instanceof Object // true
function(){} instanceof Obejct // true
另外,使用instanceof也不能检测基本数据类型,所以instanceof并不是一个很好的选择
3:toString
上面拆箱操作中提到了toString函数,我们可以调用它实现从引用类型的转换
每一个引用类型都有
toString方法,默认情况下,toString()方法被每个Object对象继承。如果此方法在自定义对象中未被覆盖,toString返回"[object type]",其中type是对象的类型
const obj = {};
obj.toString() //[object Object]
注意,上面提到了如果此方法在自定义对象中未被覆盖,toString才会达到预想的效果,事实上,大部分引用类型,比如Array、Date、RegExp等都重写了toString方法。
我们可以直接调用Object原型上未被覆盖的toString()方法,使用call来改变this指向来达到我们想要的效果
可能发生隐式类型转换的场景以及转换原则,应如何避免或巧妙应用
因为JavaScript是弱类型的语言,所以类型转换发生非常频繁,上面我们说的装箱和拆箱其实就是一种类型转换。
类型转换分为两种,隐式类型转换即程序自动进行的类型转换,强制类型转换即我们手动进行的类型转换。
下面是可能发生隐式类型转换的几个场景,以及如何转换:
类型转换规则
如果发生了隐式类型转换,那么各种类型互转符合下面的规则:
if语句和逻辑语句
在if语句和逻辑语句中,如果只有单个变量,会先将变量转换为Boolean值,只有下面几种情况会转换成false,其余都转换成true:
null
undefined
''
NaN
0
false
各种数学运算符
我们在对各种非Number类型运用数学运算符(- * /)时,会先将非Number类型转换为Number类型
1 - true // 0
1 - null // 1
1 * undefined // NaN
2 * ['5'] // 10
注意+是个例外,执行+操作符时
- 当一侧为
String类型,被识别为字符串拼接,并会优先将另一侧转换为字符串类型。 - 当一侧为
Number类型,另一侧为原始类型,则将原始类型转换为Number类型。 - 当一侧为
Number类型,另一侧为引用类型,将引用类型和Number类型转换成字符串后拼接。
123 + '123' // 123123 (规则1)
123 + null // 123 (规则2)
123 + true // 124 (规则2)
123 + {} // 123[object Object] (规则3)
==
使用==时,若两侧类型相同,则比较结果和===相同,否则会发生隐私类型转换,使用==时发生的转换可以分为几种不同的情况(只考虑两侧类型不同):
- 1.NaN
NaN和其他任何类型比较永远返回false(包括和它本身)
NaN == NaN // false
- 2.Boolean
Boolean和其他任何类型比较,Boolean首先被转换为Number类型。
true == 1 // true
true == '2' // false
true == ['1'] // true (这里true被转换为1,当原始值和引用类型做比较时,引用类型会参照ToPrimitive规则转换为原始类型)
true == ['2'] // false
这里注意一个可能会弄混的点:
undefined、null和Boolean比较,虽然undefined、null和false都很容易被想象成假值,但是他们比较结果是false,原因是false首先被转换成0,而undefined只和null进行双等号(==)比较时,为true
undefined == false // false
null == false // false
- String和Number
String和Number比较,先将String转换为Number类型
123 == '123' // true
'' == 0 // true
- null和undefined
null == undefined比较结果是true,除此之外,null、undefined和其他任何结果的比较值都为false。
null == undefined // true
null == '' // false
null == 0 // false
null == false // false
undefined == '' // false
undefined == 0 // false
undefined == false // false
- 原始类型和引用类型
当原始类型和引用类型做比较时,对象类型会依照toPrimitive规则转换为原始类型:
'[object Object]' == {} //true
'1,2,3' == [1,2,3] // true
来看看下面这个比较
[] == ![] // true
首先,!的优先级高于==,![]首先会被转换为false,然后根据上面第二点,false转换成Number类型0。
然后,当原始类型0和右侧引用类型[]进行比较时,对象类型会依照toPrimitive规则转换为原始类型,因为[]没有valueOf方法,调用toString,返回'';然后按照上面的String类型转换所说,当String和Number类型比较时,会转换成Number类型再比较,Number('')转换后为0,因此上面的比较为true
[null] == false // true
[undefined] == false // true
根据数组的toPrimitive规则,数组元素为null或undefined时,该元素被当做空字符串处理,所以[null]、[undefined]都会被转换为0。
所以说了这么多,推荐使用===来判断两个值是否相等
- 有意思的面试题
如何让:a == 1 && a == 2 && a == 3为true
根据上面的拆箱转换,以及==的隐式类型转换,我们可以轻松写出答案:
const a = {
value:[3,2,1],
valueOf:function () {return this.value.pop()}
}
原型和原型链
理解原型设计模式以及JavaScript中的原型规则
原型关系
- 每个
函数或class都有显示原型prototype - 每个
实例都有隐式原型__proto__ - 实例的
__proto__指向对应函数/class的prototype
原型
在JavaScript中,每当定义一个对象(函数也是对象)时,对象中都会包含一些预定义的属性。其中每个函数对象都有一个prototype属性,这个属性指向函数的原型对象
原型链
函数的原型链对象constructor默认指向函数本身,原型对象除了有原型属性外,为了实现继承,还有一个原型链指针__proto__,该指针指向上一层的原型对象,而上一层的原型对象的结构依然类似。
因此可以利用__proto__一直指向Object的原型对象上,而Object原型对象用Object.prototype.__proto__ === null表示原型链顶端,这样形成了JavaScript的原型链继承,同时所有的JavaScript对象都有Object的基本防范。
红宝书解释
ECMA-262把原型链定义为ECMAScript的主要继承方式。其基本思路就是通过原型继承多个引用类型的属性和方法。重温一下构造函数、原型和实例的关系;每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应的另外一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。
一些图解
练习题
// 下面打印什么?
Object.prototype.__proto__
Function.prototype.__proto__
Object.__proto__
// 下面打印什么?
function F(){};
Object.prototype.a = function(){
console.log('a')
};
Function.prototype.b = function(){
console.log('b')
};
let f = new F();
F.a()
F.b()
f.a()
f.b()
instanceof的底层实现原理,手动实现一个instanceof
instanceof运算符用于判断构造函数的protortype属性是否出现在对象的原理链中的任何位置
function Person(){};
var person = new Person()
person instanceof Person; // true
person instanceof Object; // true
function myInstanceof(left,right){
// 获取对象原型
let proto = Object.getPrototypeOf(left);
// 获取构造函数的prototype对象
let prototype = right.prototype;
// 判断构造函数的prototype对象是否在对象的原型链上
while(true){
if(!proto) return false;
if(proto === prototype) return true;
// 如果没有找到,就继续从原型链上找,Object.getPrototypeOf方法用来获取指定对象的原型
proto = Object.getPrototypeOf(proto)
}
}
实现继承的几种方式以及他们的优缺点
原型链继承
原型链继承的原理很简单,直接让子类的原型对象指向父类实例,当子类实例找不到对应的属性和方法时,就会往它的原型对象,也就是父类实例上找,从而实现对父类的属性和方法的继承。
// 父类
function Parent() {
this.name = '写代码像蔡徐抻'
}
// 父类的原型方法
Parent.prototype.getName = function() {
return this.name
}
// 子类
function Child() {}
// 让子类的原型对象指向父类实例, 这样一来在Child实例中找不到的属性和方法就会到原型对象(父类实例)上寻找
Child.prototype = new Parent()
Child.prototype.constructor = Child // 根据原型链的规则,顺便绑定一下constructor, 这一步不影响继承, 只是在用到constructor时会需要
// 然后Child实例就能访问到父类及其原型上的name属性和getName()方法
const child = new Child()
child.name // '写代码像蔡徐抻'
child.getName() // '写代码像蔡徐抻'
原型链继承的缺点:
- 由于所有Child实例原型都指向同一个Parent实例,因此对某个Child实例的父类引用类型变量会修改影响所有的Child实例
- 在创建子类实例时,无法向父类构造函数传参,即没有实现
super()的功能
// 示例:
function Parent() {
this.name = ['写代码像蔡徐抻']
}
Parent.prototype.getName = function() {
return this.name
}
function Child() {}
Child.prototype = new Parent()
Child.prototype.constructor = Child
// 测试
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name) // ['foo']
console.log(child2.name) // ['foo'] (预期是['写代码像蔡徐抻'], 对child1.name的修改引起了所有child实例的变化)
构造函数继承
构造函数继承,即在子类的构造函数中执行父类的构造函数,并为其绑定子类的this,让父类的构造函数把成员属性和方法都挂到子类的this上去,这样既能避免实例之间共享一个原型实例,又能向父类构造方法传参。
function Parent(name) {
this.name = [name]
}
Parent.prototype.getName = function() {
return this.name
}
function Child() {
Parent.call(this, 'zhangsan') // 执行父类构造方法并绑定子类的this, 使得父类中的属性能够赋到子类的this上
}
//测试
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name) // ['foo']
console.log(child2.name) // ['zhangsan']
child2.getName() // 报错,找不到getName(), 构造函数继承的方式继承不到父类原型上的属性和方法
构造函数继承的缺点
继承不到父类原型上的属性和方法
组合式继承
既然原型链继承和构造函数继承各有互补的优缺点, 那么我们为什么不组合起来使用呢, 所以就有了综合二者的组合式继承
function Parent(name) {
this.name = [name]
}
Parent.prototype.getName = function() {
return this.name
}
function Child() {
// 构造函数继承
Parent.call(this, 'zhangsan')
}
//原型链继承
Child.prototype = new Parent()
Child.prototype.constructor = Child
//测试
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name) // ['foo']
console.log(child2.name) // ['zhangsan']
child2.getName() // ['zhangsan']
组合式继承的缺点
每次创建子类实例都执行了两次构造函数(Parent.call()和new Parent()),虽然这并不影响对父类的继承,但子类创建实例时,原型中会存在两份相同的属性和方法,这并不优雅
寄生式组合继承
为了解决构造函数被执行两次的问题, 我们将指向父类实例改为指向父类原型, 减去一次构造函数的执行
function Parent(name) {
this.name = [name]
}
Parent.prototype.getName = function() {
return this.name
}
function Child() {
// 构造函数继承
Parent.call(this, 'zhangsan')
}
//原型链继承
// Child.prototype = new Parent()
Child.prototype = Parent.prototype //将`指向父类实例`改为`指向父类原型`
Child.prototype.constructor = Child
//测试
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name) // ['foo']
console.log(child2.name) // ['zhangsan']
child2.getName() // ['zhangsan']
但这种方式存在一个问题,由于子类原型和父类原型指向同一个对象,我们对子类原型的操作会影响到父类原型,例如给Child.prototype增加一个getName()方法,那么会导致Parent.prototype也增加或被覆盖一个getName()方法,为了解决这个问题,我们给Parent.prototype做一个浅拷贝
function Parent(name) {
this.name = [name]
}
Parent.prototype.getName = function() {
return this.name
}
function Child() {
// 构造函数继承
Parent.call(this, 'zhangsan')
}
//原型链继承
// Child.prototype = new Parent()
Child.prototype = Object.create(Parent.prototype) //将`指向父类实例`改为`指向父类原型`
Child.prototype.constructor = Child
//测试
const child = new Child()
const parent = new Parent()
child.getName() // ['zhangsan']
parent.getName() // 报错, 找不到getName()
到这里我们就完成了ES5环境下的继承的实现,这种继承方式称为寄生组合式继承,是目前最成熟的继承方式,babel对ES6继承的转化也是使用了寄生组合式继承
我们回顾一下实现过程:
一开始最容易想到的是原型链继承,通过把子类实例的原型指向父类实例来继承父类的属性和方法,但原型链继承的缺陷在于对子类实例继承的引用类型的修改会影响到所有的实例对象以及无法向父类的构造方法传参。
因此我们引入了构造函数继承, 通过在子类构造函数中调用父类构造函数并传入子类this来获取父类的属性和方法,但构造函数继承也存在缺陷,构造函数继承不能继承到父类原型链上的属性和方法。
所以我们综合了两种继承的优点,提出了组合式继承,但组合式继承也引入了新的问题,它每次创建子类实例都执行了两次父类构造方法,我们通过将子类原型指向父类实例改为子类原型指向父类原型的浅拷贝来解决这一问题,也就是最终实现 —— 寄生组合式继承