給 Antd Table 組件編寫縮進指引線、子節點懶加載等功能,如何二次封裝開源組件?
在業務需求中,有時候我們需要基于 antd 之類的組件庫定制很多功能,本文就以我自己遇到的業務需求為例,一步步實現和優化一個樹狀表格組件,這個組件會支持:
- 每個層級縮進指示線
- 遠程懶加載子節點
- 每個層級支持分頁
本系列分為兩篇文章,這篇只是講這些業務需求如何實現。
而下一篇,我會講解怎么給組件也設計一套簡單的插件機制,來解決代碼耦合,難以維護的問題。
功能實現
層級縮進線
antd 的 Table 組件默認是沒有提供這個功能的,它只是支持了樹狀結構:
- const treeData = [
- {
- function_name: `React Tree Reconciliation`,
- count: 100,
- children: [
- {
- function_name: `React Tree Reconciliation2`,
- count: 100
- }
- ]
- }
- ]
展示效果如下:
antd-table
可以看出,在展示大量的函數堆棧的時候,沒有縮進線就會很難受了,業務方也確實和我提過這個需求,可惜之前太忙了,就暫時放一邊了。😁
參考 VSCode 中的縮進線效果,可以發現,縮進線是和節點的層級緊密相關的。
vscode
比如 src 目錄對應的是第一級,那么它的子級 client 和 node 就只需要在 td 前面繪制一條垂直線,而 node 下的三個目錄則繪制兩條垂直線。
- 第 1 層: | text
- 第 2 層: | | text
- 第 3 層: | | | text
只需要在自定義渲染單元格元素的時候,得到以下兩個信息。
- 當前節點的層級信息。
- 當前節點的父節點是否是展開狀態。
所以思路就是對數據進行一次遞歸處理,把層級寫在節點上,并且要把父節點的引用也寫上,之后再通過傳給 Table 的 expandedRowKeys 屬性來維護表格的展開行數據。
這里我是直接改寫了原始數據,如果需要保證原始數據干凈的話,也可以參考 React Fiber 的思路,構建一顆替身樹進行數據寫入,只要保留原始樹節點的引用即可。
- /**
- * 遞歸樹的通用函數
- */
- const traverseTree = (
- treeList,
- childrenColumnName,
- callback
- ) => {
- const traverse = (list, parent = null, level = 1) => {
- list.forEach(treeNode => {
- callback(treeNode, parent, level);
- const { [childrenColumnName]: next } = treeNode;
- if (Array.isArray(next)) {
- traverse(next, treeNode, level + 1);
- }
- });
- };
- traverse(treeList);
- };
- function rewriteTree({ dataSource }) {
- traverseTree(dataSource, childrenColumnName, (node, parent, level) => {
- // 記錄節點的層級
- node[INTERNAL_LEVEL] = level
- // 記錄節點的父節點
- node[INTERNAL_PARENT] = parent
- })
- }
之后利用 Table 組件提供的 components 屬性,自定義渲染 Cell 組件,也就是 td 元素。
- const components = {
- body: {
- cell: (cellProps) => (
- <TreeTableCell
- {...props}
- {...cellProps}
- expandedRowKeys={expandedRowKeys}
- />
- )
- }
- }
之后,在自定義渲染的 Cell 中,只需要獲取兩個信息,只需要根據層級和父節點的展開狀態,來決定繪制幾條垂直線即可。
- const isParentExpanded = expandedRowKeys.includes(
- record?.[INTERNAL_PARENT]?.[rowKey]
- )
- // 只有當前是展示指引線的列 且父節點是展開節點 才會展示縮進指引線
- if (dataIndex !== indentLineDataIndex || !isParentExpanded) {
- return <td className={className}>{children}</td>
- }
- // 只要知道層級 就知道要在 td 中繪制幾條垂直指引線 舉例來說:
- // 第 2 層: | | text
- // 第 3 層: | | | text
- const level = record[INTERNAL_LEVEL]
- const indentLines = renderIndentLines(level)
這里的實現就不再贅述,直接通過絕對定位畫幾條垂直線,再通過對 level 進行循環時的下標 index 決定 left 的偏移值即可。
效果如圖所示:
縮進線
遠程懶加載子節點
這個需求就需要用比較 hack 的手段實現了,首先觀察了一下 Table 組件的邏輯,只有在有children 的子節點上才會展示「展開更多」的圖標。
所以思路就是,和后端約定一個字段比如 has_next,之后預處理數據的時候先遍歷這些節點,加上一個假的占位 children。
之后在點擊展開的時候,把節點上的這個假 children 刪除掉,并且把通過改寫節點上一個特殊的 is_loading 字段,在自定義渲染 Icon 的代碼中判斷,并且展示 Loading Icon。
又來到遞歸樹的邏輯中,我們加入這樣的一段代碼:
- function rewriteTree({ dataSource }) {
- traverseTree(dataSource, childrenColumnName, (node, parent, level) => {
- if (node[hasNextKey]) {
- // 樹表格組件要求 next 必須是非空數組才會渲染「展開按鈕」
- // 所以這里手動添加一個占位節點數組
- // 后續在 onExpand 的時候再加載更多節點 并且替換這個數組
- node[childrenColumnName] = [generateInternalLoadingNode(rowKey)]
- }
- })
- }
之后我們要實現一個 forceUpdate 函數,驅動組件強制渲染:
- const [_, forceUpdate] = useReducer((x) => x + 1, 0)
再來到 onExpand 的邏輯中:
- const onExpand = async (expanded, record) => {
- if (expanded && record[hasNextKey] && onLoadMore) {
- // 標識節點的 loading
- record[INTERNAL_IS_LOADING] = true
- // 移除用來展示展開箭頭的假 children
- record[childrenColumnName] = null
- forceUpdate()
- const childList = await onLoadMore(record)
- record[hasNextKey] = false
- addChildList(record, childList)
- }
- onExpandProp?.(expanded, record)
- }
- function addChildList(record, childList) {
- record[childrenColumnName] = childList
- record[INTERNAL_IS_LOADING] = false
- rewriteTree({
- dataSource: childList,
- parentNode: record
- })
- forceUpdate()
- }
這里 onLoadMore 是用戶傳入的獲取更多子節點的方法,
流程是這樣的:
- 節點展開時,先給節點寫入一個正在加載的標志,然后把子數據重置為空。這樣雖然節點會變成展開狀態,但是不會渲染子節點,然后強制渲染。
- 在加載完成后賦值了新的子節點 record[childrenColumnName] = childList 后,我們又通過 forceUpdate 去強制組件重渲染,展示出新的子節點。
需要注意,我們遞歸樹加入邏輯的所有邏輯都在 rewriteTree 中,所以對于加入的新的子節點,也需要通過這個函數遞歸一遍,加入 level, parent 等信息。
新加入的節點的 level 需要根據父節點的 level 相加得出,不能從 1 開始,否則渲染的縮進線就亂掉了,所以這個函數需要改寫,加入 parentNode 父節點參數,遍歷時寫入的 level 都要加上父節點已有的 level。
- function rewriteTree({
- dataSource,
- // 在動態追加子樹節點的時候 需要手動傳入 parent 引用
- parentNode = null
- }) {
- // 在動態追加子樹節點的時候 需要手動傳入父節點的 level 否則 level 會從 1 開始計算
- const startLevel = parentNode?.[INTERNAL_LEVEL] || 0
- traverseTree(dataSource, childrenColumnName, (node, parent, level) => {
- parent = parent || parentNode;
- // 記錄節點的層級
- node[INTERNAL_LEVEL] = level + startLevel;
- // 記錄節點的父節點
- node[INTERNAL_PARENT] = parent;
- if (node[hasNextKey]) {
- // 樹表格組件要求 next 必須是非空數組才會渲染「展開按鈕」
- // 所以這里手動添加一個占位節點數組
- // 后續在 onExpand 的時候再加載更多節點 并且替換這個數組
- node[childrenColumnName] = [generateInternalLoadingNode(rowKey)]
- }
- })
- }
自定義渲染 Loading Icon 就很簡單了:
- // 傳入給 Table 組件的 expandIcon 屬性即可
- export const TreeTableExpandIcon = ({
- expanded,
- expandable,
- onExpand,
- record
- }) => {
- if (record[INTERNAL_IS_LOADING]) {
- return <IconLoading style={iconStyle} />
- }
- }
功能完成,看一下效果:
遠程懶加載
每個層級支持分頁
這個功能和上一個功能也有點類似,需要在 rewriteTree 的時候根據外部傳入的是否開啟分頁的字段,在符合條件的時候往子節點數組的末尾加入一個占位 Pagination 節點。
之后在 column 的 render 中改寫這個節點的渲染邏輯。
改寫 record:
- function rewriteTree({
- dataSource,
- // 在動態追加子樹節點的時候 需要手動傳入 parent 引用
- parentNode = null
- }) {
- // 在動態追加子樹節點的時候 需要手動傳入父節點的 level 否則 level 會從 1 開始計算
- const startLevel = parentNode?.[INTERNAL_LEVEL] || 0
- traverseTree(dataSource, childrenColumnName, (node, parent, level) => {
- // 加載更多邏輯
- if (node[hasNextKey]) {
- // 樹表格組件要求 next 必須是非空數組才會渲染「展開按鈕」
- // 所以這里手動添加一個占位節點數組
- // 后續在 onExpand 的時候再加載更多節點 并且替換這個數組
- node[childrenColumnName] = [generateInternalLoadingNode(rowKey)]
- }
- // 分頁邏輯
- if (childrenPagination) {
- const { totalKey } = childrenPagination;
- const nodeChildren = node[childrenColumnName] || [];
- const [lastChildNode] = nodeChildren.slice?.(-1);
- // 渲染分頁器,先加入占位節點
- if (
- node[totalKey] > nodeChildren?.length &&
- // 防止重復添加分頁器占位符
- !isInternalPaginationNode(lastChildNode, rowKey)
- ) {
- nodeChildren?.push?.(generateInternalPaginationNode(rowKey));
- }
- }
- })
- }
改寫 columns:
- function rewriteColumns() {
- /**
- * 根據占位符 渲染分頁組件
- */
- const rewritePaginationRender = (column) => {
- column.render = function ColumnRender(text, record) {
- if (
- isInternalPaginationNode(record, rowKey) &&
- dataIndex === indentLineDataIndex
- ) {
- return <Pagination />
- }
- return render?.(text, record) ?? text
- }
- }
- columns.forEach((column) => {
- rewritePaginationRender(column)
- })
- }
來看一下實現的分頁效果:
重構和優化
隨著編寫功能的增多,邏輯被耦合在 Antd Table 的各個回調函數之中,
- 指引線的邏輯分散在 rewriteColumns, components中。
- 分頁的邏輯被分散在 rewriteColumns 和 rewriteTree 中。
- 加載更多的邏輯被分散在 rewriteTree 和 onExpand 中
至此,組件的代碼行數也已經來到了 300 行,大概看一下代碼的結構,已經是比較混亂了:
- export const TreeTable = (rawProps) => {
- function rewriteTree() {
- // 🎈加載更多邏輯
- // 🔖 分頁邏輯
- }
- function rewriteColumns() {
- // 🔖 分頁邏輯
- // 🏁 縮進線邏輯
- }
- const components = {
- // 🏁 縮進線邏輯
- }
- const onExpand = async (expanded, record) => {
- // 🎈 加載更多邏輯
- }
- return <Table />
- }
有沒有一種機制,可以讓代碼按照功能點聚合,而不是散落在各個函數中?
- // 🔖 分頁邏輯
- const usePaginationPlugin = () => {}
- // 🎈 加載更多邏輯
- const useLazyloadPlugin = () => {}
- // 🏁 縮進線邏輯
- const useIndentLinePlugin = () => {}
- export const TreeTable = (rawProps) => {
- usePaginationPlugin()
- useLazyloadPlugin()
- useIndentLinePlugin()
- return <Table />
- }
沒錯,就是很像 VueCompositionAPI 和 React Hook 在邏輯解耦方面所做的改進,但是在這個回調函數的寫法形態下,好像不太容易做到?
下一篇文章,我會聊聊如何利用自己設計的插件機制來優化這個組件的耦合代碼。
記得關注后加我好友,我會不定期分享前端知識,行業信息。2021 陪你一起度過。
本文轉載自微信公眾號「前端從進階到入院」,可以通過以下二維碼關注。轉載本文請聯系前端從進階到入院公眾號。