工作少不了寫“增刪改查”,“增刪改查”中的“增”和“改”都與 Form 有關,可以說:提升了 Form 的開發效率,就提升了整體的開發效率。
本文通過總結 Form 的寫法,形成經驗文件,用以提升團隊開發效率。
1.佈局
不同人開發的表單,細看會發現:表單項的上下間距、左右間距有差別。如果 UE 同學足夠細心,挑出了這些毛病,開發同學也是各改各的,用獨立的 css 控制各自的表單樣式。未來 UE 同學要調整產品風格,開發需要改所有表單樣式,代價極高。
解決這個問題的辦法是:統一佈局方式:Form + Space + Row & Col。
以下圖表單為例,進行說明。
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>
確定、取消按鈕中間的間隔,通過 Space
元件來實現,不寫樣式。
<Space>
<button>確定</button>
<button>取消</button>
</Space>
按鈕和上方的輸入框左對齊,靠的是:設定 Form.Item
的 label
為一個空格,並且不顯示冒號。
<Form.Item label=" " colon="{false}">
<Space>
<button>確定</button>
<button>取消</button>
</Space>
</Form.Item>
還有一種做法是用柵格系統的 offset
,讓 offset
值等於 Form labelCol
的 span
。這種做法形成了依賴關係,以後調整 Form labelCol
的 span
,還需要調整 offset
,因此不建議這樣使用。
<Form.Item wrapperCol={{ offset: 4 }}>...</Form.Item>
<Form labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
...
</Form>
再來看 Address
元件。
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
有屬性 noStyle
,noStyle
讓 Form.Item
沒有樣式,這樣 Form.Item
就不會有 margin
了,Form.Item
之間就會更緊湊了。
對比一下有和無 noStyle
的區別:
有 noStyle
:
無 noStyle
:
下面來看如何用 Row
& Col
實現兩行的佈局。
第一行包含一個下拉框;第二行分為兩部分:左側部份是下拉框,右側部份根據第一行下拉框的選中條件渲染。
<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。
這裡的設計是:第二行左側部分【下拉框】的寬度是變化的,當第二行右側部分展示兩個輸入框時候,第二行左側部分寬度變小。
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
。
如果設定 noStyle
, 它的錯誤會向上傳遞:
但不寫 noStyle
,它就會有 marginBottom
,因此需去除包裹 Address
的 Form.Item
的 marginBottom
。
<Form.Item label="源IP" style={{ marginBottom: 0 }}>
<Address namePathRoot="src" />
</Form.Item>
起始、結束 IP 中間的橫槓,為了垂直居中,在 Row
上設定了 line-height
。
<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>
</>
上圖的 Address
元件在表單中出現兩次,如何保證 Form.Item
的 name
不重名?
有的同學把所有 Form.Item
的 name
作為 props
傳入元件。這種方法固然可行,但比較費事,更好的做法是利用 NamePath
。
<Form.Item name={["a", "b", "c"]}>
<Input />
</Form.Item>
Form.Item
的 name
不僅可以是字串,也可以是字串陣列,即 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.條件渲染
下拉框選擇不同,後面的表單項也會不同。遇到這種需求,有的同學使用 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.Item
的 children
傳一個函式:
<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
這些原子校驗函式寫好後,我們利用函式式的寫法,通過 and
、or
、not
組合出更強大的校驗函式。如一個輸入框可以輸入 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.Item
的 validateFirst
,順序執行 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 的好的實踐,這類總結經驗的文章,需要是活的,能隨著專案經驗積累不斷進化,而不是一寫下來就死了。