前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JS进阶:继承

JS进阶:继承

作者头像
4O4
发布2022-04-25 17:17:16
4.4K0
发布2022-04-25 17:17:16
举报
文章被收录于专栏:404404

在理解继承之前,需要知道 js 的三个东西:

  1. 什么是 JS 原型链
  2. this 的值到底是什么
  3. JS 的new 到底是干什么的

一、什么是 JS 原型链

我们知道 JS 有对象,比如

代码语言:javascript
复制
var obj = { name: 'obj' }

我们通过控制台把obj 打印出来:

我们会发现 obj已经有几个属性(方法)了。那么问题来了:valueOf/toString/constructor 是怎么来?我们并没有给 obj.valueOf 赋值。

上面这个图有点难懂,手画一个示意图:

我们发现控制台打出来的结果是:

  1. obj本身有一个属性 name (这是我们给它加的)
  2. obj还有一个属性叫做 __proto__(它是一个对象)
  3. obj还有一个属性,包括 valueOf, toString, constructor等
  4. obj.__proto__其实也有一个叫做__proto__的属性(console.log没有显示),值为 null

obj 为什么会拥有 valueOf / toString / constructor 这几个属性?这跟 __proto__有关 。

当我们「读取」 obj.toString 时,JS 引擎会做下面的事情:

  1. 看看 obj 对象本身有没有 toString 属性。没有就走到下一步。
  2. 看看 obj.__proto__ 对象有没有 toString 属性, 发现 obj.__proto__toString 属性, 于是找到了,所以 obj.toString实际就是第2步中找到的 obj.__proto__.toString
  3. 如果 obj.__proto__没有,那么浏览器会继续查看 obj.__proto__.__proto__
  4. 如果 obj.__proto__.__proto__ 也没有,那么浏览器会继续查看 obj.__proto__.__proto__.__proto__
  5. 直到找到 toString 或者 __proto__null

上面的过程,就是「读」属性的「搜索过程」。而这个「搜索过程」,是连着由 proto 组成的链子一直走的。这个链子,就叫做「原型链」。

共享原型链

现在我们还有另一个对象

代码语言:javascript
复制
var obj2 = { name: 'obj2' }

如图:

那么 obj.toStringobj2.toString 其实是同一东西, 也就是 obj2.__proto__.toString。说白了,我们改其中的一个 __proto__.toString ,那么另外一个其实也会变!

差异化

如果我们想让 obj.toStringobj2.toString 的行为不同怎么做呢?直接赋值就好了:

代码语言:javascript
复制
obj.toString = function(){ return '新的 toString 方法' }

小结

  1. [读]属性时会沿着原型链搜索
  2. [新增]属性时不会去看原型链

二、 this 的值到底是什么

你可能遇到过这样的 JS 面试题:

代码语言:javascript
复制
var obj = {
  foo: function(){
    console.log(this)
  }
}
var bar = obj.foo
obj.foo() // 打印出的 this 是 obj
bar() // 打印出的 this 是 window

请解释最后两行函数的值为什么不一样。

函数调用

JS(ES5)里面有三种函数调用形式:

代码语言:javascript
复制
func(p1, p2) 
obj.child.method(p1, p2)
func.call(context, p1, p2) // 先不讲 apply

一般,初学者都知道前两种形式,而且认为前两种形式「优于」第三种形式,其实第三种才是正常调用形式:

代码语言:javascript
复制
func.call(context, p1, p2)

其他两种都是语法糖,可以等价地变为 call 形式:

func(p1, p2)等价于 func.call(undefined, p1, p2);

obj.child.method(p1, p2) 等价于 obj.child.method.call(obj.child, p1, p2);

至此我们的函数调用只有一种形式:

代码语言:javascript
复制
func.call(context, p1, p2)

这样,this 就好解释了 this就是上面 context

this 是你 call 一个函数时传的 context,由于你从来不用 call 形式的函数调用,所以你一直不知道。

先看 func(p1, p2) 中的 this 如何确定:

代码语言:javascript
复制
// 当你写下面代码时
function func(){
  console.log(this)
}
func()

// 等价于
function func(){
  console.log(this)
}
func.call(undefined) // 可以简写为 func.call()

按理说打印出来的 this 应该就是 undefined 了吧,但是浏览器里有一条规则:

如果你传的 context

null

或者 undefined

,那么 window 对象就是默认的 context

(严格模式下默认 context 是 undefined)

因此上面的打印结果是 window。如果你希望这里的 this 不是 window,很简单:

代码语言:javascript
复制
func.call(obj) // 那么里面的 this 就是 obj 对象了

回到题目:

代码语言:javascript
复制
var obj = {
  foo: function(){
    console.log(this)
  }
}
var bar = obj.foo
obj.foo() // 转换为 obj.foo.call(obj),this 就是 obj
bar() 
// 转换为 bar.call()
// 由于没有传 context
// 所以 this 就是 undefined
// 最后浏览器给你一个默认的 this —— window 对象

[ ] 语法

代码语言:javascript
复制
function fn (){ console.log(this) }
var arr = [fn, fn2]
arr[0]() // 这里面的 this 又是什么呢?

我们可以把 arr[0]( ) 想象为arr.0( ),虽然后者的语法错了,但是形式与转换代码里的 obj.child.method(p1, p2) 对应上了,于是就可以愉快的转换了:

代码语言:javascript
复制
arr[0]() 
假想为    arr.0()
然后转换为 arr.0.call(arr)
那么里面的 this 就是 arr 了

小结:

  1. this 就是你 call 一个函数时,传入的第一个参数。
  2. 如果你的函数调用不是 call 形式, 请将其转换为 call 形式

三、JS 的 new 到底是干什么的?

我们声明一个士兵,具有如下属性:

代码语言:javascript
复制
var 士兵 = {
  ID: 1, // 用于区分每个士兵
  兵种:"美国大兵",
  攻击力:5,
  生命值:42, 
  行走:function(){ /*走俩步的代码*/},
  奔跑:function(){ /*狂奔的代码*/  },
  死亡:function(){ /*Go die*/    },
  攻击:function(){ /*糊他熊脸*/   },
  防御:function(){ /*护脸*/       }
}

我们制造一个士兵, 只需要这样:

代码语言:javascript
复制
兵营.制造(士兵)

如果需要制造 100 个士兵怎么办呢?

代码语言:javascript
复制
循环 100 次吧:
var 士兵们 = []
var 士兵
for(var i=0; i<100; i++){
  士兵 = {
    ID: i, // ID 不能重复
    兵种:"美国大兵",
    攻击力:5,
    生命值:42, 
    行走:function(){ /*走俩步的代码*/},
    奔跑:function(){ /*狂奔的代码*/  },
    死亡:function(){ /*Go die*/    },
    攻击:function(){ /*糊他熊脸*/   },
    防御:function(){ /*护脸*/       }
  }
  士兵们.push(士兵)
}
兵营.批量制造(士兵们)

哎呀,看起来好简单

质疑

上面的代码存在一个问题:浪费了很多内存

  1. 行走、奔跑、死亡、攻击、防御这五个动作对于每个士兵其实是一样的,只需要各自引用同一个函数就可以了,没必要重复创建 100 个行走、100个奔跑……
  2. 这些士兵的兵种和攻击力都是一样的,没必要创建 100 次。
  3. 只有 ID 和生命值需要创建 100 次,因为每个士兵有自己的 ID 和生命值。

改进

通过第一节可以知道 ,我们可以通过原型链来解决重复创建的问题:我们先创建一个「士兵原型」,然后让「士兵」的 proto 指向「士兵原型」。

代码语言:javascript
复制
var 士兵原型 = {
  兵种:"美国大兵",
  攻击力:5,
  行走:function(){ /*走俩步的代码*/},
  奔跑:function(){ /*狂奔的代码*/  },
  死亡:function(){ /*Go die*/    },
  攻击:function(){ /*糊他熊脸*/   },
  防御:function(){ /*护脸*/       }
}
var 士兵们 = []
var 士兵
for(var i=0; i<100; i++){
  士兵 = {
    ID: i, // ID 不能重复
    生命值:42
  }
  /*实际工作中不要这样写,因为 __proto__ 不是标准属性*/
  士兵.__proto__ = 士兵原型 
  士兵们.push(士兵)
}
兵营.批量制造(士兵们)

优雅

有人指出创建一个士兵的代码分散在两个地方很不优雅,于是我们用一个函数把这两部分联系起来:

代码语言:javascript
复制
function 士兵(ID){
  var 临时对象 = {};
  临时对象.__proto__ = 士兵.原型;
  临时对象.ID = ID;
  临时对象.生命值 = 42;
  
  return 临时对象;
}  
士兵.原型 = {
  兵种:"美国大兵",
  攻击力:5,
  行走:function(){ /*走俩步的代码*/},
  奔跑:function(){ /*狂奔的代码*/  },
  死亡:function(){ /*Go die*/    },
  攻击:function(){ /*糊他熊脸*/   },
  防御:function(){ /*护脸*/       }
}
// 保存为文件:士兵.js
 然后就可以愉快地引用「士兵」来创建士兵了:
var 士兵们 = []
for(var i=0; i<100; i++){
  士兵们.push(士兵(i))
}
兵营.批量制造(士兵们)

JS 之父看到大家都这么搞,觉得何必呢,我给你们个糖吃,于是 JS 之父创建了 new 关键字,可以让我们少写几行代码:

只要你在士兵前面使用 new 关键字,那么可以少做四件事情:

  1. 不用创建临时对象,因为 new 会帮你做(你使用「this」就可以访问到临时对象);
  2. 不用绑定原型,因为new 会帮你做(new 为了知道原型在哪,所以指定原型的名字 prototype);
  3. 不用 return 临时对象,因为 new 会帮你做;
  4. 不要给原型想名字了,因为 new 指定名字为 prototype

这一次用 new 来写

代码语言:javascript
复制
function 士兵(ID){
  this.ID = ID
  this.生命值 = 42
}
士兵.prototype = {
  兵种:"美国大兵",
  攻击力:5,
  行走:function(){ /*走俩步的代码*/},
  奔跑:function(){ /*狂奔的代码*/  },
  死亡:function(){ /*Go die*/    },
  攻击:function(){ /*糊他熊脸*/   },
  防御:function(){ /*护脸*/       }
}
// 保存为文件:士兵.js
然后是创建士兵(加了一个 new 关键字):
var 士兵们 = []
for(var i=0; i<100; i++){
  士兵们.push(new 士兵(i))
}
兵营.批量制造(士兵们)

new 的作用,就是省那么几行代码。(也就是所谓的语法糖)

注意 constructor 属性

new 操作为了记录「临时对象是由哪个函数创建的」,所以预先给「士兵.prototype」加了一个 constructor 属性:

代码语言:javascript
复制
士兵.prototype = {
  constructor: 士兵
}

如果你重新对「士兵.prototype」赋值,那么这个 constructor 属性就没了,所以你应该这么写:

代码语言:javascript
复制
士兵.prototype.兵种 = "美国大兵"
士兵.prototype.攻击力 = 5
士兵.prototype.行走 = function(){ /*走俩步的代码*/}
士兵.prototype.奔跑 = function(){ /*狂奔的代码*/  }
士兵.prototype.死亡 = function(){ /*Go die*/    }
士兵.prototype.攻击 = function(){ /*糊他熊脸*/   }
士兵.prototype.防御 = function(){ /*护脸*/       }

或者你也可以自己给 constructor 重新赋值:

代码语言:javascript
复制
士兵.prototype = {
  constructor: 士兵,
  兵种:"美国大兵",
  攻击力:5,
  行走:function(){ /*走俩步的代码*/},
  奔跑:function(){ /*狂奔的代码*/  },
  死亡:function(){ /*Go die*/    },
  攻击:function(){ /*糊他熊脸*/   },
  防御:function(){ /*护脸*/       }
}

四、继承

继承分类

继承方式

1、原型链继承

构造函数、原型和实例之间的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个原型对象的指针。

继承的本质就是复制,即重写原型对象,代之以一个新类型的实例

代码语言:javascript
复制
function SuperType() {
    this.property = true;
}
SuperType.prototype.getSuperValue = function() {
    return this.property;
}
function SubType() {
    this.subproperty = false;
}
// 这里是关键,创建SuperType的实例,并将该实例赋值给SubType.prototype
SubType.prototype = new SuperType(); 
SubType.prototype.getSubValue = function() {
    return this.subproperty;
}
var instance = new SubType();
console.log(instance.getSuperValue()); // true

原型链方案存在的缺点:多个实例对引用类型的操作会被篡改。

代码语言:javascript
复制
function SuperType(){
  this.colors = ["red", "blue", "green"];
}
function SubType(){}
SubType.prototype = new SuperType();
var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"
var instance2 = new SubType(); 
alert(instance2.colors); //"red,blue,green,black"
2、借用构造函数继承

使用父类的构造函数来增强子类实例,等同于复制父类的实例给子类(不使用原型)

代码语言:javascript
复制
function  SuperType(){
    this.color=["red","green","blue"];
}
function  SubType(){
    //继承自SuperType
    SuperType.call(this);
}
var instance1 = new SubType();
instance1.color.push("black");
alert(instance1.color);//"red,green,blue,black"
var instance2 = new SubType();
alert(instance2.color);//"red,green,blue"

核心代码是SuperType.call(this),创建子类实例时调用SuperType构造函数,于是SubType的每个实例都会将SuperType中的属性复制一份。

缺点:

  • 只能继承父类的实例属性和方法,不能继承原型属性/方法
  • 无法实现复用,每个子类都有父类实例函数的副本,影响性能
3、组合继承

组合上述两种方法就是组合继承。用原型链实现对原型属性和方法的继承,用借用构造函数技术来实现实例属性的继承。

代码语言:javascript
复制
function SuperType(name){
  this.name = name;
  this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){
  alert(this.name);
};
function SubType(name, age){
  // 继承属性
  // 第二次调用SuperType()
  SuperType.call(this, name);
  this.age = age;
}
// 继承方法
// 构建原型链
// 第一次调用SuperType()
SubType.prototype = new SuperType(); 
// 重写SubType.prototype的constructor属性,指向自己的构造函数SubType
SubType.prototype.constructor = SubType; 
SubType.prototype.sayAge = function(){
    alert(this.age);
};
var instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29
var instance2 = new SubType("Greg", 27);
alert(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27

缺点:

  • 第一次调用SuperType():给SubType.prototype写入两个属性name,color。
  • 第二次调用SuperType():给instance1写入两个属性name,color。

实例对象instance1上的两个属性就屏蔽了其原型对象SubType.prototype的两个同名属性。所以,组合模式的缺点就是在使用子类创建实例对象时,其原型中会存在两份相同的属性/方法。

4、原型式继承

利用一个空对象作为中介,将某个对象直接赋值给空对象构造函数的原型。

代码语言:javascript
复制
function object(obj){
  function F(){}
  F.prototype = obj;
  return new F();
}

object()对传入其中的对象执行了一次浅复制,将构造函数F的原型直接指向传入的对象。

代码语言:javascript
复制
var person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};
var anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");
var yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");
alert(person.friends);   //"Shelby,Court,Van,Rob,Barbie"

缺点:

  • 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
  • 无法传递参数

另外,ES5中存在Object.create()的方法,能够代替上面的object方法。

5、寄生式继承

核心:在原型式继承的基础上,增强对象,返回构造函数

代码语言:javascript
复制
function createAnother(original){
  var clone = object(original); // 通过调用 object() 函数创建一个新对象
  clone.sayHi = function(){  // 以某种方式来增强对象
    alert("hi");
  };
  return clone; // 返回这个对象
}

函数的主要作用是为构造函数新增属性和方法,以增强函数

代码语言:javascript
复制
var person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};
var anotherPerson = createAnother(person);
anotherPerson.sayHi(); //"hi"

缺点(同原型式继承):

  • 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
  • 无法传递参数
6、寄生组合式继承

结合借用构造函数传递参数和寄生模式实现继承

代码语言:javascript
复制
function inheritPrototype(subType, superType){
  var prototype = Object.create(superType.prototype); // 创建对象,创建父类原型的一个副本
  prototype.constructor = subType;                    // 增强对象,弥补因重写原型而失去的默认的constructor 属性
  subType.prototype = prototype;                      // 指定对象,将新创建的对象赋值给子类的原型
}
// 父类初始化实例属性和原型属性
function SuperType(name){
  this.name = name;
  this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){
  alert(this.name);
};
// 借用构造函数传递增强子类实例属性(支持传参和避免篡改)
function SubType(name, age){
  SuperType.call(this, name);
  this.age = age;
}
// 将父类原型指向子类
inheritPrototype(SubType, SuperType);
// 新增子类原型属性
SubType.prototype.sayAge = function(){
  alert(this.age);
}
var instance1 = new SubType("xyc", 23);
var instance2 = new SubType("lxy", 23);
instance1.colors.push("2"); // ["red", "blue", "green", "2"]
instance1.colors.push("3"); // ["red", "blue", "green", "3"]

这个例子的高效率体现在它只调用了一次SuperType 构造函数,并且因此避免了在SubType.prototype 上创建不必要的、多余的属性。于此同时,原型链还能保持不变;因此,还能够正常使用instanceofisPrototypeOf()

这是最成熟的方法,也是现在库实现的方法。

7、混入方式继承多个对象
代码语言:javascript
复制
function MyClass() {
     SuperClass.call(this);
     OtherSuperClass.call(this);
}
// 继承一个类
MyClass.prototype = Object.create(SuperClass.prototype);
// 混合其它
Object.assign(MyClass.prototype, OtherSuperClass.prototype);
// 重新指定constructor
MyClass.prototype.constructor = MyClass;
MyClass.prototype.myMethod = function() {
     // do something
};

Object.assign会把 OtherSuperClass原型上的函数拷贝到 MyClass原型上,使 MyClass 的所有实例都可用 OtherSuperClass 的方法。

8、ES6类继承extends

extends关键字主要用于类声明或者类表达式中,以创建一个类,该类是另一个类的子类。其中constructor表示构造函数,一个类中只能有一个构造函数,有多个会报出SyntaxError错误,如果没有显式指定构造方法,则会添加默认的 constructor方法,使用例子如下。

代码语言:javascript
复制
class Rectangle {
    // constructor
    constructor(height, width) {
        this.height = height;
        this.width = width;
    }
    
    // Getter
    get area() {
        return this.calcArea()
    }
    
    // Method
    calcArea() {
        return this.height * this.width;
    }
}
const rectangle = new Rectangle(10, 20);
console.log(rectangle.area);
// 输出 200
-----------------------------------------------------------------
// 继承
class Square extends Rectangle {
  constructor(length) {
    super(length, length);
    
    // 如果子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
    this.name = 'Square';
  }
  get area() {
    return this.height * this.width;
  }
}
const square = new Square(10);
console.log(square.area);
// 输出 100

extends继承的核心代码如下,其实现和上述的寄生组合式继承方式一样

代码语言:javascript
复制
function _inherits(subType, superType) {
  
    // 创建对象,创建父类原型的一个副本
    // 增强对象,弥补因重写原型而失去的默认的constructor 属性
    // 指定对象,将新创建的对象赋值给子类的原型
    subType.prototype = Object.create(superType && superType.prototype, {
        constructor: {
            value: subType,
            enumerable: false,
            writable: true,
            configurable: true
        }
    });
    
    if (superType) {
        Object.setPrototypeOf 
            ? Object.setPrototypeOf(subType, superType) 
            : subType.__proto__ = superType;
    }
}

总结

1、函数声明和类声明的区别

函数声明会提升,类声明不会。首先需要声明你的类,然后访问它,否则像下面的代码会抛出一个ReferenceError。

代码语言:javascript
复制
let p = new Rectangle(); 
// ReferenceError
class Rectangle {}

2、ES5继承和ES6继承的区别

  • ES5的继承实质上是先创建子类的实例对象,然后再将父类的方法添加到this上(Parent.call(this)).
  • ES6的继承有所不同,实质上是先创建父类的实例对象this,然后再用子类的构造函数修改this。因为子类没有自己的this对象,所以必须先调用父类的super()方法,否则新建实例报错。
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2020-07-19 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、什么是 JS 原型链
  • 共享原型链
  • 差异化
  • 二、 this 的值到底是什么
    • 函数调用
      • [ ] 语法
      • 三、JS 的 new 到底是干什么的?
        • 质疑
          • 改进
            • 优雅
              • 这一次用 new 来写
                • 注意 constructor 属性
                • 四、继承
                  • 继承分类
                    • 继承方式
                      • 1、原型链继承
                      • 2、借用构造函数继承
                      • 3、组合继承
                      • 4、原型式继承
                      • 5、寄生式继承
                      • 6、寄生组合式继承
                      • 7、混入方式继承多个对象
                      • 8、ES6类继承extends
                    • 总结
                    领券
                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档