React 中使用策略模式的案例 来源:⚛️ Applying Strategy Pattern in React (Part 2) - DEV Community

组件设计

在组件设计系统中,组件具有各种变体、颜色和尺寸,例如Button组件, props 类型如下

export type ButtonVariant = 'contained' | 'outlined' | 'text';
 
export type ButtonColor = 'primary' | 'secondary' | 'error' | 'success' | 'warning' | 'info';
 
export type ButtonSize = 'xs' | 'sm' | 'md';
 
export interface ButtonProps {
  color?: ButtonColor;
  variant?: ButtonVariant;
  size?: ButtonSize;
  children: React.ReactNode;
}

在使用策略模式之前,你可能会这样写

import { memo } from 'react';
import { Button as ThemeUIButton } from 'theme-ui';
 
import { ButtonProps } from './Button.types';
 
const ButtonBase = ({ color = 'primary', variant = 'contained', size = 'sm', children }: ButtonProps) => {
  return (
    <ThemeUIButton
      sx={{
        outline: 'none',
        borderRadius: 4,
        transition: '0.1s all',
        cursor: 'pointer',
        ...(variant === 'contained'
          ? {
              backgroundColor: `${color}.main`,
              color: 'white',
              '&:hover': {
                backgroundColor: `${color}.dark`,
              },
            }
          : {}),
        ...(variant === 'outlined'
          ? {
              backgroundColor: 'transparent',
              color: `${color}.main`,
              border: '1px solid',
              borderColor: `${color}.main`,
              '&:hover': {
                backgroundColor: `${color}.light`,
              },
            }
          : {}),
        ...(variant === 'text'
          ? {
              backgroundColor: 'transparent',
              color: `${color}.main`,
              '&:hover': {
                backgroundColor: `${color}.light`,
              },
            }
          : {}),
        ...(size === 'xs'
          ? {
              fontSize: '0.75rem',
              padding: '8px 12px',
            }
          : {}),
        ...(size === 'sm'
          ? {
              fontSize: '0.875rem',
              padding: '12px 16px',
            }
          : {}),
        ...(size === 'md'
          ? {
              fontSize: '1rem',
              padding: '16px 24px',
            }
          : {}),
      }}
    >
      {children}
    </ThemeUIButton>
  );
};
 
export const Button = memo(ButtonBase);

简单但是混乱。

应用策略模式

button/utils.ts

import { ButtonColor } from './Button.types';
 
export const getButtonVariantMapping = (color: ButtonColor = 'primary') => {
  return {
    contained: {
      backgroundColor: `${color}.main`,
      color: 'white',
      '&:hover': {
        backgroundColor: `${color}.dark`,
      },
    },
    outlined: {
      backgroundColor: 'transparent',
      color: `${color}.main`,
      border: '1px solid',
      borderColor: `${color}.main`,
      '&:hover': {
        backgroundColor: `${color}.light`,
      },
    },
    text: {
      backgroundColor: 'transparent',
      color: `${color}.main`,
      '&:hover': {
        backgroundColor: `${color}.light`,
      },
    },
  };
};
 
export const BUTTON_SIZE_STYLE_MAPPING = {
  xs: {
    fontSize: '0.75rem',
    padding: '8px 12px',
  },
  sm: {
    fontSize: '0.875rem',
    padding: '12px 16px',
  },
  md: {
    fontSize: '1rem',
    padding: '16px 24px',
  },
};

button/index.tsx

import { memo } from 'react';
import { Button as ThemeUIButton } from 'theme-ui';
 
import { ButtonProps } from './Button.types';
import { getButtonVariantMapping, BUTTON_SIZE_STYLE_MAPPING } from './Button.utils';
 
const ButtonBase = ({ color = 'primary', variant = 'contained', size = 'sm', children }: ButtonProps) => {
  const buttonVariantStyle = getButtonVariantMapping(color)[variant];
 
  return (
    <ThemeUIButton
      sx={{
        outline: 'none',
        borderRadius: 4,
        transition: '0.1s all',
        cursor: 'pointer',
        ...buttonVariantStyle,
        ...BUTTON_SIZE_STYLE_MAPPING[size],
      }}
    >
      {children}
    </ThemeUIButton>
  );
};
 
export const Button = memo(ButtonBase);

使用 TailwindCSS 与 CVA 的写法

'use client';
 
import React from 'react';
import { cva } from 'class-variance-authority';
 
import { cn } from '@/utils';
 
const _button = cva('cursor-pointer rounded text-xs transition flex items-center justify-center', {
  variants: {
    type: {
      default: ['bg-transparent', 'text-white/70', 'hover:bg-control-hover'],
      primary: ['bg-accent', 'text-white', 'hover:brightness-130'],
      ghost: ['bg-transparent', 'text-white/70', 'hover:text-white'],
      warn: ['bg-error', 'text-white', 'hover:brightness-130'],
      link: ['bg-transparent', 'text-white/70', 'hover:text-white underline'],
      normal: ['bg-white/5', 'text-caption', 'hover:bg-white/10'],
      outline: [
        'bg-transparent',
        'text-accent',
        'border border-accent',
        'hover:border-accent-hover hover:text-accent-hover',
      ],
    },
    size: {
      medium: ['h-9', 'px-6'],
      small: ['h-6', 'px-6'],
      flat: ['h-10', 'px-11'],
      superFlat: ['h-10', 'px-16'],
      modal: ['h-8', 'px-4'],
    },
    disable: {
      true: ['bg-gray-100', 'bg-control-disable', 'cursor-not-allowed', 'text-white/20'],
    },
  },
  compoundVariants: [
    // 当 type 为 default 时,size 为 medium,额外应用 className
    { type: 'primary', size: 'medium', className: '' },
    { type: 'primary', disable: true, className: 'bg-accent-disable hover:brightness-100' },
  ],
  defaultVariants: {
    type: 'default',
    size: 'medium',
  },
});
 
/**
 * Button component.
 *
 * @param {Object} props
 * @param {'primary' | 'default' | 'ghost' | 'link' | 'warn' | 'outline'} props.type 三种组件类型
 * @param {'medium' | 'small'} props.size
 * @param {string} props.className
 * @param {boolean} props.disable
 * @param {boolean} props.asChild - 是否使用 children 作为按钮内容(替换底层元素但保持样式不变)
 * @param {Function} props.onClick
 * @param {React.ReactNod} props.children
 * @returns {React.ReactNode} The rendered button component.
 */
export default function Button({ children, type, disable, size, className, onClick, asChild, ...restProps }) {
  const _className = cn(_button({ type, size, className, disable }));
 
  const handleClick = (e) => {
    if (disable) {
      return;
    }
    onClick?.(e);
  };
 
  const _props = { className: _className, onClick: handleClick, ...restProps };
 
  if (asChild) {
    if (React.isValidElement(children) === false) {
      throw new Error('children必须是一个React element');
    }
    return React.cloneElement(children, { ..._props });
  }
  return <button {..._props}>{children}</button>;
}