【前端谈设计】设计模式原则

130 阅读13分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天,点击查看活动详情

工作差不多快三年了,写了也快6年的代码,从开始的手忙脚乱到现在的得心应手,虽然对于代码的编写设计自己已经有了一套编写习惯,但是不得不说,自己设计的代码在版本迭代中总会被贴上不可扩展、难以维护的标签,有的时候想去优化代码也无从下手,很显然自己的编码设计并不是很正确,所以我选择站在巨人的肩膀上去学习,通过设计模式的学习养成良好的代码设计思维。

打算记录一下自己学习的过程!!!因为设计模式中很多模式要通过面向对象语言的特性才能很好的表现,所以这里采用TS进行代码举例。

前言

做人有做人的原则,做事有做事的原则,我们不能去做违背原则的事情,就比如烧杀抢掠!

设计代码也应有对应的原则,没规矩不成方圆,遵守代码设计规范,才能提高代码的复用性、可读性、扩展性!

作为前端程序员,在类、接口方面的使用是可有可无的,所以着重关注单一职责原则开放闭合原则就可以了

单一职责原则

一个实体(类、方法、接口、函数)只负责一个职责

其实单一职责原则应该是开发中最最容易提起的的原则,无论是开始写代码的新人,还是写了几年代码的秃头少年,在写代码时,都会提醒别人或自己要把一个大的业务抽离成一个个小业务模块,在代码上面也要实现代码的模块化,功能抽离出来,尽量实现代码粒度和业务粒度是实现一对一,而不要在一个函数中写完整个业务逻辑。

 /**
  * 修改商品库存
  * @param goodsId 商品ID
  */
 function changeStock(goodsId: string) {
     // 查找商品
     let goodsInfo = goodsList.find(i => i.goodsId === goodsId)
     // 修改库存
     goodsInfo.stock = 18
 }

在写电商项目时,需要修改库存的场景是十分多的,上面的写法也是我们最容易想到方式,但是可以发现上面这种写法,在修改库存的时,是被分为两个步骤

  1. 根据商品ID查找商品
  2. 对已查到的商品修改库存

如果单纯的实现修改库存的功能,倒也完全足够了,但是产品又让去实现查询库存的功能

 /**
  * 查询商品库存
  * @param goodsId 商品ID
  */
 function getStock(goodsId: string): number {
     // 查找商品
     let goodsInfo = goodsList.find(i => i.goodsId === goodsId)
     // 查询库存
     return goodsInfo.stock
 }

这里其实不难发现,查询库存的功能也是分为两个步骤

  1. 根据商品ID查找商品
  2. 返回已查到的商品的库存

对比changeStockgetStock函数,可以发现,两个功能的第一步都是先查找商品,这里已经造成了代码的冗余,这是因为在设计changeStock函数时,对一个函数中出现两种功能摒着纵容的态度,没有及时抽离功能!!!

当然有人会觉得这很小题大做,不就冗余一行代码嘛,但是这个时候产品突然又提出一个需求:在所有根据商品ID查询商品的地方,当查询不到商品信息时,都要采用默认商品

这其实也还能改

 /**
  * 查询商品库存
  * @param goodsId 商品ID
  */
 function getStock(goodsId: string): number {
     // 查找商品
     let goodsInfo = goodsList.find(i => i.goodsId === goodsId) || defaultGoods
     // 查询库存
     return goodsInfo.stock
 }

但是这只是一处,代码中可能有千千万万个功能,不可能去一个个改吧!!!但是如果一开始就把查找商品抽离为一个函数,也就不会出现这种情况

 /**
  * 查找商品详情
  * @param goodsId 商品ID
  * @returns 商品详情
  */
 function getGoodsById(goodsId: string) {
     // 查找商品
     return goodsList.find(i => i.goodsId === goodsId) || defaultGoods
 }
 
 /**
  * 查询商品库存
  * @param goodsId 商品ID
  */
 function getStock(goodsId: string): number {
     // 查找商品
     let goodsInfo = getGoodsById(goodsId)
     // 查询库存
     return goodsInfo.stock
 }

这里只介绍了单一职责原则对代码可复用性的影响。到这里是不是就很容易理解单一职责原则了,其实这样的代码在日常开发中会经常遇到,我们会在遇到问题的时候再反过来分割函数,其实在构建函数时,带入单一职责原则,就完全可以避免这个问题

开放闭合原则

一个实体(类、方法、接口、函数)应该对扩展开放,对修改闭合

开放闭合原则很容易理解,但是完全按照这个原则去实现代码却不是一件易事,要实现这这一原则,就要保证在搭建架构和设计代码时,要考虑到产品需求未来的所有变动和扩充,这也几乎是不可能的,难度越大收益也越大,实现这一原则对于代码的可扩展性帮助很大!!!

看看下面一个比较常见的需求,检测字符串是否符合指定类型:

 // CheckType.ts
 class CheckType {
     /**
      * 检验字符串类型
      * @param str 字符串
      * @param type 类型
      * @returns 是否符合指定类型
      */
     check(str: string, type: string): boolean {
         switch (type) {
             case 'number':
                 return /^[0-9]+$/.test(str);
             case 'text':
                 return /^\w+$/.test(str);
             default:
                 return false;
         }
     }
 }
 export default new CheckType()
 
 // app.ts
 import checkType from "./cs.ts";
 
 checkType.check("123", "number")   // true

这里使用类主要是利于后面例子的演示,使用函数其实更简单~~~

这是最容易相到的吧,但是当需要添加其他规则时,就必须在check方法中添加新的case,频繁的修改这个方法体会造成很多问题。

  1. 类体会越来越臃肿,不利于规则的分类和注释
  2. 把这个类移动到其他项目中,需要删减不需要的case
  3. 当项目有新程序到来时,需要把CheckType类的逻辑看懂,才能修改代码
  4. 频繁变动CheckType.ts文件,不利于git等代码管理工具查看代码的修改
 class CheckType {
     private rules: {
         [type: string]: (str: string) => boolean;
     } = {};
     constructor() {
         this.rules = {
             number(str: string): boolean {
                 return /^[0-9]+$/.test(str);
             },
             text(str: string): boolean {
                 return /^\w+$/.test(str);
             }
         }
     }
     /**
      * 检验字符串类型
      * @param str 字符串
      * @param type 类型
      * @returns 是否符合指定类型
      */
     check(str: string, type: string): boolean {
         return this.rules?.[type]?.(str) || false
     }
 }
 export default new CheckType()

这次优化把规则和规则判断的逻辑进行了分离,这样每次修改就不用去修改check方法,解决了问题③,不需要读懂CheckType的其他方法的实现,只需要在rules添加规则就行

解决其他问题就要保证CheckType类不在迭代版本的过程中被修改,但是又要保证能添加新的规则

// CheckType.ts
class CheckType {
     private rules: {
         [type: string]: (str: string) => boolean;
     } = {};
     constructor() {
         this.rules = {
             number(str: string): boolean {
                 return /^[0-9]+$/.test(str);
             },
             text(str: string): boolean {
                 return /^\w+$/.test(str);
             }
         }
     }
     /**
      * 检验字符串类型
      * @param str 字符串
      * @param type 类型
      * @returns 是否符合指定类型
      */
     check(str: string, type: string): boolean {
         return this.rules?.[type]?.(str) || false
     }
     /**
      * 添加规则
      * @param type 类型
      * @param fn 类型判断函数
      */
     addRule(type: string, fn: (str: string) => boolean) {
         this.rules[type] = fn
     }
 }
 export default new CheckType()

 // app.ts
 import checkType from "./cs.ts";

// 添加规则
checkType.addRule('money', function (str) {
    return /^[0-9]+(.[0-9]{2})?$/.test(str)
});
checkType.check("66.66", "money")

往外暴露了addRule方法,当有新的规则时,可以直接调用addRule方法,不需要修改CheckType类主体

想想在使用Vue框架时,需要扩充第三方模块,是不是通过**Vue*.use*方法扩充喃,而不是去修改Vue的底层代码,这也是对扩展开放,对修改闭合

里氏替换原则

使用父类对象的地方必须能使用其子类对象

面向对象的三大特性封装、继承、多态,继承是扩展类是常用的方式,但是继承本身带有侵入性,使用不当会大大增加代码的耦合性,增加维护成本,里氏替换原则就是为了解决继承所带来的副作用,对继承进行约束

  • 子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法
  • 如果需要子类覆盖并重新定义父类的方法,可以将该父类定义为抽象类,并把需要重新定义的方法抽象方法

其实只要父类是抽象类,就不能新建实例了,也就不存在子类对象去替换父类对象了~~~

class Product {
    protected price: number = 100;
    getPrice(): number {
        return this.price
    }
}

class Food extends Product {
    private discount: number = 0.8
    getPrice(): number {
        return this.price * this.discount
    }
}

Product类的getPrice的本意是输出商品的原始价格,但是当Food类继承Product类时,因为Food类继有打折的情况,所以重写了ProductgetPrice方法

let product = new Product()
product.getPrice()              // 100

let food = new Food()
food.getPrice()                // 80

这里用food对象替换product对象时,输出的价格是不一样的,所以这里是不符合这是不符合里氏替换原则,当然不代表解决不了这类问题,只要不去选择重写ProductgetPrice方法就行

class Product {
    protected price: number = 100;
    getPrice(): number {
        return this.price
    }
}
class Food extends Product {
    private discount: number = 0.8
    getDiscountPrice(): number {
        return this.getPrice() * this.discount
    }
}

let product = new Product()
product.getPrice()              // 100

let food = new Food()
food.getPrice()                // 100
food.getDiscountPrice()        // 80
里氏替换原则的约束就是:子类尽量不去重写父类的方法,需要重新时,就将父类改为抽象类,需要重写的父类方法改为抽象方法

万事也没有绝对,其实只要不影响到父类方法的的基本规则(入参限制、异常的约定、返回值限制),也是可以重写父类的方法,但是不推荐这样去做,个人感觉会很乱。

当子类的方法一定要重写父类的方法时,还需要遵守下面的规则(了解就好)

  • 方法的前置条件(即方法的输入参数)要比父类的方法更宽松

如果父类方法接受的>0的数字,那么子类重写的方法接受的参数必须包含这个范围

class Parent {
    add(num: number) {
        if (num > 0) {
            // 业务...
        }
    }
}

class Son extends Parent{
    add(num: number) {
        if (num >= 0) {    // 必须包含父级的范围
            // 业务...
        }
    }
}
  • 方法的后置条件(即方法的的输出/返回值)要比父类的方法更严格或相等

父类方法约定输出参数要>0,那么子类重写的方法的输出只能是这个范围的子集

  • 方法抛出的异常必须和父类的方法一样

如果父类方法只会抛出TypeError异常,那么重载的子类方法不能再抛出其他不同的异常,只能是TypeError异常

依赖倒置原则

高层模块和底层模块都应该依赖于抽象

  • 高层模块不要直接依赖低层模块
  • 高层模块和底层模块应该通过依赖抽象来实现间接依赖
  • 抽象不要依赖具体实现细讲,而具体实现细讲依赖抽象

简单的理解就是使用接口编程,模块之间通过接口去创建依赖

举个例子,现在你开了一个超市,负责收款,超市里面有什么日用品、食品、玩具……

image-20230201090601931

class Food {
    sale() {
        console.log("食品被卖出");
    }
}
class Daily {
    sale() {
        console.log("日用品被卖出");
    }
}

class Person {
    saleDailyGoods(daily: Daily) {
        daily.sale()
    }

    saleFoodGoods(food: Food) {
        food.sale()
    }
}

let person = new Person()
person.saleDailyGoods(new Daily())    // 日用品被卖出
person.saleFoodGoods(new Food())      // 食品被卖出

商品和收银员直接是直接关联的,收银员在卖不同商品时,都必须知道商品的特性,并且如果此时商店新增了衣用的贩卖,还必须先让收银员知道这个商品的特性,商店才能正式贩卖衣用品,这增加了收银员和商品之间的耦合度,显然这样做是很不合理的,收银员应该只是关心销售,而不是商品的特性,新入库的商品也应该和收银员无关

对应到代码中就是,不同的商品类在Person类中都有第一个独立的销售方法,如果新增商品类,也就要在Person类中添加对应的销售方法

随着商品类型越来越多,收银员不干了,他不就只拿了做销售的工资嘛,为什么他需要记那么多商品特性,收银员觉得这样很不合理,所有就找店长说诉苦,店长一想,认为说的很有道理,就决定定规则:

所有的商品都要贴上二维码,收银员只需要扫描二维码就可以销售商品,收银员不再需要知道卖的是什么商品

image-20230201090917734

其实接口就是规则,创建Goods接口,让所有的产品都去实现这个接口,收银员类去管理实现Goods接口的所有产品

这里的Goods接口就相当于店长所定的规则,所有商品都需要实现Goods接口,在每个商品上面都贴上二维码,收银员也也就不需要关心销售的是上面商品,因为都有二维码

interface Goods {
    sale(): void;
}

class Food implements Goods {
    sale() {
        console.log("食品被卖出");
    }
}
class Daily implements Goods {
    sale() {
        console.log("日用品被卖出");
    }
}

class Person {
    saleGoods(goods: Goods) {
        goods.sale()
    }
}

let person = new Person()
person.saleGoods(new Daily())    // 日用品被卖出
person.saleGoods(new Food())     // 食品被卖出
  • 收银员类没有和产品类直接依赖
  • 收银员类和产品类之间通过Goods接口间接依赖
  • 收银员类和产品类都依赖于Goods接口

当新增产品时,只需要实现Goods接口就可以了,并不需要在Person类进行修改,实现了Person类和产品类的解耦

再来聊一聊什么是依赖倒置的思想吧,如果不采用面向接口编程的方式,那么在开超市时,会先选择招收银员,再让收银员去记商品的特性,才可以将商品贩卖,商品类型的变动会直接影响到收银员,依赖就是从收银员到产品,依赖顺序:高层模块依赖底层模块

但是使用面向接口编程的方式,我们只需要定义好商品的规则,让以后的商品都只需要遵守这个规则就行,收银员也只需要处理按照这个规则处理商品就可以,

依赖顺序:高层模块和底层模块都依赖于接口

是不是就可以理解依赖倒置原则了喃,本质就是面向接口编程

接口隔离原则

接口尽量小,功能尽量单一

  • 接口只声明会使用的成员(定义一个人接口,如果在人接口里面声明飞的方法,这显然是不合理的)
  • 接口声明的成员粒度要细

来个例子论证,定义一个鸟接口,首先你不可能在鸟接口里面声明写前端的方法,毕竟鸟不会写~~~

首先认为鸟是陆空全能动物,所以声明了飞和走的方法

interface Bird {
    fly(): void;
    walk(): void;
}
class Sparrow implements Bird {
    walk(): void {
        console.log("麻雀在行走~~~");
    }
    fly(): void {
        console.log("麻雀在飞翔~~~");
    }

}
class Ostrich implements Bird {
    walk(): void {
        console.log("鸵鸟在行走~~~");
    }
    fly(): void {
        throw new Error("哈哈哈,鸵鸟不会飞!!!");
    }
}

首先定义麻雀类实现鸟接口,显示是没有任何问题的,麻雀既可以飞也可以走,但是当去定义鸵鸟类实现鸟接口,就遇到了难题,鸵鸟是不会飞的呀,根本无法完全实现鸟接口!!!

所以我们需要把鸟接口的飞和走隔离开,这已经是是接口隔离原则中的成员粒度要细

interface Bird { }

interface Fly extends Bird {
    fly(): void;
}
interface Walk extends Bird {
    walk(): void;
}

class Sparrow implements Fly, Walk {
    walk(): void {
        console.log("麻雀在行走~~~");
    }
    fly(): void {
        console.log("麻雀在飞翔~~~");
    }
}
class Ostrich implements Walk {
    walk(): void {
        console.log("鸵鸟在行走~~~");
    }
}

成员粒度细避的优势:

  • 避免了胖接口的出现
  • 接口的可扩展性更高