一个 Headless 的 React 级联输入组件,通过 Canvas 在父子层级间绘制关系线(贝塞尔曲线 / 折线),支持任意嵌套层级。
- 🎨 Canvas 关系线 — 自动在父子层级间绘制连接线,支持贝塞尔曲线和折线两种样式,可自定义颜色与粗细
- 🧩 Headless 架构 — 每列通过
renderprop 完全自定义渲染,自由集成任意 UI 库 - 📦 零依赖 — 核心无第三方依赖,体积轻量
- ⚡ 无闪烁重绘 — 使用
useLayoutEffect+ResizeObserver,数据变更时关系线无感知更新 - 🔧 TypeScript — 完整类型支持
- 🎯 React 18+ — 兼容 React 18 / 19
npm install react-cascading-input
# 或
pnpm add react-cascading-inputrender 是 ColumnConfig 的必填字段,由你决定每列渲染什么控件:
import { useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { ColumnConfig } from 'react-cascading-input';
const columns: ColumnConfig[] = [
{
title: '训练任务',
dataIndex: 'product',
width: 120,
hasAdd: true,
render: ({ value, onChange }) => (
<input
placeholder={`请输入`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
},
{
title: '训练集群',
dataIndex: 'region',
width: 120,
hasAdd: true,
render: ({ value, onChange }) => (
<input
placeholder={`请输入`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
},
{
title: '框架版本',
dataIndex: 'spec',
width: 180,
hasAdd: false,
render: ({ value, onChange }) => (
<input
placeholder={`请输入`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
},
];
function App() {
const [value, setValue] = useState([]);
return <CascadingInput columns={columns} value={value} onChange={setValue} />;
}通过 render 可以渲染任意控件,比如 select,亦或者第三方 UI 库中的组件。
import type { ColumnConfig } from 'react-cascading-input';
const columns: ColumnConfig[] = [
{
title: '训练任务',
dataIndex: 'product',
width: 160,
hasAdd: true,
render: ({ value, onChange }) => (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={`请输入`}
/>
),
},
{
title: '训练集群',
dataIndex: 'region',
width: 140,
hasAdd: true,
render: ({ value, onChange }) => (
<select
value={value || ''}
onChange={(e) => onChange(e.target.value)}
style={{ width: '100%' }}
>
<option value="">请选择</option>
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
<option value="guangzhou">广州</option>
</select>
),
},
];所有连线相关配置通过 line prop 统一管理:
{/* 折线风格 */}
<CascadingInput columns={columns} value={value} onChange={setValue} line={{ style: 'straight' }} />
{/* 自定义连线颜色和粗细 */}
<CascadingInput
columns={columns}
value={value}
onChange={setValue}
line={{ color: '#1890ff', width: 2 }}
/>
{/* 开启溯源动画(水滴沿连线流向父节点) */}
<CascadingInput
columns={columns}
value={value}
onChange={setValue}
line={{ showSource: { color: '#1890ff', size: 6, tailLength: 28 } }}
/>| Prop | Type | Default | Description |
|---|---|---|---|
columns |
ColumnConfig[] |
— | 列配置(必填) |
value |
TreeNode[] |
[] |
树数据(受控) |
onChange |
(value: TreeNode[]) => void |
— | 数据变更回调 |
line |
LineConfig |
{} |
连线配置 |
effects |
Effects |
— | 副作用/联动注册函数(Formily 风格),声明各列依赖与派生状态 |
列较多、内容超出容器宽度时会自动出现横向滚动条,无需额外配置。
组件支持
ref:const ref = useRef<CascadingInputHandle>(null),通过ref.current.validate()命令式整树校验(见下方「校验」)。
| Property | Type | Required | Description |
|---|---|---|---|
title |
ReactNode |
✓ | 列标题,支持字符串、带图标的 JSX 等 |
dataIndex |
string |
数据字段名,纯操作列可不传 | |
width |
number |
✓ | 列宽度(px) |
hasAdd |
boolean |
✓ | 是否显示"添加"按钮 |
render |
(props: CellRenderProps) => ReactNode |
✓ | 自定义单元格渲染 |
addRender |
(props: ActionRenderProps) => ReactNode |
自定义"添加"按钮渲染,位置固定在单元格下方 |
| Property | Type | Description |
|---|---|---|
value |
string |
当前单元格值 |
onChange |
(val: string) => void |
值变更回调(已绑定 path + dataIndex) |
node |
TreeNode |
当前树节点,可访问 node.children 等 |
path |
string[] |
从根到当前节点的完整路径(节点 id 数组) |
parent |
TreeNode | null |
直接父节点,根层为 null,可读取父级选中值以请求联动 options |
ancestors |
TreeNode[] |
从根到父节点的祖先链(不含当前节点) |
level |
number |
当前层级索引(0 开始) |
dataIndex |
string | undefined |
数据字段名(纯操作列为 undefined) |
onAdd |
() => void |
添加同级节点回调 |
onDelete |
() => void |
删除当前行回调(在"操作"列的 render 里调用) |
isLeaf |
boolean |
是否为叶子层级 |
width |
number |
列宽度(px) |
field |
FieldState |
由 effects 计算出的派生状态(options / disabled / loading 等),无 effects 时为空对象 |
| Property | Type | Description |
|---|---|---|
id |
string |
唯一标识 |
children |
TreeNode[] |
子节点列表 |
[key: string] |
any |
由 ColumnConfig.dataIndex 决定的动态字段 |
| Property | Type | Default | Description |
|---|---|---|---|
style |
'straight' | 'curve' |
'curve' |
连线风格 |
color |
string |
'#d9d9d9' |
连线颜色 |
width |
number |
1.5 |
连线粗细 |
showSource |
boolean | SourceAnimationOptions |
false |
溯源动画,水滴从子节点流向父节点 |
type LineStyle = 'straight' | 'curve';溯源动画是一颗水滴沿连线从子节点流向父节点(圆头 + 收成尖的拖尾)。line.showSource 设为 true 用默认配置,设为对象时可自定义:
| Property | Type | Default | Description |
|---|---|---|---|
color |
string |
跟随 line.color |
水滴颜色。默认连线色偏浅,建议单独指定较深/高饱和度颜色以便可见 |
size |
number |
6 |
水滴头部大小(px) |
tailLength |
number |
28 |
拖尾长度(px),按像素计算,短连线上也保持一致大小 |
speed |
number |
0.004 |
流动速度 |
breatheAmplitude |
number |
1 |
亮度呼吸幅度 0~1 |
breatheCycle |
number |
400 |
呼吸周期(ms) |
跨层依赖、异步取数、禁用、清空这类联动逻辑,统一通过 effects 声明——类似 Formily 的 effects。用 $.onValueChange(target, deps, handler) 声明「target 列依赖 deps 列」,deps 用 dataIndex 表达、沿目标节点的祖先链解析,因此可跨任意层级(依赖必须是更上层的列)。
handler 里可以:
return { options, disabled, loading }作为派生状态简写;异步场景用ctx.setState({...})写入;ctx.setValue(undefined)清空目标列自身的值;ctx.initial区分「初始化回显」与「用户改动上级」——详情页带初始值挂载时 handler 也会触发(以便拉取 options),此时initial为true,用if (!initial) setValue(undefined)即可保留回显值、只在用户真正改动上级时才清空;ctx.isActive()判断异步返回时本次回调是否仍最新,避免竞态覆盖。
field是 patch 合并,不是替换。 只要某条路径置起了loading: true,每一条出口都必须把它关掉(包括提前return的分支和catch分支),否则残留的loading会让该单元格永久停在"加载中"且被禁用。同理,置过disabled: true后要在恢复分支显式写disabled: false。handler 内未捕获的异常引擎会兜底:记
console.error并自动复位该单元格的loading,不会变成 unhandled rejection。但业务上的失败降级(比如失败时清空 options、给用户提示)仍应自己try/catch。
render 通过 field 读取这些派生状态。每个受影响的单元格以节点 id 为 key 独立存储自己的 field。
import type { Effects } from 'react-cascading-input';
const effects: Effects = ($) => {
// 训练集群依赖训练任务:切换任务 → 清空集群、置 loading、按新入参异步拉取
$.onValueChange('region', ['product'], async ({ deps, setValue, setState, isActive, initial }) => {
if (!initial) setValue(undefined); // 初始化回显时保留已有值,仅用户改动才清空
// 每条出口都要复位 loading:field 是 patch 合并,残留的 loading 会让格子永久禁用
if (!deps.product) return { options: [], disabled: true, loading: false };
setState({ loading: true, disabled: false });
try {
const options = await fetchRegions(deps.product);
if (isActive()) setState({ options, loading: false });
} catch {
if (isActive()) setState({ options: [], loading: false });
}
});
// 框架版本依赖训练集群,同理级联
$.onValueChange('spec', ['region'], async ({ deps, setValue, setState, isActive, initial }) => {
if (!initial) setValue(undefined);
if (!deps.region) return { options: [], disabled: true, loading: false };
setState({ loading: true, disabled: false });
try {
const options = await fetchSpecs(deps.region);
if (isActive()) setState({ options, loading: false });
} catch {
if (isActive()) setState({ options: [], loading: false });
}
});
};
// 列的 render 消费 field:
// render: ({ value, onChange, field }) => (
// <select value={value} disabled={field.disabled || field.loading} onChange={(e) => onChange(e.target.value)}>
// <option value="">{field.loading ? '加载中…' : '请选择'}</option>
// {(field.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)}
// </select>
// )
<CascadingInput columns={columns} value={value} onChange={setValue} effects={effects} />校验用 $.onValidate(target, rule, deps?) 声明,一份规则同时驱动两种时机:反应式(值变即重算,写入 field.error,首次挂载/回显不报)与命令式(拿组件 ref 调 validate(),强制对所有字段——含未改动的——跑规则、标红并返回聚合结果,提交时用,不必手动遍历 value)。rule 返回错误字符串即不通过;deps 省略只关注自身值,声明后可跨列校验。
import { useRef, useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import type { CascadingInputHandle, Effects, TreeNode } from 'react-cascading-input';
const effects: Effects = ($) => {
// 必填 + 格式:规则内可写多重判断
$.onValidate('spec', ({ value }) => {
const s = (value ?? '').trim();
if (!s) return '框架版本不能为空';
if (!/\d/.test(s)) return '需包含版本号,如 PyTorch 2.1';
});
// 跨列校验:声明 deps,沿祖先链取依赖值判定
$.onValidate('spec', ({ value, deps }) => (value && value === deps.product ? '规格不能与产品同名' : undefined), ['product']);
};
function App() {
const [value, setValue] = useState<TreeNode[]>([]);
const ref = useRef<CascadingInputHandle>(null);
const handleSubmit = () => {
const { valid, errors } = ref.current!.validate(); // 整树校验,未改动字段也标红
if (valid) submit(value);
else console.log(errors); // [{ path, dataIndex, message }]
};
return <CascadingInput ref={ref} columns={columns} value={value} onChange={setValue} effects={effects} />;
}
// render 消费 field.error:
// render: ({ value, onChange, field }) => (
// <div>
// <input value={value} onChange={(e) => onChange(e.target.value)}
// style={{ borderColor: field.error ? '#ff4d4f' : undefined }} />
// {field.error && <span style={{ color: '#ff4d4f', fontSize: 12 }}>{field.error}</span>}
// </div>
// )
validate()返回{ valid: boolean, errors: ValidateError[] },ValidateError为{ path, dataIndex, message }。校验onValidate与取数onValueChange可并存,各自setState/写入会 patch 合并进同一个field,互不覆盖。建议每列一条onValidate(多重判断写在同一 rule 内),避免多条规则争抢同一个error槽。
| Property | Type | Description |
|---|---|---|
options |
any |
供 select 等控件使用的候选项 |
disabled |
boolean |
是否禁用该单元格 |
loading |
boolean |
是否处于异步加载中 |
error |
string |
校验错误信息,由 effects 写入、render 展示;空/undefined 视为通过,patch 合并(通过时需显式写 error: undefined 清除) |
[key] |
any |
允许挂载任意自定义派生字段 |
| Property | Type | Description |
|---|---|---|
value |
any |
目标节点当前值 |
deps |
Record<string, any> |
依赖字段当前值(按 dataIndex,沿祖先链解析) |
node |
TreeNode |
目标节点,其 id 即派生状态的 key |
path |
string[] |
目标节点完整路径 |
initial |
boolean |
是否为该节点首次求值(详情页初始化回显时为 true),用于区分「初始化」与「用户改动上级」,避免误清空回显值 |
setValue |
(val: any) => void |
设置目标节点自身值(清空传 undefined) |
setState |
(patch: FieldState) => void |
合并写入派生状态,异步友好 |
setTreeValue |
(dataIndex: string, val: any) => void |
设置本分支上某祖先/自身字段的值 |
isActive |
() => boolean |
异步返回后判断本次回调是否仍最新(防竞态) |
# 安装依赖
pnpm install
# 启动文档开发服务器(含在线演示)
pnpm dev
# 运行测试
pnpm test
# 构建
pnpm buildMIT