react-checkbox的封装

用于在一组可选项中进行多选的 React 组件,参考 Ant Design 5 实现标准。

Checkbox.tsx 里面的代码

ini 复制代码
import classNames from "classnames";
import React, {
  forwardRef,
  memo,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";
import CheckboxContext, { CheckboxChangeEvent } from "./context";
import "./index.scss";

export interface CheckboxProps<T = string> {
  prefixCls?: string;
  /**
   * 默认选中
   */
  defaultChecked?: boolean;
  /**
   * 是否选中
   */
  checked?: boolean;
  /**
   * 是否禁用
   */
  disabled?: boolean;
  /**
   * 半选状态(优先级高于 checked)
   */
  indeterminate?: boolean;
  /**
   * 数值
   */
  value?: T;
  /**
   * 回调事件
   */
  onChange?: (e: CheckboxChangeEvent<T>) => void;
  /**
   * input 元素的 name 属性
   */
  name?: string;
  /**
   * input 元素的 id 属性
   */
  id?: string;
  /**
   * 自动聚焦
   */
  autoFocus?: boolean;
  /**
   * Tab 键顺序
   */
  tabIndex?: number;
  className?: string;
  children?: React.ReactNode;
  style?: React.CSSProperties;
}

function InternalCheckbox<T = string>(
  props: CheckboxProps<T>,
  ref: React.Ref<HTMLInputElement>
) {
  const {
    prefixCls = "ant-",
    onChange,
    disabled,
    value,
    indeterminate = false,
    name,
    id,
    autoFocus,
    tabIndex,
    className,
    style,
    ...others
  } = props;

  const [checked, setCheck] = useState(props.defaultChecked || false);
  const inputEl = useRef<HTMLInputElement>(null);
  const checkedRef = useRef(checked);
  const {
    onChange: conChange,
    disabled: cdisabled,
    value: values,
  } = useContext(CheckboxContext);

  useEffect(() => {
    checkedRef.current = checked;
  }, [checked]);

  // 受控模式:同步 props.checked 的变化
  useEffect(() => {
    if ("checked" in props && props.checked !== undefined) {
      setCheck(props.checked);
    }
  }, [props.checked]);

  useEffect(() => {
    if (values && "value" in props) {
      setCheck(values.indexOf(props.value) > -1);
    }
  }, [values, props.value]);

  // 同步 indeterminate 状态到原生 input
  useEffect(() => {
    if (inputEl.current) {
      inputEl.current.indeterminate = indeterminate;
    }
  }, [indeterminate]);

  const handleClick = (e) => {
    if (disabled || cdisabled) {
      return;
    }

    const state = !checkedRef.current;
    if (!("checked" in props)) {
      setCheck(state);
    }

    // 创建规范的事件对象,而不是直接修改原始事件
    const checkboxChangeEvent: CheckboxChangeEvent<T> = {
      target: {
        checked: state,
        value: value as T,
      },
      nativeEvent: e,
    };

    if (typeof onChange === "function") {
      onChange(checkboxChangeEvent);
    }

    if (typeof conChange === "function") {
      conChange(checkboxChangeEvent);
    }
  };

  const handleChange = () => {};

  const cls = classNames({
    [`${prefixCls}checkbox`]: true,
    [`${prefixCls}checkbox-checked`]: checked && !indeterminate,
    [`${prefixCls}checkbox-disabled`]: props.disabled,
    [`${prefixCls}checkbox-indeterminate`]: indeterminate,
  });

  const wrapperCls = classNames(
    {
      [`${prefixCls}checkbox-wrapper`]: true,
      [`${prefixCls}checkbox-wrapper-disabled`]: props.disabled,
    },
    className
  );

  return (
    <span className={wrapperCls} style={style} onClick={handleClick}>
      <span className={cls}>
        <input
          type="checkbox"
          ref={(node) => {
            // 合并内部 ref 和外部 ref
            (
              inputEl as React.MutableRefObject<HTMLInputElement | null>
            ).current = node;
            if (typeof ref === "function") {
              ref(node);
            } else if (ref) {
              (ref as React.MutableRefObject<HTMLInputElement | null>).current =
                node;
            }
          }}
          name={name}
          id={id}
          value={value as any}
          checked={checked}
          disabled={disabled || cdisabled}
          autoFocus={autoFocus}
          tabIndex={tabIndex}
          onChange={handleChange}
          aria-checked={indeterminate ? "mixed" : checked ? "true" : "false"}
          aria-disabled={disabled || cdisabled}
        />
        <span className="ant-checkbox-inner"></span>
      </span>
      <span>{props.children}</span>
    </span>
  );
}

// 使用 memo 优化性能,避免不必要的重渲染
// 正确的顺序:先 forwardRef,再 memo
const CheckboxWithRef = forwardRef(InternalCheckbox);
const Checkbox = memo(CheckboxWithRef) as (<T = string>(
  props: CheckboxProps<T> & { ref?: React.Ref<HTMLInputElement> }
) => React.ReactElement) & { displayName?: string };

Checkbox.displayName = "Checkbox";

export default Checkbox;

CheckboxGroup.tsx 里面的代码

ini 复制代码
import classNames from "classnames";
import React, { useCallback, useEffect, useRef, useState } from "react";
import Checkbox from "./Checkbox";
import CheckboxContext, { CheckboxChangeEvent } from "./context";
import "./index.scss";

export interface CheckboxOptionType<T = string> {
  label: string;
  value: T;
  disabled?: boolean;
  indeterminate?: boolean;
  style?: React.CSSProperties;
  className?: string;
}

export interface GroupProps<T = string> {
  /**
   * 默认数值
   */
  defaultValue?: Array<T>;
  /**
   * 数值
   */
  value?: Array<T>;
  onChange?: (values: Array<T>) => void;
  /**
   * 是否禁用
   */
  disabled?: boolean;
  /**
   * 排列方向:水平或垂直
   */
  direction?: "horizontal" | "vertical";
  /**
   * 选项数组(用于简化使用)
   */
  options?: CheckboxOptionType<T>[] | T[];
  /**
   * 统一的 name 属性
   */
  name?: string;
  /**
   * 回调事件
   */
  className?: string;
  children?: React.ReactNode;
  style?: React.CSSProperties;
  /**
   * 测试用id
   */
  "data-testid"?: string;
}

function Group<T = string>(props: GroupProps<T>) {
  const {
    disabled,
    children,
    onChange,
    direction = "horizontal",
    options,
    name,
    "data-testid": dataTestId,
    ...others
  } = props;

  const [value, setValue] = useState<T[]>(
    props.defaultValue || props.value || []
  );

  // 使用 ref 保存最新的 value 和 onChange,避免 useCallback 重新创建
  const valueRef = useRef<T[]>(value);
  const onChangeRef = useRef(onChange);

  useEffect(() => {
    valueRef.current = value;
  }, [value]);

  useEffect(() => {
    onChangeRef.current = onChange;
  }, [onChange]);

  useEffect(() => {
    if ("value" in props && props.value !== undefined) {
      setValue(props.value);
    }
  }, [props.value]);

  const cls = classNames({
    "ant-checkbox-group": true,
    "ant-checkbox-group-vertical": direction === "vertical",
    "ant-checkbox-group-horizontal": direction === "horizontal",
  });

  // 性能优化:使用 ref 避免 useCallback 依赖变化
  const handleChange = useCallback((e: CheckboxChangeEvent<T>) => {
    const targetValue = e.target.value;
    const checked = e.target.checked;
    let newValue = [...valueRef.current];

    // checked为true时添加,checked为false时移除
    if (checked) {
      if (!newValue.includes(targetValue)) {
        newValue.push(targetValue);
      }
    } else {
      newValue = newValue.filter((item) => item !== targetValue);
    }

    setValue(newValue);
    onChangeRef.current?.(newValue);
  }, []); // 空依赖数组,函数永远不会重新创建

  // 处理options模式
  const renderOptions = () => {
    if (!options) return null;

    return options.map((option, index) => {
      const isObject = typeof option === "object";
      const optionValue = isObject ? option.value : option;
      const optionLabel = isObject ? option.label : String(option);
      const optionDisabled = isObject ? option.disabled : false;
      const optionStyle = isObject ? option.style : undefined;
      const optionClassName = isObject ? option.className : undefined;

      return (
        <span
          key={`checkbox-option-${index}`}
          className={classNames("ant-checkbox-group-item", optionClassName)}
          style={optionStyle}
        >
          <Checkbox
            checked={value.includes(optionValue)}
            disabled={disabled || optionDisabled}
            value={optionValue}
            onChange={handleChange}
            name={name}
          >
            {optionLabel}
          </Checkbox>
        </span>
      );
    });
  };

  return (
    <div className={cls} style={props.style} data-testid={dataTestId}>
      <CheckboxContext.Provider
        value={{
          onChange: handleChange,
          disabled: disabled || false,
          value,
        }}
      >
        {options ? renderOptions() : children}
      </CheckboxContext.Provider>
    </div>
  );
}

export default Group;

context.tsx 代码

typescript 复制代码
import { createContext } from "react";

export interface CheckboxChangeEventTarget<T = string> {
  value: T;
  checked: boolean;
}

export interface CheckboxChangeEvent<T = string> {
  target: CheckboxChangeEventTarget<T>;
  nativeEvent?: React.MouseEvent<HTMLSpanElement>;
}

export interface CheckboxContextProps<T = string> {
  value: Array<T>;
  onChange: (e: CheckboxChangeEvent<T>) => void;
  disabled: boolean;
}

const checkboxContext = createContext<CheckboxContextProps<any>>({
  value: [],
  onChange: () => {},
  disabled: false,
});

export default checkboxContext;

index.scss

css 复制代码
*,*:before,*:after {
    box-sizing: border-box
  }

  /* CSS 变量定义 - 支持主题定制 */
  :root {
    --checkbox-primary-color: #1890ff;
    --checkbox-border-color: #d9d9d9;
    --checkbox-bg-color: #fff;
    --checkbox-text-color: #000000d9;
    --checkbox-disabled-bg: #f5f5f5;
    --checkbox-disabled-text: #00000040;
    --checkbox-disabled-border: #d9d9d9;
  }
  
  .ant-checkbox {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    color: var(--checkbox-text-color);
    font-size: 14px;
    font-variant: tabular-nums;
    line-height: 1.5715;
    list-style: none;
    font-feature-settings: "tnum";
    position: relative;
    top: .2em;
    line-height: 1;
    white-space: nowrap;
    outline: none;
    cursor: pointer
  }
  
  .ant-checkbox input{
    display: none;
  }
  
  .ant-checkbox-wrapper:hover .ant-checkbox-inner,.ant-checkbox:hover .ant-checkbox-inner,.ant-checkbox-input:focus+.ant-checkbox-inner {
    border-color: var(--checkbox-primary-color)
  }
  
  .ant-checkbox-checked:after {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    border: 1px solid var(--checkbox-primary-color);
    border-radius: 2px;
    visibility: hidden;
    -webkit-animation: antCheckboxEffect .36s ease-in-out;
    animation: antCheckboxEffect .36s ease-in-out;
    -webkit-animation-fill-mode: backwards;
    animation-fill-mode: backwards;
    content: ""
  }
  
  .ant-checkbox:hover:after,.ant-checkbox-wrapper:hover .ant-checkbox:after {
    visibility: visible
  }
  
  .ant-checkbox-inner {
    box-sizing: border-box;
    position: relative;
    top: 0;
    left: 0;
    display: block;
    width: 16px;
    height: 16px;
    direction: ltr;
    background-color: var(--checkbox-bg-color);
    border: 1px solid var(--checkbox-border-color);
    border-radius: 2px;
    border-collapse: separate;
    transition: all .3s
  }
  
  .ant-checkbox-inner:after {
    position: absolute;
    top: 50%;
    left: 22%;
    display: table;
    width: 5.71428571px;
    height: 9.14285714px;
    border: 2px solid #fff;
    border-top: 0;
    border-left: 0;
    transform: rotate(45deg) scale(0) translate(-50%,-50%);
    opacity: 0;
    transition: all .1s cubic-bezier(.71,-.46,.88,.6),opacity .1s;
    content: " "
  }
  
  .ant-checkbox-input {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 1;
    width: 100%;
    height: 100%;
    cursor: pointer;
    opacity: 0
  }
  
  .ant-checkbox-checked .ant-checkbox-inner:after {
    position: absolute;
    display: table;
    border: 2px solid #fff;
    border-top: 0;
    border-left: 0;
    transform: rotate(45deg) scale(1) translate(-50%,-50%);
    opacity: 1;
    transition: all .2s cubic-bezier(.12,.4,.29,1.46) .1s;
    content: " "
  }
  
  .ant-checkbox-checked .ant-checkbox-inner {
    background-color: var(--checkbox-primary-color);
    border-color: var(--checkbox-primary-color)
  }
  
  .ant-checkbox-disabled {
    cursor: not-allowed
  }
  
  .ant-checkbox-disabled.ant-checkbox-checked .ant-checkbox-inner:after {
    border-color: var(--checkbox-disabled-text);
    -webkit-animation-name: none;
    animation-name: none
  }
  
  .ant-checkbox-disabled .ant-checkbox-input {
    cursor: not-allowed
  }
  
  .ant-checkbox-disabled .ant-checkbox-inner {
    background-color: var(--checkbox-disabled-bg);
    border-color: var(--checkbox-disabled-border)!important
  }
  
  .ant-checkbox-disabled .ant-checkbox-inner:after {
    border-color: var(--checkbox-disabled-bg);
    border-collapse: separate;
    -webkit-animation-name: none;
    animation-name: none
  }
  
  .ant-checkbox-disabled+span {
    color: var(--checkbox-disabled-text);
    cursor: not-allowed
  }
  
  .ant-checkbox-disabled:hover:after,.ant-checkbox-wrapper:hover .ant-checkbox-disabled:after {
    visibility: hidden
  }
  
  .ant-checkbox-wrapper {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    color: var(--checkbox-text-color);
    font-size: 14px;
    font-variant: tabular-nums;
    line-height: 1.5715;
    list-style: none;
    font-feature-settings: "tnum";
    display: inline-flex;
    align-items: baseline;
    line-height: unset;
    cursor: pointer
  }
  
  .ant-checkbox-wrapper:after {
    display: inline-block;
    width: 0;
    overflow: hidden;
    content: "\a0"
  }
  
  .ant-checkbox-wrapper.ant-checkbox-wrapper-disabled {
    cursor: not-allowed
  }
  
  .ant-checkbox-wrapper+.ant-checkbox-wrapper {
    margin-left: 8px
  }
  
  .ant-checkbox+span {
    padding-right: 8px;
    padding-left: 8px
  }
  
  .ant-checkbox-group {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    color: var(--checkbox-text-color);
    font-size: 14px;
    font-variant: tabular-nums;
    line-height: 1.5715;
    list-style: none;
    font-feature-settings: "tnum";
    display: inline-block
  }
  
  .ant-checkbox-group-item {
    margin-right: 8px
  }
  
  .ant-checkbox-group-item:last-child {
    margin-right: 0
  }
  
  .ant-checkbox-group-item+.ant-checkbox-group-item {
    margin-left: 0
  }
  
  .ant-checkbox-indeterminate .ant-checkbox-inner {
    background-color: var(--checkbox-bg-color);
    border-color: var(--checkbox-border-color)
  }
  
  .ant-checkbox-indeterminate .ant-checkbox-inner:after {
    top: 50%;
    left: 50%;
    width: 8px;
    height: 8px;
    background-color: var(--checkbox-primary-color);
    border: 0;
    transform: translate(-50%,-50%) scale(1);
    opacity: 1;
    content: " "
  }
  
  .ant-checkbox-indeterminate.ant-checkbox-disabled .ant-checkbox-inner:after {
    background-color: var(--checkbox-disabled-text);
    border-color: var(--checkbox-disabled-text)
  }
  
  .ant-checkbox-rtl {
    direction: rtl
  }
  
  .ant-checkbox-group-rtl .ant-checkbox-group-item {
    margin-right: 0;
    margin-left: 8px
  }
  
  .ant-checkbox-group-rtl .ant-checkbox-group-item:last-child {
    margin-left: 0!important
  }
  
  .ant-checkbox-group-rtl .ant-checkbox-group-item+.ant-checkbox-group-item {
    margin-left: 8px
  }

index.tsx

typescript 复制代码
import InternalCheckbox from "./Checkbox";
import Group from "./CheckboxGroup";

// 导出类型
export type { CheckboxProps } from "./Checkbox";
export type {
  CheckboxOptionType,
  GroupProps as CheckboxGroupProps,
} from "./CheckboxGroup";
export type { CheckboxChangeEvent, CheckboxChangeEventTarget } from "./context";

type CheckboxType = typeof InternalCheckbox;
interface CheckboxInterface extends CheckboxType {
  Group: typeof Group;
}

const Checkbox = InternalCheckbox as CheckboxInterface;
Checkbox.Group = Group;
export default Checkbox;
相关推荐
乐园游梦记3 小时前
告别Ctrl+F5!解决VUE生产环境缓存更新的终极方案
前端
岁月宁静3 小时前
用 Node.js 封装豆包语音识别AI模型接口:双向实时流式传输音频和文本
前端·人工智能·node.js
猪猪拆迁队3 小时前
前端图形架构设计:AI生成设计稿落地实践
前端·后端·ai编程
岁月宁静3 小时前
Vue 3.5 + WangEditor 打造智能笔记编辑器:语音识别功能深度实现
前端·javascript·vue.js
非凡ghost3 小时前
BiliLive-tools(B站录播一站式工具) 中文绿色版
前端·javascript·后端
yi碗汤园3 小时前
【一文了解】八大排序-冒泡排序、选择排序
开发语言·前端·算法·unity·c#·1024程序员节
非凡ghost3 小时前
bkViewer小巧精悍数码照片浏览器 中文绿色版
前端·javascript·后端
三小河4 小时前
JS 自定义事件:从 CustomEvent 到 dispatchEvent
前端