前端应该如何面向对象 - 五大基本原则解读

250 阅读8分钟

单一职责原则(SRP:Single responsibility principle)又称单一功能原则,面向对象五个基本原则(SOLID)之一。它规定一个类应该只有一个发生变化的原因。

好处: 类的复杂度和耦合性降低、可读性提高、可维护性提高、扩展性提高、降低了变更引起的风险。

如何理解职责单一

顾名思义就是一个类只完成一件主要事情或达成一个目的。

频繁更改代码代码的风险

未按职责单一思想进行开发的代码存在频繁更改的问题,因为一个代码模块中有太多不同职责的代码需要维护,任何一个职责的变更都需要修改这个模块。只要有代码修改就会产生一定的代码风险,如果一个对象或者类包含了太多职责,那么每一个职责引发的代码修改都将有可能影响到其他职责的代码。因为职责耦合将导致代码内部关联性增加,牵一发而动全身。局部代码更改可能直接导致难以察觉到关联性异常。比如更改了某个职责代码的依赖,而未考虑模块中其他职责代码所可能产生的兼容性问题

职责单一的代码更有利于代码阅读

最好的注释就是代码本身,如果看一个人的代码能通过文件名,类名,方法名直接可以大致理解到代码所具有的逻辑意图,那这样的代码就是很好的。如果没有很好的理解职责单一原则,我们很难写出这样的代码。职责单一的首要解决的就是职责的划分,从职责拆分到逻辑拆分逐步写出让人一目了然的代码。更容易阅读的代码也能让阅读者更容易帮助开发者发现问题。

如何确定是否需要进行拆分

问问自己:这个类或对象是要完成什么事情?如果你的回答是: xx 和 xx 的时候,那就要考虑是不是可以去掉 ‘和’ 。比如写一个文本修改功能,我们会问自己这个模块要完成什么事情。回答是修改文本和打印文本。那么我们就可以考虑写成两个模块: 文本修改模块 和 文本打印模块。

避免过度理解

应用理解职责单一问题时,没有一个严格标准,每个开发者都有他对于某一个类职责的理解。

我们还要考虑业务需求,软件架构,问题域之类的问题,导致对职责单一的理解会比较主观。

在实践职责单一的时候很容易过度单一,造成甚至有点类或对象中只有一个方法,在实践过程中我们应该就依旧实际问题进行调整。过度单一的实践也可能导致使用这些代码的时候产生非常多的引用关系,并且这些过度解读的职责单一导致颗粒度太低命名混乱从而导致引入关系复杂。

反面例子

class TextManipulator {
 
    TextManipulator(String text) {
        this.text = text;
    }

    getText() {
        return this.text;
    }

    appendText(String newText) {
  this.text  = this.text.concat(newText);
    }
    
    findWordAndReplace(String word, String replacementWord) {
        if (  this.text .contains(word)) {
    this.text = this.text.replace(word, replacementWord);
        }
        return this.text ;
    }
    
    findWordAndDelete(String word) {
        if (  this.text .contains(word)) {
    this.text = this.text .replace(word, "");
        }
        return text;
    }

    printText() {
       console.log(this.textManipulator.getText());
    }

    printRangeOfCharacters(startingIndex, endIndex) {
        console.log(this.textManipulator.getText().substring(startingIndex, endIndex));
    }
}

改进:

class TextPrinter {
    public TextPrinter(TextManipulator textManipulator) {
        this.textManipulator = textManipulator;
    }

    public void printText() {
        console.log(this.textManipulator.getText());
    }

    printRangeOfCharacters(startingIndex, endIndex) {
        console.log(this.textManipulator.getText().substring(startingIndex, endIndex));
    }
}

传统开闭原则

简单来说就是对扩展开放,对修改封闭。这样对一个类进行扩展但不用通过修改它的源码进行实现。

如果一个模块可以被扩展那么我们就说这个模块是打开的。

如果一个模块已经有完善的定义,数据和方法的封装,不再需要进行扩展,直接被其他模块使用,那么我们就说这个模块是封闭的。

开闭原则依赖对象继承或接口实现来进行应用。

子类对父类实现细节有依赖,子类和父类耦合了。

多态开闭原则

多态开闭原则的定义倡导对抽象基类的继承,通过接口定义,允许不同实现。

接口可以通过继承来重用,但是实现不必重用。

已存在的接口对于修改是封闭的,并且新的实现必须,至少,实现那个接口。

示例

public class Rectangle
{
    public double Width { get; set; }
    public double Height { get; set; }
}
 
public class Circle
{
    public double Radius { get; set; }
}
 
public class AreaCalculator
{
    public double Area(Rectangle[] shapes)
    {
      double area = 0;
      foreach (var shape in shapes)
      {
        if (shape is Rectangle)
        {
            Rectangle rectangle = (Rectangle) shape;
            area += rectangle.Width*rectangle.Height;
        }
        else
        {
            Circle circle = (Circle)shape;
            area += circle.Radius * circle.Radius * Math.PI;
        }
      }
 
     return area;
    }
}
 
///////////////////////////////////////////////////////////////
public abstract class Shape
{
    public abstract double Area();
}
 
public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
    public override double Area()
    {
        return Width*Height;
    }
}
 
public class Circle : Shape
{
    public double Radius { get; set; }
    public override double Area()
    {
        return Radius*Radius*Math.PI;
    }
}
public class AreaCalculator
{
    public double Area(Shape[] shapes)
    {
        double area = 0;
        foreach (var shape in shapes)
         {
             area += shape.Area();
        }
 
     return area;
    }
}

开闭原则的理解

对扩展开放,对修改封闭

新功能不由通过修改现有源代码实现,避免修改源代码带来的所有依赖修改

对前端开发的影响

使用 继承进行扩展 (没有多态)

JS 的自由度高,但是随意扩展或修改已有对象可能代码非常严重的潜在问题,例如重写 JSON.Stringify 导致其他库运行失败等。如果要扩展已有类,使用继承进行扩展。

使用 DI

替换或扩展业务服务,使用 DI 替换服务可以在不影响其他业务代码情况下进行扩展。

使用 代理

不修改对象源码,但对对象行为进行代理,不影响对象其他使用方。

慎用重写对象属性

万不得已才重写

里氏替换原则

此规则也适用于继承。它可以看作是应用开闭原理的一种方法。开闭原则让开发不要修改原始代码,通过继承实现。但是继承重写父类行为也可能造成破坏性。

里氏替换规则解释了如何正确覆盖父类方法。

里氏替换原则可以总结如下:如果你有一个使用类 A 或则其函数的类 X,那么将类 A 替换为其任何子类不应该破坏 X 的功能。

它是关于如何实现继承,子类不能与父类的行为或含义相矛盾或改变父类方法的行为。

如果父方法被覆盖,它必须支持父逻辑并且不影响其结果。用子类对象替换父类对象不会破坏任何东西(测试、功能)。

坏实践: 如果用 SquareService 替换 RectangleService, 传两个参数的计算方式将破坏原始行为。

改进:

在这个例子中,我们现在可以用它的子类 SquareService 替换父类 RectangleService。不违反里氏替换原则。 这样使用父类进行实力化的对象将不受影响。

接口隔离

接口隔离是指,代码不应该强行被要求使用/实现它本身不需要的接口方法。这个也和职责单一很像,这个思想指导于进行系统解耦合,分离业务接口。一个类中定义的方法/接口应该是含义清晰的,逻辑定位准确的。接口实现者/使用者只需要关注他需要关注的点,而无需实现/关注/使用他不需要的方法。

在重构二期的设计思想中,即应用了接口隔离的思想,将各个阶段/过程进行了 interceptor 开发接口隔离,开发者只需要关注自己的业务开发点即可。

下面是 angular 的生命周期开发接口,开发者也只需要关心自己的开发点进行代码填写。

依赖倒置 DI

依赖倒置简单来说就是一个类应该只依赖另一个类(接口)的定义/抽象,而不依赖于其具体实现。

先说依赖:

比如我们有个手机 类 Phone,它需要做一个功能是播放媒体资源。为了解偶,我们新建了一个 播放器 类 Player。这时候就产生了依赖关系,Phone 类依赖了Player 类。

一开始我们可以这么做。

class Phone {

    public Player player {set;get;}

    Phone(){

     this.player =  new MusicPlayer()

  }

}

但是这么做的问题是,一旦我们想要改变 Player 的播放行为,比如从使用 本地播放功能改为使用支持视频播放的播放行为。最直接的做法可能是 修改 Phone 类,像下面这样:

class Phone {

    public Player player {set;get;}

    Phone(){

     this.player =  new MediaPlayer();

  }

}

这么做的确完成了我们的需求,但是问题是我们需要去修改Phone 类的代码。利用依赖倒置思想我们可以这么想这个需求, Phone 需要一个 Player 类型的实例 (Phone 依赖 Player),但是 Phone 不知道具体是哪个 Player (具体如何实现)。

一个简单示例:


interface IPlayer {

     public bool play(string mediaSource);

}

class MediaPlayer implements IPlayer {

     public bool play(string mediaSource) { ... }

}

class Phone {

    public IPlayer player {set;get;}

    Phone( IPlayer player ){

       this.player  = player;

   }

}

在使用时候 传入实际需要的实现, Phone phone =new Phone(new MediaPlayer()); 有许多框架实现了这样的思想,通常我们会基于配置来完成这样的初始化。

下面是一个依赖框架自动注入的示例


@MediaPlayer

class MediaPlayer implements IPlayer {

     public bool play(string mediaSource) { ... }

}

@injectable

class Phone {

    @Inject

    @MediaPalyer

    public IPlayer player {set;get;}

    Phone(){

   }

}

总结

整个面向对象的 5 个基本原则都围绕着如何写出耦合性小,模块化好,易扩展的代码。常常记着这些原则可以帮助我们降低复杂逻辑场景下的代码复杂度和耦合性,也能帮助我们避免一些常见问题。单一职责可以说是核心思想,一旦违反单一职责原则,后面的原则基本上就很难避免不违反了。