js原型继承

前言

这是一道非常经典的前端面试题,尤其是对于初中级前端来说,基本上是必考题。当我还是一个前端萌新的时候,也被问到过很多次。当时我对原型、原型链、继承的理解,也就是面试题看懂了,面试的时候能忽悠几句的程度,面试官也不一定真的搞懂了,所以也总能蒙混过关。
但是随着新一代前端的成长,大家用的都是class,相信这道题很快很成为历史,到时候只有类,没有原型这个词。
为什么原型这么难懂?我觉得有下面三个主要原因:
1、它确实比较难,你找一个写java的人来解释js的原型继承他也不一定能讲清楚
2、你可能干了好几年都不一定会深入使用它,即使react玩家天天在写,但是应该有很多人只是在套模板,更何况vue那种更舒服的模板写法(vue最终也是原型继承,比如一开始的 new vue())。
3、网上的博客五花八门,有说继承有五种,有说有六种,有的说有八种,而且名字还可能不一样
本文试图通过一个实际应用的例子来帮助初学者理解原型继承 (但是没成功,有兴趣看最后一节)
本文对原型继承的几种方式做了整理,算是一篇笔记

一、原型的特性

虽然很多教程跟书本上都有,这里还是简单介绍一下,避免有些同学忘记
原型有三大特性:封装 继承 多态
封装:指的是把很多属性、方法全都集中到原型里,要访问的时候都是统一入口,不会像过程式编程(后面有介绍)一样到处都是作用域比较广的变量。
继承:指的是两个原型之间,子类可以继承父类的属性和方法。
多态:父类有一个属性叫做“name”,子类也可以有一个叫“name”的属性,子类只会生效自己的“name”。

这里是按我自己的理解简单介绍的,书面化的介绍请看《javascript权威指南》,毕竟作者也不一定是对的,不管是博客还是视频,都不是权威的,都可能会讲错,对于自己不理解的东西,一定要从源头找答案对照。

二、原型继承到底有几种

讲了这么多废话,先上个总结吧,毕竟刷到这篇博客的人基本上都是为了面试:
分细一点应该有八种,看了好多其它的博客,最多的也是介绍到八种。
1、原型链继承(Child.prototype=new Parent())
2、构造函数继承(在构造函数里面使用call)
3、组合继承(原型链继承+构造继承)
4、原型式继承(用了object.create,也有把他归类到寄生式)
5、寄生式继承(原型式继承的进阶版)(也有叫拷贝继承)
6、寄生组合式继承(寄生式+组合式,是class出现前的终极继承方案)
7、对象冒充(不知道谁先想出来的怪招)
8、class继承(大家都嫌寄生组合太麻烦了,所以出现了它,屠龙术)
因果关系记忆法:
因为原型链继承不能传参,所以有了构造继承,但是构造继承不能继承父级的原型,所以出现了结合两种方式的组合继承。
因为组合继承实例化了两次父类,性能有缺陷,强迫症的前端们忍不了,所以想出了原型式继承,再增强成寄生继承,把寄生继承跟组合继承一结合,变成了寄生组合继承,用来解决组合继承的小缺陷
有一位脑洞清奇的人才,发现了对象冒充,让大家的面试题又多了一个答案。
最后大家都觉得写寄生组合继承太费劲了,所以出现了class

1、原型链继承

优点:继承了父类的所有,包括原型
缺点:
1、不能给父类传参
2、引用类型的属性会被子类修改(比如一个子类改了父类的Array类型的属性,另一个子类的原型上Array也会变)

var Parent = function(type){
    this.parentType = type;
}
var Child = function(type){
    this.childType = type
}
// 直接实例化之后赋值到Child.prototype
Child.prototype = new Parent();

2、构造函数继承

优点:
1、可以传参数
2、引用类型的属性不会被子类修改
缺点:
1、不能继承原型,因为只是把属性用call绑定到了this上
2、每次构造函数都要多走一个函数(call)

var Child = function(childType, parentType){
	// call、apply都行,入参不同,后面有简单介绍,通常面试也会顺带问问他们
    Parent.call(this, parentType)
    this.childType = childType
}

3、组合式继承

优点:
1、可以传参又继承了原型
2、引用类型的属性不会被子类修改
缺点:
1、实例和原型上存在两份相同的属性,一份在this,一份在prototype(this不等于prototype,虽然简单用起来差不多),也就是说实例化了两次Parent,性能上欠佳,所以才有了下面的原型式到寄生组合的进化
2、每次构造函数都要多走一个函数(call)

var Child = function(childType, parentType){
	// 说了用apply也行,只不过入参是个数组
    Parent.apply(this, [parentType])
    this.childType = childType
}
Child.prototype = new Parent();
Child.prototype.constructor = Child;

4、原型式继承

优点:不用实例化父类,只是用Object.create创建了一个副本
缺点:不能改动自己的原型(因为返回new已经实例化了),所以也不能复用

// 自己简单点写是这样,作用约等于Object.create()
function create(options){
    var Temp = function(){};
    Temp.prototype = options; 
    return new Temp();        
}
// 这里命名为parentPrototype跟寄生组合有关,往下看寄生组合
var parentPrototype = {
    name:'me'
}
var ChildPrototype = create(parentPrototype);
// 下面写的也是原型式
var ChildPrototype = Object.create(parentPrototype);

5、寄生式继承

也有把原型式归到寄生式里面的,因为就包了个壳
优点:在原型式继承的基础上,增强了对象
缺点:
1、多包了一层,当然是多走了一次函数啦
2、也是不能复用,跟原型式一样

function clone(options){   
    let clone = Object.create(options);
    clone.personName = "you";
    return clone        
}        
var parentPrototype = {            
    name:"me"     
}        
var ChildPrototype = clone(parentPrototype); 

6、寄生组合继承

优点:
以上所有的优点
缺点:
1、写起来复杂
2、多执行了call、create

function Parent(name) {
    this.name = name;
}
 // 定义原型上的一个方法
Parent.prototype.method = function () {
    console.log(this.name);
};
// 函数式
function Child(name, age) {
    Person.call(this, name);
}
// 如果没有Object.create方法,就自己简单写一下
if (!Object.create) {
    Object.create = function (proto) {
        function Temp() {}
        Temp.prototype = proto;
        return new Temp();
    };
}
// 寄生
function clone(options){   
    let clone = Object.create(options);
    clone.personName = "you";
    return clone        
} 
// 原型链上寄生
Child.prototype = clone(Parent.prototype);
// 修改constructor指向
Child.prototype.constructor = Child;

7、对象冒充

优点: 让你的面试答案多了一个
缺点:
1、当父类的属性相同时,后面定义的会覆盖前面定义的属性(看实现就知道为什么)
2、每次构造函数都要多走一个函数

function Parent1(name) {        
    this.name1 = name;          
}    
function Parent2(name) {        
    this.name2 = name;          
}    
function Child(name1, name2) {
	// 就是把父类先挂到子类的一个方法上,this就指向了子类上
    this.Method = Parent1;
    // 一执行,父类上this的东西就挂到子类上了
    this.Method(name1);
    // 对象到手就甩,渣男
    delete this.Method;        
    
    this.Method = Parent2;
    this.Method(name2);
    delete this.Method;
}    
var child = new Child("me", "you"); 

8、class继承

优点:优点就是没有缺点

class Parent {    
    static staticMethod() {        
        return true;    
    }    
    constructor(name) {        
        this.name = name;          
    }    
    ParentMethod() {        
        console.log('method2');    
    }
};
class Child extends Parent{    
    constructor(name) {        
        super(name);        
        this.childName = 'child';    
    }    
    childMethod() {        
        console.log('childMethod');    
    }
}

三、引出的几道经典题目

1、new的时候做了什么

也就是 var Child = new Parent(); 干了啥
通过对原型的理解,我们很容易解答
1、创建一个空对象(不创建一个空的怎么往里面塞东西)
2、让Prarent中的this指向Child,并执行Parent的函数体(classconstructor,Parent本身)
3、设置原型链,将Child的__proto__的成员指向了Prarent的prototype的成员
4、给Child赋值,Parent的返回值类型是个值child就是个值,是个对象,child就是这个对象
也有回答说:将初始化完毕的新对象地址,保存到等号左边的变量中
就是赋值,没啥好解释的,面试官听不懂公司就没必要去了
名词解释:
函数体:用class就是constructor,用构造函数就是Parent本身
prototype是原型才有的属性,__proto__对象跟原型都有,__proto__里面存的是Parent的constructor
__proto__跟prototype可以额外找别的文章看

2、es6箭头函数的特性

1、简洁,直接返回的时候可以省略花括号跟return,一个参数的时候可以不写入参括号
2、this指向上层,上一层是箭头函数继续向上
3、不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。
4、不可以使用yield命令,因此箭头函数不能用作 Generator 函数。
5、不可以作为构造函数,因为this不是指向自己(这里只是为了说明这一点)

4、var、let、const的区别

这里主要是想说一下var的声明提前,为什么我的例子里面都是用的var
因为在我们练手的时候经常会反复声明同一个变量,只有var可以反复赋值
而且声明提前可以让我们把想放一起的代码集中到一块
但是在实际使用中我们并不希望声明提前,并且反复声明,所以用let、const
同作用域的情况下:
1、var可以反复赋值,var是声明提前
2、let、const只能赋值一次
3、const是常量,数值不能改变
4、const如果是声明的对象,只是引用被固定

const a = {}
// 下面的可以
a.name = 1;
下面不可以
a = {};
// 如果想要对象不可变
const a = Object.freeze({name:1});

5、call、apply、bind的区别

首先他们的作用都是改变this的指向(就是给this赋值)
区别:
1、入参方面 call、bind都是接收一个个逗号隔开的参数,apply接收的是数组
2、使用入参的时候都一样,apply入参是数组,取的时候还是跟call,bind一样一个个逗号隔开
3、call、apply是立即执行this赋值,bind返回了一个函数,需要手动执行了才会给this赋值
通过下面的代码来理解

var a = function(name, name1){
	this.name = name;
	this.name1 = name1;
}
var b = function(name){
	this.name = name
}
var c = function(name){
	this.name1 = name
}
var d = {};
// 顺序执行
a.call(d,'a', 'a'); // d = { name: 'a', name1: 'a' }
b.call(d, 'b'); // d = { name: 'b', name1: 'a' }
c.call(d, 'c'); // d = { name: 'b', name1: 'c' }
// 使用appply实现一样的效果
a.apply(d, ['a', 'a']) 
b.apply(d, ['b']) 
c.apply(d, ['c']) 
// 使用bind
var e = a.bind(d, 'a', 'a');
// bind返回了一个新函数,没有执行不会赋值
e();
// 一般写成立即执行
a.bind(d, 'a', 'a')() 

6、编程的几种范式

1、声明式编程----html、css,看到他们你应该懂的
2、过程式编程----一步步变量声明,执行函数下来,这个是基础,只要是开发都在用,即使是面向对象里面也会存在过程式
3、面向对象编程----也叫oop,就是原型和对象
4、函数式编程----比较复杂
个人感觉跟过程式也差不多,各种说法都有,我也不敢写太绝对,这里就讲我知道的
函数式要先知道纯函数的概念
像是数组方法 slice是截取生成新函数,不改变输入的值,就是纯函数
像是 splice会改变原数组,就不是纯函数
函数式的典型,输入一个函数,返回一个函数
代码全都是(也有人认为大多数是)纯函数来写,使用了大量的可复用的函数的编程,就差不多算是函数式编程。
咱级别不够,接触不到函数式写得很正宗的大佬,只能用自己的微薄经验总结一下:代码里多点纯函数,复用性会强一点

使用原型封装一个插件

本来是想要通过一个实际例子来讲解一下原型继承,但是写着写着发现举个例子说明起来更不好理解了。本着写了就不要浪费的原则,这里把还没写一半的代码贴出来凑凑字数,感兴趣可以瞅瞅。
在vue跟react统治国内前端的情况下,很多人都是在写组件、套模板,只有以前jquery时代大家会经常用原型封装插件。现在需要从头写原型的场景很少,个人认为canvas是最可能自己写代码会深入使用原型继承的。

假如我们想用canvas画一个圆弧进度条
在这里插入图片描述
1、先贴出html部分

<head>
    <style>
        .canvas{
            width: 400px;
            height: 400px;
            color: rgb(252, 44, 44);
        }
    </style>
</head>
<body>
    <canvas id="canvas" width="300" height="300" class="canvas"></canvas>
    <script>
		// js写在dom之后才拿得到
	</script>
</body>

2、如果习惯过程式编程的人(比如作者,因为写起来确实顺手),最开始会写成下面的风格

// 角度转弧度
function radians(degrees){
    return (Math.PI / 180) * degrees
}
// canvas画圆弧,可以不用懂
function drawArc(strokeStyle, endAngle, ctx, lineWidth, centerXY, startAngle, round){
    // 开始一段绘制
    ctx.beginPath();
    // 颜色
    ctx.strokeStyle = strokeStyle;
    // 线宽
    ctx.lineWidth = lineWidth;
    // 线的两端以圆角结束
    ctx.lineCap = 'round';
    // 画圆弧参数依次为: 中心点x,y坐标,半径,起始弧度,结束弧度
    ctx.arc(centerXY, centerXY, round, radians(startAngle), radians(endAngle));
    // 结束绘制
    ctx.stroke();
}
// 画圆弧
function draw(opitons) {
    let {
        lineWidth = 10, 
        bgColor = 'rgb(198, 219, 223)', 
        startAngle = 150, 
        endAngle = 30, 
        round, 
        margin = 20, 
        color = '#3399ff', 
        percent, 
        centerXY,
        id
    } = opitons;
    // 中心点等于半径+左(上)边距
    let centerXY = centerXY || (round + margin);
    // 获取canvas画笔
    var canvasDom = document.getElementById(id);
    var ctx = canvasDom.getContext('2d');
    // 画背景圆弧
    drawArc(bgColor, endAngle, ctx, lineWidth, centerXY, startAngle, round);
    // 根据百分比算出弧度
    let end = percent * 240 / 100 + 150;
        end= end > 360 ? (end - 360) : end;
    drawArc(color, end, ctx, lineWidth, centerXY, startAngle, round)
}
// 执行
draw({percent: 80,  id: 'canvas', round: 80})

3、当作者想要做成插件的时候,就会改成面向对象编程(用class)

// 因为上面注释很充足,这边就不写注释了
class Echarts {
    centerXY = 0;
    startAngle = 150;
    endAngle = 30;
    round = 0;
    margin = 0;
    color = '#3399ff';
    bgColor = 'rgb(198, 219, 223)';
    name = '进度';
    percent = 100;
    lineWidth = 10;
    constructor(opitons){
        this.name = opitons.name || this.name;
        this.round = opitons.round || this.round;
        this.margin = opitons.margin || this.margin;
        this.color = opitons.color || this.color;
        this.percent = opitons.percent || this.percent;
        this.centerXY = this.centerXY || (this.round + this.margin);
        var canvasDom = document.getElementById(opitons.id);
        this.ctx = canvasDom.getContext('2d');
    }
    // 画进度条
    drawGauge(){
        this.drawArc(this.bgColor, this.endAngle);
        let endAngle = this.percent * 240 / 100 + 150;
        endAngle = endAngle > 360 ? (endAngle - 360) : endAngle;
        this.drawArc(this.color, endAngle);
    }
    // 画圆弧
    drawArc(strokeStyle, endAngle){
        let ctx = this.ctx
        ctx.beginPath();
        ctx.strokeStyle = strokeStyle;
        ctx.lineWidth = this.lineWidth;
        ctx.lineCap = 'round';
        ctx.arc(this.centerXY, this.centerXY, this.round, this.radians(this.startAngle), this.radians(endAngle));
        ctx.stroke();
    }
    // 角度转弧度
    radians(degrees) {
        return (Math.PI / 180) * degrees
    }
}
var echarts = new Echarts({percent: 80,  id: 'canvas', round: 80});
echarts.drawGauge();

可以很直观的看到原型的封装的优点:
1)内部属性可以储存一些配置,内部方法可用,不用通过参数传进去
2)暴露到外部的只有原型本身,不会出现很多全局变量

4、当想要在原本的圆弧的基础上加一个圆弧,变成这样
在这里插入图片描述
实际项目中用到继承需要比较复杂的场景,作者也只是在刚学习前端的时候写了中国象棋、俄罗斯方块练了练手就再也没在项目中遇到过了,这里刻意用一下继承(用得很生硬)

class Echarts {
	...
}
class EchartsChild extends Echarts {
	constructor(options){
		super(options);
		// 父类特地不给centerXY入参,由子类自己定义centerXY,这就是原型多态
		this.centerXY = options.centerXY;
	}
	// 加多一个画双圆的方法
    drawDoubleGauge(){
        this.drawGauge();
        // 下面对round的处理是不对的,不应该对入参做变更
        // 但是由于作者偷懒,给举一个反面教材,硬实现
        this.round = this.round/2;
        this.drawGauge();
    }
}
var echarts = new EchartsChild({percent: 80,  id: 'canvas', round: 80, centerXY: 160});
echarts.drawDoubleGauge();
  • 22
    点赞
  • 57
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值