用于在一组可选项中进行多选的 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;