提升前端代码质量之SOLID设计原则-SRP单一职责

1,869 阅读10分钟

前言

在程序设计领域, SOLID 指代了面向对象设计的五个基本原则的缩写。当这些原则被一起应用时,可以让软件更加健壮和稳定,能够让你写出更可读、可维护性、可测试的代码。

  • S : 单一职责原则 -Single Responsibility Principle
  • O : 开放封闭原则 -Open-closed Principle
  • L : 里氏替换原则 -Liskov Substitution Principle
  • I : 接口隔离原则 -Interface Segregation Principle
  • D : 依赖倒置原则 -Dependence Inversion Principle

使用他的原因,最开始是因为对PD(产品经理)需求的变更频繁的支配,这可能大部分开发都经常遇到的问题,就是需求变动非常频繁。 因为我开发中涉及到最多是中后台系统,内部也有一些交互非常复杂。如果运气不好接手到一些shi山代码,简直是人生折磨,说来也巧,我就是这个幸运儿,想想一个可编辑表格加点交互逻辑一个文件2000多行代码,看到瞬间手里的键盘瞬间就不香了,而且这块需求也是不断,最终在后面的迭代中也是慢慢重构掉了一部分。所以,有些系统不仅遗留的问题很棘手,而且有时候产品提的新的不合理的需求同样连续不断。

在我看来,一个好的业务设计有这么几个特点:

  • 易于删除
  • 易于扩展。
  • 易于修改。

也就是在业务中的crud,其实就增删改查的成本最小,代表我们需要改动的点就小,危险系数也就比较低。

大部分人都经历过牵一发动全身的问题,也出现因为改动的链路比较长导致有遗漏点从而导致上线有 Bug 。如何有效去规避这些问题呢?一个实践就是平时开发时候尽量按照SOLID 原则去思考。

当我们团队合作开发时掌握五种设计模式对我们如何写代码、如何编程、如何思考、如何让我们的代码更简单易读以及可维护的影响很大。那么现在我们来看看如何将这五种设计原则应用到react生态中,如何利用这五个原则写出更好的代码。

SOLID相关的文章索引:

本文结合代码(React)主要讲解 SOLID 原则中的SRP - 单一职责

职责单一原则(Single responsibility principle)

就一个类而言,应该仅有一个引起它变化的原因。

上面的定义太宽泛了,通俗的说,就是一个类的修改,不受多个功能修改的影响。这个类在我们的例子中基本就是一个组件或者一个函数

我们来看一个例子,现在有一个需求:需要展示商品列表,然后可以通过价格筛选商品,很简单也很常见的例子。

于是我们不难写下面代码:

//src\typing\index.ts
export interface IProduct {
  img?: string;
  title?: string;
  price?: number;
  discount?: number;
  storeName?: string;
  id?: string;
}
export interface IFilterData {
  minNum?: number | undefined | null;
  maxNum?: number | undefined | null;
}

//src\page\SRP\index.tsx
import React, { useEffect, useRef, useState } from 'react';
import { Button, Col, Form, Input, InputNumber, Row } from 'antd';
import { SearchOutlined, TaobaoCircleOutlined } from '@ant-design/icons';
import { mockList } from './mock'
import { IProduct } from '@/typing';

export default () => {
  const [form] = Form.useForm();
  const productRef = useRef<IProduct[]>([]);
  const [productList, setProductList] = useState<IProduct[]>([]);
  // 筛选商品
  const filterProductList = () => {
    const { minNum, maxNum } = form.getFieldsValue();
    const allProductList = productRef.current;
    const isNull = (value: unknown) => value === undefined || value === null;
    const hasValue = (value: unknown) => value !== undefined && value !== null;
    const discountedPrice = (item: IProduct) => item.price * (item.discount || 1);
    const filterDataMap = {
      all: () => setProductList(allProductList),
      onlyMin: (value: number) =>
        setProductList(allProductList.filter(item => discountedPrice(item) >= value)),
      onlyMax: (value: number) =>
        setProductList(allProductList.filter(item => discountedPrice(item) <= value)),
      rangeValue: (minValue: number, maxValue: number) =>
        setProductList(allProductList.filter(item => discountedPrice(item) <= maxValue && discountedPrice(item) >= minValue)),
    }
    if (isNull(minNum) && isNull(maxNum)) {
      filterDataMap['all']();
    }
    if (hasValue(minNum) && isNull(maxNum)) {
      filterDataMap['onlyMin'](minNum);
    }
    if (isNull(minNum) && hasValue(maxNum)) {
      filterDataMap['onlyMax'](maxNum);
    }
    if (hasValue(minNum) && hasValue(maxNum)) {
      filterDataMap['rangeValue'](minNum, maxNum);
    }
  }
  const fetchData = () => {
    new Promise((reslove) => {
      setTimeout(() => {
        reslove(mockList);
      }, 1000);
    }).then((data: IProduct[]) => {
      productRef.current = data;
      filterProductList();
    });
  }
  useEffect(() => {
    fetchData();
  }, [])
  return <>
    <Form form={form}>
      <Input.Group compact>
        <Button>价格区间</Button>
        <Form.Item name={"minNum"}>
          <InputNumber style={{ width: 100, textAlign: 'center' }} placeholder="最小值" />
        </Form.Item>
        <Input
          style={{
            width: 30,
            borderLeft: 0,
            borderRight: 0,
            pointerEvents: 'none',
          }}
          placeholder="~"
          disabled
        />
        <Form.Item name={'maxNum'}>
          <InputNumber
            style={{
              width: 100,
              textAlign: 'center',
            }}
            placeholder="最大值"
          />
        </Form.Item>
        <Button type="primary" shape="circle" onClick={filterProductList} icon={<SearchOutlined />} />
      </Input.Group>
    </Form>
    <Row gutter={12} style={{ marginTop: '12px' }}>
      {productList.map(item =>
        <Col key={item.id} span={6}>
          <div
            style={{
              padding: '16px',
              margin: '8px',
              display: 'flex',
              flexDirection: 'column',
              border: '1px solid #f2f2f2',
              width: '234px',
              height: '316px'
            }}>
            <img src={item.img} />
            <h3
              style={{
                overflow: 'hidden',
                whiteSpace: 'nowrap',
                textOverflow: 'ellipsis'
              }}
            >{item.title}</h3>
            <div>
              <span
                style={{
                  color: 'red'
                }}
              >
                ¥{(item.price * (item.discount || 1)).toFixed(2)}
              </span>&emsp;
              {item.discount && <span style={{ textDecoration: 'line-through', opacity: '0.6' }}>¥{item.price.toFixed(2)}</span>}
            </div>
            <div>
              <TaobaoCircleOutlined style={{ fontSize: '12px', color: '#fd3f31' }} />
              <span style={{ opacity: '0.6', fontSize: '12px' }}> {item.storeName}</span>
            </div>
          </div>
        </Col>)}
    </Row>
  </>
}

效果图:
1672670440825.png 在上面的例子中,我们有一个表单筛选项商品展示页包括获取商品的fetchData方法、筛选商品方法filterProductList都在一个组件内完成的,其实我们仔细观察发现,我们如果要修改某块内容需要把整个代码都整理一遍然后小心翼翼的去修改,如果不小心改到其他东西,造成了线上bug那绩效估计无缘了。
如果我们的需求足够简单,就像上面一样展示+筛选,那这样写完全没问题的,过度去拆分反而会增加编写代码的复杂度。不过真实的情况并不是这样,比如后续添加 商品大小、款式、衣服类型等一系列的筛选,再加点商品的一些交互:鼠标悬浮展示部分详情、多商品滑动查看等等。此刻我已经想到了在一个组件写完整套逻辑组件的庞大了。

既然这样,把组件按照SRP单一职责把组件拆分出来。

我们仔细看上面的代码:我们看到整个组件并非只做一件事情,而是在同一个位置做一大堆事情,也没有使用React特性,比如组合组件,分割他们到更小的组件当中,重复去使用,而是在一个组件一次性做了所有事情。所以我们观察上面的组件,很明显可以把商品展示单独提出取来。

//src\page\SRP\ShowProduct.tsx

import React from 'react';
import { TaobaoCircleOutlined } from '@ant-design/icons';
import { IProduct } from '@/typing';

interface IShowProductProps {
  data: IProduct,
}
export default (props: IShowProductProps) => {
  const { data: productItem } = props;
  return <>
    <div
      style={{
        padding: '16px',
        margin: '8px',
        display: 'flex',
        flexDirection: 'column',
        border: '1px solid #f2f2f2',
        width: '234px',
        height: '316px'
      }}>
      <img src={productItem.img} />
      <h3
        style={{
          overflow: 'hidden',
          whiteSpace: 'nowrap',
          textOverflow: 'ellipsis'
        }}
      >{productItem.title}</h3>
      <div>
        <span
          style={{
            color: 'red'
          }}
        >
          ¥{(productItem.price * (productItem.discount || 1)).toFixed(2)}
        </span>&emsp;
        {productItem.discount && <span style={{ textDecoration: 'line-through', opacity: '0.6' }}>¥{productItem.price.toFixed(2)}</span>}
      </div>
      <div>
        <TaobaoCircleOutlined style={{ fontSize: '12px', color: '#fd3f31' }} />
        <span style={{ opacity: '0.6', fontSize: '12px' }}> {productItem.storeName}</span>
      </div>
    </div>
  </>
}

同样的操作可以把筛选商品的UIJSX也提取出来:

// src\page\SRP\Filter.tsx
import React, { useEffect, useState } from 'react';
import { Button, Form, Input, InputNumber } from 'antd';
import { SearchOutlined } from '@ant-design/icons';

export default () => {
  const [form] = Form.useForm();
  return <>
    <Form form={form}>
      <Input.Group compact>
        <Button>价格区间</Button>
        <Form.Item name={"minNum"}>
          <InputNumber style={{ width: 100, textAlign: 'center' }} placeholder="最小值" />
        </Form.Item>
        <Input
          style={{
            width: 30,
            borderLeft: 0,
            borderRight: 0,
            pointerEvents: 'none',
          }}
          placeholder="~"
          disabled
        />
        <Form.Item name={'maxNum'}>
          <InputNumber
            style={{
              width: 100,
              textAlign: 'center',
            }}
            placeholder="最大值"
          />
        </Form.Item>
        <Button type="primary" shape="circle" onClick={} icon={<SearchOutlined />} />
      </Input.Group>
    </Form>
  </>
}

为了实现更好的解耦,筛选的数据可以采取custom HooksuseFilterProduct这个hooks就只有一个作用,用来存储和处理我们筛选的数据,也就是符合我们SRP,因为目前数据简单,所以handleFilter可以直接用来设置filterData,不管后期的筛选数据多复杂,我们都有专门的hooks来处理数据。

// src\page\SRP\hooks\useFilter.ts

import { useState } from 'react';
import { IFilterData } from '@/typing'

export const useFilterProduct = () => {
  const [filterData, setFilterData] = useState<IFilterData>({});
  const handleFilter = (data: IFilterData) => {
    setFilterData(data);
  }
  return {
    filterData,
    handleFilter
  }
}

我们再仔细观察一下之前的代码,因为当前组件还涉及到远程请求产品数据,也就是useState+uesEffect,为了使顶层组件变得更单一,所以我们也应当考虑custom hooks

// src\page\SRP\index.tsx

export default () => {
  ...
  const fetchData = () => {
    new Promise((reslove) => {
      setTimeout(() => {
        reslove(mockList);
      }, 1000);
    }).then((data: IProduct[]) => {
      productRef.current = data;
      filterProductList();
    });
  }
  useEffect(() => {
    fetchData();
  }, [])
  return <>
    <Form form={form}>
     ...
    </Form>
    <Row gutter={12} style={{ marginTop: '12px' }}>
      {productList.map(item =>
        <Col key={item.id} span={6}>
          ...
        </Col>)}
    </Row>
  </>
}

/** uesProduct */
// src\page\SRP\hooks\useProduct.ts
import { IProduct } from '@/typing';
import { useState, useEffect } from 'react';
import { mockList } from '../mock'

export const useProduct = () => {
  const [productList, setProductList] = useState<IProduct[]>([]);
  const fetchData = () => {
    new Promise((reslove) => {
      setTimeout(() => {
        reslove(mockList);
      }, 1000);
    }).then((data: IProduct[]) => setProductList(data));
  }
  useEffect(() => {
    fetchData();
  }, [])
  return {
    productList,
  }
}

现在我们已经按职责拆分了组件和数据:
两个组件:

  1. src\page\SRP\ShowProduct.tsx ShowProduct 组件负责商品的渲染。
  2. src\page\SRP\Filter.tsx Filter筛选器负责产品筛选。

两个hooks:

  1. src\page\SRP\hooks\useFilter.tsuseFilter用来保存我们组件Filter中的筛选数据。
  2. src\page\SRP\hooks\useProduct.tsuseProduct用来请求和存储商品数据。

我们虽然按职责拆分了组件和数据,但是我们还关联起来,因为按职责划分过后还是独立的。
此时我们的上层组件还是这样:

// src\page\SRP\index.tsx
import React from 'react';
import { Col, Row } from 'antd';
import ShowProduct from './ShowProduct';
import Filter from './Filter';

export default () => {
  return <>
    <Filter />
    <Row gutter={12} style={{ marginTop: '12px' }}>
      {[].map(item =>
        <Col key={item.id} span={6}>
          <ShowProduct data={item} />
        </Col>)}
    </Row>
  </>
}

首先我们肯定得把productList引进来:

import React from 'react';
import { Col, Row } from 'antd';
import ShowProduct from './ShowProduct';
import Filter from './Filter';

export default () => {
  const { productList } = useProduct();
  return <>
    <Filter />
    <Row gutter={12} style={{ marginTop: '12px' }}>
      {productList.map(item =>
        <Col key={item.id} span={6}>
          <ShowProduct data={item} />
        </Col>)}
    </Row>
  </>
}

商品是渲染出来,但是我们筛选还没有联动起来,因为我们单独用hooks封装起来的。

  • 我们还需要把筛选产品的表单数据引进来,当筛选商品的数据filterData发生变化的时候,能够及时更新我们的产品列表。
  • 同时我们还需要把handleFilter作为Filterprops传进去,当筛选的表单发生变化时候去触发我们自定义hooks来更新整个组件。
  • 接下来需要把我们的过滤方法filterProductList放到Filter组件里面,这样Filter组件只需要筛选数据的表单值和提供一个过滤的方法。
// src\page\SRP\Filter.tsx
import React from 'react';
import { Button, Form, Input, InputNumber } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { IFilterData, IProduct } from '@/typing';

interface IFilterProps {
  handleFilter?: (filterData: IFilterData) => void;
}
export const filterProductList = (productList,filterData: IFilterData): IProduct[] => {
  const { minNum, maxNum } = filterData;
  const isNull = (value: unknown) => value === undefined || value === null;
  const hasValue = (value: unknown) => value !== undefined && value !== null;
  const discountedPrice = (item: IProduct) => item.price * (item.discount || 1);
  const filterDataMap = {
    all: () => productList,
    onlyMin: (value: number) =>
      productList.filter(item => discountedPrice(item) >= value),
    onlyMax: (value: number) =>
      productList.filter(item => discountedPrice(item) <= value),
    rangeValue: (minValue: number, maxValue: number) =>
      productList.filter(item => discountedPrice(item) <= maxValue && discountedPrice(item) >= minValue),
  }
  if (isNull(minNum) && isNull(maxNum)) {
    return filterDataMap['all']();
  }
  if (hasValue(minNum) && isNull(maxNum)) {
    return filterDataMap['onlyMin'](minNum);
  }
  if (isNull(minNum) && hasValue(maxNum)) {
    return filterDataMap['onlyMax'](maxNum);
  }
  if (hasValue(minNum) && hasValue(maxNum)) {
   return filterDataMap['rangeValue'](minNum, maxNum);
  }
}
export default (props: IFilterProps) => {
  const { handleFilter } = props;
  const [form] = Form.useForm();
  return <>
    <Form form={form}>
      <Input.Group compact>
        <Button>价格区间</Button>
        <Form.Item name={"minNum"}>
          <InputNumber style={{ width: 100, textAlign: 'center' }} placeholder="最小值" />
        </Form.Item>
        <Input
          style={{
            width: 30,
            borderLeft: 0,
            borderRight: 0,
            pointerEvents: 'none',
          }}
          placeholder="~"
          disabled
        />
        <Form.Item name={'maxNum'}>
          <InputNumber
            style={{
              width: 100,
              textAlign: 'center',
            }}
            placeholder="最大值"
          />
        </Form.Item>
        <Button type="primary" shape="circle" onClick={() => {
          handleFilter?.(form.getFieldsValue() as IFilterData);
        }} icon={<SearchOutlined />} />
      </Input.Group>
    </Form>
  </>
}

// src\page\SRP\index.tsx
import React from 'react';
import { Col, Row } from 'antd';
import ShowProduct from './ShowProduct';
import { useFilterProduct } from './hooks/useFilter';
import Filter, { filterProductList } from './Filter';
import { useProduct } from './hooks/useProduct';

export default () => {
  const { productList } = useProduct();
  const { filterData, handleFilter } = useFilterProduct();
  return <>
    <Filter handleFilter={handleFilter} />
    <Row gutter={12} style={{ marginTop: '12px' }}>
      {filterProductList(productList, filterData).map(item =>
        <Col key={item.id} span={6}>
          <ShowProduct data={item} />
        </Col>)}
    </Row>
  </>
}

这样我们的需求就被拆分成了几个单一功能的组件和hooks。这样后面不管需求怎么变化,我们总是能够很快定位到相应的组件里面去处理,并且不会影响其他组件。

总结

SRP原则的优点

降低了单个类或者对象的复杂度,按照职责把对象分解成更小的粒度,这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责

SRP原则的缺点

  • SRP 原则也有一些缺点,最明显的是会增加编写代码的复杂度。当我们按照职责把对象分解成更小的粒度之后,实际上也增大了这些对象之间相互联系的难度。就如上面的例子,一个大组件被我们拆分了多个小组件然后组合起来,从某个角度来讲确实是增加了组件的复杂度。
  • 在方便性与稳定性之间要有一些取舍。具体是选择方便性还是稳定性,并没有标准答案,而是要取决于具体的应用环境。

最后

坦白的说,大部分开发的人经常在违反着单一职责,SRP 原则是所有原则中最简单也是最难正确运用的原则之一。两个实践的思路:

  • 不要过度设计,因为我们无法一次性把后面需求预测完, 只能大概感知它可能会朝某个方向演变。就像上面例子,如果需求本身就这样简单,逻辑都在一个组件里面也没什么问题,但是我们的经验和实践告诉我们,这个需求有很大几率会往往我们所想的方向走,所以我们需要按照职责去拆分。
  • 持续重构,随着业务每次迭代,不断的拆分和封装,持续重构我们自己的代码,当需要的时候,重构就好。

文中如有错误,欢迎指出~

相关的代码已经整理成一个仓库上传至github,感兴趣可以结合仓库代码阅读: REACT-SOLID
目录结构

react-solid
├─ src
│  ├─ index.html
│  ├─ index.js
│  ├─ page
│  │  ├─ App.tsx
│  │  ├─ DIP
│  │  │  ├─ index.tsx
│  │  │  ├─ LoginFormBad.tsx
│  │  │  ├─ LoginFormGood.tsx
│  │  │  └─ modeSdk
│  │  │     ├─ comment.ts
│  │  │     ├─ index.ts
│  │  │     ├─ mode.ts
│  │  │     └─ share.ts
│  │  ├─ ISP
│  │  │  ├─ Image.tsx
│  │  │  ├─ index.tsx
│  │  │  └─ Product.tsx
│  │  ├─ LSP
│  │  │  ├─ CustomInput.tsx
│  │  │  ├─ index.tsx
│  │  │  └─ retransmission.ts
│  │  ├─ OCP
│  │  │  ├─ BadButton.tsx
│  │  │  ├─ GoodButton.tsx
│  │  │  ├─ index.tsx
│  │  │  ├─ utils
│  │  │  │  └─ formValidate.ts
│  │  │  └─ Validate.tsx
│  │  └─ SRP
│  │     ├─ Bad.tsx
│  │     ├─ Filter.tsx
│  │     ├─ Good.tsx
│  │     ├─ hooks
│  │     │  ├─ useFilter.ts
│  │     │  └─ useProduct.ts
│  │     ├─ index.tsx
│  │     ├─ mock.js
│  │     └─ ShowProduct.tsx
│  └─ typing
│     └─ index.ts
├─ package-lock.json
├─ package.json
├─ README.md
├─ tsconfig.json
└─ webpack.config.js