这里写自定义目录标题
前言
这是一道非常经典的前端面试题,尤其是对于初中级前端来说,基本上是必考题。当我还是一个前端萌新的时候,也被问到过很多次。当时我对原型、原型链、继承的理解,也就是面试题看懂了,面试的时候能忽悠几句的程度,面试官也不一定真的搞懂了,所以也总能蒙混过关。
但是随着新一代前端的成长,大家用的都是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();