hawtim.github.io icon indicating copy to clipboard operation
hawtim.github.io copied to clipboard

理解 JavaScript 面向对象的 封装、继承、多态 三大特性

Open hawtim opened this issue 5 years ago • 0 comments

前言

最近学了 JavaScript 相关的设计模式,书名《JavaScript设计模式与开发实践》,为加深对 OOP 和设计模式的理解,分成三个部分来理解 JavaScript 的面向对象设计。

面向对象的 JavaScript (OOJS)

面向对象三大特性:

  • 封装
  • 继承
  • 多态

封装

「封装」 把客观事物封装成抽象的类,隐藏属性和方法,仅对外公开接口。

私有属性和方法

只能在构造函数内访问不能被外部所访问(在构造函数内使用 var 声明的属性)

公有属性和方法(或实例方法)

对象外可以访问到对象内的属性和方法(在构造函数内使用 this 设置,或者设置在构造函数原型对象上比如 Person.prototype.xxx)

静态属性和方法

定义在构造函数上的方法(比如 Person.xxx),不需要实例就可以调用;

ES6 之前的封装(非 Class)

函数(function)

首先来看零散的语句

var oDiv = document.getElementsByTagName("div")[0];
var p = document.createElement("p");
body.style.backgroundColor = "green";
p.innerText ="我是新增的标签内容";
oDiv.appendChild(p);

缺点很明显:

  • 每次都会执行这段代码,造成浪费资源
  • 用以被同名变量覆盖掉——因为是在全局作用域下申明的,所以容易被同名变量覆盖

接下来将零散的的语句写进函数的花括号内,成为函数体。

function createTag() {
  var oDiv = document.getElementsByTagName("div")[0];
  var p = document.createElement("p");
  body.style.backgroundColor = "green";
  p.innerText = "我是新增的标签内容";
  oDiv.appendChild(p);
}

优点也很明显

提高了代码的复用性; 按需执行——解析器读到此处,函数并不会立即执行,只有当被调用的时候才会执行; 避免全局变量——因为存在函数作用的问题。

原始模式

var p1 = {};
p1.name = 'a';
p2.sex = 'male';

var p2 = {};
p2.name = 'b';
p2.sex = 'female'

优点:一把梭 缺点:生成几个实例,相似的对象,代码重复;而且实例与原型之间没有什么联系;

工厂模式

function Person(name, sex) {
  // this.name = name
  // this.sex = sex
  return { name, sex }
}

var p1 = new Person('a', 'male')
var p2 = new Person('b', 'female')

优点:解决代码重复的问题 缺点:p1 和 p2 没有内在联系,不能反映出是同一个原型的实例

构造函数模式

构造函数其实就是一个普通函数,但是内部有 this 指向,对构造函数使用 new 运算符就可以生成构造函数的实例。并且 this 会指向新生成的实例。

比如工厂模式中的 Person 改造成构造函数

function Person(name, sex) {
  this.name = name;
  this.sex = sex;
  this.getName = function() {
    console.log(this.name)
  }
}
// 通过 new 运算符
var p1 = new Person('a','male');
var p2 = new Person('b','female');
p1.getName()
console.log(p1) //{name: "a", sex: "male"}
console.log(p2) //{name: "b", sex: "female"}

优点:解决代码重复的问题,反映出 p1 和 p2 是同一个原型的实例。 缺点:多个实例有重复的属性和方法,占用内存。(所有的实例都会有 getName 方法)

Prototype 模式

每一个构造函数都有一个 prototype 属性,指向另一个对象。这个对象的所有属性和方法,都会被构造函数的实例继承。这意味着那些不变的属性和方法,可以直接定义在 prototype 对象上。

function Person(name, sex) {
  this.name = name
  this.sex = sex
}
Person.prototype.getName = function() {
  console.log(this.name)
}
var p1 = new Person('a','male');
var p2 = new Person('b','female');
p1.getName()
console.log(p1) //{name: "a", sex: "male"}
console.log(p2) //{name: "b", sex: "female"}

优点:所有实例的 getName 方法指向都是同一个内存地址,指向 prototype 对象,减少内存占用,提高效率。

一个完整的例子

function Person(name, sex) {
  // !!! 私有属性和方法
  var id = +new Date()
  var getID = function() {
    return id
  }
  // !!! 公有属性和方法
  this.name = name
  this.sex = sex
  this.description = '我是公有属性'
  this.getInfo = function() {
    var id = getID()
    return { id, name, sex }
  }
}
// !!! 静态属性和方法
Person.description = '我是静态属性'
Person.work = function() {
  return 'freelancer'
}
// !!! 原型上的属性和方法
Person.prototype.description = '我是原型上的属性'
Person.prototype.hobby = ['游泳', '跑步'];
Person.prototype.getHobby = function() {
  console.log(this.hobby)
}

var p1 = new Person('小明','male');
// 试着输出私有属性 id
console.log(p1.id) // undefined
// 输出公有属性或方法
console.log(p1.name, p1.sex, p1.description, p1.getInfo())
// 输出静态属性或方法
console.log(Person.description, Person.work())
// 输出原型上的属性或方法
console.log(p1.hobby, p1.getHobby()

注意 p1.description 输出的是 ”我是公有属性“

ES6 之后的封装

在 ES6 之后,新增了 class 这个关键字,代表传统面向对象语言的类的概念。但是并不是真的在 JavaScript 中实现了类的概念,还是一个构造函数的语法糖。存在只是为了让对象的原型功能更加清晰,更加符合面向对象语言的特点。

class Person {
  constructor(name, sex) {
    this.name = name
    this.sex = sex
  }
  getInfo() {
    console.log(this.name, this.sex)
  }
}

注意 class 内部的属性和方法都是不可枚举的 类的数据类型就是函数,类本身指向构造函数即 Person.prototype.constructor === Person 成立

类的公有属性和方法

class Person {
  constructor(name, sex) {
    // 公有属性和方法
    this.name = name
    this.sex = sex
    this.getInfo = function() {
      return { name, sex }
    }
  }
}

类的私有属性和方法

class Person {
  constructor(name, sex) {
    this.name = name
    this.sex = sex
    this.getInfo = function() {
      return { id, name, sex }
    }
    // 私有属性和方法
    var id = +new Date()
    var getID = function() {
      return id
    }
  }
}
let p = new Person('小明','male')
console.log(p.getInfo())

原型上的属性和方法

class Person {
  constructor(name, sex) {
    this.name = name
    this.sex = sex
    // 原型上的属性和方法
    a = 1
    getInfo() {
      console.log(this.name, this.sex)
    }
  }
}

let p = new Person('小明','male')
console.log(p.a)

静态属性和静态方法(static)

class Person {
  constructor(name, age) {
    this.name = name
    this.age = age
  }
  static description = '我是一个静态属性'
  static getDescription() {
    console.log("我是一个静态方法")
  }
}

// 或者
class Person {
  constructor(name, age) {
    this.name = name
    this.age = age
  }
}
Person.description = '我是一个静态属性'
Person.getDescription = function() {
  console.log("我是一个静态方法")
}

// 我是一个静态属性
console.log(Person.description)
// 我是一个静态方法
Person.getDescription()

类的实例属性

class Person {
  constructor(name, age) {
    this.name = name
    this.age = age
    this.getName = function() {
      console.log(this.name)
    }
  }
  // !!! 实例的属性和方法
  myProp = '我是实例属性'
  getMyProp = function() {
    console.log(this.myProp + '=')
  }
  // !!! 类的原型上的方法
  getMyProp() {
    console.log(this.myProp)
  }
  getInfo() {
    console.log('获取信息')
  }
}

let p = new Person('小明','male')

// 我是实例属性
p.getMyProp() // 我是实例属性=,实例属性优先于类的原型的上的属性和方法

console.log(p.hasOwnProperty('getName'))  // true
console.log(p.hasOwnProperty('getMyProp'))  // true
console.log(p.hasOwnProperty('getInfo'))  // false

注意事项

  • class 不会变量提升 new Foo();class Foo{} 会报错;
  • class 中如果存在同名的属性或者方法,用 this 定义的方法会覆盖用”等号“定义的属性或方法;

继承

继承是面向对象语言中最有意思的概念。 许多面向对象语言都支持两种继承方式,继承通常包括"实现继承"和"接口继承"。

  • 接口继承:继承方法签名
  • 实现继承:继承实际方法

由于 JS 中没有签名,所以无法实现接口继承,只支持实现继承,依赖原型链实现。

原型和实例的关系

  • 构造函数.prototype 指向原型对象
  • 原型对象.constructor 指向构造函数
  • 实例对象.proto 指向原型对象

原型和实例的关系

看一个简单的原型链实现继承的例子

function Father(name){
    this.fatherName = name
}
Father.prototype.getFatherValue = function(){
    return this.fatherName
}
function Son(name){
    this.sonName = name
}
// 继承 Father
Son.prototype = new Father('Dad') // Son.prototype 被重写, 导致 Son.prototype.constructor 也一同被重写
Son.prototype.getSonValue = function() {
    return this.sonName
}
// 实例化 son
var son = new Son('Son')
console.log(son.getFatherValue()) // Dad

原型链实现继承存在的问题

  1. 当原型链中包含引用类型值的原型时,该引用类型值会被所有实例共享
  2. 在创建子类型时,不能向超类(例如父类)的构造函数中传递参数

实践中很少会单独使用原型链,有几种办法尝试弥补原型链的不足

借助构造函数 / 经典继承

在子类构造函数的内部调用超类构造函数

// 构造函数
function Father() {
  this.colors = ['red', 'blue', 'green']
  // 添加超类方法
  this.test = function() {
    console.log('测试添加超类方法')
  }
}

function Son(name, age) {
  this.name = name
  this.age = age
  // 相当于调用 Father 函数将父类的属性和方法添加到子类上,原型没有被修改
  return Father.call(this) // 继承了 Father,且向父类型传递参数
}

var son1 = new Son('son1', 12)
son1.colors.push('black')
console.log(son1.colors) // "red,blue,green,black"

var son2 = new Son('son2', 13)
console.log(son2.colors) // "red,blue,green" 可见引用类型值是独立的

优点:

  • 原型链中引用类型值不再被所有实例共享
  • 子类型创建的时候也能够向父类传递参数

缺点:

  • 方法都在构造函数里定义,函数无法复用
  • 超类中定义的方法,对子类是不可见的,即子类无法直接调用父类的方法

组合继承 / 伪经典继承

使用原型链实现对原型属性和方法的继承,通过借用构造函数来实现对实例属性的继承

function Father(name) {
  this.name = name
  this.colors = ['red', 'blue', 'green']
}

Father.prototype.sayName = function() {
  console.log(this.name)
}

function Son(name, age) {
  Father.call(this, name) // 第二次调用 Father() 使用构造函数来继承实例属性
  this.age = age
}
// 第一次调用 Father() 使用原型链实现对原型属性和方法的继承
Son.prototype = new Father()
Son.prototype.sayAge = function() {
  console.log(this.age)
}

var son1 = new Son('son1', 10)
son1.colors.push('black')
son1.sayName(), son1.sayAge()

console.log('son1.colors', son1.colors)

var son2 = new Son('son2', 12)
son2.sayName(), son2.sayAge()
console.log('son2.colors', son2.colors)

组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为 JavaScript 中最常用的继承模式。而且, instanceofisPrototypeOf() 也能用于识别基于组合继承创建的对象。同时我们还注意到组合继承其实调用了两次父类构造函数,造成了不必要的消耗。

规范化原型继承

在 es5 中,通过 Object.create() 方法规范化了上面的原型式继承,接收了两个参数,一个用作新对象原型的对象、一个为新对象定义额外属性的对象(可选的)。

提醒:因为对传入的对象使用的是浅拷贝,所以包含引用类型值的属性始终都会共享相应的值,就像使用原型模式一样。

var person = {
  friends: ["Van","Louis","Nick"]
}

var anotherPerson = Object.create(person, {
  name: {
    value: "Louis"
  }
})
anotherPerson.friends.push("Rob")
// 在创建一个人,修改朋友列表
var yetAnotherPerson = Object.create(person)
yetAnotherPerson.friends.push("Style")

console.log(person.friends) // "Van,Louis,Nick,Rob,Style” 不同实例对象始终共享引用类型值

寄生式继承

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

function createAnother(origin) {
  var clone = Object.create(origin)
  clone.sayHi = function() {
    console.log('hi')
  }
  return clone
}

var a = {
  test: 1
}

var a1 = createAnother(a)
console.log(a1)

输出

a1: {
  sayHi: f(),
  __proto__: {
    test: 1,
    __proto__: {
     constructor: f Object()
    }
  }
}

使用寄生式继承来为对象添加函数,无法做到函数复用而降低效率。

寄生组合继承

组合继承是最常用的,但是会调用两次父类构造函数。

一是在创建子类型原型的时候,另一次是在子类型的构造函数内部。

寄生组合式继承就是为了降低父类构造函数的开销而出现的。

集寄生式继承和组合式继承的优点于一身,是最有效的实现类型继承的方法。

// 寄生继承
function extend(subClass, superClass) {
  //基于超类(构造函数)的原型对象创建新的原型对象
  var prototype = Object.create(superClass.prototype)
  // 子类的原型对象指向新创建的原型对象
  subClass.prototype = prototype // 指定对象
  // 原型对象的构造函数指向子类
  prototype.constructor = subClass // 增强对象
}

function Father(name){
  console.log('调用了 father')
  this.name = name;
  this.colors = ["red","blue","green"];
}

Father.prototype.sayName = function(){
  console.log(this.name);
};

// 组合继承
function Son(name, age){
  Father.call(this, name); //继承实例属性,第一次调用Father()
  this.age = age;
}

extend(Son, Father) // 继承父类方法,此处并不会第二次调用Father()

Son.prototype.sayAge = function(){
  console.log(this.age);
}

多态

看完了上面的封装和继承,到了多态这一步就没那么多复杂的代码了。

概念

同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。

在强类型语言中,如 C++、C#、Java

通常采用抽象类或者接口,进行更高一层的抽象,从而可以直接使用该抽象,即”向上转型“。本质上是为了弱化具体类型带来的限制。

在弱类型语言中,如 JavaScript

在 JavaScript 中,万物皆对象,对象的多态性是与生俱来的。多态可以把过程化的条件分支语句转化为对象的多态性,从而消除条件分支语句。

常见的两种实现方式

  • 覆盖,子类重新定义父类方法,不同的子类可以自定义实现父类的方法。
  • 重载,多个同名但参数不同的方法。

JavaScript 中的两种多态

通过子类重写父类方法的方式实现多态。

function Person(name, sex) {
 this.name = name
 this.sex = sex
}
Person.prototype.getInfo = function() {
 return "I am " + this.name + " and sex is " + this.sex
}

function Employee(name, sex, age) {
 this.name = name
 this.sex = sex
 this.age = age
}
Employee.prototype = new Person()
Employee.prototype.getInfo = function() {
 return "I am " + this.name +
    " and sex is " + this.sex +
    " and age is " + this.age
}

var person = new Person('xiaoming', 'male')
var employee = new Employee('xiaoming', 'male', 12)

console.log(person.getInfo())
console.log(employee.getInfo())

鸭子类型

一个 JavaScript 对象,既可以表示 Duck 类型的对象,又可以表示 Chicken 类型的对象,这意味着JavaScript 对象的多态性是与生俱来的。

var makeSound = function(animal) {
  animal.sound()
}
var Duck = function() {}
Duck.prototype.sound = function() {
  console.log('gagaga')
}
var Chicken = function() {}
Chicken.prototype.sound = function() {
  console.log('gegege')
}

makeSound(new Duck())
makeSound(new Chicken())

总结

在 JavaScript 中,会很难看到多态性的影响。因为 JavaScript 具有动态类型系统,因此在编译时没有函数重载或自动类型强制。由于语言的动态特性,我们甚至都不需要 JavaScript 中的参数多态性。 但是 JavaScript 仍具有两种多态的形式:

  1. 类型继承的形式,来模仿子类型多态性
  2. 鸭子类型(duck typing)的形式,关注的是对象的行为而不是对象本身。

总而言之,多态的设计是为了面向对象编程时共享对象的行为。

附录:

new 运算符里做了什么?

function myNew(func, ...args) {
  // 创建一个空对象
  let obj = {}
  // 将对象与构造函数原型链接起来
  Object.setPrototypeOf(obj, func.prototype)
  // 将构造函数的 this 指向新生成的空对象
  let result = func.apply(obj, args)
  // 最后返回新对象的实例
  return result
}
  • 创建一个空对象
  • 将空对象的 proto 指向构造函数对象的 prototype 属性(即继承构造函数的原型)
  • 将构造函数内部的 this 指针替换成 obj,然后再调用构造函数
  • 将构造函数上的属性和方法添加到空对象上
  • 最后返回这个改造后的对象

遍历实例对象属性的三种方法

  • 使用 for...in... 能获取到实例对象自身的属性和原型链上的属性;
  • 使用 Object.keys()Object.getOwnPropertyNames() 只能获取实例对象自身的属性;
  • 使用 hasOwnProperty() 方法传入属性名来判断一个属性是不是实例自身的属性;

属性查找

  1. instance.hasOwnProperty('property') 检查属性是否在指定实例对象上
  2. Father.prototype.isPrototypeOf(instance) 判断调用该方法的对象是不是参数实例对象的原型对象

模拟 Object.create()

先创建一个临时的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回这个构造函数的一个新实例。

function objectCreate(o) {
  var F = function() {}
  F.prototype = o
  return new F()
}
subClass.prototype = objectCreate(superClass.prototype)

参考文章

hawtim avatar Sep 22 '20 14:09 hawtim

个人能力及理解有限,欢迎提问交流~

hawtim avatar Sep 22 '20 14:09 hawtim