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