处理双向联动逻辑(通过自定义 React Hook)
背景
最近在做业务的时候碰到一个表单项双向联动的需求,还算有趣,记录一下。
表单有 2 个下拉列表,一个代表部门,一个代表职位。部门和职位是一对多的关系,即一个部门可以有多个职位,但一个职位只能属于一个部门。
普通的联动逻辑是:初始不显示职位,当部门选定后,再显示对应的职位,其选项是该部门下的职位列表。这种联动的方向是单向的,即部门决定职位,比较容易实现。
但产品要求做双向联动:
- 初始显示职位,其选项是所有部门的所有职位
- 当职位改变,自动设置部门为该职位所属的部门
- 当部门改变,清空职位的值(如果有的话),设置职位的选项为该部门下的职位列表
- 当清空部门时,职位选项是所有部门的所有职位
逻辑没那么简单,况且此次需求要求在 2 个不同的表单做同样的事情,显然是把逻辑抽象为自定义 Hook 的好时机。
数据结构
部门和职位是一对多的关系,可以用以下数据结构表示:
type IDepartment = {
id: number;
name: string;
positions: IPosition[];
};
type IPosition = {
id: number;
name: string;
};
假设所有部门和职位的数据如下:
const allDepartments: IDepartment[] = [
{
id: 1,
name: 'CEO办公室',
positions: [
{ id: 1, name: 'CEO' },
{ id: 2, name: '助理' },
],
},
{
id: 2,
name: '技术部',
positions: [
{ id: 3, name: '后端工程师' },
{ id: 4, name: '前端工程师' },
],
},
];
自定义 Hook 的使用方式
分析可以发现,这种联动关系的结果无非是 2 种数据:
- 下拉列表的值(通过 onChange 函数来设置)
- 下拉列表的选项
我使用的是 Arco Design 里的 Form 组件,表单项的值可以通过 form.setFieldValue()
设置。所以对于第一种数据可以通过返回的事件处理函数来设置。预计 Hook 的使用方式如下:
import { Form, Select } from "@arco-design/web-react";
const DemoForm = () => {
const [form] = Form.useForm(); // Arco Design 的 Form 组件提供的 useForm Hook
const FieldName = {
Department: "Department",
Position: "Position",
};
const { options, handlers } = useDepartmentPosition(form, allDepartments, {
department: FieldName.Department,
position: FieldName.Position,
});
return (
<Form form={form} style={{ width: 900 }}>
<Form.Item label="部门" field={FieldName.Department}>
<Select
placeholder="请选择部门"
onChange={handlers.onDepartmentChange}
options={options.department}
allowClear
/>
</Form.Item>
<Form.Item label="职位" field={FieldName.Position}>
<Select
placeholder="请选择职位"
onChange={handlers.onPositionChange}
options={options.position}
allowClear
/>
</Form.Item>
</Form>
);
};
自定义 Hook 的实现
有了设计思路,实现就是简单的逻辑堆砌了。首先定义 Hook 的函数签名:
type IUseDepartmentPositionHook = (
form: FormInstance,
departments: IDepartment[],
fields: {
department: string;
position: string;
},
) => {
options: {
department: SelectOption[];
position: SelectOption[];
};
handlers: {
onDepartmentChange: (value: number | undefined) => void;
onPositionChange: (value: number | undefined) => void;
};
};
然后实现 Hook:
import { useMemo, useCallback, useState } from "react";
interface IOption {
value: number;
label: string;
}
export const useDepartmentPosition: IUseDepartmentPositionHook = (
form,
departments,
fields,
) => {
const departmentOptions: IOption[] = useMemo(() => {
return departments.map((department) => ({
value: department.id,
label: department.name,
}));
}, [departments]);
const allPositionOptions: IOption[] = useMemo(() => {
return departments.reduce((acc, cur) => {
const curOptions = cur.positions.map((position) => ({
value: position.id,
label: position.name,
}));
return acc.concat(curOptions);
}, [] as IOption[]);
}, [departments]);
const [positionOptions, setPositionOptions] =
useState<IOption[]>(allPositionOptions);
const onPositionChange = useCallback(
(value: number | undefined) => {
if (value === undefined) {
return;
}
const department = departments.find((department) =>
department.positions.some((position) => position.id === value),
);
if (!department) {
console.error("Cannot find department with position id of", value);
return;
}
form.setFieldValue(fields.department, department.id);
},
[form, fields.department, departments],
);
const onDepartmentChange = useCallback(
(value: number | undefined) => {
form.setFieldValue(fields.position, undefined);
if (value === undefined) {
setPositionOptions(allPositionOptions);
return;
}
const department = departments.find(
(department) => department.id === value,
);
if (!department) {
console.error("Cannot find department with id of", value);
return;
}
// find all positions under this department
const departmentPositionOptions = department.positions.map(
(position) => ({
value: position.id,
label: position.name,
}),
);
setPositionOptions(departmentPositionOptions);
},
[form, fields.position, departments, allPositionOptions],
);
return {
options: {
department: departmentOptions,
position: positionOptions,
},
handlers: {
onDepartmentChange,
onPositionChange,
},
};
};