JavaScript 中的面向对象

89 阅读10分钟

JavaScript 中的面向对象

js中面向对象的概念和其他语言(c++、java等)不同,js严格来说没有“类”的概念,而是用原型链的形式实现了多态、继承和封装。因此,能否理解原型链一定程度上直接决定了是否真正理解了js中的面向对象。

一、创建对象

使用 Object 构造函数或对象字面量都可以创建单个对象。为了使用统一接口创建多个对象,同时避免代码的冗余,人们开始使用工厂模式的一种变体。

1、工厂模式

工厂模式的思想是设计一种函数,将对象的创建、初始化等细节封装在函数中。

 function createPerson(name, age) {
     var o = new Object();
     o.name = name;
     o.age = age;
     o.sayName = function() {
         alert(this.name);
     }
     return o;
 }
 ​
 var person1 = createPerson("Greg", 27);

工厂模式解决了多个相似对象的问题,但却没有解决对象识别的问题(即怎样知道一个对象的类型)。于是构造函数模式应运而生。

2、构造函数模式

 function Person(name, age) {
     this.name = name;
     this.age = age;
     this.sayName = function() {
         alert(this.name);
     }
 }
 ​
 var person1 = new Person("Greg", 27);

在这个例子中,Person() 函数取代了 createPerson() 函数。它们的不同之处在于:

  • 没有显示地创建对象
  • 直接将属性和方法赋值给 this 对象
  • 没有 return 语句

要创建 Person 的新实例。必须使用 new 操作符。以这种方式调用构造函数实际上会经历以下4个步骤:

  1. 创建一个新对象
  2. 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象)
  3. 执行构造函数中的代码
  4. 返回新对象

构造函数模式也有一定缺点。在前面的例子中,person1 和 person2 都有一个名为 sayName() 的方法,但这两个方法不是同一个 function 的实例。因此,可以像下面这样,把函数定义转移到构造函数外部。

 function Person(name, age) {
     this.name = name;
     this.age = age;
     this.sayName = sayName;
 }
 ​
 function sayName() {
     alert(this.name);
 }
 ​
 var person1 = new Person("Greg", 27);

这样做确实解决了两个函数做同一件事的问题,可是把函数定义在全局作用域中就破坏了封装性。于是,出现了原型模式。

3、原型模式

我们创建的每个函数都有一个 prototype(原型)属性,这个属性是一个指针,指向一个对象,这个对象包含由特定类型的所有实例共享的属性和方法。通过使用原型对象,我们可以让所有对象实例共享它所包含的属性和方法。

 function Person() {
 }
 ​
 Person.prototype = {
     name: "Greg",
     age: 27,
     sayName: function() {
         alert(this.name);
     }
 }
 ​
 var person1 = new Person();

原型模式也不是没有缺点。首先,它省略了为构造函数传递初始化参数这一环节,结果所有实例在默认情况下都取得相同的属性值。尤其是对于包含引用类型的属性来说,问题就比较突出了。

 function Person() {
 }
 ​
 Person.prototype = {
     constructor: Person,
     name: "Greg",
     age: 27,
     friends: ["Shelby", "Court"],
     sayName: function() {
         alert(this.name);
     }
 }
 ​
 var person1 = new Person();
 var person2 = new Person();
 ​
 person1.friends.push("Van");
 ​
 alert(person1.friends);   //"Shelby,Court,Van"
 alert(person2.friends);   //"Shelby,Court,Van"
 alert(preson1.friends === person2.friends);   //true

4、组合使用构造函数模式和原型模式

构造函数模式用于定义实例属性,原型模式用于定义方法和共享属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用。

 function Person(name, age) {
     this.name = name;
     this.age = age;
     this.friends = ["Shelby", "Court"];
 }
 ​
 Person.prototype = {
     constructor: Person,
     sayName: function() {
         alert(this.name);
     }
 }
 ​
 var person1 = new Person("Nicholas", 29);
 var person2 = new Person("Greg", 27);
 ​
 person1.friends.push("Van");
 alert(person1.friends);   //"Shelby,Court,Van"
 alert(person2.friends);   //"Shelby,Court"
 alert(person1.friends === person2.friends);   //false
 alert(person1.sayName === person2.sayName);   //true

5、动态原型模式

动态原型模式把所有信息都封装在了构造函数中。

 function Person(name, age) {
     this.name = name;
     this.age = age;
     
     if(typeof this.sayName != "function") {   //判断sayName()方法是否存在,如果不存在则创建。这段代码只会在初次调用构造函数时才会执行。
         Person.prototype.sayName = function() {
             alert(this.name);
         }
     }
 }
 ​
 var friend = new Person("Greg", 27);
 friend.sayName();

使用动态原型模式时,不能使用对象字面量重写原型。如果在已经创建了实例的情况下重写原型,那么就会切断现有实例与新原型之间的联系。

6、寄生构造函数模式

 function Person(name, age) {
     var o = new Object();
     o.name = name;
     o.age = age;
     o.sayName = function() {
         alert(this.name);
     }
     return o;
 }
 ​
 var person = new Person("Greg", 27);

除了使用 new 操作符并把使用的包装函数叫做构造函数之外,这个模式跟工厂模式其实是一模一样的。构造函数在不返回值的情况下,默认会返回新对象实例。而通过在构造函数的末尾添加一个 return 语句,可以重写调用构造函数时返回的值。

关于寄生构造函数模式,返回的对象与构造函数或者构造函数的原型属性之间没有关系,为此,不能依赖 typeof 操作符来确定对象类型。

7、稳妥构造函数模式

所谓稳妥对象,指的是没有公共属性,而且其方法也不引用 this 对象。稳妥对象最适合在一些安全的环境中(这些环境中会禁止使用 this 和 new),或者防止数据被其他应用程序改动时使用。

 function Person(name, age) {
     var o = new Object();
     var name = name, age = age;
     o.sayName = function() {
         alert(name);
     }
     return o;
 }
 ​
 var person = new Person("Greg", 27);

这样,变量 friend 中保存的是一个稳妥对象,而除了调用 sayName() 方法外,没有别的方式可以访问其数据成员。

二、继承

1、原型链

每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象内部的指针。假如我们让原型对象等于另一个类型的实例。那么,此时的原型对象将包含另一个原型的指针。如此层层递进,就构成了实例与原型的链条,即原型链。

 function SuperType() {
     this.property = true;
 }
 ​
 SuperType.propertype.getSupervalue = function() {
     return this.property;
 }
 ​
 function Subtype() {
     this.subproperty = false;
 }
 ​
 //继承了 SuperType
 SubType.prototype = new SuperType();
 ​
 SubType.prototype.getSubValue = function() {
     return this.subproperty;
 }
 ​
 var instance = new SubType();
 alert(instance.getSuperValue());  //true

在上面的代码中,没有使用 SubType 默认提供的原型,而是给它换了一个新原型;这个新原型就是 SuperType 的实例。

image-20210103141412689

原型链的问题

包含引用类型值的原型属性会被所有实例共享,这也是为什么要在构造函数中,而不是在原型属性中定义属性的原因。

 function SuperType() {
     this.colors = ["red", "blue", "green"];
 }
 ​
 function SubType() {
 }
 ​
 //继承了 SuperType
 SubType.prototype = new SuperType();
 var instance1 = new SubType();
 instance1.color.push("black");
 alert(instance1.colors);    //"red,blue,green,black"
 ​
 var instance2 = new SubType();
 alert(instance2.colors);    //"red,blue,green,black"

这个例子中的 SuperType 构造函数定义了一个 colors 属性,该属性包含一个数组(引用类型值)。当 SubType 通过原型链继承了 SuperType 之后,SubType.prototype 就变成了 SuperType 的一个实例,因此它也拥有了一个它自己的 colors 属性,结果是 SubType 的所有实例都会共享这一个 colors 属性。

2、借用构造函数

这种技术的基本思想相当简单,即在子类型构造函数的内部调用超类型构造函数。

 function SuperType() {
     this.colors = ["red", "blue", "green"];
 }
 ​
 function SubType() {
     //继承了 SuperType
     SuperType.call(this); //通过使用 apply() 或 call() 方法可以在新创建的对象上执行构造函数
 }
 ​
 var instance1 = new SubType();
 instance1.colors.push("black");
 alert(instance1.colors);  //"red,blue,green,black"
 ​
 var instance2 = new SubType();
 alert(instance2.colors);  //"red,blue,green"

传递参数

 function SuperType(name) {
     this.name = name;
 }
 ​
 function SubType() {
     //继承了 SuperType,同时还传递了参数
     SuperType.call(this, "Greg");
     
     //实例属性
     this.age = 27;
 }
 ​
 var instance = new SubType();
 alert(instance.name);   //"Greg"
 alert(instance.age);    //27

3、组合继承

顾名思义,组合继承指的是将原型链和借用构造函数的技术结合到一块。其思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。

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

4、原型式继承

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

在 object() 函数内部,先创建了一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回了这个临时类型的一个新实例。从本质上讲,object() 对传入其中的对象执行了一次浅复制。

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

ECMAScript5 通过新增 Object.create() 方法规范化了原型式继承。

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

5、寄生式继承

寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真的是它做了所有工作一样返回对象。

 function createAnother(original) {
     var clone = object(original);
     clone.sayHi = function() {
         alert("Hi");
     };
     return clone;
 }
 var person = {
     name: "Greg",
     friends: ["Shelby", "Court", "Van"]
 };
 ​
 var anotherPerson = createAnother(person);
 anotherPerson.sayHi();    //"hi"

6、寄生组合式继承

组合继承是 JavaScript 中最常用的继承模式。不过,它也有自己的不足。组合继承最大的问题就是无论什么情况下,都会调用两次超类型构造函数,如下所示:

 function SuperType(name) {
     this.name = name;
     this.colors = ["red", "blue", "green"];
 }
 ​
 SuperType.prototype.sayName = function() {
     alert(this.name);
 }
 ​
 function SubType(name, age) {
     //继承属性
     SuperType.call(this, name);     //第二次调用 SuperType()
     this.age = age;
 }
 ​
 //继承方法
 SubType.prototype = new SuperType();  //第一次调用 SuperType()
 SubType.prototype.constructor = SubType;
 SubType.prototype.sayAge = function() {
     alert(this.age);
 }

在第一次调用 SuperType 构造函数时,SubType.prototype 会得到两个属性:name 和 colors;当调用 SubType 构造函数时,又会调用一次 SuperType 构造函数,这一次又在新对象上创建了实例属性 name 和 colors。于是,这两个属性就屏蔽了原型中的两个同名属性。

解决方法是寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。

 function inheritPrototype(subType, superType) {
     var prototype = object(superType.prototype);  //创建对象
     prototype.constructor = subType;        //增强对象
     subType.prototype = prototype;          //指定对象
 }
 function SuperType(name) {
     this.name = name;
     this.colors = ["red", "blue", "green"];
 }
 ​
 SuperType.prototype.sayName = function() {
     alert(this.name);
 };
 ​
 function SubType(name, age) {
     SuperType.call(this, name);
     this.age = age;
 }
 ​
 inheritPrototype(SubType, SuperType);
 SubType.prototype.sayAge = function() {
     alert(this.age);
 };

image-20210104115142046

三、对象中简单类型与引用类型的不同表现

1、在构造函数中的属性

使用构造函数创建对象时,每个对象开辟自己的堆空间,对构造函数中所有类型的属性执行深拷贝并存入堆空间。

也就是说,使用构造函数创建出来的对象,其属性相互独立,互不影响。

示例:

 function Person() {
     this.name = 'emiria';
     this.age = [14, 104];
 }
 var person1 = new Person();
 var person2 = new Person();
 person1.age.push(200)
 console.log("person1's age:", person1.age);   // [14, 104, 200]
 console.log("person2's age:", person2.age);   // [14, 104]

2、在原型对象中的属性

原型对象中的属性由所有实例共享,但实例并不能重写原型中的属性。

如果我们对实例赋值一个属性,而该属性名与实例原型中的某个属性名同名,那么我们就在实例中创建了该属性,而非对实例中的属性进行重写。

示例:

 function Person() {
 }
 Person.prototype.hobby = ['sing', 'write'];
 var person1 = new Person();
 var person2 = new Person();
 person1.hobby = ['play games', 'also play games'];
 console.log("person1's hobby:", person1.hobby);   // ["play games", "also play games"]
 console.log("person2's hobby:", person2.hobby);   // ["sing", "write"]

注意:不能重写原型中的属性不代表不能改写。

事实上,我们可以改写原型中的属性,并且由于实例共享原型中的属性,因此改写的影响会辐射到所有继承自该原型的实例。

示例:

 function Person() {
 }
 Person.prototype.hobby = ['sing', 'write'];
 var person1 = new Person();
 var person2 = new Person();
 person1.hobby.push('play games');
 console.log("person1's hobby:", person1.hobby);   // ["sing", "write", "play games"]
 console.log("person2's hobby:", person2.hobby);   // ["sing", "write", "play games"]

参考书目:《JavaScript高级程序设计》