如何高效地寫 Form

apolis發表於2022-02-10

工作少不了寫“增刪改查”,“增刪改查”中的“增”和“改”都與 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。

這裡的設計是:第二行左側部分【下拉框】的寬度是變化的,當第二行右側部分展示兩個輸入框時候,第二行左側部分寬度變小。

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 條件渲染,邏輯是割裂的,會給閱讀和維護程式碼造成麻煩。更好的方式是採用 renderProp

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。這種宣告式的寫法更清晰高效。


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 的好的實踐,這類總結經驗的文章,需要是活的,能隨著專案經驗積累不斷進化,而不是一寫下來就死了。

相關文章