likes
comments
collection
share

React通用解决方案——组件生成器

作者站长头像
站长
· 阅读数 19

1. 前话

「组件生成器」,就如其名,它的主要作用便是生成组件。而为什么需要组件生成器,这需要回到实际业务场景开发进行讲解。

在「React通用解决方案」系列前面的篇章有说过,业务场景开发离不开业务组件,业务组件是基础组件+业务数据的二次封装。

二次封装的实现可以是组件继承、默认属性和高阶组件等。接收一个配置返回一个二次封装的组件的方法,这便是「组件生成器」

回到「为什么需要组件生成器」的问题,通过「组件生成器的定义与实现」可知,除了可用与生成业务组件和复用业务组件,「组件生成器」可以作为实际业务场景开发中「组件拆分颗粒度」的一把尺子,让开发者更好的组织业务代码逻辑

React开发中万物皆为组件,通过组件的组合完成业务场景的搭建,组件的颗粒度决定了组件的耦合度。通俗地讲就是组件逻辑越多代码就越复杂🤔,慢慢就变成了屎山😨,后续迭代屎山就越来越高😂。

组件生成器作为一个函数,它的命名应该契合它的作用——生成XXX,因此命名规则可为「buildXXX」、「createXXX」、「generateXXX」或「makeXXX」等,作者这里根据个人习惯选择「buildXXX」。

下面用实际业务场景代码进行说明。

2. 正文

业务场景中经常见到的一类页面,表单页面,示例场景代码如下:

import React, { useCallback, useEffect, useState } from "react";

import { Form, Radio } from "@arco-design/web-react";

const Page: React.FC = () => {
  // 选项值
  const [options, setOptions] = useState<
    Array<{ label: string; value: number }>
  >([]);

  // 初始化选项值
  useEffect(() => {
    fetchOptions().then(setOptions);
  }, []);

  // 提交表单逻辑
  const handleSubmit = useCallback((formValue) => {
    // 略 ...
  }, []);

  return (
    <div className="page">
      <Form onSubmit={handleSubmit}>
        <Form.Item field="a">
          <Radio.Group>
            <Radio value={1}></Radio>
            <Radio value={2}></Radio>
          </Radio.Group>
        </Form.Item>
        <Form.Item field="b">
          <Radio.Group>
            <Radio value={1}></Radio>
            <Radio value={2}></Radio>
          </Radio.Group>
        </Form.Item>
        <Form.Item field="c">
          <Radio.Group>
            {options.map((o) => (
              <Radio key={o.value} value={o.value}>
                {o.label}
              </Radio>
            ))}
          </Radio.Group>
        </Form.Item>
      </Form>
    </div>
  );
};

export default Page;

简单过下代码可知,上面的表单页面存在三个表单字段,三个表单都使用了同个基础组件「Radio」。

该表单页面很简单当然满足了业务需求,但同时也存在以下明显的优化空间:

  • 「a」和「b」字段使用的组件重复
  • 「c」字段使用的组件的业务数据取数逻辑与表单组件耦合

2.1 组件抽离

为了解决「重复」和「耦合」的问题,我们首先要做的是将它们进行「组件抽离」成「业务组件」。

2.1.1 简单逻辑抽离

简单的逻辑抽离结果如下:

import { Radio } from "@arco-design/web-react";

// 「a」和「b」字段业务组件
const Radio1: React.FC<{
  value?: number;
  onChange?: (value?: number) => void;
}> = ({ value, onChange }) => {
  return (
    <Radio.Group value={value} onChange={onChange}>
      <Radio value={1}></Radio>
      <Radio value={2}></Radio>
    </Radio.Group>
  );
};

// 「c」字段业务组件
const Radio2: React.FC<{
  value?: number;
  onChange?: (value?: number) => void;
}> = ({ value, onChange }) => {
  // 选项值
  const [options, setOptions] = useState<
    Array<{ label: string; value: number }>
  >([]);

  // 初始化选项值
  useEffect(() => {
    fetchOptions().then(setOptions);
  }, []);

  return (
    <Radio.Group value={value} onChange={onChange}>
      {options.map((o) => (
        <Radio key={o.value} value={o.value}>
          {o.label}
        </Radio>
      ))}
    </Radio.Group>
  );
};

但是细心的读者们会发现,「简单抽离的业务组件」仍存在「类型定义重复」的问题,同时因为只支持了「value」和「onChange」属性而丢失了「Radio」组件的一些「原生能力」

2.2.2 默认属性抽离

结合前篇「React通用解决方案-组件二次包装」的知识,我们应用「默认属性」进行改造,结果如下:

import { Radio, RadioGroupProps } from "@arco-design/web-react";

const Radio1: React.FC<RadioGroupProps> = ({
  options = [
    {
      label: "是",
      value: 1,
    },
    {
      label: "否",
      value: 2,
    },
  ],
  ...restProps
}) => {
  return <Radio.Group options={options} {...restProps} />;
};

const Radio2: React.FC<RadioGroupProps> = (props) => {
  // 选项值
  const [options, setOptions] = useState<
    Array<{ label: string; value: number }>
  >([]);

  // 初始化选项值
  useEffect(() => {
    fetchOptions().then(setOptions);
  }, []);

  return <Radio.Group options={options} {...props} />;
};

通过上面的业务组件抽离后我们的表单变得精简了,业务组件和表单组件的耦合度降低,代码如下:

const Page: React.FC = () => {
  // 略 ...
  
  return (
    <div className="page">
      <Form onSubmit={handleSubmit}>
        <Form.Item field="a">
          <Radio1 />
        </Form.Item>
        <Form.Item field="b">
          <Radio1 />
        </Form.Item>
        <Form.Item field="c">
          <Radio2 />
        </Form.Item>
      </Form>
    </div>
  );
};

2.3 组件生成

但是我们细心观察「Radio1」和「Radio2」组件,可以发现,它俩「长得太像了」,它们仅仅在「业务数据」上存在区别,我们是否能够统一它们的「取数逻辑」,通过配置「数据源」生成它们?类似这样:

const Radio1 = buildRadio(options1);

const Radio2 = buildRadio(options2);

这种实现,便是「组件生成器」,它的核心公式为 Component = buildComponent(options)

有同学可能会联想到「高阶组件」,高阶组件的核心公式一般为 WrappedComponent = withComponent(Component, options?),较「组件生成器」多了个定制「被包装的组件」的能力。

同样要注意的是,组件生成器需要遵循组件二次包装的要求,也就是说,通过组件生成器生成的组件具备被包装的组件的所有能力,这点与「高阶组件」的思想一致。

下面对「如何写组件生成器」进行说明。

2.3.1 统一逻辑

找到相同类型的业务组件的统一逻辑是「写组件生成器」的第一步,就如前面所说,「Radio1」和「Radio2」的「取数逻辑」可以作为它们的「统一逻辑」。

对于被包装的组件Radio.Group来说,它接收一个「options」属性,因此将「取数逻辑的结果」作为其「options」属性的兜底值,代码演示如下:

<Radio.Group 
  options={props.options ?? 取数逻辑的结果 ?? []} 
/>

「Radio1」的「数据源」是「静态的/同步的」,而「Radio2」的「数据源」是「动态的/异步的」。

在定义「取数逻辑」时我们采用「兼容性原则」,「动态的/异步的」是兼容「静态的/同步的」。

因此我们定义一个对应「取数逻辑」的名为「getOptions」的配置参数,它的定义如下:

type IRadioOption = { label: React.ReactNode; value: number | string };

type getOptions = () => Promise<Array<IRadioOption>>;

2.3.2 生成器配置

有了上面总结得出的配置参数,接下来我们就可以定义「组件生成器」函数,函数名就叫做「buildRadio」,定义如下:

type IRadioOption = { label: React.ReactNode; value: number | string };

type IBuildRadioOptions = {
  /** 取数逻辑 */
  getOptions: () => Promise<Array<IRadioOption>>;
};


export function buildRadio(options: IBuildRadioOptions) {
  // TODO 生成器实现
}

2.3.3 生成器实现

明确了生成器定义,再结合遵循组件二次包装的要求,我们的生成器实现如下:

type IRadioOption = { label: React.ReactNode; value: number | string };

type IBuildRadioOptions = {
  /** 取数逻辑 */
  getOptions: () => Promise<Array<IRadioOption>>;
};

export function buildRadio(options: IBuildRadioOptions) {
  const { getOptions } = options;

  const RadioBase = (
    { options: propsOptions, ...restProps }: RadioGroupProps,
    ref: ForwardedRef<any>
  ) => {
    // 选项值
    const [options, setOptions] = useState<Array<IRadioOption>>([]);

    // 初始化选项值
    useEffect(() => {
      getOptions().then(setOptions);
    }, []);

    return (
      <Radio.Group
        {...restProps}
        ref={ref}
        options={propsOptions ?? options ?? []}
      />
    );
  };

  return forwardRef<any, RadioGroupProps>(RadioBase);
}

通过上面实现的「组件生成器」,「Radio1」与「Radio2」组件即可通过「组件生成器」生成,示例代码如下:

const Radio1 = buildRadio({
  getOptions: () =>
    Promise.resolve([
      {
        label: "是",
        value: 1,
      },
      {
        label: "否",
        value: 2,
      },
    ]),
});

const Radio2 = buildRadio({
  getOptions: fetchOptions,
});

3. 进阶

到这里,很多读者可能会觉得,这个「组件生成器」不就是「组件二次包装函数」嘛,又创造新名词,挺唬人的😆。

其实 「组件生成器」是否强大在于我们在围绕其核心公式实现它时的是否赋予了它足够强大的配置能力。下面我将对上面的「组件生成器」的配置能力进行升级。

我这里再贴一个表单联动场景,示例场景代码如下:

const Page: React.FC = () => {
  // 选项值
  const [options, setOptions] = useState<
    Array<{ label: string; value: number }>
  >([]);

  // 提交表单逻辑
  const handleSubmit = useCallback((formValue) => {
    // 略 ...
  }, []);

  return (
    <div className="page">
      <Form onSubmit={handleSubmit}>
        <Form.Item field="d">
          <Radio1 onChange={(v) => fetchOptions(v).then(setOptions)} />
        </Form.Item>
        <Form.Item field="e">
          <Radio.Group options={options} />
        </Form.Item>
      </Form>
    </div>
  );
};

通过对上面场景代码分析可知,有两个表单字段「d」和「e」,其中表单字段「d」的表单值的变化会触发请求表单字段「e」的选项值。也就是说,表单字段「e」的业务组件需要支持「依赖更新」

这里就不过多阐述,直接对「组件生成器」进行进阶能力升级,升级后的代码实现如下:

type IRadioOption = { label: React.ReactNode; value: number | string };

type IBuildRadioOptions<P> = {
  /** 依赖属性 */
  deps?: Array<string>;
  /** 取数逻辑 */
  getOptions: (props: P) => Promise<Array<IRadioOption>>;
};

export function buildRadio<P = {}>(options: IBuildRadioOptions<P>) {
  const { deps = [], getOptions } = options;

  const RadioBase = (props: P & RadioGroupProps, ref: ForwardedRef<any>) => {
    const { options: propsOptions, ...restProps } = props;

    // 选项值
    const [options, setOptions] = useState<Array<IRadioOption>>([]);

    // 监听依赖属性更新选项值
    useEffect(
      () => {
        getOptions(props).then(setOptions);
      },
      deps.map((name) => (props as Record<string, unknown>)[name])
    );

    return (
      <Radio.Group
        {...omit(restProps, deps)}
        ref={ref}
        options={propsOptions ?? options ?? []}
      />
    );
  };

  return forwardRef<any, P & RadioGroupProps>(RadioBase);
}

因此通过「进阶版组件生成器」我们可以对表单字段「e」的业务组件进行抽离,代码如下:

const Radio3 = buildRadio<{
  d: number;
}>({
  deps: ["d"],
  getOptions: ({ d }) => fetchOptions(d),
});

场景代码优化后的结果如下:

const Page: React.FC = () => {
  // 选项列表
  const [form] = Form.useForm();

  // 提交表单逻辑
  const handleSubmit = useCallback((formValue) => {
    // 略 ...
  }, []);

  return (
    <div className="page">
      <Form onSubmit={handleSubmit} form={form}>
        <Form.Item field="d">
          <Radio1 />
        </Form.Item>
        <Form.Item field="e" shouldUpdate={true}>
          <Radio3 d={form.getFieldValue("d")} />
        </Form.Item>
      </Form>
    </div>
  );
};

4. 拓展

4.1 buildCheckbox

基础组件「Checkbox」与「Radio」相似,因此其「组件生成器」也类似,实现如下:

type ICheckboxValue = number | string;

type ICheckboxOption = { label: React.ReactNode; value: ICheckboxValue };

type IBuildCheckboxOptions<P> = {
  /** 依赖属性 */
  deps?: Array<string>;
  /** 取数逻辑 */
  getOptions: (props: P) => Promise<Array<ICheckboxOption>>;
};

export function buildCheckbox<P = {}>(options: IBuildCheckboxOptions<P>) {
  const { deps = [], getOptions } = options;

  const CheckboxBase = (
    props: P & CheckboxGroupProps<ICheckboxValue>,
    ref: ForwardedRef<any>
  ) => {
    const { options: propsOptions, ...restProps } = props;

    // 选项值
    const [options, setOptions] = useState<Array<ICheckboxOption>>([]);

    // 监听依赖属性更新选项值
    useEffect(
      () => {
        getOptions(props).then(setOptions);
      },
      deps.map((name) => (props as Record<string, unknown>)[name])
    );

    return (
      <Checkbox.Group
        {...omit(restProps, deps)}
        ref={ref}
        options={propsOptions ?? options ?? []}        
      />
    );
  };

  return forwardRef<any, P & CheckboxGroupProps<ICheckboxValue>>(CheckboxBase);
}

用法与「buildRadio」一致,这里就不过多演示了。

4.2 buildSwitch

基础组件「Switch」较为特殊,它作为一个开关只可能存在两个值,要么「开」要么「关」,在实际业务场景中它的值一般也是固定的,因此我们一般也只需考虑「静态/同步」的「取数逻辑」,实现如下:


type IBuildSwitchOptions<T> = {
  /** 值映射配置 */
  valueMap: {
    /** 开启值 */
    on: T;
    /** 关闭值 */
    off: T;
  };
};

type ISwitchProps<T> = Omit<SwitchProps, "onChange"> & {
  /** 组件值 */
  value?: T;
  /** 值变更回调函数 */
  onChange?: (value: T) => void;
};

export function buildSwitch<T = boolean>(options: IBuildSwitchOptions<T>) {
  const { valueMap } = options;

  function SwitchBase(props: ISwitchProps<T>, ref: ForwardedRef<any>) {
    const { value, onChange, ...restProps } = props;

    return (
      <Switch
        {...restProps}
        ref={ref}
        checked={value === valueMap.on}
        onChange={(v) => {
          onChange?.(v ? valueMap.on : valueMap.off);
        }}
      />
    );
  }

  return forwardRef<any, ISwitchProps<T>>(SwitchBase);
}

「buildSwitch」使用实例代码如下:

const DemoSwitch = buildSwitch({
  valueMap: {
    on: "Hello",
    off: "World",
  },
});

// 略 ...

return (
  <>
    <DemoSwitch />
  </>
);

5. 最后

「组件生成器」是读者在实际开发中经常用到的一类方法,「build」这种编程思想也时刻指导着作者去写好每一个组件,如果你能够看到这里,我也希望你能有所感悟与收获,更希望能够在实际开发中给你带来帮助。

「React通用解决方案」是作者这三年多来的搬砖生涯中的个人总结与沉淀,没有其他资料参考想到啥就写啥,接下来会写「React通用解决方案——表单容器」,读者们尽请期待哈~

最后的最后字节有大量岗位放出来(校招社招统统都有,内推链接在作者个人介绍),期待有兴趣的大佬们踊跃投递哈,我嘛也就顺便挣点内推奖金(红包也就几毛钱)。

React通用解决方案——组件生成器