从重构看Javascript中的设计模式(一)

173 阅读9分钟

前言

本文参考《代码大全2》,《重构:改善既有代码的设计》,《修改代码的艺术》,结合自己的体会,使用js和vue,记录一下自己对重构和设计模式的理解。

点击这里可以到个人博客中查看原文

为什么从重构的角度?

如果像别的文章讲一些抽象的概念,你大概率依然无法理解设计模式,也不会注意到设计模式的应用会如此普遍。但是如果从重构的角度,从代码的角度来看待设计模式,很容易向你展示什么样的代码要用什么样的模式处理。并且很重要的一点是,你不能为了用设计模式而用设计模式,那样代码会写的极其糟糕,甚至更难维护。而重构告诉你,你可以等到发现新功能很难添加进去,需要重构处理的时候,再考虑改变设计。

这里贴一个stack overflow上关于如何学习设计模式的回答,我对此深深的赞同。大概意思就是:你应当先学TDD,然后学习重构,最后是设计模式。

Some people already mentioned, practice and refactoring. I believe the right order to learn about patterns is this: Learn Test Driven Development (TDD) Learn refactoring Learn patterns Most people ignore 1, many believe they can do 2, and almost everybody goes straight for 3. For me the key to improve my software skills was learning TDD. It might be a long time of painful and slow coding, but writing your tests first certainly makes you think a lot about your code. If a class needs too much boilerplate or breaks easily you start noticing bad smells quite fast The main benefit of TDD is that you lose your fear of refactoring your code and force you to write classes that are highly independent and cohesive. Without a good set of tests, it is just too painful to touch something that is not broken. With safety net you will really adventure into drastic changes to your code. That is the moment when you can really start learning from practice. Now comes the point where you must read books about patterns, and to my opinion, it is a complete waste of time trying too hard. I only understood patterns really well after noticing I did something similar, or I could apply that to existing code. Without the safety tests, or habits of refactoring, I would have waited until a new project. The problem of using patterns in a fresh project is that you do not see how they impact or change a working code. I only understood a software pattern once I refactored my code into one of them, never when I introduced one fresh in my code.

多态

在讲工厂模式前,不得不说一下多态。说出来感到羞耻,以前的我以为自己知道什么是多态,但又好像没用到过,直到看了《重构》这本书,才发现多态如此普遍(太羞耻了)。

什么是多态?何时使用?

多个方法里出现相同的判断逻辑时,就应当使用多态,也就是定义多个类,这些类都继承自同一个父类,他们都有共同的方法签名,但是有不同的实现。

假如有一个弹框负责登录或注册:

<h3>{{type==='login'?'登录':'注册'}}>
...
<button @click==="submit">{{type='login'?'登录':'注册'}}</button>

在以上的代码中可以看到重复的逻辑判断,无论是标题还是按钮的文字说明,都要根据表单类型做判断。 不仅如此,submit方法也要做逻辑判断。


const submit = ()=>{
  if(type==='login'){
    http.post('login')...
  }else {
    http.post('register')...
  }
}

注意:出现相同的逻辑判断就意味着你应当使用多态了。

那么如何改呢?

多态与类

如果使用类,需要一个父类确定接口,然后需要两个子类来实现。 父类:

class UserForm{
  title: '用户信息表单'
  submit(){
    
  }
}

登录的表单:

class LoginForm extends UserForm{
  title: '登录'
  submit(){
    http.post('login')...
  }
}

注册的表单:

class RegisterForm extends UserForm{
  title: '注册'
  submit(){
    http.post('register')...
  }
}

你看业务的逻辑被拆分到了各自的区域里, 如果你是要登录,你就使用LoginForm,如果你要注册,你就使用RegisterForm就好了。

此时如果出现新的需求,比如当前博客会自动下发一个游客账号,而如果是游客账号,页面显示的就不是注册而是转为正式用户,按钮发送的请求也不一样。这时候只需要在添加一个类,然后使用它就可以了:

class RegisterForm{
  title: '转为正式用户'
  submit(){
    http.post('to-normal-user')...
  }
}

一定要用类和继承来实现多态吗?

那倒不是。在像Java这种语言里,他只能通过类和继承的方式才能实现多态。但是在js中就不是了,js是弱类型语言,他不在乎类型,天生就是多态的。即使使用Typescript,也是“鸭式辨型”,你的接口只要和他匹配就行了,他根本不在乎两个对象是不是同一个类。 这意味着写js根本就不需要定义一个类,也不需要什么父类。 比如在vue3中,使用composition api,我定义了一个游客用户注册为正式用户的表单:

export const useTouristRegisterForm = (form: { email: string; password: string }) => {
    const title = '转为正式账号'
    const submitBtnName = '游客转为正式账号'
    const { loading, request: submit } = useUserPost<LoginResData>('auth/tourist-to-user', form)
    return reactive({
        title,
        submitBtnName,
        loading,
        submit,
    })
}

由于不论是否是游客,登录都一样,所以登录表单如下:

export const useUserLoginForm = (form: { email: string; password: string }) => {
    const title = '登录'
    const submitBtnName = '登录'
    const { loading, request: submit } = useUserPost<LoginResData>('auth/login', form)
    return reactive({
        title,
        submitBtnName,
        loading,
        submit,
    })
}

普通用户注册:

export const useUserRegisterForm = (form: { email: string; password: string })=>{
    const title = '注册'
    const submitBtnName = '注册'
    const { loading, request: submit } = useUserPost<LoginResData>('auth/register/user', form)

    return reactive({
        title,
        submitBtnName,
        loading,
        submit,
    })
}

可以看到每一个表单hook都在负责自己分内的事情,没有多余的内容,并且添加新的表单很容易。

实现业务时,如果是你手动指定某个类的话,其实到此就为止了,但是如果是根据数据获得不同的对象呢? 这就需要工厂模式了。

工厂模式

什么是工厂模式?什么时候使用工厂模式?

根据数据得到不同的对象。 其实上面的表单的例子,本身就应该使用工厂模式。 因为我是根据某个类型变量做判断,然后显示不同的内容,执行不同的请求的。

const _getCurrentForm = () => {
    if (authModalType.value === AuthModalType.LOGIN) {
        return userLoginForm
    }
    if (auth.isTourist) {
        return touristRegisterForm
    }
    return userRegisterForm
}

从重构的角度来说,如果你的代码中,许多方法内有相同的逻辑判断,就先用多态处理一下。 如果还需要根据数据得到对象,那就需要工厂模式。

策略模式

策略模式的通常发生在:你要在运行的时候改变方法的实现。 比如,假如当前这个博客要加入微信登录,qq登录,支付宝登录等等。 用户点击不同的按钮切换到不同的登录,但是表单只有一个。 如果不使用策略模式,直接写ifelse,那就是以下样子:

const form = {
  submit(){
    if(type==='qq'){
        http.post('...')
    }else if(type ==='wechat'){
        http.post('...')
    }
  }

}

然而如果使用策略模式: 表单会有一个submit方法:

const form = {
  submit(){
     this.type.submit()
  }
}

你注意到区别了吗? 原本type只是一个基本类型的变量,现在替换成一个对象了!

从重构的角度讲,策略模式的核心点在于:用对象取代基本类型。然后你就能感受到策略模式的威力了。这个表单form对象并不知道你要怎么登录,也不知道你未来添加什么登录方式。 但是当你想更换为支付宝登录时,只需要,form.type = aliLoginForm 然后form.submit()会去调用aliLoginForm.submit()。至此,form没有做任何改动,而我们却轻而易举的添加了新的表单类型。

取代基本类型的一定得是对象吗?

我认为函数也可以(当然函数也是对象,你懂我意思就行)。其实在Java中,就有函数式接口。这种接口只有一个方法。上面例子的form.type是不是特别像一个函数式接口,我们只是需要它有一个submit方法。所以策略模式,简单的使用就是函数替代基本类型,不过稍微复杂一点,还是得用对象。

状态模式

现在我要讲状态模式,为啥在这讲状态模式呢?因为状态模式与策略模式极其相似,他也是在运行的时候改变方法的实现,也是用对象取代基本类型。而不同点是,策略模式是使用者手动的改变类型,手动的改变方法的实现,但是状态模式是内部自己切换类型。对象是如何从某个类型切换到另一个类型的,这个逻辑在对象内部,而不是使用者切换的。

比如发送验证码的按钮 文本的显示,发送验证码的方法的实现,各种状态下是不一样的。比如有从未发送状态,发送中状态,发送失败状态,可以再次发送的状态。

如果直接写的话,和上面表单的例子差不多,你需要反复的做类型判断。如果你已经理解我之前在讲什么,你就知道接下来要用多态重构,但是状态的切换怎么处理呢?用策略模式?除了一开始的点击有触发的时机,但是之后,状态是内部自己变换的

用composition api实现状态模式:

以下是发送验证码的方法。重点是.then和.catch里的内容。发送成功就切换到发送中状态,发送失败就切换到发送失败状态。当然他得接受这些方法。如果使用类的话,这些改变状态的方法定义在高层类中,然后传递给状态类使用。 希望我的变量命名能让你理解我在表达什么。

//这里是一个生成发送验证码的方法的方法,一个高阶函数。不过这不是重点,你需要注意下里面对状态的切换。
const genSendCode = (sendCoder: {
  toSendingState: () => any;
  toSendErrorState: () => void;
}) => {
  return () => {
    return commonHttp
      .post("email-code/send", {
        email: props.email,
      })
      .then(() => sendCoder.toSendingState()) //切换到发送中状态
      .catch(() => {
        sendCoder.toSendErrorState();//切换到发送失败状态
      });
  };
};

这里展示一个发送中状态,其他状态差不多。在发送中,倒计时结束后,应当改为可以再次发送的状态:

//发送中状态
const SendingStateCodeSender = (sendCoder: StateNeed) => {
  const text = ref("60秒后可重新发送");
  const loading = toRef(sendCoder, "loading");
  const disabled = ref(true);

  const countDown = useCountDown("code_count_down", 60); //倒计时
  watchEffect(() => { //倒计时中,改变文本
    text.value = `${countDown.currentSecond}秒后可重新发送`;
  })
  countDown.onEnd(() => sendCoder.toSendAgainState());//倒计时结束切换到可以再次发送的状态
  countDown.run(); //开启倒计时

  const sendCode = async () => {}; 
  return reactive({
    text,
    loading,
    sendCode,
    disabled,
  });
};

用类实现状态模式:

  //验证码发射器
   class CodeSender {
      state = new NeverSendState(this) //初始化为未发送过状态,并将父类传递给子类,因为子类需要父类的修改状态的方法。
      toSendingState(){...}//切换到发送中状态。
      toSendingErrorState(){...}//切换到发送错误的状态。
      ...
  }
  
  //从未发送的状态
  class NeverSendState {
    text='发送验证码'
    constuctor(private codeSender: CodeSender ){
    
    }
    sendCode(){
        http.post('...').then(()=>this.codeSender.toSendingState())...
    }
  }

我这里已经将要点解释出来了。因为贴出详尽的代码会导致篇幅过长,你读起来费事,我写起来也很累。所以你可以参考别的文章的例子。(比如:菜鸟教程-状态模式