HomeGithub

处理双向联动逻辑(通过自定义 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 种数据:

  1. 下拉列表的值(通过 onChange 函数来设置)
  2. 下拉列表的选项

我使用的是 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,
    },
  };
};