• 如何高效地写 Form


    工作少不了写“增删改查”,“增删改查”中的“增”和“改”都与 Form 有关,可以说:提升了 Form 的开发效率,就大幅提升了整体的开发效率。

    本文通过总结 Form 的写法,形成经验文档,用以提升团队开发效率。


    1.布局

    不同人开发的表单,细看会发现:表单项的上下间距、左右间距有差别。如果 UE 同学足够细心,挑出了这些毛病,开发同学也是各改各的,用独立的 css 控制各自的表单样式。未来 UE 同学要调整产品风格,开发需要改所有表单样式,代价极高。

    解决这个问题的办法是:统一布局方式:Form + Space + Row & Col。

    以下图表单为例,进行说明。

    form-1

    const App = () => {
      const [form] = Form.useForm();
      return (
        <Form
          form={form}
          labelCol={{ span: 4 }}
          wrapperCol={{ span: 20 }}
          requiredMark={false}
          onFinish={console.log}
        >
          <Form.Item name="name" label="名称" rules={[Required]}>
            <Input />
          </Form.Item>
          <Form.Item label="源IP" style={{ marginBottom: 0 }}>
            <Address namePathRoot="src" />
          </Form.Item>
          <Form.Item label="目的IP" style={{ marginBottom: 0 }}>
            <Address namePathRoot="dst" />
          </Form.Item>
          <Form.Item label=" " colon={false}>
            <Space>
              <Button type="primary" htmlType="submit">
                确定
              </Button>
              <Button>取消</Button>
            </Space>
          </Form.Item>
        </Form>
      );
    };
    

    antd 采用的是 24 栅格系统,即把宽度 24 等分。以下代码设置了:标签占 4 个栅格,内容占 20 个栅格。

    <Form labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
      ...
    </Form>
    

    form-2.png

    确定、取消按钮中间的间隔,通过 Space 组件来实现,不写样式。

    <Space>
      <button>确定</button>
      <button>取消</button>
    </Space>
    

    按钮和上方的输入框左对齐,靠的是:设置 Form.Itemlabel 为一个空格,并且不显示冒号。

    <Form.Item label=" " colon="{false}">
      <Space>
        <button>确定</button>
        <button>取消</button>
      </Space>
    </Form.Item>
    

    还有一种做法是用栅格系统的 offset,让 offset 值等于 Form labelColspan。这种做法形成了依赖关系,以后调整 Form labelColspan,还需要调整 offset,因此不建议这样使用。

    <Form.Item wrapperCol={{ offset: 4 }}>...</Form.Item>
    
    <Form labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
      ...
    </Form>
    

    再来看 Address 组件。

    form-3

    Address 组件被用在两个地方:

    <>
      <Form.Item label="源IP" style={{ marginBottom: 0 }}>
        <Address namePathRoot="src" />
      </Form.Item>
      <Form.Item label="目的IP" style={{ marginBottom: 0 }}>
        <Address namePathRoot="dst" />
      </Form.Item>
    </>
    
    const Address = ({ namePathRoot }) => {
      return (
        <Row gutter={[8, 8]}>
          <Col span={24}>
            <Form.Item name={[namePathRoot, "type"]} initialValue="ip" noStyle>
              <Select>
                <Select.Option value="ip">IP地址</Select.Option>
                <Select.Option value="iprange">IP地址段</Select.Option>
              </Select>
            </Form.Item>
          </Col>
          <Col flex={1}>
            <Form.Item name={[namePathRoot, "version"]} initialValue="v4">
              <Select>
                <Select.Option value="v4">IPV4</Select.Option>
                <Select.Option value="v6">IPV6</Select.Option>
              </Select>
            </Form.Item>
          </Col>
          <Col flex={2}>
            <Form.Item
              dependencies={[
                [namePathRoot, "type"],
                [namePathRoot, "version"],
              ]}
              noStyle
            >
              {({ getFieldValue }) => {
                const type = getFieldValue([namePathRoot, "type"]);
                const version = getFieldValue([namePathRoot, "version"]);
                if (type === "ip") {
                  return (
                    <Form.Item
                      name={[namePathRoot, "ip"]}
                      dependencies={[
                        [namePathRoot, "type"],
                        [namePathRoot, "version"],
                      ]}
                      validateFirst
                      rules={[Required, version === "v4" ? IPv4 : IPv6]}
                    >
                      <Input placeholder="请输入IP地址" />
                    </Form.Item>
                  );
                } else {
                  return (
                    <Row gutter={8} style={{ lineHeight: "32px" }}>
                      <Col flex={1}>
                        <Form.Item
                          name={[namePathRoot, "iprange", "start"]}
                          dependencies={[
                            [namePathRoot, "type"],
                            [namePathRoot, "version"],
                          ]}
                          validateFirst
                          rules={[Required, version === "v4" ? IPv4 : IPv6]}
                        >
                          <Input placeholder="请输入起始IP" />
                        </Form.Item>
                      </Col>
                      -
                      <Col flex={1}>
                        <Form.Item
                          name={[namePathRoot, "iprange", "end"]}
                          dependencies={[
                            [namePathRoot, "type"],
                            [namePathRoot, "version"],
                            [namePathRoot, "iprange", "start"],
                          ]}
                          validateFirst
                          rules={[
                            Required,
                            version === "v4" ? IPv4 : IPv6,
                            buildMultiFieldsRule(
                              [
                                [namePathRoot, "iprange", "start"],
                                [namePathRoot, "iprange", "end"],
                              ],
                              (start, end) => ipToInt(end) > ipToInt(start),
                              "结束IP需要大于起始IP"
                            ),
                          ]}
                        >
                          <Input placeholder="请输入结束IP" />
                        </Form.Item>
                      </Col>
                    </Row>
                  );
                }
              }}
            </Form.Item>
          </Col>
        </Row>
      );
    };
    

    注意 Address 组件中第一个 Form.Item 有属性 noStylenoStyleForm.Item 没有样式,这样 Form.Item 就不会有 margin 了,Form.Item 之间就会更紧凑了。

    对比一下有和无 noStyle 的区别:

    noStyle
    form-4

    noStyle
    form-5


    下面来看如何用 Row & Col 实现两行的布局。

    第一行包含一个下拉框;第二行分为两部分:左侧部份是下拉框,右侧部份根据第一行下拉框的选中项进行条件渲染。

    form-6

    <Row gutter={[8, 8]}>
      <Col span={24}>第一行</Col>
      <Col flex={1}>第二行左侧部分</Col>
      <Col flex={2}>第二行右侧部分</Col>
    </Row>
    

    gutter={[8, 8]} 指定 Col 之间的水平间隔和垂直间隔。

    <Col span={24}>第一行</Col>,antd 采用 24 栅格系统,因此该 Col 占满整行。Row 默认自动换行 wrap={true},所以后面的 Col 会换行。

    <>
      <Col flex={1}>第二行左侧部分</Col>
      <Col flex={2}>第二行右侧部分</Col>
    </>
    

    第二行的实现有个细节,两个 Col 的宽度用的不是 span,而是 flex。如果用 span={8}span={16},那么这两个 Col 的宽度会固定为 1:2。

    这里的设计是:第二行左侧部分【IPv4 下拉框】的宽度是变化的,当第二行右侧部分展示两个输入框时候,第二行左侧部分宽度变小。

    form-7

    Col 使用 flex 指定宽度可以实现这个效果,对应的 css 样式如下:

    Col:第二行左侧部分 Col:第二行右侧部分
    flex={1} flex={2}
    flex-grow: 1;
    flex-shrink: 1;
    flex-basis: auto;
    flex-grow: 2;
    flex-shrink: 2;
    flex-basis: auto;

    这样的效果是:

    • 如果组件默认宽度总和小于行宽,剩余的宽度根据 flex-grow 的比例来分配;
    • 如果组件默认宽度总和大于行宽,超出的宽度根据 flex-shrink 的比例来缩小。

    我们的目标是在项目中统一布局方式,不要把“不写样式”作为规则规范,那会让我们束手束脚。

    实际上这个表单也写了两处样式。

    源 IP、目的 IP 的 Form.Item 设置了 marginBottom: 0

    <Form.Item label="源IP" style={{ marginBottom: 0 }}>
      <Address namePathRoot="src" />
    </Form.Item>
    

    这是因为输入框的错误要显示在输入框的正下方,这样 Address 组件内的输入框就不能写 noStyle

    form-8

    如果设置 noStyle, 它的错误会向上传递:

    form-9

    但不写 noStyle,它就会有 marginBottom,因此需去除包裹 AddressForm.ItemmarginBottom

    <Form.Item label="源IP" style={{ marginBottom: 0 }}>
      <Address namePathRoot="src" />
    </Form.Item>
    

    起始、结束 IP 中间的横杠,为了垂直居中,在 Row 上设置了 line-height

    form-10

    <Row style={{ lineHeight: "32px" }}>...</Row>
    

    2.name 重名

    <>
      <Form.Item label="源IP">
        <Address namePathRoot="src" />
      </Form.Item>
      <Form.Item label="目的IP">
        <Address namePathRoot="dst" />
      </Form.Item>
    </>
    

    form-6

    上图的 Address 组件在表单中出现两次,如何保证 Form.Itemname 不重名?

    有的同学把所有 Form.Itemname 作为 props 传入组件。这种方法固然可行,但比较费事,更好的做法是利用 NamePath

    <Form.Item name={["a", "b", "c"]}>
      <Input />
    </Form.Item>
    

    Form.Itemname 不仅可以是字符串,也可以是字符串数组,即 NamePath。这样表单项生成的 value 会是嵌套结构:

    {
      a: {
        b: {
          c: "xxxx";
        }
      }
    }
    

    我们只需要让两个 Address 实例 NamePath 的根不同,就可以做到区分,就像指定了不同的命名空间。

    <>
      <Form.Item label="源IP">
        <Address namePathRoot="src" />
      </Form.Item>
      <Form.Item label="目的IP">
        <Address namePathRoot="dst" />
      </Form.Item>
    </>
    
    const Address = ({ namePathRoot }) => {
      return (
        <Row gutter={[8, 8]}>
          <Col span={24}>
            <Form.Item name={[namePathRoot, "type"]}>...</Form.Item>
          </Col>
          ...
        </Row>
      );
    };
    

    有的同学问:实际项目中,后台数据是扁平结构的怎么办?
    我的建议是:前台在 action 层做数据转换。


    3.条件渲染

    form-6

    下拉框选择不同,后面的表单项也会不同。遇到这种需求,有的同学使用 state 来实现:

    const Address = () => {
      const [option, setOption] = useState("ip");
      return (
        <>
          <Form.Item name="type" onChange={setOption}>
            <Select>
              <Select.Option value="ip">IP地址</Select.Option>
              <Select.Option value="iprange">IP地址段</Select.Option>
            </Select>
          </Form.Item>
          {option === ip ? "IP地址表单项" : "IP地址段表单项"}
        </>
      );
    };
    

    实现条件渲染,这种做法需要在 3 处写代码:声明 state、设置 state、根据 state 条件渲染,逻辑是割裂的,会给阅读和维护代码造成麻烦。更好的方式是采用 render props

    Form.Itemchildren 传一个函数:

    <Form.Item>
      {form => {
        const type = form.getFieldValue("type");
        if (type === "ip") {
          return "ip地址表单项";
        } else {
          return "ip地址段表单项";
        }
      }}
    </Form.Item>
    

    除此以外,还需要在 Form.Item 上说明,在什么情况下,需要执行 children 函数。

    <Form.Item shouldUpdate>
      {form => {...}}
    </Form.Item>
    

    以上代码相当于设置 shouldUpdate={true},即每次 render,都重新渲染 children,显然这样性能不好。

    <Form.Item
      shouldUpdate={(preValue, curValue) => preValue.type !== curValue.type}
    >
      {form => {...}}
    </Form.Item>
    

    当表单值发生变化时,检查 type 值是否改变,改变了才重新渲染 children。这种做法消除了性能问题,但还不是最好的做法。

    <Form.Item dependencies={["type"]}>
      {form => {...}}
    </Form.Item>
    

    上述 dependencies 表示:该表单项依赖 type 字段,当 type 发生改变时,需要重新渲染 children。这种声明式的写法更清晰高效。

    注:antd version >= 4.5.0 才支持 dependencies + render props 写法。


    4.校验

    从经验来看,能在各个项目中复用的校验逻辑是 isXyz

    declare function isXyz(str: string): boolean;
    

    如:

    • isIPv4
    • isIPv4NetMaskIP
    • isIPv4NetMaskInt
    • isIPv6

    这些原子校验函数写好后,我们利用函数式的写法,通过 andornot 组合出更强大的校验函数。如一个输入框可以输入 IPv4 也可以输入 IPv6,那校验函数就是:

    or(isIPv4, isIPv6);
    

    在校验函数之上,我们再提供 buildRule 方法,将校验函数转成 antd 的 Rule。

    const buildRule = (validate, errorMsg) => ({
      validator: (_, value) =>
        validate(value) ? Promise.resolve() : Promise.reject(errorMsg),
    });
    

    还有一种比较复杂的情况,是多个表单项的关联校验,如起始 IP 和结束 IP,结束 IP 的要大于起始 IP。

    这个需求核心的校验逻辑是判断 IP 的大小:

    (start, end) => ipToInt(end) > ipToInt(start);
    

    这个函数能正常执行的前提是:起始 IP 和结束 IP 输入框都输入了合法的 IP。

    <>
      <Form.Item name="start" validateFirst rules={[Required, IPv4]}>
        <Input placeholder="请输入起始IP" />
      </Form.Item>
      <Form.Item
        name="end"
        dependencies={["start"]}
        validateFirst
        rules={[
          Required,
          IPv4,
          buildMultiFieldsRule(
            ["start", "end"],
            (start, end) => ipToInt(end) > ipToInt(start),
            "结束IP需要大于起始IP"
          ),
        ]}
      >
        <Input placeholder="请输入结束IP" />
      </Form.Item>
    </>
    

    我们让 Rule 有层层递进的关系:

    [
      Required,
      IPv4,
      buildMultiFieldsRule(
        ["start", "end"],
        (start, end) => ipToInt(end) > ipToInt(start),
        "结束IP需要大于起始IP"
      ),
    ];
    

    先校验填了,再校验是 IPv4,最后校验大小合适。

    同时,我们设置了 Form.ItemvalidateFirst,顺序执行 Rule,有一个出错了,后续的就不执行了。

    buildMultiFieldsRule 方法中,封装判断各个 field 都填写正常的逻辑:

    const buildMultiFieldsRule =
      (fields, validate, errorMsg) =>
      ({ getFieldValue, isFieldTouched, getFieldError }) => ({
        validator: () => {
          if (fields.some(f => !isFieldTouched(f) || getFieldError(f).length > 0)) {
            return Promise.resolve();
          } else {
            return validate(...fields.map(getFieldValue))
              ? Promise.resolve()
              : Promise.reject(errorMsg);
          }
        },
      });
    

    5.结束语

    以上总结了项目中开发 Form 的好的实践,这类总结经验的文章,需要是活的,能随着项目经验积累不断进化,而不是一写下来就死了。

  • 相关阅读:
    如果你想开发一个应用(1-17)
    如果你想开发一个应用(1-16)
    如果你想开发一个应用(1-15)
    0079 Ehcache 3.x应用入门及通过JCache与Spring整合
    0078 Java与MySQL时间戳传递/存储/协调问题--userLegacyDatetimeCode--userTimezone--serverTimezone
    0077 web.xml中配置Spring MVC时,Servlet-name上报Servlet should have a mapping的错误
    0076 判断回文串
    0075 字符串的反转
    0074 几道面试题
    0073 javacTask: 源发行版 1.8 需要目标发行版 1.8
  • 原文地址:https://www.cnblogs.com/apolis/p/15879144.html
Copyright © 2020-2023  润新知