👍结合SOLID设计原则,浅谈如何提升前端代码质量

2,589 阅读16分钟

前言:什么是SOLID设计原则, 为什么使用它


  • 在程序设计领域, SOLID(S: 单一功能、O: 开闭原则、L: 里氏替换、I: 接口隔离、D: 依赖反转),指代了面向对象设计的五个基本原则。当这些原则被一起应用时,可以让软件更加健壮和稳定。

  • 我为什么要使用它,最开始是因为对产品经理需求的恐惧,这可能是很多前端都面临的一个问题,就是需求变动非常频繁。我现在做的是后台系统,我们组的后台系统是全公司最复杂的。系统不仅遗留的问题很棘手,而且产品提的新的不合理的需求同样连续不断。(我们公司产品是大哥,开发没地位)

  • 两个字: 恼火

  • 注:此文参考了大量的资料, 如下(想深入研究的同学可以移步到这些内容):
    • 《Javacript设计模式与开发实践》
    • 《Javascript设计模式》
    • 《重构:改善既有代码的设计》
    • 《Javascript函数式编程》
    • 微信文章:一个技术总监的忠告:精通那么多技术,为何还做不好一个项目? mp.weixin.qq.com/s/yFxpb8b7B…
    • MDN:可选操作符developer.mozilla.org/zh-CN/docs/…
    • 掘金文章:前端防御性编程juejin.cn/post/684490…
    • 极客时间:设计模式之美
    • ramda官方文档
    • 掘金小册:Javacript设计模式精讲
  • 好了,正文开始

职责单一原则(Single responsibility principle)

  • 它规定一个类应该只有一个发生变化的原因

  • 上面的定义太宽泛了,我们如何界定是否类的设计职责单一呢?

  • 是否职责单一要看业务的复杂程度是否复杂到必须要拆分这个类或者函数

  • 举一个例子

// 例如你要写一个用户的类,如下:
// 注意,这里面的属性和方法,很多跟这个人的职业相关
class UserInfo {
    constructor(name, gender, profession, workingTime, salary){
        this.name = name; // 名字
        this.gender = gender;  // 性别
        this.profession = profession; // 职业是什么(跟职业相关)
        this.workingTime  = workingTime;// 从业时间(跟职业相关)
        this.salary = salary; // 薪资(跟职业相关)
    }
    isWorkBusy(){ // 工作是否忙碌(跟职业相关)
        if(xx){
            return false;
        }
        return true;
    }
}

  • 你觉的这个类是否满足职责单一原则呢?
  • 有的人会说,UserInfo类就是应该把所有跟用户信息相关的属性和方法都写进去,所以上面这个类满足单一职责原则;有的人说,用户职业相关的信息在UserInfo类里比重较高,可以拆分成一个UserProfession类,然后里引用这个类的实例就行了,拆分之后的两个类的职责更加单一。

  • 为什么我们强调要根据业务情况去看是否职责单一呢?
  • 比如产品的需求,最近好几年都不会涉及到修改关于UserInfo的职业信息,那么不拆分完全没有问题,因为拆分的是什么,是变化的内容,不变化就没必要拆(即使代码写的烂也可不拆,因为年轻的你不太可能一家公司熬很多年)
  • 比如产品后面大把的需求涉及到改UserInfo的职业信息,那么就应该单独把这些信息封装出来


总结:

  • 单一职责原则可以简要的理解为,对于变化频繁的内容,并且可以独立出来单独封装成类,那么就需要提取这些内容
  • 我们在业务中可以在开始写一个粗粒度的类,当发现远远不能满足业务的变化时,需要更细粒度的划分类里面的内容,就需要提取封装这些内容了
  • 这就涉及到两个概念,一是就是不要过度设计,我们不是神,不能预料产品和市场的变化,二是持续重构,当需要重构的时候,重构就好

注意: 有些同学可能为了职责单一而单一,将类的内聚性破坏了!

  • 比如,有使用过原生reduxdva的同学吗,为什么后面大家更喜欢dva的设计,不太满意redux的使用呢,因为redux的文件太散了,dva内聚性更高。我们来感受一下,如下redux简单使用流程,是不是要操作非常多的文件(个人感觉内聚性不够的一种表现就是你需要的东西散落在各个文件里面,不是在一个类里)

  • Redux/actionType.js

export const ADDNAME = 'ADDNAME'
export const ADDAGE = 'ADDAGE'
  • Redux/actions.js
import { ADDNAME,ADDAGE } from "./action-type";

export const addNameCreater = (name) =>({type:ADDNAME,data:name})
export const addAgeCreater = (age) => ({type:ADDAGE,data:age})
export const addNameAsync = (name) =>{
    return dispatch =>{
        setTimeout(()=>{
            dispatch(addNameCreater(name))
        },2000);
    }
}
  • Redux/reducer.js
import {ADDNAME, ADDAGE} from './action-type'
import {combineReducers} from 'redux'
function addName(state='initRedux',action){ //形参默认值
    switch(action.type){
        case ADDNAME:
            return action.data
        default:
            return state
    }
}
function addAge(state=0,action){
    switch(action.type){
        case ADDAGE:
            return action.data
        default:
            return state
    }
}

我们再看看dva的处理,以下是很简单的示意代码:

export default {
  namespace: 'todo',
  state: {
    list: []
  },
  reducers: {
    save(state, { payload: { list } }) {
      return { ...state, list }
    }
  },
  effects: {
    *addTodo({ payload: value }, { call, put, select }) {
    },
  },
  subscriptions: {
    setup({ dispatch, history }) {
      // 监听路由的变化,请求页面数据
    }
  }
}
  • 使用过redux的同学很可能都会跟我有一样的感受,写一个很简单的reducer, Redux/actionType.js,Redux/action ,Redux/reducer,还有引用的项目代码来回切换,非常繁琐!我们再看看dva的处理,把actionType,action,reducer写到了一起,是不是内聚性就高很多,写起业务代码就更轻松了呢?
  • 有些同学就说了,高内聚,低耦合,如何在高内聚的前提下低耦合呢,后面会提一些方法,我在这里就简单说一个方法,就是类里面依赖的方法,依赖别的类里的属性或者方法等等,最后都依赖抽象,也就是面向接口编程,这就是为什么我个人觉得typescript特别重要,js没有接口的原生语法。

单一职责原则在业务中的体现:倒金字塔结构——业务逻辑组件需要呈现为倒金字塔结构


  • 组成金字塔的砖,说白了就是组件

  • 业务逻辑层应该被设计成一个个功能非常单一的小组件,所谓小是指 API 数量少、代码行数少;

  • 由于职责单一因此必然组件数量多,每一个组件对应一个很具体的业务功能点(或者几个相近的);

  • 于是系统架构就自然呈现出倒立的金字塔形状:越接近顶层的业务场景组件数量越多,越往下层的复用性高,于是组件数量越少。

  • 跟职责单一相关的设计模式(个人观点,会随着对设计模式的理解变化而变化)

    • 代理模式
    • 桥接模式
    • 外观模式
    • 责任链模式
    • 状态模式
  • 比如上面说的代理模式,如何体现呢

  • 高阶组件,个人觉得有点类似代理模式

  • 比如你要访问某个组件,先要通过高阶组件(高阶组件是代理类)是否满足一定条件才能访问到这个组件

  • 好了,我们接着看第二点

开闭原则(Open Closed Principle)

  • 简单的说,开闭原则就是,添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。(不是绝对的,下一点马上会聊到)
  • 先举一个很简单的例子,感受一下这个原则的好处,用策略模式改写if-else(后面第二例子难度会深一些)
// 注意里面有一个获取用户信息的方法是getUserData

// 这里有很多判断,根据urlType参数不同,请求不同的接口
calss User{
  getUserData(urlType){
    if (urlType === 'a') {
		// to do something
    } else if(urlType === 'b'){
		// to do something
    } else {
		// to do something
    }
  }
}
  • 如果我我们要加一个if判断,我们往往会去在getUserData方法里去加, 问题是这是面向过程的思维方式

  • 面向对象的修改,最好是以增加类、方法的方式,这是面向对象的思维

  • 为什么这么说,以我们理解就是,我们的方法无非是针对一个业务逻辑所写的代码写的一个特定的函数

  • 那么如果业务逻辑变了,我们就不要在以前的函数里面修修补补,因为你不知道以前的逻辑很容易出bug

  • 这里需要说明的是,我们并不是要消灭面向过程,因为普通的if-else函数很好用,面向过程也挺好的,但如果能更进一步,封装面向过程的代码并且有很好的拓展性,最重要的是满足业务的频繁变化的需求,何乐而不为呢?

  • 好的,我们改装一下上面的方法,让其在多添加一条if-else的时候,不用修改这个方法,而是拓展它

// 我们要做的是,重新加一个函数,替代之前的函数,如下:
const Strategy = {
  a() {
    // TODO
  },
  b() {
    // TODO
  },
  other() {
    // TODO
  },
}

// 注意哦,这里的getUserData改装之后的意思是,如果urlType匹配到你传入的参数,就调用Strategy类里面相同方法名的函数

// 如果urlType没有匹配到你传入的参数,调用other方法(是不是很像if-else里最后的else)
calss User{
  getUserData(urlType){
    Strategy[urlType] ? Strategy[urlType] : Strategy['other'];
  }
}

// 如果增加一种判断我们只需要这么改

const Strategy = {
  a() {
    // TODO
  },
  b() {
    // TODO
  },
  c() {
    // 新增方法  <--------------重点
  },
  other() {
    // TODO
  },
}

// 新增的c方法,满足开闭原则,拓展类或者方法,而不是去修改getUserData方法

但是开闭原则有一点very very 需要注意!


开闭原则并不是说完全杜绝修改而是以最小的修改代码的代价来完成新功能的开发


  • 修改代码有时候在实际的业务场景和个人能力有限的范围内很难做到类或者方法的替换,我们需要做到的是为后面可能的情况留口子,尽量让修改范围变得比较小。

  • 刚开始进步,只要代码比以前写的更好维护,更可读,其实对茫茫的像我一样的业务开发仔来说就已经提高非常多的效率了,慢慢积累,慢慢改进,不断进步!

  • 好了,关于开闭原则我举第二个例子,其实很多设计模式都是为开闭原则量身定做的,所以用这些模式来举例是肯定没错的,尤其是行为型设计模式,它是用来识别对象之间的常用交流模式并加以实现。如此,可在进行这些交流活动时增强弹性。弹性可以简单理解为更容易修改。

  • 这类例子真的是随手从行为型里面抓一个都行呢,我们来抓一个聊聊

责任链模式

  • 如果你对函数式编程compose熟悉,或者webpack的生命周期实现类库tapable熟悉,
  • 或者你对koa源码中compose熟悉,或者对redux洋葱模型属性,那么以上这些就是责任链模式的体现
// 举一个函数组合compose的例子
// 假如说一个函数有多个if-else,逻辑为
function a(b){
	if(b>3000) {
    	// TODO
    }else if (b <= 3000 && b >= 1000){
    
    } else if ( b > 0 && b < 1000) {
    
    }
}

改造这个函数很容易想到责任链,为什么呢,请看下图!

  • 上面这不就是个有序的链条吗?
// 责任链怎么改呢,我们简单写一下
function chainA(b){
	if(b > 3000){ return '责任链1' }
}
function chainB(b){
	if(b <= 3000 && b >= 1000){ return '责任链2' }
}
function chainC(b){
	if(b > 0 && b < 1000){ return '责任链3' }
}
// call函数是关键,只有返回值不是undefined的情况下才会走洗一个函数
 class syncBailHook {
     constructor(array) {
         this.tasks = array;
     }

     call(...args) {
         let ret;
         let index = 0;
         do {
             ret = this.tasks[index++](...args)
         } while(ret === undefined && index < this.tasks.length)
         
         return ret;
     }
 }
 
 const chain = new syncBailHook([chainA, chainB, chainC]);
 chain.call(2000);  // 返回责任链二
  • 当然这只是责任链的冰山一角,其它情况可以搜索google或者百度 - 'tapable 实现'

  • 以上代码的好处是什么,增加if-else的判断其实就是在传参上增删函数就行了,比起去修改if-else更容易(这个是相对的概念,一般情况下我会用if-else解决问题,没什么不好的,问题在于如果你的业务经常变动这几个if else里的函数,每次变动都比较大,那你一定要考虑责任链模式了)

  • 其次,如果你写一些公共的组件啊,方法什么的给别人用,在一些关键的部分,比如实现插件机制,实现拦截器等等,最好用高级一点的技巧

  • 关于函数的复用性

    • js自带的方法并不多,可以看到js的类库拓展了很多的方法,比如jquery, underscore, loadash,ramda

    • 通常,前端需要处理后台各种各样的数据,尤其是数组里嵌套着对象

    • 类似数据的处理,可以借助一些函数库

  • 假如我们有个人员数组

var persons = [{
  name: 'Summ',
  age: 24,
  address: 'safrouscsco',
  school: 'picking university',
  contry: 'china'
}, {
  name: 'Lucy',
  age: 11,
  address: 'safrouscsco',
  school: 'picking university',
  contry: 'china'
}, {
  name: 'Block',
  age: 30,
  address: 'safrouscsco',
  school: 'picking university',
  contry: 'china'
}]
  • 我们想要筛选出年龄大于十八岁的人,并且我们只需要他们的 nameage 字段
  • 这听上去是一个比较复杂的操作,但是使用 ramda库却异常简单
var isAdult = R.pipe( R.prop('age'), R.lte('18') )
var list = R.pipe(
  R.filter(isAdult),
  R.map(R.pick(['name', 'age']))
)(persons)
  • 相关设计原则
    • 装饰器模式
    • 策略模式
    • 职责责任链模式
    • 状态模式
    • 基于接口而非实现编程
    • 多态
    • 命令模式
    • 观察者模式
    • 中介者模式
    • 访问者模式
    • 组合模式
    • 桥接模式
    • 建造者模式

里氏替换原则(了解一下,不是重点)

  • 里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。

  • 里氏代换原则告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象

  • 比如说父类有一个setState方法,子类就不要去改写了,改写之后就把原有的setState的意图改变了,导致这个组件的不能够用setState渲染页面

接口隔离(了解即可)

  • 客户端不应该依赖它不需要的接口。一个类对另一个类的依赖应该建立在最小的接口上
  • 这个原则,如果使用typescript的话,可以理解为一个接口上定义的东西,如果用到的类都没有使用接口上的某些方法或者属性,就删掉它

依赖倒转原则(Dependence Inversion Principle)

  • 提倡:高层模块不应该依赖低层模块。两个都应该依赖抽象

  • 抽象不应该依赖细节,细节应该依赖抽象

  • 面向接口编程,不要针对实现编程

  • 举一个跟业务相关的例子

  • 业务一依赖业务二,那么业务二变化很可能影响业务一的逻辑, 比如业务一用了业务二的3个方法,业务二重构了,这些方法也变了,是不是很危险呢?

  • 这就跟一个类里面,引用另一个类的方法或者属性一样,让自己的不稳定性极具增加。

  • 所以业务一应该依赖接口,将业务二这个类提供的数据抽象成接口,让业务一依赖业务二抽象出来的接口,这样业务二无论如何变化,都要提供相同的东西,比如方法都要提供相同输出,输出字符串啊,输出数组(数组成员一般是对象,这些对象的属性也是用接口规范的)。 ○ 比如说这里的问题:

class A(){
     // B是一个类的实例
     constructor(B){
         this.B = B;
     }
     fn(){
         this.B.heihei()
     }
 }

class B(){
    heihei(){
    }
}

new A(new B());
  • 上面的问题在于,如果Bheihei方法被改写了,A就会报错
  • 也就是说A依赖B里面的一切东西,B类还是别的同事写的,B变化较大的时候,A就报错,是不是有点烦呢
  • 这时候就是体现typescript的优点了,面向接口编程
  • 接下来接口,好处就是,如果有一天B业务的逻辑有很大的变动,我们不怕!!!
//定义接口
interface BI{
    fn(string: a): string
 }
 class B implements BI{
   fn(string){
      console.log(string)
   }
 }
 
 class A{
     // B是一个类的实例
     constructor(public B:BI){
         this.B = B;
     }
     fn(){
         this.B.fn('heihei')
     }
 }
 
// 都要提供一个BI接口规定的fn函数,fn函数的返回值和参数也规定了,所以B业务的变动对A可以做到没有大的影响

new A(new B()).fn();

  • 设计模式真的非常建议大家去好好研究一下,在复杂的重构,尤其是需要开闭原则的时候,大部分设计模式已经提供了现成的解决方案,只有用熟了这些, 就慢慢可以站在更高的维度去抽象出自己的模式了。
  • 除了这些设计模式,其实对于前端而言还有一些很实用的模式, 而且随着前端的不断演进,会出现更多的特定的模式
    • 例如,mvvm模式,数据双向绑定
    • 链模式, 类似jquery里
$('input[type="button"]')
  .eq(0).click(() => {
    alert('点击我!');
  }).end()
  .eq(1)
  .click(() => {
    $('input[type="button"]:eq(0)').trigger('click');
  })
  .end()
  .eq(2)
  .toggle(() => {
    $('.aa').hide('slow');
  }, () => {
    $('.aa').show('slow');
  });
  • 中间件模式(比如洋葱模型,redux中间件实现,compose函数)
  • 函数柯里化(虽然不是模式,但是在JS里是一种编写函数特别有用的套路, 提高函数复用性)

持续重构

  • 当你的重构是在代码因为改不动了,然后导致修改成本高,随时可能引发各种线上BUG的时候,其实重构的最好时机已经被耽误了,这里引用一下破窗效应的概念。

  • 破窗效应

    • 破窗效应理论认为环境中的不良现象如果被放任存在,会诱使人们仿效,甚至变本加厉

  • 没有一劳永逸的代码, 重构对工程师的技术成长非常有帮助, 在重构上发现更好的技巧,或者将学到的高级技巧运用其中

常见的重构策略

  • 思想来自于《重构:改善既有代码的设计》

提炼函数(对应单一职责原则和开闭原则,以及可读性)

  • 如果遇到代码量多的函数,很可能你需要重构这个函数了
  • 函数应该短小、职责单一,我们可以借助函数类库,并在此基础上,拓展自己经常用的函数
var getUserInfo = function () {
  ajax('http:// xxx.com/userInfo', function (data) {
    console.log('userId: ' + data.userId);
    console.log('userName: ' + data.userName);
    console.log('nickName: ' + data.nickName);
  });
};
var getUserInfo = function () {
  ajax('http:// xxx.com/userInfo', function (data) {
    printDetails(data);
  });
};
var printDetails = function (data) {
  console.log('userId: ' + data.userId);
  console.log('userName: ' + data.userName);
  console.log('nickName: ' + data.nickName);
};
  • 以上的代码为啥要用printDetails()函数把这几个console封装起来了,因为这几个console都表示一个意思,即打印数据,所以可以封装到一个函数中
  • 因为真正业务中,你的ajax函数里肯定有很多逻辑,如果能把这些面向过程的逻辑抽象出个个函数,那么在业务变动,需要修改函数的时候,你的修改范围就缩小了,而不是整个ajax函数去找对应的代码。其次,当业务变动,需要添加逻辑的时候,你添加的范围也是在某个抽象的函数里去找,而不是整个ajax函数。
  • 这就是我们开闭原则里面说的,尽量让修改范围变小

把条件分支语句提炼成函数(对应单一职责原则和开闭原则,以及提升代码可读性)

  • 提前让函数推出代替嵌套条件分支(对应单一职责原则和开闭原则,以及提升代码可读性)
var getPrice = function (price) {
  var date = new Date();
  if (date.getMonth() >= 6 && date.getMonth() <= 9) {
    return price * 0.8;
  }
  return price;
}
var isSummer = function () {
  var date = new Date();
  return date.getMonth() >= 6 && date.getMonth() <= 9;
}


var getPrice = function (price) {
  var date = new Date();
  if (isSummer()) {
    return price * 0.8;
  }
  return price;
}

嵌套条件分支语句是非常恶心和难以阅读的,如果if else语句之间是平级关系(什么是平级关系请看如下代码),就可以平铺if else

var fn = function(a){
	if(a>100){
    	return '1',
    } else if (a > 200) {
    	return '2'
    } else {
    	return '3'
    }
}

平铺之后如下

var fn = function(a){
	if(a>100){
    	return '1',
    }
    if (a > 200) {
    	return '2'
    } 
    if{
    	return '3'
    }
}
  • 少用三目运算符(难以阅读)

额外话题: 在业务上比较实用的防御性编程的一些技巧

  • 按钮防重复点击
防抖、节流或者请求发起置灰,请求结束恢复点击状态
  • 接口返回值出现异常,比如本来应该返回数组,结果返回null
    • 用可选操作符来更轻松的解决
let nestedProp = obj.first && obj.first.second;
  • 以上代码意思是为了避免报错,在访问obj.first.second之前,要保证 obj.first 的值既不是 null,也不是 undefined。如果只是直接访问 obj.first.second,而不对 obj.first 进行校验,则有可能抛出错误.

  • 有了可选链操作符(?.),在访问 obj.first.second 之前,不再需要明确地校验 obj.first 的状态,再并用短路计算获取最终结果:

let nestedProp = obj.first?.second;
  • 还有空值合并操作符??, 可以在使用可选链时设置一个默认值:
let customer = {
  name: "Carl",
  details: { age: 82 }
};
let customerCity = customer?.city ?? "暗之城";
console.log(customerCity); // “暗之城”