最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    正文概述 掘金(纳撸多)   2021-02-18   421

    0. 前言

    你好,我是纳撸多。今天我要分享的主题是前端技术考核:JavaScript 原型、类与面向对象编程。在这里祝福大家新年快乐~,牛气冲天!让我们一起精进,成为更好的自己。

    如果有人向你请教「原型」这两个字是什么意思,你会怎么解释呢?不同领域的人可能有不同的见解。在文学电影作品领域,主线剧情便是一个原型,例如罗密欧与茱丽叶的原型是遭遇家人反对为爱殉情的男女主角,跟中国故事的梁山伯与祝英台很像。另外,故事中的人物也可以是原型,例如英雄的原型。在游戏开发中,原型是可以快速试玩的 demo。对于网站开发,更早的原型是产品经理手中的线框图。

    那么原型是什么呢,接下来本文会从一般的原型说到面向对象、以及 JavaSc 的原型和原型链。

    文章大纲

    通过阅读完本文,你可以学到:

    1. 使用面向对象思想构造“原型”
    2. 基于「类」实现面向对象思想
    3. 基于「原型」实现面向对象思想
    4. JavaScript 原型的应用场景有哪些

    阅读时长:15 分钟

    1. “原型”与面向对象

    “原型”的一般概念

    举个例子,你还记得 2016 年大火的动画电影是什么?那就是新海诚的作品《你的名字》,且不说奏乐动听,在剧情上也让人印象深刻。剧情并非以单纯恋爱题材为主,而是“有着见面的命运却迟迟无法相见的两人”。而这部作用的原型,便是以早期的新海城《十字路口》为原型。

    在文学、电影、游戏创作等领域,除了故事情节外,人物主角也是由原型一步步改良而来。比如虐杀原型 游戏中的主角纸面原型如下,除了设定好人物的外表外,更重要的是要设定他的行为功能:

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    无论是故事剧情、人物,还是制造一个工业产品,所谓原型就是最初的样例、模型(很多时候模型和原型视为同一个东西)或者是为了测试概念而构建的产品。

    通过面向对象实现计算机中的“原型”

    在计算机编程中是通过面向对象编程概念实现原型的。要想了解什么是面向对象编程,它解决了什么问题?有必要先回顾面向过程编程。

    举个例子,假设我们需要在屏幕上画出四方形、圆形与三角形,在用户点选图形时,图形需要按顺时钟方向旋转 360 度并根据形状的不同播放不同的 AIF 音效文件,如下图所示。

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    计算机是不能识别这么复杂的命令,这些命令需要拆分为一系列简单命令,分步执行,这就是面向过程的思维。基于这个程序要执行哪些动作,我们会写出以下的代码:

    // 严格来说,下面的代码对于计算机来说,也很复杂,不过足够让我们理解面向过程的思维了。
    
    // 使用形状编号来找寻特定编号的图形
    rotate(shapeNum) {
     // 旋转 360°
    }
    
    playSound(shapeNum) {
      // 查询播放哪个 AIF 音频文件
      // 播放
    }
    

    好的,看起来程序能够满足需求。接着需求说:要新增一个阿尔米巴原虫(Amoeba)形状并播放 .hif(高保真(HiFi) 音频)声音文件。原来的 rotate 程序还可以用,但是 playSound 需要修改。

    playSound(shapeNum) {
      if 不是 Amoeba
         // 查询使用哪个 AIF 文件
         // 播放
      else
        // 播放 amoeba.hif
    }
    

    这个时候已经对原来测试过的程序代码进行了一部分的修改。假设我们一开始对形状的旋转逻辑是这样写的:

    1. 找出指定形状的外接四边形。
    2. 计算出四边形的中心点,以此点为轴做旋转。

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    而后续才被告知阿尔巴原虫的旋转轴心不是图形的中心,而是绕着一端旋转,类似秒针那样。现在,我们也要修改已经测试好的rotate 程序了,给它加上轴心点的参数。

    rotate(shapNum, xPt, yPt) {
      if 不是 Amoeba
         // 计算中心点
         // 然后旋转
      else
        // 以 xPt 和 yPt 作为旋转中心
        // 然后旋转
    }
    

    如果现在程序还要支持新的形状,可能又要对 rotateplaySound 操刀了。可见这样的代码很脆弱,只要加入新的功能很可能影响现有稳定的代码。

    面向过程思考的角度是这个程序要执行什么动作,如果使用面向对象的思维是怎么做的呢?

    那面向对象,首先得有个对象。

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    当然,这是现实世界中的人物对象。在编程中,对象得通过类进行实例化生成。所以要实现面向对象,先要实现类的定义,因为对象是靠类的模型塑造出来的。

    举个例子:

    // 定义
    public class Person {
      private void walk() {}
    }
    // 实例化
    Person personA = new Person();
    personA.walk()
    Person personB = new Person();
    personB.walk()
    

    这个时候可以让 personA 和 personB 共享 Person 类对象中的属性和方法,都具有行走的功能,大大提高可维护性和复用性,这里思考的角度是这个程序有什么样的事物(Person)?这些事物有什么事件、功能与动作(walk)?

    相信你已经了解面向过程和面向对象的区别了,让我们使用面向对象的思维重构前面的代码,首先分别为 3 个几何图形各写出一个类:

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    我们发现这三个类都有共同的类方法 rotate()playSound(),可以再抽象出一个父类出来:

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    而对于新增的阿尔米巴原虫形状,我们只需要新建一个类,继承于 Shape 父类,只需要覆写父类的 rotate() 和 playSound 方法即可,无需改动另外三个类。

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    好了,让我们再总结下面向对象的基本定义。

    • (前面例子的 Person 类)
    • 类方法(walk 方法)与属性
    • 类实例(personA 和 personB)

    基本概念:

    • 封装(对于访问者来说,访问的权限如何)
    • 继承(类、接口之间的继承,把共同的程序代码放在父类中)
    • 多态(同一个方法,不同的对象表现出不同的行为,例如 rotate)
    • 抽象(把子类的共同属性和行为抽象到父类中)

    为了加深面向对象的理解,接下来我们再设计一个程序,可以让用户将一群动物丢到某种环境中以观察会发生什么事情,

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    假设现在的程序只有一部分的动物,后续会加入其他的动物。每个动物都用一个对象来表示,且动物会在环境中活动,执行任何被设计出的行为,分析步骤如下:

    1. 分析并找出具有共同属性和行为的对象(比如 picture 图片、food 食物、hunger 饥饿程度、boundaries 活动边界、makeNoise 发出的声音、eat 吃饭、roam 散步)。
    2. 抽象出代表共同状态与行为的父类(Animal)。
    3. 决定子类是否需要让某项行为有特定不同的运作方式。(覆盖 eat、makeNoise)
    4. 通过寻找使用共同行为的子类找出更多抽象化的机会(比如 Canine 犬科、Feline 猫科)。

    可以看到以下这张图片:

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    最终得出一个简单的分类结构。

    • Animal(动物类)
      • Canine(犬科)
        • Dog(狗)
        • Wolf(狼)
      • Feline(猫科)
        • Lion(狮子)
        • Tiger(老虎)
        • Cat(猫)

    实现面向对象思想有两种方法,基于「类实现」和「基于原型」实现,下面将使用 Java 和 JavaScript 语言来实现。

    2. 基于类实现的面向对象思想

    要理解面向对象编程,最重要的特点就是根据类封装创建对象类之间继承的概念了。在 Java 中,我们可以通过声明一个类作为基类,然后通过继承这个基类定义一个新类。

    根据前面的类树状结构,我们首先定义 Animal 类,代码如下:

    public class Animal {
    	public String picture; // 动物 JPEG 图像的名称
    	public String food; // 此动物所吃的食物
    	public int hunger; // 代表饥饿程度。它会根据动物吃了多少东西而改变
    	public Boundaries boundaries; // 代表动物活动范围区域的长宽
    
    	public Animal(String picture, String food, int hunger, Boundaries boundaries) {
    		this.picture = picture;
    		this.food = food;
    		this.hunger = hunger;
    		this.boundaries = boundaries;
    	}
    
    	public void makeNoise() { // 动物发出声音的行为
    		System.out.println("Animal makeNoise!");
    	}
    
    	public void eat() { // 动物遇到食物时的行为程序
    		System.out.println("Animal eat!");
    	}
    
    	public void sleep() { // 睡眠的行为程序
    		System.out.println("Animal sleep!");
    	}
    
    	public void roam() { // 不在进食或睡眠时的行为程序
    		System.out.println("Animal roam!");
    	}
    }
    

    然后,基于 Animal 继续扩展子类,它们都覆盖了 Animal 的 makeNoise 和 eat 方法。

    编写 Canine 类如下:

    public class Canine extends Animal {
    
    	public Canine(String picture, String food, int hunger, Boundaries boundaries) {
    		super(picture, food, hunger, boundaries);
    	}
    
    	public void makeNoise() {
    		System.out.println("Canine makeNoise!");
    	}
    
    	public void eat() {
    		System.out.println("Canine eat!");
    	}
    }
    

    Feline 类:

    public class Feline extends Animal {
    
    	public Feline(String picture, String food, int hunger, Boundaries boundaries) {
    		super(picture, food, hunger, boundaries);
    	}
    
    	public void makeNoise() {
    		System.out.println("Feline makeNoise!");
    	}
    
    	public void eat() {
    		System.out.println("Feline makeNoise!");
    	}
    }
    
    

    接着,编写基于 Feline 和 Canine 类的 Dog 类 和 Cat 类:

    public class Cat extends Feline {
    	public Cat(String picture, String food, int hunger, Boundaries boundaries) {
    		super(picture, food, hunger, boundaries);
    	}
    }
    
    public class Dog extends Canine {
    
    	public Dog(String picture, String food, int hunger, Boundaries boundaries) {
    		super(picture, food, hunger, boundaries);
    	}
    }
    

    最后,我们可以根据 Dog 类和 Cat 类进行实例化并进行调用:

    public class Main {
    
    	public static void main(String[] args) {
    		Boundaries boundariesDog = new Boundaries();
    		Boundaries boundariesCat = new Boundaries();
    		Dog keji = new Dog("柯基", "骨头", 100, boundariesDog);
    		Cat meiduan = new Cat("美短", "鱼", 100, boundariesCat);
    		keji.makeNoise(); // Canine makeNoise!
    		meiduan.makeNoise(); // Feline makeNoise!
    	}
    }
    

    在这里,我们可以分析它的「原型」关系,Keji 的原型是 DogDog 的原型是 Canine,而 Canine 的原型是 Animal

    接下来,我们看看基于原型实现的面向对象思想是怎么实现的。

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    3. 基于原型实现的面向对象思想

    要想知道如何基于原型实现面向对象思想,先要知道 JavaScript 的原型与原型链是怎么来的。

    3.1 JavaScript 原型与原型链是怎么来的?

    ECMAScript 5.1 并没有正式支持面向对象的结构,比如类和继承。原型和原型链的出现是为了解决对象创建与继承的问题。对于对象创建来说,虽然使用 Object 构造函数或者对象字面量可以方便地创建对象,但是这些方式也有明显不足的地方,比如创建具有同样接口的多个对象需要重复编写很多代码。

    业界上有几种方式可以创建对象,其中包括使用原型模式来创建对象。

    1. 工厂模式
    function createPerson(name, age, job) {
      let o = new Object();
      o.name = name;
      o.age = age;
      o.job = job;	
      o.sayName = function() {
        console.log(this.name);
      };
      return o;
    }
    let person1 = createPerson("Nicholas", 29, "Software Engineer");
    let person2 = createPerson("Greg", 27, "Doctor");
    

    基本思路:这里 createPerson() 接收 3 个参数,根据这几个参数构建了一个包含 Person 信息的对象。可以用不同的参数多次调用这个函数,每次都返回包含 3 个属性和 1 个方法的对象。

    优点:解决创建多个类似对象的问题

    缺点:没有解决对象标识问题(即创建的对象是什么类型)。

    1. 构造函数模式

    基本思路:使用 ECMAScript 中的构造函数创建特定类型对象的。

    function Person(name, age, job) {
      this.name = name;
      this.age = age;
      this.job = job;
      this.sayName = function() {
        console.log(this.name);
      }
    }
    
    let person1 = new Person("Nicholas", 29, "Software Engineer");
    let person2 = new Person("Greg", 27, "Doctor");
    

    优点:通过这种方式创建的对象实例,可以通过 instanceof(更可靠) 或 constructor 确定对象类型。

    console.log(person1.constructor == Person); // true
    console.log(person2.constructor == Person); // true
    
    console.log(person1 instanceof Person); // true
    console.log(person2 instanceof Person); // true
    

    缺点:其定义的方法会在每个实例上都创建一遍。除非把方法定义在函数外部,但是反过来又会把外部作用域污染了。

    function Person(name, age, job) {
      this.name = name;
      this.age = age;
      this.job = job;
      this.sayName = sayName;
    }
    function sayName() {
      console.log(this.name);
    }
    
    1. 原型模式(基于原型实现面向对象)

    基本思路:每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型

    function Person() {}
    Person.prototype.name = "Nicholas";
    Person.prototype.age = 29;
    Person.prototype.job = "Software Engineer";
    Person.prototype.sayName = function() {
      console.log(this.name);
    }
    
    let person1 = new Person();
    person1.sayName(); // Nicholas
    
    let person2 = new Person();
    person2.sayName(); // Nicholas
    
    console.log(person1.sayName == person2.sayName); // true
    

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    • 只要创建一个函数,就会按照特定规则为这个函数创建一个 prototype 属性。
    • prototypecontructor 属性指向构造函数

    实例的 __proto__ 指向构造函数的 prototype

    • 实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
    • hasOwnProperty() 方法用于确定某个属性是在实例上还是在原型对象上。
    • in 操作符会在可以通过对象访问指定属性时返回 true,无论该属性是在实例上还是在原型上。

    原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。所有原生引用类型的构造函数(Object、Array、String 等)都在原型上定义了实例方法。

    优点:使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。

    缺点:传参以及共享引用类型的特性,要解决也简单,结合构造函数一起用,使用构造函数传参数,只把共享的方法放到原型对象上来。

    这里就是基于原型进行类的定义了。而对于原型链的出现,则与继承相关,继续往下看。

    3.2 JavaScript 类与继承

    ECMAScript5(简称:ES5) 中要实现类的继承,主要是通过原型链实现。具体可以分为原型链继承、经典继承(盗用构造函数)、组合继承、原型式继承、寄生式继承、寄生式组合继承 5 种。

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    1. 原型链继承以及优缺点

    基本思路:前面说过,每个构造函数都有一个原型对象,原型有一个属性指向构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另个构造函数。这样就在实例与原型之间构造了一条原型链。这就是原型链的基本构想。

    举个例子:

    function SuperType() {
      this.property = true;
    }
    
    SuperType.prototype.getSuperValue = function() {
      return this.property;
    }
    
    function SubType() {
      this.subproperty = false;
    }
    
    // 继承 SuperType
    SubType.prototype = new SuperType();
    SubType.prototype.getSubValue = function() {
      return this.subproperty;
    };
    
    let instance = new SubType();
    console.log(instance.getSuperValue()); // true
    

    这里的 SubType.prototype = new SuperType() 就把 SuperType 的实例直接赋给了 SubType 的原型,进而扩展了前面描述的原型搜索机制。在读取 SubType 实例上的属性时,首先会在实例上搜索这个属性。如果没找到,则会继续搜索实例的原型。在通过原型链实现继承之后,搜索就可以继承向上,搜索原型的原型,对属性和方法搜索会一直持续到原型链的末端。

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    优点:子类可以访问父类的所有属性和方法。 缺点:所有继承的属性和方法都会在对象实例间共享,无法做到实例私有。

    • 主要问题出现在原型上中包含引用值的时候。
      function SuperType() {
        this.colors = ["red", "blue", "green"];
      }
      function SubType() {}
      // 继承 SuperType
      SubType.prototype = new SuperType();
      let instance1 = new SubType();
      instance1.colors.push("black");
      console.log(instance1.colors) // "red,blue,green,black"
      
      let instance2 = new SubType();
      console.log(instance2.colors) // "red,blue,green,black"
      
    • 子类型在实例化时不能给父类型的构造函数传参。
    2. 盗用构造函数(经典继承)

    为了解决了原型包含引用值导致的继承问题,社区中出现一种叫做“盗用构造函数”的技术。 基本思路:在子类构造函数中调用父类构造函数。因为函数就是在特定上下文执行代码的简单对象,所以可以使用 apply()call() 方法以新创建的对象为上下文执行构造函数。

    function SuperType() {
      this.colors = ["red", "blue", "green"];
    }
    
    function SubType() {
      // 继承 SuperType
      SuperType.call(this); // <--关键代码
    }
    
    let instance1 = new SubType();
    instance1.colors.push("black");
    console.log(instance1.colors); // "red,blue,green,black"
    
    let instance2 = new SubType();
    console.log(instance2.colors); // "red,blue,green"
    

    new SubType()实例化后,调用了 SubType()的构造函数,间接调用了SuperType.call(this); 让每个实例都会有自己的 colors 属性。

    优点

    • 每个实例都有自己的属性,避免引用类型的混用。
    • 可以在子类构造函数中向父类构造函数传递参数。
    function SuperType(name) {
      this.name = name;
    }
    
    function SubType() {
      // 继承 SuperType 并传参
      SuperType.call(this, "Nicholas");
    
      // 实例属性
      this.age = 29;
    }
    
    let instance = new SubType();
    console.log(instance.name); // "Nicholas";
    console.log(instance.age); // 29
    

    缺点

    • 必须在构造函数中定义方法,因此函数不能重用。
    • 子类不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。
    3. 组合继承

    组合继承(有时候也叫伪经典继承)综合参考了原型链和盗用构造函数,将两者的优点集中了起来。

    基本思路:使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。

    function SuperType(name) {
      this.name = name;
      this.colors = ["red", "blue", "green"];
    }
    
    SuperType.prototype.sayName = function() {
      console.log(this.name);
    }
    
    function SubType(name, age) {
      // 继承属性
      SuperType.call(this, name);
      this.age = age;
    }
    // 继承方法
    SubType.prototype = new SuperType();
    SubType.prototype.sayAge = function() {
      console.log(this.age);
    }
    
    let instance1 = new SubType("Nicholas", 29);
    instance1.colors.push("black");
    console.log(instance1.colors); // "red,blue,green,black"
    instance1.sayName(); // "Nicholas"
    instance1.sayAge(); // 29
    
    let instance2 = new SubType("Greg", 27);
    console.log(instance2.colors); // "red,blue,green"
    instance2.sayName(); // "Greg"
    instance2.sayAge(); // 27
    

    优点:组合继承弥补了原型链和盗用构造函数的不足。既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。

    缺点:组合继承存在效率问题,最主要的效率问题就是父类构造函数始终会被调用两次:一次在是创建子类型原型时调用,另一次是在子类构造函数中调用。

    可以看到以下代码中两处调用的地方:

    function SuperType(name) {
      this.name = name;
      this.colors = ["red", "blue", "green"];
    }
    
    SuperType.prototype.sayName = function() {
      console.log(this.name);
    }
    
    function SubType(name, age) {
      SuperType.call(this, name); // 第二次调用 SuperType()
      this.age = age;
    }
    
    SubType.prototye = new SuperType(); // 第一次调用 SuperType()
    SubType.prototype.constructor = SubType;
    SubType.prototype.sayAge = function() {
      console.log(this.age)
    };
    
    4. 原型式继承

    前面 3 种类型都需要自定义类型才能共享信息,Douglas Crockford 提出即使不自定义类型也可以通过原型实现对象之间的信息共享。

    基本思路:创建一个 object 函数,这个函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时类型的一个实例。

    function object(o) {
      function F() {}
      F.prototype = o;
      return new F();
    }
    

    本质上,object() 是对传入的对象执行了一次浅赋值,如下代码所示,注意 console.log() 打印的东西。

    let person = {
      name: "Nicholas",
      friends: ["Shelby", "Court", "Van"]
    };
    
    let anotherPerson = object(person);
    anotherPerson.name = "Greg";
    anotherPerson.friends.push("Rob");
    
    let yetAnotherPerson = object(person);
    yetAnotherPerson.name = "Linda";
    yetAnotherPerson.friends.push("Barbie");
    
    console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"
    

    Crockford 推荐的原型式继承适用于这种情况:你有一个对象,想在它的基础上再创建一个新对象。你需要把这个对象先传给 object(),然后再对返回的对象进行适当修改。

    ECMAScript5 通过增加 Object.create() 方法将原型式继承的概念规范化了。

    let person = {
      name: "Nicholas",
      friends: ["Shelby", "Court", "Van"]
    };
    
    let anotherPerson = Object.create(person);
    anotherPerson.name = "Greg";
    anotherPerson.friends.push("Rob");
    
    let yetAnotherPerson = Object.create(person);
    yetAnotherPerson.name = "Linda";
    yetAnotherPerson.friends.push("Barbie");
    
    console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"
    
    

    优点:原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。

    缺点:属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

    5.寄生式继承

    与原型式继承比较接近的一种继承方式是寄生式继承(parasitic inheritance)。

    基本思路:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。

    function createAnother(original) {
      let clone = object(original);// 通过调用函数创建一个新对象
      clone.sayHi = function() { // 以某种方式增强这个对象
        console.log("hi");
      }
      return clone; // 返回这个对象
    }
    

    object() 函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。

    let person = {
      name: "Nicholas",
      friends: ["Shelby", "Court", "Van"]
    }
    
    let anotherPerson = createAnother(person);
    anotherPerson.sayHi(); // "hi"
    

    优点:寄生式同样适合主要关注对象,而不在乎类型和构造函数的场景。

    缺点:通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。

    6. 寄生式组合继承

    寄生式组合继承的出现是「为了解决组合继承的两次调用 SuperType 构造函数的效率问题」。

    function SuperType(name) {
      this.name = name;
      this.colors = ["red", "blue", "green"];
    }
    
    SuperType.prototype.sayName = function() {
      console.log(this.name);
    }
    
    function SubType(name, age) {
      SuperType.call(this, name); // 第二次调用 SuperType()
      this.age = age;
    }
    
    SubType.prototye = new SuperType(); // 第一次调用 SuperType()
    SubType.prototype.constructor = SubType;
    SubType.prototype.sayAge = function() {
      console.log(this.age)
    };
    

    有两组 name 和 colors 属性:一组在实例上,另一组在 SubType 的原型上。这是调用两次 SuperType 构造函数的结果。

    寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法来解决上面的问题。

    基本思路:不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。

    function inheritPrototype(subType, superType) {
      let prototype = object(superType.prototype); // 创建对象
      prototype.constructor = subType; // 增强对象
      subType.prototype = prototype; // 赋值对象
    }
    

    这个函数接收两个参数:子类构造函数和父类构造函数。在这个函数内部,第一步是创建父类原型的一个副本。然后,给返回的 prototype 对象设置 constructor 属性,解决由于重写原型导致默认 constructor 丢失的问题。最后将新创建的对象赋值给子类型的原型。

    代码如下:

    function SuperType(name) {
      this.name = name;
      this.colors = ["red", "blue", "green"];
    }
    
    SuperType.prototype.sayName = function() {
      console.log(this.name);
    };
    
    function SubType(name, age) {
      SuperType.call(this, name);
      this.age = age;
    }
    
    inheritPrototype(SubType, SuperType);
    
    SubType.prototype.sayAge = function() {
      console.log(this.age);
    };
    

    这里你可能有会疑问:这里为什么不可以直接把 subType.prototype = superType.prototype 呢? 这样的话,SubType 的原型与 SuperType 的完全一致了,后续如果在 SubType.prototype.xxx 也会影响 SuperType.prototype 了。

    优点:只调用了一次 SuperType 构造函数,避免了 SubType.prototype 上不必要也用不到的属性。而且,原型链仍然保持不变,因此 instanceof 操作符和 isPrototypeOf() 方法正常有效。

    缺点:需要额外实现 object() 寄生式副本函数,可以直接使用 Object.create(superType.prototype)解决。

    总的来说,寄生式组合继承可以算是引用类型继承的最佳模式。

    3.3 实现动物类程序

    通过前面的学习,相信你已经对如何使用 JavaScript 原型和原型链实现类的定义与继承比较熟悉了。现在让我们稍微回到前面提到的动物类程序,使用 JavaScript 来实现:

    Animal(动物类)

    • Canine(犬科)
      • Dog(狗)
      • Wolf(狼)
    • Feline(猫科)
      • Lion(狮子)
      • Tiger(老虎)
      • Cat(猫)

    ES5 方式

    ES5 通过「原型链」进行继承,具体可以使用「寄生式组合继承」。

    定义类
    1. 首先定义一个构造函数,通过构造函数创建对象(任何的 javaScript 函数都可以是构造函数)。
    2. 然后使用 prototype 定义公共的属性和方法以此替代没有类的定义。
    function Animal(picture, food, hunger, boundaries) {
      this.picture = picture;
      this.food = food;
      this.hunger = hunger;
      this.boundaries = boundaries;
    }
    
    Animal.prototype.makeNoise = function() {
      console.log("Animal makeNoise!");
    };
    
    Animal.prototype.eat = function() {
      console.log("Animal eat!");
    };
    
    Animal.prototype.sleep = function() {
      console.log("Animal sleep!");
    };
    
    Animal.prototype.roam = function() {
      console.log("Animal roam!");
    };
    
    
    子类和继承

    我们通过设置子类的 prototype 的值为父类的值,并在子类的构造函数调用超类的构造函数实现继承。

    function inheri(subClass, superClass) {
      subClass.__proto__ = superClass; // 子类的构造函数指向父类的构造函数,以便实现静态方法、静态属性的继承
      subClass.prototype = Object.create(superClass.prototype); // 实现原型链上的继承,以便访问公共的属性和方法
      Object.defineProperty(subClass, "constructor", {
        enumerable: false,
        value: subClass,
        writable: true,
      });
    }
    
    function Canine(picture, food, hunger, boundaries) {
      return Animal.call(this, picture, food, hunger, boundaries); // 调用父类的构造函数
    }
    inheri(Canine, Animal);
    
    // 对方法进行覆盖
    Canine.prototype.makeNoise = function() {
      console.log("Canine makeNoise!");
      return "Canine makeNoise!";
    };
    
    Canine.prototype.eat = function() {
      console.log("Canine eat!");
    };
    
    function Dog(picture, food, hunger, boundaries) {
      Canine.call(this, picture, food, hunger, boundaries); // 调用父类的构造函数
    }
    inheri(Dog, Canine);
    

    最后实例化 Dog

    const boundariesDog = new Boundaries(0, 0, 5, 5);
    const keji = new Dog("柯基", "骨头", 100, boundariesDog);
    keji.makeNoise();
    

    ES6 方式

    前面说过了 ES5 的 5 种继承方式,使用 ECMAScript 5 的特性来模拟类似于类(class-like)的行为。不难看出,各种策略都有自己的优缺点,实现继承的代码也显得非常冗长和混乱。

    为解决这些问题,ES6 引入了 class 关键字具有正式定义类的能力。虽然 ECMAScript 6 类表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念

    class SuperType {
      constructor() {
        // 添加到 this 的所有内容都会存在不同的实例上
        this.locate = () => {}
      }
      sayName() {} // 在类块中定义的所有内容都会定义在类的原型上
    }
    
    class SubType extends SuperType {
      constructor(param) {
        super(param) // 相当于 super.constructor(),调用 super() 会调用父类构造函数,并将返回的实例赋值给 this
      }
      sayAge() {}
    }
    

    ECMAScript 中,类构造函数必须使用 new 操作符。而普通构造函数如果不使用 new 调用,那么就会以全局的 this(通常是 window)作为内部对象。

    从各个方面来看,ECMAScript 类就是一种特殊函数。声明一个类之后,通过 typeof 操作符检测类标识符,表明它是一个函数。

    优点:类的语法让开发者可以优雅地定义向后兼容的类,既可以继承内置类型,也可以继承自定义类型。类有效地跨域了对象实例、对象原型和对象类之间的鸿沟。 缺点:类是新增的语法糖,刚开始接触时可能不习惯。

    接下来实现动物类程序:

    class Animal {
      constructor(picture, food, hunger, boundaries) {
        // 定义一个构造函数,当使用关键字 new 调用类时,会调用这个构造函数
        this.picture = picture;
        this.food = food;
        this.hunger = hunger;
        this.boundaries = boundaries;
      }
    
      makeNoise() {
        // 定义在原型上的共有方法
        // 动物发出声音的行为
      }
    
      eat() {
        // 动物遇到食物时的行为程序
      }
    
      sleep() {
        // 睡眠的行为程序
      }
    
      roam() {
        // 不在进食或睡眠时的行为程序
      }
    
      // 可以通过 static 关键字,实现静态函数
      static compare(canine1, canine2) {
        return canine1.hunger - canine2;
      }
    }
    

    通过 extend 关键字进行类的继承,在构造函数中通过关键字 super() 调用超类的构造函数:

    class Canine extends Animal {
      constructor(picture, food, hunger, boundaries) {
        super(picture, food, hunger, boundaries);
      }
    
      makeNoise() {}
    
      eat() {}
    }
    

    4. 应用场景

    前面一直在讨论面向对象编程以及 JavaScript 的原型,那在实际开发中原型我们可以做什么呢。

    前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    4.1 给 JavaScript API 扩展属性

    很多时候,想要用自然语言来描述日期距离某个特定时间点的时间间隔。比如,“某件事情发生在 3 秒钟前” 这种表达,远要比“某件事情发生在 x 年 x 月 x 日” 这种表达更容易理解。常见的场景例如微信朋友圈的发布时间、博客发帖等。

    下面的例子,为所有的 Date 实例都添加了 ago 获取器,它会返回自然语言描述的日期距离现在 的时间间隔。简单地访问该属性就会调用事先定义好的函数,无须显式调用。

    Object.defineProperty(Date.prototype, "ago", {
      get: function() {
        const diff = (new Date().getTime() - this.getTime()) / 1000; // get seconds
        let dayDiff = Math.floor(diff / 86400); // get days
        return (
          (dayDiff === 0 && diff < 60 && "just now") ||
          (diff < 120 && "1 minute ago") ||
          (diff < 3600 && Math.floor(diff / 60) + "minutes ago") ||
          (diff < 7200 && "1 hour ago") ||
          (diff < 86400 && Math.floor(diff / 3600) + "hours ago") ||
          (dayDiff == 1 && "Yesterday") ||
          (dayDiff < 7 && dayDiff + "days ago") ||
          Math.ceil(dayDiff / 7) + "weeks ago"
        );
      },
    });
    
    const a = new Date("2018-11-22T09:30:00");
    console.log(a.ago); // 91 weeks ago
    

    4.2 给第三方库、框架增加属性

    拿 Vue 项目举例,在开发过程中,如果要写一个 Vue 消息弹窗插件,通过下面代码这样使用:

    this.$Message(options);
    

    通过查看 Vue 打包后的源码,可以发现实例公共的方法、属性都放到 prototype 对象上了。而 Vue 框架提供了 install 实现规范,让添加 Vue 实例方法,通过把它们添加到 Vue.prototype 上实现。

    Message 组件

    <template>
      <transition>
        <div class="parent" v-if="show">
          <div class="child">{{ message }}</div>
        </div>
      </transition>
    </template>
    <script>
      export default {
        name: "toast",
        data() {
          return {
            message: "message",
            show: false,
          };
        },
      };
    </script>
    

    编写的插件如下

    import toastComponent from "./toast.vue";
    
    const Toast = {};
    
    let $vm; // 存储 Vue 实例
    
    Toast.install = function(Vue, options) {
      // 不重复
      if (Toast.installed) return;
      Toast.installed = true;
      // 挂载 $toast 到 Vue 的原型链中
      // 后续只需要通过 this.$toast 即可使用
      // 传递了四个参数
      // message 展示的内容
      // duration 时长
      // callback 关闭时的回调函数
      // config 暂时无用
      console.log(options);
      function createToast(message, duration, callback, config) {
        console.log(config);
        // 使用 Vue.extend 创建一个子类
        let Ext = Vue.extend(toastComponent);
        if (!$vm) {
          $vm = new Ext({
            el: document.createElement("div"),
          });
        }
    
        // 给对象赋值
        $vm.message = message || "message";
        $vm.duration = duration || 2500;
        $vm.show = true;
    
        // 挂载到 dom 中
        document.body.appendChild($vm.$el);
    
        // 延时消失
        setTimeout(() => {
          $vm.show = false;
          typeof callback === "function" && callback();
        }, $vm.duration);
      }
      Vue.prototype.$toast = createToast;
    };
    
    export default Toast;
    

    上面 Vue.extend API,用于命令式的创建组件,而不是声明式,最后手动 append 到 dom 中。在 iview 组件库的 message 组件,是用这个 api 去动态构造组件的

    注册到 Vue 上

    import Vue from "vue";
    import toast from "./src/index.js";
    Vue.use(toast);
    

    外部进行使用

    <template>
      <div><h2>Hello, MyPlugin</h2></div>
    </template>
    
    <script>
      export default {
        mounted() {
          console.log(this.$toast("Hello, Jecyu!"));
        },
      };
    </script>
    
    

    5. 小结

    本文通过引进原型的概念,来谈谈面向对象的思想以及如何通过传统的面向对象语言 Java 来怎样新建对象以及继承类的。接着说说 JavaScript 又是如何通过原型来实现这两个概念的,它们的本质上的区别是基于类继承与基于原型继承。

    另外,要进一步学习面向对象思想,往往需要掌握 UML 建模,顾名思义建立模型,也是建立原型的过程,之后再对系统进行具体的编码,具体可以看《面向对象与设计分析》。

    参考资料

    • Trying to understand the difference between prototype and constructor in JavaScript

    起源地下载网 » 前端技术考核:JavaScript 原型、类与面向对象编程 |牛气冲天新年征文

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元