React 中的 Solid 原则

275 阅读11分钟

如果您是一名软件开发人员,您可能会面临“您以前听说过 Solid 原则吗?” 面试中的问题。您可能已经遇到过这样的问题。我相信你也提到过,你听说过它,并且在日常生活中使用过它,但你现在无法举例说明。 😂 现在让我们通过示例了解一下 Solid 是什么,以及如何在 React 项目中应用这些原则,以便我们在以后的面试中永远不会忘记。😎

在我们开始之前:这将是一篇很长的文章,现在喝杯咖啡吧:)☕️

Solid 是软件设计中的一组五个重要规则。这些规则有助于使代码易于理解、灵活且可维护。

1. 单一职责原则 Single Responsibility Principle(SRP)

类应该服务于单一的、明确定义的目的,从而减少频繁更改的需要。

通过确保应用程序中的每个组件都有特定的、明确定义的用途,我们可以在 React 项目中坚持单一职责原则 (SRP)。例如,组件可以负责显示特定部分、处理用户输入或调用 API 来获取数据。通过将组件限制为单个、定义良好的任务,我们可以提高代码库的清晰度和可维护性。

以下是在 React 应用程序中实施单一职责原则 (SRP) 的一些指南:

  • 让你的组件变小,只做一件事:尝试让你的组件变小并且专注,为每个组件分配一个单一的、明确定义的职责。
  • 不要混合不同的工作:不要将不相关的任务捆绑到一个组件中。例如,负责显示表单的组件不应同时处理 API 调用以获取列表数据。
  • 使用组合:通过组合较小的组件来创建可重用的 UI 组件。这使得开发人员可以将复杂的 UI 分解为更小、更易于管理的部分,这些部分可以轻松地在应用程序的不同部分中重用。
  • 明智地处理 propsstateprops 就像将数据和操作传递给子组件的信使。另一方面,状态就像组件的个人记事本,保存其独特的信息。使用状态来获取不限于一件的信息。

为了用反模式示例解释这个想法,请看下面的基本代码片段。此代码的工作是获取并显示列表项。

import React, { useEffect, useState } from "react";  
import axios from "axios";
  
const ListItems = () => {  
    const [listItems, setListItems] = useState([]);  
    const [isLoading, setIsLoading] = useState(true);
    
    useEffect(() => {  
        axios.get("https://.../listitems/").then((res) => {  
            setListItems(res.data)  
        }).catch(e => {  
            errorToast(e.message);  
        }).finally(() => {  
            setIsLoading(false);  
        })  
    }, [])

    if (isLoading){  
        return <div>Loading...</div>  
    }
  
    return (  
        <div>  
            {listItems.map(item => {  
                return (  
                    <div>  
                        <img src={item.img} alt="" />  
                        <p>{item.name}</p>  
                        <p>{item.description}</p>  
                    </div>  
                )  
            })}  
        </div>  
    );  
};  
  
  
export default ListItems;

乍一看,代码可能看起来写得很好,因为它符合我们的习惯。然而,当我们深入研究组件定义时,我们开始注意到一些“问题”。 ListItems 组件承担多种角色:

  1. 管理国家数据。
  2. 发出列表数据请求并进行处理。
  3. 组件渲染。

尽管它最初看起来像是一个典型的组件,但根据单一职责原则 (SRP),它处理的职责比应有的要多。

那么,我们怎样才能让它变得更好呢?

通常,如果组件内有 useEffect,我们可以创建一个自定义挂钩来处理该操作并将 useEffect 保留在组件之外。

在本例中,我们将使用自定义挂钩创建一个新文件。该 Hooks 将负责获取列表数据和管理状态的逻辑。

import React, { useEffect, useState } from "react";
import axios from "axios";
  
const useFetchListItems = () => {  
    const [listItems, setListItems] = useState([]);  
    const [isLoading, setIsLoading] = useState(true);  
  
    useEffect(() => {  
        axios.get("https://.../listitems/").then((res) => {  
            setListItems(res.data)  
        }).catch(e => {  
            errorToast(e.message);  
        }).finally(() => {  
            setIsLoading(false);  
        })  
    }, [])  

    return { listItems, isLoading };  
}

通过分离处理状态和获取列表项的任务,我们的初始组件变得更加简单,更容易阅读和理解。它现在唯一的职责是显示信息,使其更易于维护和扩展。

我们重构并兼容 SRP 组件将如下所示:

import { useFetchListItems } from "@/hooks/useFetchListItems";
  
const ListItems = () => {  
    const { listItems, isLoading } = useFetchListItems();

    if (isLoading){  
        return <div>Loading...</div>  
    }  

    return (
        <div>
            {listItems.map(item => {
                return (
                    <div>
                        <img src={item.img} alt="" />  
                        <p>{item.name}</p>
                        <p>{item.description}</p>
                    </div>
                )
            })}
        </div>
    );
};

export default ListItems;

但如果我们仔细观察我们新的 Hooks,我们会发现它做了一些事情。它负责管理状态和获取列表项。因此,它并不真正遵循单一责任原则所建议的只做一件事的想法。

为了解决这个问题,我们可以分离获取列表项的逻辑。简而言之,我们将创建一个名为 api.js 的新文件,并将负责获取列表数据的代码移至此处:

import axios from "axios";  
import errorToast from "./errorToast";  

const fetchListItems = () => {  
    return axios  
        .get("https://.../listitems/")  
        .catch((e) => {  
            errorToast(e.message);  
        })  
        .then((res) => res.data);  
};

🎉 还有我们新重构的自定义 Hooks:

import { useEffect, useState } from "react";
import { fethListItems } from "./api";
  
const useFetchListItems = () => {  
    const [listItems, setListItems] = useState([]);
    const [isLoading, setIsLoading] = useState(true);

    useEffect(() => {  
        fetchListItems()  
            .then((listItems) => setListItems(listItems))
            .finally(() => setIsLoading(false));
    }, []);

    return { listItems, isLoading };
};

让我们谈谈如何让事情变得简单。遵循 SRP 可以帮助我们组织代码并避免错误。但是,这并不总是那么容易。它可能会使我们的文件结构更加复杂,并且规划可能需要额外的时间。

在我们的示例中,我们让每个文件做一件事,使我们的结构稍微复杂一些,但符合原则。然而,请记住,严格遵守 SRP 并不总是最好的。有时,我们的代码有一点复杂性是可以的,而不是让它变得过于复杂。

在某些情况下,我们不必严格遵循 SRP,例如:

  1. 表单组件:表单执行许多工作,例如检查数据、管理状态和更新信息。拆分这些任务可能会使事情变得混乱,尤其是当我们使用其他工具或库时。
  2. 表格组件:表格还处理不同的任务,例如显示数据和管理用户交互方式。将它们分成单独的部分可能会使我们的代码更加混乱。

2. 开闭原理 Open/Closed Principle(OCP)

软件实体(类、模块、函数等)应该对扩展开放,但对修改关闭。

  • 开放扩展:这意味着您可以向组件添加新的行为、功能和特性,而无需更改其已有的工作方式。
  • 关闭修改:创建并实现 React 组件后,除非不可避免,否则应避免直接操作其源代码。

让我们看一下以下基本的 React 组件:

import React from 'react';
  
interface IListItem {  
    title: string;  
    image: string;  
    isAuth: boolean;  
    onClickMember: () => void;  
    onClickGuest: () => void;  
}  
  
const ListItem = ({ title, image, isAuth, onClickMember, onClickGuest }: IListItem) => {
    const handleMember = () => {
        // Some logic
        onClickMember();  
    };
  
    const handleGuest = () => {
        // Some logic
        onClickGuest();
    };  
    return (  
        <div>  
            <img src={image} />  
            <p>{title}</p>  
            {  
                isAuth ?  
                <button onClick={handleMember}>Add to cart +</button>  
                :  
                <button onClick={handleGuest}>Show Modal</button>  
            }
        </div>
    );
};

正如你所看到的,上面的代码不符合这个原则。它根据身份验证状态呈现不同的功能。

如果我们想用不同的逻辑渲染不同的按钮,最好修改这个代码块:

interface IButtonHandler {
    handle(): void;
}
  
export const GuestButtonHandler = ({ onClickGuest }: { onClickGuest?: () => void }) => {  
    const handle = () => {  
        // Guest 的一些逻辑
        onClickGuest();  
    };  

    return <button onClick={handle}>Show Modal</button>;  
};  

export const MemberButtonHandler = ({ onClickMember }: { onClickMember?: () => void }) => {  
    const handle = () => {  
        // Member 的一些逻辑
        onClickMember();  
    };  

    return <button onClick={handle}>Add to cart +</button>;  
};
import React from 'react';  
  
interface IListItem {  
    title: string;
    image: string;
    children: React.ReactNode;
}  

export const ListItem = ({ title, image, children }: IListItem) => {  
  
    return (  
        <div>  
            <img src={image} />  
            <p>{title}</p>  
            {children}  
        </div>  
    );  
};  
  
export default ListItem;

最后,我们删除不必要的代码并创建新的 props.children ,以便其他组件可以通过将其作为子组件传递来扩展该组件:

import { ListItem } from "../index.tsx"  
import { GuestButtonHandler, MemberButtonHandler } from "../handlers"  
  
const App = () => {
    return (
        <ListItem title={item.title} image={item.image}>  
            {
                isAuth ? <MemberButtonHandler /> : <GuestButtonHandler /> 
            } 
        </ListItem>  
    );
};  
  
export default App

🎉 现在我们的 ListItem 组件将打开以进行扩展,关闭以进行修改。这种方法更有效,因为我们现在有了独立的组件,不需要大量的道具来显示各种东西。我们只需展示具有所需功能的正确部分即可。此外,如果一个组件在内部做了很多事情,它可能会违反单一职责原则(SRP)的规则。因此,这种新方法有助于保持代码的组织和清晰。

3. 里氏替换原理 Liskov Substitution Principle(LSP)

超类的对象应该可以用其子类的对象替换。

这意味着特定类的子类应该能够替换超类而不破坏任何功能。

例子:

在 React 中,里氏替换原则 (LSP) 的核心思想是:子类组件应该能够替换父类组件,而不改变应用程序的行为。这意味着子类组件应该继承父类组件的所有功能,并且可以扩展它们,而不会破坏现有功能。

让我们看一个 React 例子来说明这个原则。假设我们有一个 Button 组件,它接受 onClicklabel 作为 props,并渲染一个按钮:

import React from 'react';

export interface ButtonProps {
  onClick: () => void;
  label: string;
}

const Button = ({ onClick, label }: ButtonProps) => {
  return <button onClick={onClick}>{label}</button>;
};

export default Button;

现在,我们想创建一个 PrimaryButton 组件,它应该继承 Button 的所有功能,并且还可以自定义样式:

import React from 'react';
import Button, { ButtonProps } from './Button';

const PrimaryButton: React.FC<ButtonProps> = ({ onClick, label }) => {
  return (
    <Button onClick={onClick} label={label} style={{ backgroundColor: 'blue', color: 'white' }} />
  );
};

export default PrimaryButton;

在这个例子中,PrimaryButton 组件继承了 Button 的所有功能,并添加了自定义样式。PrimaryButton 仍然是一个按钮,可以处理点击事件并显示标签。我们可以将 PrimaryButton 替换为 Button,不改变应用程序的预期行为。

import React from 'react';
import Button from './Button';
import PrimaryButton from './PrimaryButton';

const App = () => {
  const handleClick = () => {
    alert('Button clicked');
  };

  return (
    <div>
      {/* 使用 Button 组件 */}
      <Button onClick={handleClick} label="Click me" />

      {/* 使用 PrimaryButton 组件 */}
      <PrimaryButton onClick={handleClick} label="Primary Button" />
    </div>
  );
};

export default App;

在 App 组件中,我们可以无缝地使用 ButtonPrimaryButton,而不改变逻辑。PrimaryButton 继承了 Button 的所有功能,因此我们可以用 PrimaryButton 替换 Button,而不会影响应用程序的功能。这符合里氏替换原则 (LSP),确保子组件可以替换父组件,而不破坏应用程序的行为。

4. 接口隔离原则 Interface Segregation Principle(ISP)

任何代码都不应该被迫依赖于它不使用的方法。 对于 React 应用程序,我们将其重新表述为“组件不应依赖于它们不使用的 props"。

让我们深入一个例子来更好地理解:

const ListItem = ({ item }) => {  
  
    return (  
        <div>  
            <img url={item.image} />  
            <p>{item.title}</p>  
            <p>{item.description}</p>  
        </div>  
    );  
};

我们有一个 ListItem 组件,它只需要 item 属性中的一些数据,即 image 、 title 和 description. 通过将 ListItem 作为 props 提供,我们最终会提供比组件实际需要的更多的内容,因为 item props 本身可能包含组件不需要的数据。

为了解决这个问题,我们可以将 props 限制为组件所需要的。

const ListItem = ({ image, title, description }) => {  
  
    return (  
        <div>  
            <img url={image} />  
            <p>{title}</p>  
            <p>{description}</p>  
        </div>  
    );  
};

🎉 现在我们的组件已经兼容了 ISP 原理。

5. 依赖倒置原则 Dependency Inversion Principle(DIP)

  1. 高级模块不应从低级模块导入任何内容。两者都应该依赖于抽象(例如接口)。
  2. 抽象不应该依赖于细节。细节(具体实现)应该取决于抽象。

在 React 的上下文中,这一原则确保高级组件不应该直接依赖于低级组件,但两者都应该依赖于一个共同的抽象。在这种情况下,“组件”指的是应用程序的任何部分,无论是 React 组件、函数、模块、基于类的组件还是第三方库。让我们看一下例子:

const CreateListItemForm = () => {  
    const handleCreateListItemForm = async (e) => {  
        try {  
            const formData = new FormData(e.currentTarget);  
            await axios.post("https://myapi.com/listItems", formData);  
        } catch (err) {  
            console.error(err.message);  
        }  
    };  

    return (  
        <form onSubmit={handleCreateListItemForm}>  
            <input name="title" />  
            <input name="description" />  
            <input name="image" />  
        </form>  
    );  
};

上面的组件显示了一个表单,用于通过呈现表单并将提交的数据发送到 API 来处理创建列表项。

考虑这种情况。还有另一种形式用于编辑具有相同 UI 的列表项,但仅在逻辑方面有所不同(在我们的示例中它是 API 端点)。我们的表单将无法重用,因为我们需要另一个端点来提交编辑表单。因此,我们需要创建一个不依赖于特定低级模块的组件。

const ListItemForm = ({ onSubmit }) => {  
    return (  
        <form onSubmit={onSubmit}>  
            <input name="title" />  
            <input name="description" />  
            <input name="image" />  
        </form>  
    );  
};

我们已经从表单中删除了依赖关系,现在我们可以通过 props 为其提供必要的逻辑。

const CreateListItemForm = () => {  
    const handleCreateListItem = async (e) => {  
        try {  
            const formData = new FormData(e.currentTarget);  
            await axios.post("https://myapi.com/listItems", formData);  
        } catch (err) {  
            console.error(err.message);  
        }  
    };  
    return <ListItemForm onSubmit={handleCreateListItem} />;  
};
const EditListItemForm = () => {  
    const handleEditListItem = async (e) => {  
        try {  
            // 编辑逻辑
        } catch (err) {  
            console.error(err.message);  
        }  
    };  
    return <ListItemForm onSubmit={handleEditListItem} />;  
};

通过这种简化和应用 DIP,我们甚至可以单独测试每个组件,而不必担心无意中影响其他连接的部件,因为没有任何连接的部件。

总结来说,Solid 原则提供了一套强大的指导方针,使我们的代码更具可维护性、扩展性和清晰性。通过在 React 项目中应用这些原则,我们可以创建更稳健的应用程序,减少错误的发生,并提高团队协作的效率。

在日常开发中,严格遵循每一个原则可能会带来一些挑战,例如代码结构的复杂化或开发时间的增加。然而,理解并适当应用这些原则能帮助我们权衡这些成本与长期的收益之间的关系。我们可以从中灵活地选择和应用最合适的方案,而不是一刀切地应用所有原则。

希望通过这篇文章,您对 Solid 原则有了更深入的了解,并能在实际项目中熟练运用。无论是在面试中还是在日常工作中,这些原则都将成为您编写高质量代码的重要工具。现在,您可以放下咖啡杯,继续您的开发旅程了!😎