01 面象对象的JS

354 阅读16分钟

读书笔记:JS设计模式与开发实践

1 动态类型语言和鸭子类型

静态类型语言

  • 优点是在编译时就能发现不匹配的错误
  • 缺点是迫使程序员依照强契约来编写程序

动态类型语言

  • 优点是编写的代码数量更少,更简洁
  • 缺点是无法保证变量的类型(TS的意义)

JS是一门动态类型语言,其对变量类型的宽容给实际编码带来了很大的灵活性。 鸭子类型(duck typing)

JS国王喜欢鸭子的叫声,要组建一个1000只鸭子的合唱团。大臣们找遍全国,终于找到999只鸭子,但始终还差一只,最后大臣发现一只非常特别的鸡,它的叫声跟鸭子一模一样,于是这只鸡就成了合唱团的最后一员。

这个故事告诉我们,国王要听的只是鸭子的叫声,这个声音是鸭子发出还是鸡并不重要。鸭子类型指导我们只关注对象的行为,而不关注对象本身(魔道祖师中-魏无羡修魔道却行义事同理)也就是关注HAS-A,而不是IS-A。

const duck = {
    duckSinging: ()=>{
        console.log('ga ga ga')
    }
}

const chicken = {
    duckSinging: ()=>{
        console.log('ga ga ga')
    }
}

var choir = []

var joinChoir = function(animal){
    if(animal && typeof animal.duckSinging === 'function'){
        choir.push(animal)
        console.log('welcome to choir')
        console.log('The choir has number of members:', choir.length)
    }
}
joinChoir(duck)
joinChoir(chicken)

2 多态

‘多态’一词源于希腊文polymorphism,拆开来看是poly复数+morph形态+ism,复数形态。 实际含义是:同一操作作用于不同的对象上面,可产生不同的解释和不同的执行结果。换句话说,给不同的对象发送同一个消息的时候,这些对象会根据这个消息分别给出不同的反馈。

主人家养了两只宠物,一只鸭和一只鸡,当主人向它们发出“叫”的命令时,鸭会“ga ga ga”地叫,而鸡会“ge ge ge”地叫。鸡鸭都会以自己的方式来发出叫声。

2-A 一段“多态”JS代码

const makeSound = (animal)=>{
    if(animal instanceof Duck){
        console.log('ga ga ga')
    }
    if(animal instanceof Chicken){
        console.log('ge ge ge')
    }
}

const Duck = function(){}
const Chicken = function(){}

makeSound( new Duck() )     // ga ga ga
makeSound( new Chicken() )  // ge ge ge

这样的多态是半成品,如果后来又增加了一只狗,我们必须得改动makeSound函数。我们总是懒得改,改多了makeSound还可能变肥。

多态背后的思想是将“做什么”和“谁去做以及怎样去做”分离开来,也就是将“不变的事物”与“可能改变的事物”分离开来。 把不变的隔离,把可变的封装,这给予了我们扩展的能力,程序可生长,也符合开放-封闭原则。即~优雅

2-B 对象的多态性

首先我们把不变的部分隔离出来,那就是都会“叫”

const makeSound = function(animal){
    animal.sound()
}

然后把可变部分各自封装起来

const Duck = function(){}

Duck.prototype.sound = function(){
    console.log('ga ga ga')
}

const Chicken = function(){}
Chicken.prototype.sound = function(){
    console.log('ge ge ge')
}

makeSound(new Duck())    // ga ga ga
makeSound(new Chicken()) // ge ge ge

当Duck对象和Chicken对象的类型(叫)都被隐藏在prototype身后,Duck对象和Chicken对象就能被交换使用,这是让对象表现出多态性的必经之路,而多态性的表现正是实现众多设计模式的目标。

2-C JS的多态

多态的思想实际上是把“做什么”和“谁去做”分离开来,要实现这一点,归根结底要先消除类型之间的耦合关系。如果类型之间的耦合关系都没有被消除,那么我们在makeSound方法中指定了发出叫声的对象是某个类型,它就不可能再被替换为另外一个类型。 而JS的变量类型在运行期是可变的。一个JS对象,即可表示Duck类型的对象,也可表示Chicken类型的对象,这意味着JS对象多态性是天生的。

我们即可行makeSound函数里传递duck对象当做参数,也可传递chicken对象当作参数。 由此可见,某一种动物能否发出叫声,只取决于它有没有makeSound方法,而不取决于它是否是某种类型的对象,这里这存在任何程度上的“类型耦合”。

2-D 多态在面向对象程序设计中的作用

《重构》里写到:

多态的最根本好处在于,你不必在向对象询问“你是什么类型”而后根据答案调用对象的某个行为——你只管调用该行为就是了,其他的一切多态机制都会为你安排妥当。

换句话说:多态最根本的作用就是通过把过程化的条件分支语句转化为对象的多态性,从而消除这些条件分支语句。

在电影拍摄现场,当导演喊出 action 时,主角开始背台词、照明师...、群众演员...、道具...。在得到同一个命令时,每个对象都知道自己该做什么。如果不利用对象的多态性,而是用面向过程的方式来编写这一段代码,那么相当于在action后,导演每次都要走到每个人的面前,确认分工(类型),然后告诉他们要做什么。映射到程序中,将充斥着条件分支语句。

将行为分布在各个对象中,并让这些对象各自负责自己的行为,这正是面向对象设计的优点。

一个地图应用,有两家地图API提供商google后 baidu,目前我们选择了google

const googleMap = {
    show: ()=>{
        console.log('Begin to render a map')
    }
}
const renderMap = ()=>{
    googleMap.show()
}

renderMap(); // Begin to render a map

后来某些原因,要切换为baidu地图,为了让renderMap函数保持一定的弹性,我们用一些条件分支来让renderMap函数同时支持google和baidu

const googleMap = {
    show: ()=>{
        console.log('Begin to render a google map')
    }
}
const baiduMap = {
    show: ()=>{
        console.log('Begin to render a baidu map')
    }
}
const renderMap = (type){
    if(type === 'google'){
        googleMap.show()
    }
    if(type === 'baidu'){
        baiduMap.show()
    }
}
renderMap('google')
renderMap('baidu')

虽然保持了一定的弹性,但很脆弱。一旦需要换成soso地图,还得改动renderMap函数,继续往里面堆砌条件语句。(但我们懒得改)

还是先把程序中相同的部分抽象出来

const renderMap = (map)=>{
    if(map.show instanceof Function){
        map.show()
    }
}
renderMap(googleMap)
renderMap(baiduMap)

现在来找碴(多态性),当向google对象和baidu对象分别发出show的消息时,会分别调用它们的show方法,就会产生不同就执行结果。对象的多态性提示我们,“做什么”(what do you do)和“怎么做”(How to do it)是可分开的,即使以后增加了soso地图renderMap函数仍然不需要做任何改变。(可以偷懒了)

const sosoMap = {
    show: ()=>{
        console.log('Begin to render soso map')
    }
}
renderMap(sosoMap)

例子中,每个地图API展示地图都是show,在实际开发中不会这么便宜你,这时可借助适配器模式来偷懒。

2-E 设计模式与多态

绝大部分设计模式的实现都离不开多态性的思想。

命令模式中:请求被封装在一些命令对象中,这使得命令的调用者和命令的接收者可完全解耦,当调用命令execute方法时,不同命令会做不同的事情,从厄会产生不同的执行结果。而做这些事情的过程是早已被封装在命令对象内部的,作为调用命令的客户,根本不必去关心命令执行的具体过程。

组合模式中:多态性使得客户可完全忽略组合对象和叶节点对象之前的区别,这正是组合模式最大的作用所在。对组合对象和叶节点对象发出同一个消息的时候,它们会各自做自己该做的事情,组合对象把消息继续转发给下面的叶节点对象,叶节点对象则会对这些消息做出真实的反馈。

策略模式中:context并没有执行算法的能力,而是把这个职责委托给了某个策略对象。每个策略对象负责的算法已被各自封装在对象内部。当我们对这些策略对象发出“计算”的消息时,它们会返回各自不同的计算结果。

在JS这种将函数作为一等对象的语言中,函数本身也是对象,函数用来封装行为并能被四处传递。当我们对一些函数发出“调用”的消息时,这些函数会返回不同的执行结果,这是“多态性”的一种体现,也是很多设计模式在JS中可以用高阶函数来代替实现的原因。

3 封装

封装的目的是将信息隐藏。一般而言,我们讨论的封装是封装数据和封装实现。还有封装类型和封装变化。

3-A 封装数据

JS需要依赖变量的作用域来实现封装特性且只能模拟出pubic和private这两种封装性。

除了let外,一般通过函数来创建作用域:

var myObject = (function(){
    var __name = 'sven';
    return {
        getName: function(){
            return __name;
        }
    }
})()

console.log(myObject.getName())   // sven
console.log(myObject.__name)      // undefined

另外还可通过Symbol创建私有属性。

3-B 封装实现

封装不仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。 封装使得对象内部对其他对象是不透明的,其他对象或用户不关心它的内部实现。封装使得对象之间的耦合部松散,对象之间只通过暴露的API接口来通信。当我们修改对象时,可随意地修改内部实现,只要对外的接口没变化,就不会影响程序的其他功能。

3-C 封装类型

静态类型语言中:想方设法地去隐藏对象的类型,也是促使这些模式诞生的原因之一。如工厂模式、组合模式等。

JS中,没有对抽象类和接口的支持。JS本身也是一门类型模糊的语言。在封装类型方面,JS没有能力,也没必要。

3-D 封装变化

封装在更重要的层面体现为封装变化。

“考虑你的设计中哪些地方可能变化,这种方式与关注会导致重新设计的原因相反。它不是考虑什么时候会迫使你的设计改变,而是考虑你怎样才能在不重新设计的情况下进行改变。这里的关键在于封装发生变化的概念,这是许多设计模式的主题。”

找到变化并封装之 —— 设计模式被分为创建型模式、结构型模式和行为型模式

拿创建型模式来说,要创建一个对象,是一种抽象行为,而具体创建什么对象则是可变化的,创建型模式的目的就是封装创建对象的变化。而结构型模式封装的是对象之间的组合关系。行为型模式封装的是对象的行为变化。

通过封装变化的方式,把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已封装好的,替换起来也相对容易。这可最大程度地保证程序的稳定性和可扩展性。

4 原型模式和基于原型继承的JS对象系统

  • 在以类为中心的面向对象编程语言中,类和对象的关系可想象成铸模和铸件的关系。
  • 在原型编程思想中,类并不是必需的,对象未必需要从类中创建而来。一个对象是通过克隆另外一个对象所得到的。就像克隆绵羊没爹没娘。

4-A 使用克隆的原型模式

原型模式是用于创建对象的一种模式,如果我们想要创建一个对象,一种方法是先指定它的类型,然后通过类来创建。原型模式选择了另一种方式,我们不再关心对象的具体类型,而是找到一个对象,然后克隆一个一模一样的对象。

假设要实现多重影分身技能,如不使用原型模式,那么在创建分身前,无疑必须先保存红蓝、攻防等级等信息,随后将这些信息设置到新创建的身体上,才能实现。

如果用原型模式,只需调用克隆方法。

原型模式的实现关键,是语言本身是否提供了clone方法。ES5提供了Object.create方法

var Naruto = function(){
  this.blood = 100;
  this.attackLevel = 1;
  this.defenseLevel = 1;
}
var naruto = new Naruto()
naruto.blood = 500;
naruto.attackLevel = 10;
naruto.defenseLevel = 7;

var cloneNaruto = Object.create( naruto );
console.log(cloneNaruto.blood)          // 500
console.log(cloneNaruto.attackLevel)    // 10
console.log(cloneNaruto.defenseLevel)   // 7

4-B 克隆是创建对象的手段

原型模式真正目的并非在于需要得到一个一模一样的对象,而是提供了一种便捷方式,克隆只是创建这个对象的过程和手段。

静态类型语言,类型之间的解耦非常重要。依赖倒置原则提醒我们创建对象的时候要避免依赖具体类型,而用new创建对象显得很僵硬。工厂方法模式和抽象工厂模式可解决这个问题,但这两个模式会带来许多跟产品类平行的工厂类层次,也会增加很多额外代码。

原型模式通过克隆对象,就不用再关心对象的具体类型名字。这就像小孩想要礼物,但不知道怎么说飞机或船,但小孩可以指着橱柜里的样品说“我要这个” 。

在JS这种类型模糊的语言中,创建对象非常容易,也不存在类型耦合的问题。从设计模式角度讲,原型模式的意义不大。但JS本身是一门基于原型的面向对象语言,它的对象系统就是使用原型模式来搭建的,称为原型编程范型更合适。

4-C 原型编程范型的一些规则

在JS语言中不存在类的概念,对象也并非从类中创建出来的,所有的JS对象都是从某个对象上克隆而来的。

基于原型链的委托机制就是原型继承的本质。即当对象无法响应某个请求时,会把请求委托给它自己的原型。

原型编程基本规则

  • 所有的数据都是对象。
  • 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。
  • 对象会记住它的原型。
  • 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。

分别讨论下上面规则

4-C-a 所有的数据都是对象:

JS中的根对象是Object.prototype对象。是一个空对象,JS中每个对象都是从Object.prototype克隆而来的,Object.prototype就是它们的原型。

4-C-b 要得到一个对象,不个通过实例化类,而是找到一个对象作为原型并克隆它:

JS语言中,我们不必关心克隆的细节,这是引擎的活。我们只要显式地调用var obj1 = new Object()或var obj2 = {} 。此时,引擎会从Object.prototype上面克隆一个对象出来,我们最终得到的就是这个对象。 在看看new运算符从构造器中得到一个对象

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

Person.prototype.getName = function(){
  return this.name;
}

var a = new Person('sven')

console.log(a.name);        // sven
console.log(a.getName());   // sven
console.log(Object.getPrototypeOf(a) === Person.prototype);  // true

在JS中没有类的概念,但上面明明不是调用了 new Person()吗?

在这里Person并不是类,而是函数构造器,JS的函数即可作为普通函数被调用,也可作为构造器被调用。当使用new运算符来调用函数时,此时的函数就是一个构造器。用new运算符来创建对象的过程,实际上也是克隆Object.prototype对象。

4-C-c 对象会记住它的原型:

就JS真正实现来说,其实并不能说对象有原型,而只能说对象的构造器有原型。对于“对象把请求委托给它自己的原型”,更好的说法是对象把请求委托给它的构造器原型。那么对象如何把请求顺利地转交给它的构造器的原型呢?

JS给对象提供了一个名为__proto__的隐藏属性,某个对象的__proto__属性默认会指向它的构造器原型对象。

var a = new Object()
console.log(a.__proto__ === Object.prototype);  //true

4-C-d 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型:

这条即为原型继承的精髓所在。JS中每个对象都是从Object.prototype对象克隆而来,即每个对象的继承自Object.prototype对象,这样显然非常受限。

实际上,JS的对象构造器的原型是可动态指定的。当对象a需要借用对象b的能力时,可有选择地把对象a的构造器原型指向对象b,从而达到继承的效果。 下面是最常用的原型继承方式:

var obj = {name: 'sven'}
var A = function(){}
A.prototype = obj;

var a = new A()
console.log(a.name);  //sven

看看执行这段代码时,引擎做了哪些事情

  • 首先,尝试遍历对象a中的所有属性,但没有找到name这个属性
  • 查找name属性的这个请求被委托给对象a的构造器的原型,它被a.__proto__记录着且指向A.prototype,而A.prototype被设置为对象obj
  • 在对象obj中找到了name属性,并返回它的值。

当期望一个“类”继承自另外一个“类”的效果时,往往会用下面的代码来模拟实现:

var A = function(){}
A.prototype = {name: 'sven'}

var B = function(){}
B.prototype = new A()

var b = new B()
console.log(b.name);  //sven

看看执行这段代码时,引擎做了哪些事情

  • 首先,尝试遍历对象b中的所有属性,没找到name
  • 查找name属性的请求委托给对象b的构造器的原型,它被b.__proto__记录着并且指向B.prototype,而B.prototype被设置为一个通过new A() 创建出来的对象。
  • 在该对象中依然没找到name属性,于是请求被继续委托给A.prototype
  • 在A.prototype中找到了name属性并返回

4-D 原型继承的未来

设计模式在很多时候其实都体现了语言的不足,JS中有些模式已天然实现,如Object.create就是原型模式的天然实现。使用Object.create来完成原型继承看起来更能体现原型模式的精髓。 Object.create(null)可创建出没有原型的对象。 ES6的class背后仍是通过原型机制来创建对象。

class Animal{
  constructor(name){
    this.name = name;
  }
  getName(){
    return this.name;
  }
}
class Dog extends Animal{
  constructor(name){
    super(name)
  }
  speck(){
    return 'woof'
  }
}
var dog = new Dog('Scamp')
console.log(dog.getName(), 'says', dog.speck())  // Scamp says woof