cover_image

React 开发中使用开闭原则

ikoofe KooFE前端团队
2025年02月05日 01:49

本文翻译自 Open-Closed Principle in React: Building Extensible Components,主要是在 React 应用的背景下讨论开闭原则。

开闭原则(Open-Closed Principle, OCP)指出,软件实体应该对扩展开放,但对修改关闭。在 React 中,这意味着:组件应该易于扩展,而不需要修改其现有代码。让我们看看这在实际中是如何体现的。

封闭组件的问题

以下是一个常见的反模式:

// 不要这样做const Button = ({ label, onClick, variant }: ButtonProps) => {  let className = "button";
  // 直接为每种 variant 修改  if (variant === "primary") {    className += " button-primary";  } else if (variant === "secondary") {    className += " button-secondary";  } else if (variant === "danger") {    className += " button-danger";  }
  return (    <button className={className} onClick={onClick}>      {label}    </button>  );};

这违反了开闭原则,因为:

  • 添加新 variant 需要修改组件
  • 组件需要知道所有可能的 variant
  • 每次添加新 variant 都会使测试变得更加复杂

构建开放组件

让我们重构这个组件以遵循开闭原则:

type ButtonBaseProps = {  labelstring,  onClick() => void,  className?: string,  children?: React.ReactNode,};
const ButtonBase = ({  label,  onClick,  className = "",  children,}: ButtonBaseProps) => (  <button className={`button ${className}`.trim()} onClick={onClick}>    {children || label}  </button>);
// variant 组件扩展基础组件const PrimaryButton = (props: ButtonBaseProps) => (  <ButtonBase {...props} className="button-primary" />);
const SecondaryButton = (props: ButtonBaseProps) => (  <ButtonBase {...props} className="button-secondary" />);
const DangerButton = (props: ButtonBaseProps) => (  <ButtonBase {...props} className="button-danger" />);

现在我们可以轻松添加新 variant,而无需修改现有代码:

// 添加新 variant 而不修改原始组件const OutlineButton = (props: ButtonBaseProps) => (  <ButtonBase {...propsclassName="button-outline" />);

组件组合模式

让我们看一个更复杂的组合示例:

type CardProps = {  titlestring,  childrenReact.ReactNode,  renderHeader?: (title: string) => React.ReactNode,  renderFooter?: () => React.ReactNode,  className?: string,};
const Card = ({  title,  children,  renderHeader,  renderFooter,  className = "",}: CardProps) => (  <div className={`card ${className}`.trim()}>    {renderHeader ? (      renderHeader(title)    ) : (      <div className="card-header">{title}</div>    )}
    <div className="card-content">{children}</div>
    {renderFooter && renderFooter()}  </div>);
// 无需修改即可扩展const ProductCard = ({ product, onAddToCart, ...props }: ProductCardProps) => (  <Card    {...props}    renderFooter={() => (      <button onClick={onAddToCart}>Add to Cart - ${product.price}</button>    )}  />);

高阶组件扩展

高阶组件(HOC)提供了另一种遵循开闭原则的方式:

type WithLoadingProps = {  isLoading?: boolean;};
const withLoading = <P extends object>(  WrappedComponent: React.ComponentType<P>) => {  return ({ isLoading, ...props }: P & WithLoadingProps) => {    if (isLoading) {      return <div className="loader">Loading...</div>;    }
    return <WrappedComponent {...props as P} />;  };};
// 使用const UserProfileWithLoading = withLoading(UserProfile);

遵循开闭原则的自定义 Hook

自定义 Hook 也可以遵循开闭原则:

const useDataFetching = <T,>(url: string) => {  const [data, setData] = useState<T | null>(null);  const [error, setError] = useState<Error | null>(null);  const [loading, setLoading] = useState(true);
  useEffect(() => {    fetchData();  }, [url]);
  const fetchData = async () => {    try {      const response = await fetch(url);      const result = await response.json();      setData(result);    } catch (e) {      setError(e as Error);    } finally {      setLoading(false);    }  };
  return { data, error, loading, refetch: fetchData };};
// 无需修改即可扩展const useUserData = (userId: string) => {  const result = useDataFetching<User>(`/api/users/${userId}`);
  // 添加用户特定功能  const updateUser = async (data: Partial<User>) => {    // 更新逻辑  };
  return { ...result, updateUser };};

测试优势

开闭原则使测试变得更加简单:

describe("ButtonBase", () => {  it("renders with custom className", () => {    render(<ButtonBase label="Test" onClick={() => {}} className="custom" />);
    expect(screen.getByRole("button")).toHaveClass("button custom");  });});
// 新变体可以有各自的测试describe("PrimaryButton", () => {  it("includes primary styling", () => {    render(<PrimaryButton label="Test" onClick={() => {}} />);
    expect(screen.getByRole("button")).toHaveClass("button button-primary");  });});

关键要点

  • 使用组合而非修改——通过 props 和 render props 进行扩展
  • 创建易于扩展的基础组件
  • 利用高阶组件和自定义 Hook 实现可重用的扩展
  • 从扩展点的角度思考——哪些部分可能需要变化?
  • 使用 TypeScript 确保扩展的类型安全

开闭原则与“组合优于继承”

React 团队推荐的 “组合优于继承” 与开闭原则完美契合。以下是原因:

// 基于继承的方法(灵活性较低)class Button extends BaseButton {  render() {    return (      <button className={this.getButtonClass()}>        {this.props.icon && <Icon name={this.props.icon} />}        {this.props.label}      </button>    );  }}
// 基于组合的方法(更灵活,遵循开闭原则)const Button = ({  label,  icon,  renderPrefix,  renderSuffix,  ...props}: ButtonProps) => (  <ButtonBase {...props}>    {renderPrefix?.()}    {icon && <Icon name={icon} />}    {label}    {renderSuffix?.()}  </ButtonBase>);
// 现在我们可以无需修改即可扩展行为const DropdownButton = ({ items, ...props }: DropdownButtonProps) => (  <Button    {...props}    renderSuffix={() => <DropdownIcon />}    onClick={() => setIsOpen(true)}  />);
const LoadingButton = ({ isLoading, ...props }: LoadingButtonProps) => (  <Button    {...props}    renderPrefix={() => isLoading && <Spinner />}    disabled={isLoading}  />);

这种基于组合的方法:

  • 使组件对扩展开放(通过 props 和 render 函数)
  • 保持基础组件对修改关闭
  • 允许无限的行为组合
  • 保持类型安全和 props 透明性

React 团队对组合的偏好不仅仅是风格问题,而是关于创建可扩展、可维护的组件,这些组件自然遵循开闭原则。

结论

开闭原则可能看起来有些抽象,但在 React 中,它转化为使我们的组件更易于维护和灵活的实际模式。结合 SOLID 原则,它有助于创建一个易于扩展和维护的健壮架构。





继续滑动看下一个
KooFE前端团队
向上滑动看下一个