当面试官问你什么是设计原则时

915 阅读6分钟

在学习设计模式时,一定会学到 SOLID 设计原则,分别是:单一职责原则、开放封闭原则、里氏替换原则、接口隔离原则、依赖反转原则。

在许多学习资料中都是以"类"的设计来解释这些原则,那么作为前端开发该如何理解和实践者几种原则呢?

前端多以页面开发为主,页面由 N 个组件搭建而成,可以把组件理解为"类"来学习和理解 SOLID 设计原则,下面会以 React 为示例,看看在日常开发中是如何应用设计原则的。

单一职责原则(Single Responsibility Principle)

定义:任何一个软件模块都应该只对某一类行为者负责

放在前端:每个模块只做特定的事情,例如 UI 组件只负责渲染、过滤逻辑只写在过滤模块里等。

下面以 React 示例代码讲解下:

import { useEffect, useState, useMemo } from "react";
import getFilmsApi from "../http/getFilms";
import { Card, Space, Rate } from "antd";

const BadSRP = () => {
  const [films, setFilms] = useState([]);
  const [filterRate, setFilterRate] = useState(1);

  const fetchFilms = async () => {
    const data = await getFilmsApi();

    data && setFilms(data);
  };

  useEffect(() => {
    fetchFilms();
  }, []);

  const onRating = (rate) => {
    setFilterRate(rate);
  };

  const filterFilms = useMemo(() => {
    return films.filter((film) => film.starCount >= filterRate);
  }, [films, filterRate]);

  return (
    <>
      <div style={{ marginBottom: "20px" }}>
        <Rate value={filterRate} onChange={onRating} />
      </div>

      <Space wrap>
        {filterFilms.map((film) => (
          <Card
            size="small"
            hoverable
            style={{ width: 120 }}
            cover={<img alt="example" src={film.cover} />}
          >
            <Card.Meta
              title={film.name}
              description={
                <Rate
                  style={{ fontSize: "12px" }}
                  disabled
                  defaultValue={film.starCount}
                />
              }
            />
          </Card>
        ))}
      </Space>
    </>
  );
};

export default BadSRP;

image.png 上面展示了一段示例代码和对应的页面,其功能也很简单:页面初始化的时候拉取电影列表并展示,用户可以通过星标来筛选电影列表。

代码很简单也能正常运行,但是显然将页面所有逻辑放在一个组件里面是不符合单一职责的,我们需要将其进行拆分。

首先,我们将页面拆分成 FilterRate 和 FilmList 两个组件:

// FilterRate Component
import { Rate } from "antd";

export const filterFilms = (films, rate) => {
  return films.filter((film) => film.starCount >= rate);
};

export const FilterRate = (props) => {
  const { filterRate, onRating } = props;
  return <Rate value={filterRate} onChange={onRating} />;
};

// FilmList Component
import { Space, Card, Rate } from "antd";

export default (props) => {
  const { films } = props;

  return (
    <Space wrap>
      {films.map((film) => (
        <Card
          size="small"
          hoverable
          style={{ width: 120 }}
          cover={<img alt="example" src={film.cover} />}
        >
          <Card.Meta
            title={film.name}
            description={
              <Rate
                style={{ fontSize: "12px" }}
                disabled
                defaultValue={film.starCount}
              />
            }
          />
        </Card>
      ))}
    </Space>
  );
};

这里要注意两点:

  1. 我们没有将业务逻辑写在组件里面,因为业务逻辑是可以复用的,而 UI 组件只需要负责渲染即可
  2. 我们将 filterFilms 功能函数放在了 FilterRate 文件里面,是因为这个函数的作用是过滤电影的规则,按照只为某一类(过滤)负责的原则,将其放在这里

然后我们要将业务逻辑做一次抽离,将可复用逻辑写成 useFilter 和 useFilms 两个 hooks:

// useFilter:负责过滤业务逻辑
import { useState } from "react";

export default () => {
  const [filterRate, setFilterRate] = useState(1);

  const onRating = (rate) => {
    setFilterRate(rate);
  };

  return {
    filterRate,
    onRating
  };
};

// useFilms:负责电影数据拉取
import { useEffect, useState } from "react";
import getFilmsApi from "../../http/getFilms";

export default () => {
  const [films, setFilms] = useState([]);

  const fetchFilms = async () => {
    const data = await getFilmsApi();

    data && setFilms(data);
  };

  useEffect(() => {
    fetchFilms();
  }, []);

  return {
    films
  };
};

抽离之后的代码将业务逻辑、UI 进行了隔离,并且每一块内容只做特定的事情: image.png

// page.jsx
import { FilterRate } from "./FilterRate";
import FilmList from "./FilmList";
import useFilms from "./hooks/useFilms";
import useFilter from "./hooks/useFilter";
import { filterFilms } from "./FilterRate";

export default () => {
  const { films } = useFilms();
  const { filterRate, onRating } = useFilter();
  return (
    <>
      <FilterRate filterRate={filterRate} onRating={onRating} />

      <FilmList films={filterFilms(films, filterRate)} />
    </>
  );
};

开放封闭原则(Open-Closed Principle)

定义:设计良好的计算机软件应该易于拓展,同时抗拒修改

放在前端:组件要拓展的时候,尽可能地不要修改组件内部。

下面以 React 示例代码讲解下:

import { Button } from "antd";
import { RightOutlined, LeftOutlined } from "@ant-design/icons";
interface IProps {
  text: string;
  rule?: "back" | "forword";
}

export default (props: IProps) => {
  const { text, rule } = props;
  // 存放不同形态的 icon
  const ruleIcon = {
    back: <LeftOutlined />,
    forword: <RightOutlined />
  };
  return <Button icon={ruleIcon[rule] ?? null}>{text}</Button>;
};

image.pngimage.png

上面这段代码写的是一个 Button 组件,组件的接口对应了两种形态:back 和 forword,每个形态都对应不同的 icon。

如果此时要对组件进行拓展,新增一个 moveUp 形态,那么就需要更改到组件内的代码,新增 ruleIcon 的属性,显然这样是违背"开放封闭原则"的。

我们将这段代码优化下:

import { Button } from "antd";
import { ReactNode } from "react";

interface IProps {
  text: string;
  icon?: ReactNode;
}

export default (props: IProps) => {
  const { text, icon } = props;

  return <Button icon={icon ?? null}>{text}</Button>;
};

改动之后,Button 组件的 icon 由外部传入,此时再新增形态,就无需修改组件内部代码。

里氏替换原则(Liskov Substitution Principle)

定义:程序中的对象都应该能够被各自的子类实例替换,而不会影响到程序的行为

放在前端:衍生组件可以替换父组件,而不会影响程序运行。

下面以 React 示例代码讲解下:

// in Button.tsx
import { Button, ButtonProps } from "antd";

export interface IButtonProps extends ButtonProps {}

export default (props: IButtonProps) => {
  const { ...restProps } = props;
  return <Button {...restProps}></Button>;
};

// in RedButton.tsx
import Button, { IButtonProps } from "./Button";

interface IRedButtonProps extends IButtonProps {}

export default (props: IRedButtonProps) => {
  return <Button {...props} style={{ backgroundColor: "red" }}></Button>;
};

这里有两个组件,Button 和 RedButton。其中 RedButton 组件封装了 Button 组件,因此将 Button 组件替换成 RedButton 也不会有问题。

接口隔离原则(Interface Segregation Principle)

定义:软件设计不要依赖它不需要的东西

放在前端:不要传不必要的 props 给组件。 image.png 以电影卡片组件为例,假设我们定义了电影的数据结构:

interface IFilm {
  name: string;
  cover: string;
  starCount: number;
}

定义 Card 组件

import { Space } from "antd";
import Cover from "./Cover";

interface ICardProps {
  film: IFilm;
}

export default (props: ICardProps) => {
  const { film } = props;
  return (
    <Space direction="vertical">
      <Cover film={film} />
      <span>{name}</span>
    </Space>
  );
};

而 Card 组件内使用了 Cover 组件,用于展示电影封面,其对应的 props 接口为:

interface ICoverProps {
  file: IFilm
}

这里 ICoverProps 接受整个 film 对象,但是 Cover 组件并不需要 film 的所有属性,它只需要一个封面链接,这里就违背了接口隔离原则。

我们需要将 Cover 组件接口改为只接受一个封面链接:

interface ICoverProps {
  cover: string;
}

依赖反转原则(Dependency Inversion Principle)

定义:依赖抽象,而不是依赖具体的实现

放在前端:将业务逻辑控制进行抽离,UI 组件仅负责展示,并通过定义抽象接口来接收具体业务实现函数。

下面以 React 代码作为示例:

import { useState } from "react";

export default (props: IFormProps) => {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = () => {
    // 发送请求.....
  };

  return (
    <form action="" onSubmit={handleSubmit}>
      <div>
        <input
          type="text"
          value={username}
          onChange={(e) => {
            setUsername(e.target.value);
          }}
          placeholder="请输入用户名"
        />
      </div>
      <div>
        <input
          type="text"
          value={password}
          onChange={(e) => {
            setPassword(e.target.value);
          }}
          placeholder="请输入密码"
        />
      </div>
      <div>
        <button>提交</button>
      </div>
    </form>
  );
};

image.png

上面这段代码是个简单的表单提交,输入用户名和密码然后提交表单。

如果此时有别的地方要复用这个表单,但是提交时需要做一些和原来不一样的处理,按照这种写法就需要给 props 新增标识,并且在 handleSubmit 中做判断。显然这是不合理的。

按照"依赖反转原则",表单组件只需要依赖抽象接口,这个接口接收 onSubmit 处理函数:

import { useState } from "react";

interface IFormProps {
  onSubmit: (username: string, password: string) => void;
}

export default (props: IFormProps) => {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");

  const { onSubmit } = props;

  const handleSubmit = () => {
    onSubmit(username, password);
  };

  return (
    <form action="" onSubmit={handleSubmit2}>
      <div>
        <input
          type="text"
          value={username}
          onChange={(e) => {
            setUsername(e.target.value);
          }}
          placeholder="请输入用户名"
        />
      </div>
      <div>
        <input
          type="text"
          value={password}
          onChange={(e) => {
            setPassword(e.target.value);
          }}
          placeholder="请输入密码"
        />
      </div>
      <div>
        <button>提交</button>
      </div>
    </form>
  );
};

这样的话我们就可以为 Form 表单封装多种不同 onSubmit 行为的表单了。

总结

前端开发中,都在时刻应用着设计原则:

  • 单一职责:UI 和不同业务逻辑进行抽离
  • 开放封闭:组件内部尽量保持稳定,易变动的内容由外部传入
  • 里氏替换:衍生组件可以替换父组件(红色按钮可以替换普通按钮)
  • 接口隔离:不要传无用的 props 给组件
  • 依赖反转:组件的业务逻辑抽象为接口,由外部传入,从而可以封装成多种行为的组件

更多学习资料