JavaScript的面向对象编程

320 阅读8分钟

什么是原型

在不同的编程语言中,设计者也利用各种不同的语言特性来抽象描述对象。

  • 一种是基于类来描述对象。
    • 在这类语言中,总是先有类,再从类去实例化一个对象。类与类之间又可能会形成继承、组合等关系。
    • 基于原型的面向对象系统通过“复制”的方式来创建新对象。一些语言的实现中,还允许复制一个空对象。这实际上就是创建一个全新的对象
  • 另一种是基于原型来描述对象。
    • 这种更倾向于关注一系列对象实例的行为,再关心如何将这些对象划分到最近的使用方式相似的原型对象,而不是将它们分成类。
    • JavaScript的原型并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用

JavaScript的原型

  • 如果所有对象都有私有字段[[prototype]],就是对象的原型;
  • 读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止。

es6新增的访问操纵原型方法

  • Object.create 根据指定的原型创建新对象,原型可以是null;
  • Object.getPrototypeOf 获得一个对象的原型;
  • Object.setPrototypeOf 设置一个对象的原型。

浏览器中也可以通过对象的__proto__属性(前后各两个下划线),用来读取或设置当前对象的原型对象(prototype)。但是,__proto__属性本质上是一个内部属性,而不是一个正式的对外的 API,只是由于浏览器广泛支持,才被加入了 ES6。标准明确规定,只有浏览器必须部署这个属性,其他运行环境不一定需要部署,而且新的代码最好认为这个属性是不存在的。因此,无论从语义的角度,还是从兼容性的角度,都不要使用这个属性。

早期版本的类与原型

在早期版本的JavaScript中,“类”的定义是一个私有属性 [[class]],可以通过Object.prototype.toString来访问,可以用这个方法来判断变量的类型。

Object.prototype.toString.call('123'); // "[object String]"
Object.prototype.toString.call(123); // "[object Number]"
Object.prototype.toString.call(true); // "[object Boolean]"
Object.prototype.toString.call(null); // "[object Null]"
Object.prototype.toString.call(undefined); // "[object Undefined]"

在ES5开始,[[class]] 私有属性被Symbol.toStringTag代替,我们可以自定义Object.prototype.toString的行为。

var o = { [Symbol.toStringTag]: "MyObject" }
console.log(o + ""); // [object MyObject]

构造函数

JavaScript 语言使用构造函数(constructor)作为对象的模板。所谓“构造函数”,就是专门用来生成实例对象的函数。

var Vehicle = function () {
  this.price = 1000;
};

上面就是一个构造函数,其实就是一个普通的函数,但是有自己的特征:

  • 函数体内部使用了this关键字,代表了所要生成的对象实例。
  • 生成对象的时候,必须使用new命令。
  • 为了与普通函数区别,构造函数名字的第一个字母通常大写。

new 命令

基本用法

new命令的作用,就是执行构造函数,返回一个实例对象。

var Vehicle = function () {
  this.price = 1000;
};

var v = new Vehicle();
v.price // 1000

在这个过程中,实际做了几件事(具体步骤见下文):

  • 创建新的实例对象;
  • new命令执行时,构造函数内部的this,就代表了新生成的实例对象,this.price表示实例对象有一个price属性,值是1000。
  • 返回新建的对象。

注意:new命令本身就可以执行构造函数,所以后面的构造函数可以带括号,也可以不带括号。

// 推荐的写法
var v = new Vehicle();
// 不推荐的写法
var v = new Vehicle;

上面的两种写法是等价的,但为了表示调用函数,推荐添加上括号。

判断是否通过new调用

如果忘了使用new命令,那么构造函数就作为一个普通函数执行,无法返回我们需要的实例对象。

var Vehicle = function (){
  this.price = 1000;
};

var v = Vehicle(); // 这时Vehicle里的this,代表全局对象
v // undefined
price // 1000

为了保证构造函数必须与new命令一起使用,有以下几个方法:

  1. 构造函数内部使用严格模式
function Vehicle(foo, bar){
  'use strict';
  this.price = 1000;
}

Vehicle()
// TypeError: Cannot set property 'price' of undefined

在严格模式中,this不代表全局变量,表示为undefined,所以这里直接执行构造函数会报错。

  1. 构造函数内部判断是否使用new命令
function Vehicle() {
  if (!(this instanceof Vehicle)) {
    return new Vehicle();
  }
  this.price = 1000;
}

Vehicle().price // 1000
(new Vehicle()).price // 1000

instanceof运算符,用来判断一个构造函数的prototype属性所指向的对象是否存在另外一个要检测对象的原型链上。

  1. 使用new.target

new.target属性允许你检测函数或构造方法是否是通过new运算符被调用的。在通过new运算符被初始化的函数或构造方法中,new.target返回一个指向构造方法或函数的引用。在普通的函数调用中,new.target的值是undefined

所以,上面的方法我们可以这样修改下:

function Vehicle() {
  if (!new.target) {
    return new Vehicle();
  }
  this.price = 1000;
}

Vehicle().price // 1000
(new Vehicle()).price // 1000

new 命令的原理

上面我们了解了new命令会生成一个实例对象,那么这个实例对象是如何产生的呢?

当我们使用new操作符调用函数时,JavaScript 为我们做了这些事:

  • 创建一个空对象,作为将要返回的对象实例。
  • 将这个空对象的原型,指向构造函数的prototype属性(注意与对象的私有字段[[prototype]]的区分)。
  • 将这个空对象赋值给函数内部的this关键字。
  • 开始执行构造函数内部的代码。
  • 返回对象。

注意:如果构造函数内部有return语句,而且**return后面跟着一个对象,new命令会返回return语句指定的对象**;否则,就会不管return语句,返回this对象。

// 返回this对象
function Vehicle(){
  this.price = 1000;
  return 2000;
}

new Vehicle().price; // 1000

// 返回return对象
function Vehicle1(){
  this.price = 1000;
  return { price: 2000};
}

new Vehicle1().price; // 2000

若是构建函数中没有对this对象的操作,也没有return后跟着一个对象,则返回一个空对象。

function Vehicle(){
  return 2000;
}

new Vehicle(); // {}

所以,我们可以用下面的代码来简化下new的执行逻辑。

function Vehicle(price){
  this.price = price;
}

function _new(/* 构造函数 */ constructor, /* 构造函数参数 */ ...params) {
    // 1.创建一个空对象
    const createObj = {};
    // 2.将空对象的原型,指向构造函数的`prototype`属性
    Object.setPrototypeOf(createObj, constructor.prototype);
    // 3.将这个空对象赋值给函数内部的`this`关键字,并执行构造函数
    const resObj = constructor.call(createObj, ...params);
    // 4.返回创建的对象
    return (typeof resObj === 'object' && resObj != null) ? result : createObj; 
}

// 实例
const actor = _new(Vehicle, 1000); // {price: 1000}

对象继承方式

原型链继承

function Parent() {
    this.name = 'parent';
}
Parent.prototype.sayName = function() {
    console.log(this.name);
};

function Child() {
    this.age = 18;
}
Child.prototype = new Parent();
const child = new Child();
child.sayName(); 

优点:

  • 操作简单:只需将子类的原型设置为父类的实例即可

缺点:

  • 共享原型属性:多个实例可共享父类的对象、数组等数据
  • 无法传参:创建子类时无法通过子类传递参数给父类构造函数

构造函数继承

在子类的构造函数中,使用call 或 apply 方法来调用父类的构造函数。

function Parent(name) {
    this.name = name;
    this.colors = ['red', 'green', 'blue'];
}
function Child(name) {
    Parent.call(this, name);
    this.age = 18;
}
const child = new Child('Alice');
console.log(child.name); 
console.log(child.age); 

优点:

  • 属性独立:子类型可以拥有父类型实例的独立属性
  • 能传参:多个实例能给父类传参

缺点:

  • 不继承原型属性和方法:只继承父类型的实例属性,不继承父类型的原型属性和方法
  • 性能问题:方法都在构造函数中定义,每次创建实例都会创建一遍方法,会有性能损耗

组合继承

function Parent(name) {
    this.name = name;
    this.colors = ['red', 'green', 'blue'];
}
Parent.prototype.sayName = function() {
    console.log(this.name);
};
function Child(name, age) {
    Parent.call(this, name); 
    this.age = age;
}
Child.prototype = new Parent (); 
Child.prototype.constructor = Child ;  
const child = new Child('Alice', 18);
child.sayName(); 
console.log(child.colors); 

虽然不设置 Child.prototype.constructor = Child 不一定会影响代码的基本功能,但为了保持代码的清晰性、遵循编程惯例以及避免潜在的问题,建议设置下。

优点:

  • 属性独立
  • 能传参
  • 继承原型属性和方法

缺点:

  • 性能问题:调用了两次父类的构造函数,且子类有冗余属性

寄生组合式继承

function inheritPrototype(child, parent) {
    // 创建对象,创建父类原型的一个副本
    let prototype = Object.create(parent.prototype);
    // 增强对象,弥补因重写原型而失去的默认的constructor属性
    prototype. constructor = child;
    // 指定对象,将新创建的对象赋值给子类的原型
    child. prototype = prototype;
}

function Parent(name) {
    this.name = name;
    this.num = [0, 1, 2];
}
Parent.prototype.sayName = function () {
    alert(this.name);
};

function Child(name, age) {
    Parent.call(this, name);
    this.age = age;
}

inheritPrototype(Child, Parent);

const child = new Child('Alice', 20);
child.sayName(); // 输出 'Alice'
console.log(child.num); // 输出 [0, 1, 2]
  • 使用Object.create(parent.prototype),减少一次父类对象的实例调用
  • 修改子类对象的prototype,基础父类对象的属性,减少一次父类对象的实例调用

ES6 中的类

在ES6中加入了新特性class,我们可以通过ES6的语法来定义类,而令function回归原本的函数语义。

class NewVehicle {
    constructor(price) {
        this.price = price;
    }
}

new NewVehicle().price; // 1000

此外,类提供了继承能力。

class Animal { 
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(this.name + ' makes a noise.');
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name); // call the super class constructor and pass in the name parameter
  }

  speak() {
    console.log(this.name + ' barks.');
  }
}

let d = new Dog('Mitzie');
d.speak(); // Mitzie barks.

参考