JavaScript OOP前传:ES6前JS如何苦苦追求面向对象?

63 阅读8分钟

前言

我们都知道OOP(面向对象编程)是一个大型语言中非常重要的一个特性,这篇文章就来给大家详细介绍JS在早期ES6之前是如何实现OOP的!



OOP是什么

OOP就是面向对象编程,它有三个特征:

  • 封装 Encapsulation
  • 继承 Inheritance
  • 多态 Polymorphism

它的核心思想就是通过类来抽象事物,将属性和方法封装在一起,类通过继承实现代码复用,并且可以通过多态允许不同类对同一方法有同一实现,我们可以创建该类的一个具体对象,模拟现实世界的具体事物。



其它语言是如何实现OOP的?

这里拿大家熟悉的Java来举例,在Java中,有关键词Class来帮我们设计一个类,然后可以通过new 类名()的方式来创建一个具体的对象

// 定义Dog 类
public class Dog {
    //属性 
    String name;
    int age;
    
    //构造方法
    public Dog(String name,int age) {
        this.name = name;
        this.age = age;
    }
    //普通方法
    public void say( ) {
        System.out.println("汪汪汪");
    }
}

// 通过new 来创建一个对象
myWhiteDog = new Dog("White",2);


JS的历史:为什么曾经没有class?

但在我们今天要介绍ES之前的JS,所以彼时并没有Class关键字 支持我们方便快捷地申明类和创建对象。

当初JS的发明者布兰登·艾克在1995年被网景公司催促,要求在10天内开发出JS,目的是为了实现页面的显示和交互,以增强网页的能力,这哥们考虑要实现class的设计往往需要花费大量的时间,而且老板的要求是为了注重于能够增强页面的显示,于是,他就没有像其它大型语言一样去设计class,而是用原型的一系列设计(下文我们会提到)来暂时实现面向对象的效果。

所以这就是为什么ES6之前并没有class的原因了。那么这个时候我们到底具体是用什么来实现面向对象编程的呢?且听我娓娓道来!

函数模拟类

试想一下,如果我们要创建很多有相同属性的对象,我们可以通过对象字面量的方式创建。

const zs = {
    name: "张三",
    age: 18,
}

const ww = {
    name: "王五",
    age: 20,
}

const zl = {
    name: "赵六",
    age: 19,
}

但这样子是不是太麻烦了?我们在做很多重复的工作欸。

于是JS的发明团队看不下去了,故使用函数来模拟一个类,该函数比较特殊,称为构造函数。

我们可以通过new这个构造函数的方式来进行对象的创建。

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

let zs = new Person("张三",18);
let ww = new Person("王五",20);
let zl = new Person("赵六",19);

我们这里的函数Person在JS中,即是构造函数,也是Person类,对“人”进行了封装,申明了name和age这两个属性,所以我们可以基于这个类来创建不同的具体对象。(这也许在你看来很离谱,但当时JS就是那么窘迫,使用一个函数来身兼两职,又要当类,又要当类的构造函数,没办法,谁叫这是在10天内开发出来的语言呢)

注意:我们一般在写构造函数的时候,要将构造函数的名字进行首字母大写(Person,Dog,Apple),这是一个规范,表示一个类!



原型式的面向对象

刚刚我们已经成功创建了构造函数,并且使用该构造函数创建了对象。

那么,接下来我们该如何声明该构造函数的方法呢?

你可能会说:在构造函数中直接声明不就好啦,但这是不对的,你说的这个是JS中的闭包(函数内声明函数)。而下面才是我们的正确做法!

Person.prototype = {
    sayHello: function(){
        console.log("hello"+ this.name)
    }
}

接下来让我向你隆重的介绍JS中的原型!!!


prototype与__proto__

在 JavaScript 中,prototype 和 __proto__  是理解原型继承(Prototype Inheritance)的关键概念,它们密切相关但用途不同。以下是对他们两个的解释与对比:

prototype(显式原型)

prototype仅存在于函数对象上(一般是指构造函数)

它的作用是:定义由该构造函数创建的对象的共享属性和方法! 请看下面的代码

function Dog(name) {
  this.name = name;
}
// 通过 prototype 添加共享方法
Dog.prototype.bark = function() {
  console.log(this.name + " says: Woof!");
};

const dog1 = new Dog("Buddy");
dog1.bark(); // "Buddy says: Woof!"

__proto__(隐式原型)

JS中的每个对象都有__proto__属性,包括函数对象。

__proto__的作用是访问其构造函数的原型(prototype)。

在上面的代码中,dog1对象的__porto__就是指向其构造函数的原型prototype

console.log(dog1.__proto__)  //{ bark: [Function (anonymous)] } 表示匿名函数(anonymous)bark

能够输出{ bark: [Function (anonymous)] } 是因为我们定义了Dog构造函数的prototypeDog.prototype.bark = function() { console.log(this.name + " says: Woof!"); };

倘若我们不定义构造函数Dog的原型prototype的话,那么则会输出{}

function Dog(name) {
  this.name = name;  
}
// 没有定义构造函数Dog的prototype
const dog1 = new Dog("Buddy");
console.log(dog1.__proto__);  {}

这是因为每个函数都自带一个prototype属性,这个属性的初始值是一个空对象。

这里我们没定义,所以就会输出构造函数Dog自带的{}啦!

所以我们既可以定义构造函数的Prototype,也可以默认不定义,此时__proto__的输出结果也会不同!


默认__proto__

我们创建的每个普通对象的原型在默认情况下为[Object: null prototype] {}。你也可以去修改__proto__来修改它的原型。

let obj1 = {
    a:1
}

let obj2 = {
    b:2
}


console.log(obj1.__proto__)  //[Object: null prototype] {}
console.log(obj2.__proto__)  //[Object: null prototype] {}

//也可以自己修改
obj1.__proto__ = {name:"haahahahahah"}
console.log(obj1.__proto__)  //{ name: 'haahahahahah' }

而我们创建的每个函数的原型在默认情况下为[Function (anonymous)] Object。同样的,你可以通过修改该函数的__proto__来修改其原型。


    var fn1 = function()
    {
        console.log("Im a function")
    }

    function fn2(name)
    {
        this.name=name
    }

    console.log(fn1.__proto__)  //[Function (anonymous)] Object
    console.log(fn2.__proto__)  //[Function (anonymous)] Object
    
    //也可以自己修改
    fn1.__proto__ = ["heheheeheheheheheheheh"];
    console.log(fn1.__proto__)  //[ 'heheheeheheheheheheheh' ]

这里要注意,函数的默认原型为[Function (anonymous)] Object,函数的默认原型的原型仍然是[Object: null prototype] {}

console.log(fn1.__proto__.__proto__)  //[Object: null prototype] {}


原型链查找机制

那么知道了__proto__和prototype之后,原型链是如何查找的呢,请看下面的代码:

function Dog(name) {
    this.name = name;
  }
  // 通过 prototype 添加共享方法
  Dog.prototype.bark = function() {
    console.log(this.name + " says: Woof!");
  };
  const dog1 = new Dog("Buddy");


  
  //原型链: dog1 实例 -> Dog.prototype -> Object.prototype -> null
    console.log(dog1.__proto__ === Dog.prototype); // true
    console.log(Dog.prototype.__proto__ === Object.prototype); //true
    console.log(Object.prototype.__proto__ === null)  //true

在原型链的查找中,会一层一层往上找,到null则为终点,实际上万物的原型链的终点都为null。

所以

当我们访问一个对象的属性时,JavaScript 会执行以下步骤:

  1. 首先在对象自身属性中查找
  2. 如果找不到,则在其原型对象(__proto__)中查找
  3. 继续沿着原型链向上查找,直到找到该属性或到达原型链末端(null)


JS的设计哲学:对象和构造函数没有血缘关系

理解了上面原型后,我希望你明白这一点:对象和构造函数没有血缘关系。

在其它传统的面向对象语言中,例如Java是有严格的继承体系,实例对象内会存放父类的信息,而且对象和类之间是严格且不可变的,所以它们是有血缘关系的。

而在JS中的原型式的面向对象中,对象实例仅仅只是通过__proto__与构造函数的prototype关联,实例仅仅只是通过new引用了构造函数这一种创建方式。它们并没有直接的关系,我的理解是构造函数只是一个工具,对象只是借用了这个工具完成了自己的产品。

下面是一些代码上的体现:

  // 1. 构造函数销毁不影响实例
function Cat() {}
const cat = new Cat();
Cat = null; // 销毁构造函数
cat.__proto__ === Cat.prototype // false(但实例仍正常工作)

// 2. 动态修改原型链
function Animal() {}
const obj = {};
Object.setPrototypeOf(obj, Animal.prototype);
console.log(obj instanceof Animal); // true(临时建立"继承"关系)

// 3. 构造函数不是实例的owner
function Car() {}
const car = new Car();
console.log(car.parent === Car); // undefined(不存在血缘标记)

"没有血缘关系"准确描述了 JavaScript 原型继承的松散耦合特性。对象与构造函数之间是通过原型链建立的动态委托关系,而非传统面向对象中严格的父子继承关系。这种设计提供了更大的灵活性。



总结

本文介绍了 JS在ES6之前是如何通过原型的方式来实现面向对象的编程的,这是一种很独特的方式,也是学习JS的学者必须熟悉的一种方式,即使后面ES6引入了class语法,但其实底层仍然是通过这种方式来实现的;同时我们还了解到了这种原型式的面向对象中,对象和构造函数没有血缘关系,对象与构造函数之间是通过原型链建立的动态委托关系。这种设计提供了更大的灵活性,但也需要开发者明确理解原型机制。



🌇结尾

感谢你看到最后,最后再说两点~
①如果你持有不同的看法,欢迎你在文章下方进行留言、评论。
②如果对你有帮助,或者你认可的话,欢迎给个小点赞,支持一下~
我是3Katrina,一个热爱编程的大三学生

(文章内容仅供学习参考,如有侵权,非常抱歉,请立即联系作者删除。)

作者:3Katrina
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。