我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。 本文作者:佳嵐 可編輯表格在數棧產品中是一種比較常見的表單數據交互方式,一般都支持動態的新增、刪除、排序等基礎功能。 交互分類 可編輯表格一般為兩種交互形式: 實時保存 ...
我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。
本文作者:佳嵐
可編輯表格在數棧產品中是一種比較常見的表單數據交互方式,一般都支持動態的新增、刪除、排序等基礎功能。
交互分類
可編輯表格一般為兩種交互形式:
- 實時保存的表格,即所有單元格都可以直接進行編輯。
- 可編輯行表格,即需要手動點擊編輯才能進入行編輯狀態。
對比兩種交互形式:
- 第一種交互更加友好,但對應的性能開銷會非常大,不需要手動進入單元格編輯狀態。
- 對於第二種交互方式,更多的場景是在數據量很大,不需要頻繁修改,或者批量更新會對後端資料庫操作會有較大性能影響的場景下。它還有一個很好的好處就是在
編輯
狀態時,能夠對已填入數據進行回退。
數棧產品中絕大多數都採用了第一種交互方式。
要實現一個可編輯表格,Table 組件肯定是不可或缺,是否要引入 Form 做數據收集,還要具體場景具體分析。
如果不引入 Form , 採用自行管理數據收集的方式, 其一般實現如下。
const EditableTable = () => {
const [dataSource, setDataSource] = useState([]);
const handleAdd = () => {
const newData = {
key: shortid(),
name: 'New User',
};
setDataSource([...dataSource, newData]);
};
const handleDelete = (key) => {
const newData = dataSource.filter(item => item.key !== key);
setDataSource(newData);
};
const handleChange = (value, key, field) => {
const newData = dataSource.map(item => {
if (item.key === key) {
return { ...item, [field]: value };
}
return item;
});
setDataSource(newData);
};
const handleMove = (key, direction) => {
const index = dataSource.findIndex(item => item.key === key);
const newData = [...dataSource];
const [item] = newData.splice(index, 1);
newData.splice(direction === 'up' ? index - 1 : index + 1, 0, item);
setDataSource(newData);
};
const columns = [
{
title: 'Name',
dataIndex: 'name',
render: (text, record) => (
<Input
value={text}
onChange={e => handleChange(e.target.value, record.key, 'name')}
/>
),
},
{
title: 'Action',
dataIndex: 'action',
render: (_, record) => (
<span>
<Button
onClick={() => handleMove(record.key, 'up')}
>
上移
</Button>
<Button
onClick={() => handleMove(record.key, 'down')}
>
下移
</Button>
<Button onClick={() => handleDelete(record.key)}>
刪除
</Button>
</span>
),
},
];
return (
<div>
<Button
onClick={handleAdd}
>
添加
</Button>
<Table
columns={columns}
dataSource={dataSource}
pagination={false}
/>
</div>
);
};
export default EditableTable;
存在的問題:
- 無法對每行進行單獨校驗。
- 組件完全受控,表單數量很多時輸入會卡頓嚴重。
優點:
- 非常靈活。
- 不用考慮
Form
的依賴渲染問題。 - 可進行表格前端分頁,這能一定程度上解決性能問題。
如果使用 Form
,最正確的做法是通過 Form.List
來實現。 Form 在綁定欄位時,namePath
如果是字元串數組 ["user", "name"]
,則會收集為對象結構 user.name
,如果 namePath
包含整型,則收集為數組 ["users", 0, "name"]
⇒ users[0].name
。
Form.List
中會暴露出維護的 fields
元數據與增刪移動操作的 opeartion
, 那麼與 table
相結合,實現起來會變得更加簡單。
其中 field
對象包含 key
與 name
,key
是單調遞增無重覆的,如果刪除了該數據,則 name
為其在數組中的下標。
我們為 FormItem
註冊的 name
雖然是 [0, "name"]
,但是處於 Form.List
中的 Form.Item
組件都會自動拼上 parentNamePrefix
首碼,也就是最終會變成 [”users”, 0, “name”]
。
<Form form={form}>
<Form.List name="users">
{(fields, operation) => (
<>
<Table
key="key"
dataSource={fields}
columns={[
{
title: "姓名",
key: "name",
render: (_, field) => (
<FormItem name={[field.name, "name"]}>
<Input />
</FormItem>
),
},
{
title: "操作",
key: "actions",
render: (_, field) => (
<Button
onClick={() =>
operation.remove(field.name)
}
>
刪除
</Button>
),
},
]}
pagination={{ pageSize: 3 }}
/>
<Button onClick={() => operation.add({ name: "Jack" })}>
添加
</Button>
</>
)}
</Form.List>
</Form>
我們可以看到,使用 Form.List 實現,甚至可以使用分頁,我們通過 form.getFieldsValue()
查看,數據是正常的。
為何被銷毀的第一頁的表單數據能夠保存下來?
預設情況下 preserve
為 true
的欄位在銷毀時仍能保存數據,只是需要通過 getFieldsValue(true)
才能拿到,但對於 Form.List
, 不需要加 true
參數也能拿到所有數據。
Form.List
本身內部也是一個 Form.Item
,不過添加了 isList
來區分,不光是 List 中的子項,其本身也會被註冊。如下圖所示,表格中有 5 條數據,由於分頁原因只有當前頁的數據表單會在 Form 中註冊收集,
額外的會將 users
也單獨作為一個欄位進行收集。
然後,在 getFieldsValue
源碼中,直接就取了 Form.List 註冊的值。
因此,使用 Form.List
完成分頁,從源碼層面分析下來是可行的,但實際沒怎麼見到有人這樣配合用過。
應用
案例 1
以運行參數為例,其實現使用了 Table
的自定義 components
, 在 EditableCell
中再去定義表單如何渲染。
const RunParamsEditTable = () => {
const [dataSource, setDataSource] = useState([])
const components = {
body: {
row: EditableFormRow,
cell: EditableCell,
},
};
const initColumns = () => {
return [
// xxx欄位
];
};
const columns = initColumns().map((col) => {
if (!col.editable) {
return col;
}
return {
...col,
onCell: (record, index) => ({
index,
record,
editable: col.editable,
dataIndex: col.dataIndex,
title: record[col.dataIndex] || col.title,
errorTitle: col.title,
save,
// 還有很多其他狀態需要傳遞
}),
};
});
return (
<div>
<Table components={components} dataSource={dataSource} columns={columns} />
<span onClick={this.handleAdd}>添加運行參數</span>
</div>
);
};
在 EditableCell
中, 通常需要傳遞大量的 props 來和父組件進行通訊,且表格列定義與表單定義拆分成兩個組件,這樣寫個人感覺太割裂了,且對於產品中絕大部分 EditableTable
來說使用自定義 components
有點大題小用。
const EditableCell = ({ editable, dataIndex, children, save, ...restProps }) => {
const renderCell = () => {
switch (dataIndex) {
case 'name':
return (
<Form.Item name={dataIndex} onChange={(v) => save(v)}>
<Input />
</Form.Item>
);
// 所有其他欄位
}
};
return <td>{editable ? renderCell() : children}</td>;
};
在代碼中,實際又自定義了 Row
來為每一行創建一個 Form
,這樣才實現的同時編輯多個行, 且 Form 只是用來做校驗的,後面都通過 save
來手動收集的。假如改為上述 Form.List
的形式,那麼這將會變得很好維護,在 onValuesChange 中將列表數據同步到上層 store
中。
個人認為 Table
的自定義 components
應在表格行或單元格要維護一些自身狀態時才應該去考慮,如行列拖拽,單元格可在編輯狀態進行切換等場景下使用。
案例 2
每個表單項都是下拉框,且下拉選項是通過級聯請求過來的。
在這裡,我們可能會這樣做,維護一個 state 用來存放不用資料庫對應的數據表列表, 並以 dbId
為鍵。
const [tableOptionsMap, setTableOptionsMap] = useState(new Map())
在 columns render
中直接消費對應的 tableOptions 進行渲染。
<FormItem dependencies={[["list", field.name, "dbId"]]}>
{() => {
const dbId = form.getFieldValue(["list", field.name, "dbId"]);
const tableOptions = tableOptionsMap.get(dbId);
return (
<FormItem name={[field.name, "table"]}>
<Select options={tableOptions} />
</FormItem>
);
}}
</FormItem>;
這一切正常,但當我把數據加到百行數量級的時候,卡頓已經非常明顯了
由於我們是把 state
存放在父組件的,每次請求會造成 table
進行 render 一遍,如果再加入 loading 等狀態,render 次數會更多。Table
組件預設情況下沒有對 rerender 行為做優化,父組件更新,如果 columns
中的提供了自定義 render 方法, 對應的每個 Cell
都會重新 render 。
針對這種情況我們就需要進行優化,根據 shouldCellUpdate
來自定義渲染時機。
那麼每個 Cell 的渲染時機應該是:
FormItem
增刪位置變動時- 該
Cell
消費的對應tableOptions
變動時
第一種情況很好判斷, Form.List
中 field.name
指代下標,只需比較即可
shouldCellUpdate: (prev, curr) => {
return prev.name !== curr.name;
}
第二種情況我們沒法直接知道 tableOptions
是否有變化,所以需要自行寫個 hooks usePreviousStateRef
,這裡需要非常註意的點:返回的是 ref
而不是 ref.current
,在 shouldCellUpdate
中使用會有閉包問題。
const usePreviousStateRef = <T>(state: T): React.MutableRefObject<T> => {
const ref = React.useRef<typeof state>();
useEffect(() => {
ref.current = state;
}, [state]);
return ref;
};
const prevTableOptionsMapRef = usePreviousStateRef(tableOptionsMap);
那麼組合起來,重新渲染的條件就變成了
shouldCellUpdate: (prev, curr) => {
// 位置變化直接渲染
if (prev.name !== curr.name) return true;
// 只對數據表下拉數據變動的行進行重新渲染
const dbId = form.getFieldValue(['list', curr.name, 'dbName']),
const prevTableInfo = prevTableOptionsMapRef.current?.get(dbId);
const currTableInfo = tableOptionsMap?.get(dbId);
return prevTableInfo !== currTableInfo;
},
改完後明細流暢許多
通過 shouldCellUpdate
可解決性能問題,但對應的如果 render 中依賴了外部 state, 就要自行保存 prevState 去判斷了。
總結:
Form.List + Table 的組合能滿足絕大部分需求,所以後續開發中最先應該考慮這種方式,當每行中存在各自狀態需要維護時再嘗試採用自定義 components ,永遠不要 state 與 Form 混用!
此外還需要考慮足夠的性能因素,特別是面對存在大量下拉框時。
最後
歡迎關註【袋鼠雲數棧UED團隊】~
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star
- 大數據分散式任務調度系統——Taier
- 輕量級的 Web IDE UI 框架——Molecule
- 針對大數據領域的 SQL Parser 項目——dt-sql-parser
- 袋鼠雲數棧前端團隊代碼評審工程實踐文檔——code-review-practices
- 一個速度更快、配置更靈活、使用更簡單的模塊打包器——ko
- 一個針對 antd 的組件測試工具庫——ant-design-testing