【JavaScript】面向对象与原型🚀案例+图解

146 阅读6分钟

但行好事 莫问前程

前言🎀

JavaScript是一门支持面向对象的语言,我们可以自由的在开发中使用类、对象与继承,这使得代码具有更好的扩展性和复用性。

可能你已经习惯于在代码中使用 class / new / extends,它们仿佛与其他语言的类没什么区别:

// Java
public class Father { ... }

public class Son extends Father {
    public Son() {
        super();
    }
    ...
}

Son son = new Son();

// JavaScript
class Father { constructor(...) {} }

class Son extends Father { 
    constructor(...) { 
        super(); 
    } 
    ...
}

const son = new Son();

但在ES6之前实现这些操作并不是一件简单的事情,它涉及到 对象、函数 之间的应用 和 JavaScript中特有的原型机制,而且它们的实现可能与你预想的很不一样。

本文我们一起学习 面向对象与原型 的相关知识,提升对JavaScript的理解,希望能对你有所帮助~

面向对象 OOP

面向对象 是当前主流的编程范式,让我们(开发者)能在软件中对真实世界的事物进行抽象建模,其核心思想可以概括为:封装、继承、多态。

根据面向对象的思想,我们将客观的事物根据特征抽象为 对象,并将有相同特征的一系列对象规定为 ,之后以类为模板 实例化 出具体对象,且类通过 继承 机制支持扩展和重写。

oop.png

而JavaScript作为一门支持面向对象的编程语言,自然需要实现上述的 类、实例化、继承

Class

类是创建对象的模板,包含属性和方法的定义,开发中,我们通常先定义类再实例化对象。

JavaScript的设计中并没有 类(class) 关键字,仅能通过 构造函数 来模拟类,基于 原型 实现继承

虽然ECMAScript6中引入了classextends关键字,让开发者可以像其他语言一样使用类,但这其实只是一种语法糖,其底层实现仍然是构造函数和原型

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  sayHello() {
    console.log(`Hello, my name is ${this.name}, I'm ${this.age} years old.`);
  }
}

// 等价于

function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.sayHello = function() {
  console.log(`Hello, my name is ${this.name}, I'm ${this.age} years old.`);
};

构造函数

首字母大写、用于创建对象 的函数通常被称为构造函数,但构造函数与普通函数的区别只有是否使用new关键字调用

使用new关键字的函数调用会变成 “构造函数调用”,创建对象实例并返回:

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayHello = function () { 
        console.log(`Hello, my name is ${this.name}, I'm ${this.age} years old.`); 
    }
}

const p1 = new Person('xiaoming', 20);
console.log(p1);  // Person {name: 'xiaoming', age: 20, sayHello: ƒ}
const p2 = new Person('xiaohong', 18);
console.log(p2);  // Person {name: 'xiaohong', age: 18, sayHello: ƒ}

console.log(p1 === p2);  // false

通过 new 和 构造函数,JavaScript 初步模拟了类,实现了类的实例化。

但如果没有 继承机制,JavaScript 中的类只是一个空架子

继承

继承机制 允许我们从现有类中创建一个新类并 继承它的属性和方法,且支持对继承到的内容进行重写和扩展

extends.png

继承建立了类与类之间的关系,形成逻辑和现实世界对象之间的关系模型。我们可以通过子类继承父类,进一步对真实事物进行描述。

class Person { ... }

// Programmer类 继承于 Person类
class Programmer extends Person {
  constructor(name, age, language) {
    super(name, age);
    // 扩展
    this.language = language;
  }
  // 重写
  sayHello() {
    console.log(`Hello, my name is ${this.name}, I'm ${this.age} years old. I'm a ${this.language} programmer.`);
  }
}

const person1 = new Person('John', 30);
const programmer1 = new Programmer('Jane', 25, 'JavaScript');

person1.sayHello(); //  "Hello, my name is John, I'm 30 years old."
programmer1.sayHello(); //  "Hello, my name is Jane, I'm 25 years old. I'm a JavaScript programmer."

而在JavaScript中,继承是基于 原型 来实现的。

原型

传统面向对象语言中继承意味着复制操作,但 JavaScript(默认)并不会复制对象属性,相比之下JavaScript的继承更像是委托而不是复制

JavaScript会在两个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。

而对象之间的关联是通过原型来实现的,接下来我们一起学习JavaScript是如何实现这套继承机制的~

原型对象

在JavaScript中,每个函数在被定义的同时会生成一个原型对象,并赋值给函数的 prototype 属性。

prototype

prototype 是函数特有的属性,指向了原型对象。

原型对象包含了类的公共属性和方法,且原型对象自身会生成 constructor 属性与创建它的函数关联。

根据原型对象,我们重写前文的构造函数:

function Person(name, age) {
    this.name = name;
    this.age = age;
-   this.sayHello = function () { 
-       console.log(`Hello, my name is ${this.name}, I'm ${this.age} years old.`); 
-   }
}

+ Person.prototype.sayHello = function() { 
+   console.log(`Hello, my name is ${this.name}, I'm ${this.age} years old.`); 
+ };

prototype.png

之后,在实例对象中可以正常使用sayHello方法,即使该方法不在实例自身:

const person1 = new Person('xiaoming', 20);

person1.hasOwnProperty('sayHello');  // false

person1.sayHello();  // "Hello, my name is xiaoming, I'm 20 years old."

Q:为什么实例对象可以访问到自身不存在的属性和方法?
A:访问属性时,引擎会调用内部的默认[[Get]]操作, [[Get]]操作会检查对象本身是否包含这个属性,如果没找到会去对象的 __proto__ 属性上查找。

此外这种方式可以节省内存,并且代码更易于维护

__proto__

JavaScript 实例化对象时,会为每个对象添加一个内部属性__proto__并指向构造函数的原型对象prototype,使对象可以访问原型对象中的属性和方法。

person1.__proto__ === Person.prototype; // true

注:每个对象都有__proto__属性,但只有函数对象才有prototype属性

原型链

prototypeChain.png

原型对象也是对象,自身也有__proto__属性指向原型对象的原型,就这样,原型对象之间形成了一个链条,我们把这种链式结构称为原型链

所以[[Get]]操作更准确的讲是会从对象自身开始,去遍历查找对象的原型链。

原型链的尽头

进一步展开对象的原型链,我们可以看到正常情况下(原型对象是可以被覆盖的)原型对象prototype__proto__属性指向了Object的原型对象。

image.png

Object函数的原型对象的原型(即Object.prototype.__proto__)指向了null,所以我们可以认为原型链的尽头是null

person1.__proto__.__proto__.__proto__ === null

此外,函数也是对象,函数对象同样拥有__proto__属性,指向Function函数的原型对象,即 Person.__proto__ === Function.prototyoe,而Function.prototyoe__proto__指向了Object.prototype,尽头最终仍是指向了null。

总结

对于JavaScript的继承来说,“委托” 是一个很合适的术语,因为对象之间的关系不是复制而是委托,而委托则是借助原型机制实现的。

面向对象:

  1. JavaScript 是一门支持面向对象的语言,需要实现 类 与 继承
  2. 可以为模板 实例化 出具体对象,继承 使类支持 扩展 和 重写
  3. JavaScript 中class、extends只是语法糖,本质上是 构造函数 和 继承
  4. 任何使用 new关键字 的函数调用会变成 “构造函数调用”,会创建对象实例并返回

原型与原型链:

  1. 每个函数在定义时会生成 原型对象 并赋值给函数的prototype属性
  2. 原型对象:包含了类的公共属性和方法,生成的同时创建constructor属性指向函数
  3. 每个对象存在代表原型的内部属性__proto__,指向它构造函数的原型对象prototype
  4. 原型对象同样存在__proto__属性,指向原型对象的原型
  5. prototype是函数属性,__proto__是对象属性
  6. __proto__ 将原型对象连接起来组成了原型链
  7. [[Get]]操作时会从对象自身开始,遍历查找对象的原型链
  8. Object是原型链的顶端,它的原型对象是null
  9. 函数对象的 原型__proto__ 指向 Function原型对象Function.prototype

题外话

部分内容没有深入讨论,更深的细节还需自己钻研,例如:

  • 实现class的 寄生组合式继承
  • new调用函数的流程、this的指向问题
  • hasOwnProperty判断属性和方法、Object.create创建纯净的空对象
  • . . . . . .

更多JavaScript相关知识,欢迎查阅:

# 【JavaScript】详解作用域与闭包🚀案例+图解
# 【JavaScript】面试高频代码原理与实现,防抖节流、深拷贝、继承 、Promise....

结语🎉

不要光看不实践哦,希望本文能对你有所帮助~

如果有收获还望 关注+点赞+收藏 🌹

才疏学浅,如有问题或建议还望指教!