antd踩坑二三事

2,169 阅读10分钟

1. Form 相关

1.1. 杂项

1.1.1. ant.design 的 Form.list 组件怎么默认显示多少条?

设置 initialValue={[{}]} 即可

1.1.2. shouldUpdate快速实现表单项之间联动

之前表单项联动都是通过 onValuesChange 观察改变的数据,来进行表单项的展示与否,shouldUpdate API能快速实现这一需求。

关键点在使用 shouldUpdatedependencies),该方法主要用于表单项之间存在依赖,并且会渲染出不同组件时使用的:

...
const [form] = useForm();

<Form form={form}>
    <Form.Item
      name="gender"
      label="Gender"
    >
      <Select
        placeholder="Select a option and change input text above"
        allowClear
      >
        <Option value="male">male</Option>
        <Option value="female">female</Option>
        <Option value="other">other</Option>
      </Select>
    </Form.Item>
    <Form.Item
      noStyle
      dependencies={['gender']}
      // 使用shouldUpdate也能实现 shouldUpdate={(prevValues, currentValues) => prevValues.gender !== currentValues.gender}
    >
      {
        ({ getFieldValue }) => (
          <Form.Item
            name="customizeGender"
            label="Customize Gender"
            rules={[
              {
                required: true,
              },
            ]}
          >
            <Input disabled={getFieldValue('gender') === 'other'} />
          </Form.Item>
        )
    }
    </Form.Item>
 </Form>

1.1.3. 自定义表单实现内容格式化

以上是一个带默认值的输入框,输入的内容前面自带前缀,且无法删除,借助自定义表单和自定义校验规则来实现:

export interface CustomeContractNameInputProps {
  onChange: (value: string) => void;
  value?: string;
}

const  CustomeContractNameInput = (props: CustomeContractNameInputProps) => {
  const { value, onChange, ...otherProps } = props;
  const onValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { value: inputValue } = e.target;
    const reg = /^前缀内容-/;
    if (inputValue === '') {
      onChange('前缀内容-');
    } else if (reg.test(inputValue)) {
      onChange(inputValue);
    }
  }

  React.useEffect(() => {
    // 用于初次 form.setFieldsValue 的时候contractName的值不带前缀,在这里将内容格式化
    if (value) {
      onChange(value?.startsWith('前缀内容-') ? value : `前缀内容-${value ?? ''}`);
    }
  }, []);

  return (
    <Input
      allowClear
      {...otherProps}
      value={value?.startsWith('前缀内容-') ? value : `前缀内容-${value ?? ''}`}
      onChange={onValueChange}
    />
  )
};

// 使用
<Form.Item
  label="合同名称"
  name="contractName"
  rules={[
    validator(_, value) { // 自定义校验规则
      if (value === '前缀内容-' || !value) {
        return Promise.reject(new Error('请输入合同名称'));
      }
      return Promise.resolve();
    }
  ]}
>
  <SupplementaryContractNameInput />
</Form.Item>

1.2. 验证相关

1.2.1. 清除字段验证状态

以上是一个字段联动的校验需求,当单选字段为是时,输入框必填,为否时,输入框非必填;如果不做任何处理,在校验状态下切换至否时,校验状态不会清除,我们需要切换至否的时候清除状态,并且不会清空已输入的内容,如果使用 resetFields() 不仅会清除状态,同时也会初始化表单项的内容,所以使用 setFields() 来重置表单的状态:

form.setFields([{ name: "需要重置的field字段", errors: undefined }]);

1.2.2. 验证防抖(debounce Promise)

实际开发过程中通常会有这样的需求:后台在服务器端校验名称是否重复,这个时候需要使用自定义校验功能进行远程校验,如下是一个远程校验的案例:

const handleValidate = (_, value) => {
  if (!value) {
    return Promise.reject(new Error("请输入名称"));
  }
  if (value === "123") {
    // 模拟重复
    return Promise.reject(new Error("名称重复"));
  }
  return Promise.resolve();
};

const MyForm = () => {
  // ...
  return (
    <Form>
      // ...
      <Form.Item
        label="Username"
        name="username"
        rules={[
          {
            validator: handleValidate,
          },
        ]}
      >
        <Input />
      </Form.Item>
    </Form>
  )
}

这种情况设置表单组件的校验时机为onBlur没有问题的,但是体验感并不好,如果在失去焦点后用户才知道名称重复了,需要再次获取焦点进行输入:

那么就需要在onChange的时机进行校验,这种情况下,如果不考虑防抖,会造成频繁的请求(注意右下角的控制台输出):

此时就需要加入防抖机制,但是因为自定义校验功能返回的为Promiselodash的防抖功能在这种情况下是有问题的,会出现验证步频和debounceresult不对应的问题:

这个时候就需要借助另一个插件:p-debounce来实现promise的防抖:

实现一种比较完美的校验方案。

1.2.3. form.validateFields()会自动触发Form.Provider组件的onFormChange()以及Form组件的onFieldsChange()

这是因为 validateFields 时,表单项状态变了(值没变)。

github.com/ant-design/…

1.3. Upload组件

1.3.1. Upload上传组件,如何阻止上传?

beforeUpload 中使用 return false 无法阻止文件继续上传,仍会触发请求。beforeUpload 同时还支持使用 Promise,通过返回一个 Promise 直接 reject 的方式,就不会再进行文件的上传了,也不会再触发 change 事件。如下:

beforeUpload: file =>
  new Promise((resolve, reject) => {
    if (file.size / 1048576 > 100) {
      message.error('文件大小不能超过:100M');
      reject(false);
      return;
    }
    resolve(true);
  }),

1.3.2. 被Form.Item包裹(控制)的Upload,fileList属性失效怎么办?

当用Form.Item对表单组件进行包装后,表单数据会被Form接管,这个时候再对上传列表进行扩展是比较困难的,想要实现一些自定义的功能,如列表拖拽,列表排序等等就比较麻烦,来看下官方对这两个功能的说明

被设置了 name 属性的 Form.Item 包装的控件,表单控件会自动添加 value(或 valuePropName 指定的其他属性) onChange(或 trigger 指定的其他属性),数据同步将被 Form 接管,这会导致以下结果:

  1. 不再需要也不应该用 onChange 来做数据收集同步(你可以使用 Form 的 onValuesChange),但还是可以继续监听 onChange 事件。
  2. 你不能用控件的 value 或 defaultValue 等属性来设置表单域的值,默认值可以用 Form 里的 initialValues 来设置。注意 initialValues 不能被 setState 动态更新,你需要用 setFieldsValue来更新。
  3. 你不应该用 setState,可以使用 form.setFieldsValue 来动态改变表单值。

可见,这两个属性是有冲突的,但是他们各有用处:

  • Upload组件用Fomr.Item进行包裹后,可以使用表单的方法控制上传组件的逻辑,如:form.getFieldsValue()获取表单项的值,form.setFiledsValue()设置表单项的值,以及antd表单自带的所有方法都可以,但是这个时候对上传组件设置fileList进行受控是无效的 ,也就无法扩展上传列表了
  • 如果用fileList来控制Upload组件,这时候无法使用表单的方法进行控制了

如果既想通过Form来控制上传组件,又想通过fileList来控制上传列表该怎么做呢,答案就是:自定义表单组件,通过自定义组件的方式将Upload进行二次封装,内部使用fileList进行控制,来扩展上传列表的功能,外层仍然用Form.Item进行包裹,用Form控制,就能实现需求了。

再来看下,自定义表单控件,需要遵循以下约定:

  • 提供受控属性 value 或其它与 valuePropName 的值同名的属性。
  • 提供 onChange 事件或 trigger 的值同名的事件。

根据约定,来实现自定表单控件

const CustomizedUpload: React.FC<UploadFileProps> = (props: UploadFileProps) => {
  const { value, onChange, ...otherProps } = props;
  const [files, setFiles] = useState([]);

  const handleChange = (info) => {
    let newFileList = [...info.fileList];
    // 对上传数量进行限制,只能同时上传2个文件
    newFileList = newFileList.slice(-2);
    // 为上传的附件添加链接
    newFileList = newFileList.map(file => {
      if (file.response) {
        file.url = file.response.url;
      }
      return file;
    });
    setFiles(fileList);
  }

  useEffect(() => {
    if (files.length) {
      // 再把 fileList 返回给 Form
      onChange(fileList);
    }
  }, [files]);

  useEffect(() => {
    if (value?.length) {
      // 内部使用 fileList 进行控制
      setFiles(value);
    }
  }, [value]);

  return (
    <Upload
      name="file"
      action='上传路径'
      headers={{
        authorization: 'authorization-text',
      }}
      {...otherProps}
      fileList={files}
      onChange={(info) => {
        handleChange(info);
      }}
      >
      <Button icon={<UploadOutlined />}>上传</Button>
    </Upload>
  );
};

外层,直接使用Form.Item进行包裹即可:

<Form.Item
  name="uploadFile"
  label=上传附件
>
  <UploadItem
    {...otherFormProps}
  />
</Form.Item>

1.4. datePicker组件

1.4.1. RangePicker组件选定一个时间之后,限定可选时间范围

...
state = {
	dates: [] // 时间范围
}

// 禁用的时间范围
disabledDate = current => {
  const { dates } = this.state;
  if (dates.length === 1) {
    return moment(dates[0]).subtract(1, 'month') >= moment(current) || moment(dates[0]).add(1, 'month') <= moment(current);
  }
}

// 日期切换
handleChangeDate = dates => {
  this.setState({
    dates
  })
}

render() {
  return (
    <Form>
      ...
      <Form.Item label="操作时间">
        {getFieldDecorator('dates')(
          <RangePicker
            disabledDate={this.disabledDate}
            // 这里使用的是待选日期发生变化的回调函数在选中第一个时间时确定disabledDate的范围
            onCalendarChange={this.handleChangeDate}
          />
        )}
      </Form.Item>
      ...
    </Form>
  )
}

1.4.2. datePicker设置回显值

场景:表单编辑,后台返回的时间戳为毫秒,前端需要将其转换成 moment,才能在 datePicker 中使用

for (const key in formData) {
  if (Object.hasOwnProperty.call(formData, key)) {
    const ele = formData[key];
    if (key.includes('Time')) {
      formData[key] = moment(ele); // 需要先moment一下
    }
  }
}
form.setFieldsValue(formData);

1.4.3. datePicker常用disableDate

下列案例,moment() 传入某个日期表示指定日期,未传表示当前日期

  1. 某个日期前/后日期禁用

// 日期前禁用,不包括指定日期当天
disabledDate: current => current && moment().endOf('d') > current

// 日期前禁用,包括指定日期当天
disabledDate: current => current && moment().subtract(1, 'd').endOf('d') > current

// 日期后禁用,不包括指定日期当天
disabledDate: current => moment().endOf('d') < current
  
// 日期后禁用,包括指定日期当天
disabledDate: current => moment().subtract(1, 'd').endOf('d') < current

// 日期后多少天禁用,包括指定日期当天
disabledDate: current => current => moment().subtract(1, 'd').endOf('d') < moment(current).subtract(6, 'months').endOf('d')

1.4.4. datePicker 组件间取值赋值问题

一个 datePickerA 组件从另一个 datePickerB 组件中取值时需要注意,不能用另一个 datePickerB 的值直接执行 add() 方法或者其他改变 datePickerB 的值的方法,而是先用 moment() 方法对值进行一个复制,否则为 datePickerA 赋值后,datePickerB 的值也会改变:

const { contractStartTime } = form.getFieldsValue([ 'contractStartTime' ]);
...
// moment对值进行转化
form.setFieldsValue({ contractEndTime: moment(contractStartTime).add(1, 'd') });

1.4.5. datePicker设置默认空值的问题

需要设置 initialValuenull ,或 defaultnull

2. Table相关

2.1. 列设置单元格无效

给列添加className,通过样式控制

2.2. 表格多选

直接用下面的hooks即可

import { useState } from 'react';

/**
 * 表格选中 hooks
 * 适用于表格选中,传入行数据中唯一的 id 键即可,返回基础的rowSelection配置和选中的数据
 * @param {string} rowUniqueId  指定行的唯一标识符,用于在选择行时确定哪些行被选中
 * @return {Array} 返回一个数组,包含以下三个元素:
 * rowSelection:table组件的选中项配置对象,可以在外部自行扩展其他属性和方法,这里只包含基础的全选和单选功能;
 * selectedRows:被选中的行数据数组
 * resetSelectedRows:重置已选中的行数据-用于初始选中项
 */
interface useRowSelectionParams {
  rowUniqueId: string;
}
type UseRowSelection = (value: useRowSelectionParams) => [Record<any, any>, any[], () => void]

const useRowSelection: UseRowSelection = ({ rowUniqueId }) => {
  const [selectedRows, setSelectedRows] = useState<any[]>([]);
  // table选中项配置
  const rowSelection = {
    fixed: true,
    selectedRowKeys: selectedRows.map(item => item[rowUniqueId]),
    onSelect: (record, selectedStatus: boolean) => {
      let selectedAttrs: any[];
      if (selectedStatus) {
        selectedAttrs = selectedRows.concat(record);
      } else {
        selectedAttrs = selectedRows.filter(item => item[rowUniqueId] !== record[rowUniqueId]);
      }
      setSelectedRows(selectedAttrs);
    },
    onSelectAll: (selectedStatus: boolean, curSelectedRows: any[], changeRows: any[]) => {
      let selectedAttrs: any[];
      if (selectedStatus) {
        selectedAttrs = selectedRows.concat(changeRows);
      } else {
        const changeRowIds = changeRows.map(item => item[rowUniqueId]);
        selectedAttrs = selectedRows.filter(item => !changeRowIds.includes(item[rowUniqueId]));
      }
      setSelectedRows(selectedAttrs);
    },
  };

  // 重置已选中的行数据
  const resetSelectedRows = () => {
    setSelectedRows([]);
  }

  return [rowSelection, selectedRows, resetSelectedRows];
};

export default useRowSelection;

2.3. 固定列不生效

当Tabel组件的祖先元素为flex-item时(也就是flex项时),必须为table的其中一个祖先元素设置一个固定宽度,否则固定列无法生效。这是因为Table组件设置固定列情况下,table的样式为width: max-content;其意义为:
max-content尺寸关键字代表了内容的最大宽度或最大高度。对于文本内容而言,这意味着内容即便溢出也不会被换行。
也就是说表格会自动撑开祖先元素flex-item到最大宽度,导致固定列的样式失效。

2.4. 列宽不生效

2.4.1. 场景一:列包含隐藏列,列宽生效

在包含列样式为display: none的情况下,需要将该列或多列内容放在columns数组的最后,否则会导致设置列的width不生效。

3. 主题相关

3.1. 如何通过修改主题色统一修改 disabled 的表单颜色?

场景:客户反馈禁用的表单颜色过浅,无法看清字体,看了很多方案都是通过修改 antd 样式的方式来修改颜色,但是表单类型过多,修改起来比较麻烦,就考虑到使用修改主题色的方式,修改颜色变量,来统一进行修改。

解决方案:在 node_modules/antd 中找到对应的控件所使用的变量,如下,是 Input 组件使用的颜色:

.disabled() {
  color: @input-disabled-color;
  background-color: @input-disabled-bg;
  border-color: @input-border-color;
  box-shadow: none;
  cursor: not-allowed;
  opacity: 1;

  &:hover {
    .hover(@input-border-color);
  }
}

// node_modules/antd/lib/style/theme.less 主题包下对应的变量
@input-disabled-color: @disabled-color;

@disabled-color: fade(#000, 25%); // 这里就找到了需要修改的主题色

之后在 umi 配置文件 /config.ts 中修改 theme 选项中的主题色即可:

...
theme: {
	'@disabled-color': '#333'
}
...