Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
hawtim opened this issue Sep 22, 2020 · 0 comments
Open

Comments

@hawtim
Copy link
Owner

hawtim commented Sep 22, 2020

前言

最近学了 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)

参考文章

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant