這篇文章將從零開始介紹如何開發一個 Antd 的級聯多選選擇器。先看效果:
Github,Sandbox
閱讀完這篇文章,不僅可以學會如何實現級聯多選的功能,還可以順便學會:
(如果以上內容你都很熟練掌握,繼續閱讀可能不會有太大幫助🧐 )
背景Ant Design 是阿里開源的,「The world's second most popular React UI framework「,不用多介紹,任何使用 React 開發管理後臺的前端同學肯定都非常熟悉。
Antd 提供了非常多優秀的組件,Button/DatePicker/Form/Cascader/Tree/Notifaction/Modal,太多太多,這些組件組成完整的組件生態,極大程度地提高了普通開發同學的日常開發效率。
然鵝~有些特殊的場景,特殊的需求,可能 Antd 的組件無法支持,也不計劃支持。比如這篇文章提到的級聯多選,從 github 上可以找到很多相關的 issues, Antd 開發者給出的答覆是不支持,或者建議用 TreeSelect。
(貼圖沒有不敬的意思,非常感謝 Antd 的開發者的輸出)
但是作為普通的開發者,一個複雜度如 Tree Select 的組件,可能大多數人一周時間都實現不了(這裡指的是只是 TreeSelect 的所有特性)。更何況在追求敏捷交付,快速試錯的公司根本不可能給你這麼多時間去實現一個看不到即時收益的公共組件。
你肯定也遇到過類似場景,面對產品強硬不可辯駁的需求和社區沒有合適的開源組件時,作為」優秀開源庫使用者「的一絲絲卑微無助😿。
同時,組件實現相關的教程文章也很少,所以這篇文章將介紹如何從零開始動手實現一個級聯多選選擇器。
需求理解為了讓讀者能深入地了解需求,簡單描述一下(可以在 Sandbox 上動手點一點):
點擊輸入框,在輸入框的上面或者下面展示彈窗,再次點擊彈窗以外的其他區域關閉彈窗
點擊文本展開下一級菜單,點擊 Checkbox 切換選中狀態
Checkbox 有三種狀態,選中(checked)、部分選中(indeterminate)、非選中(unchecked)
支持 Cancel、Confirm 操作,Cancel 關閉彈窗、Confirm 提交選擇。
提交選擇之後,在輸入框內展示父節點的值,也就是Antd 中的 TreeSelect.SHOW_PARENT 策略。
點擊選中項的 x 號可以刪除選中項。
組件設計在開始動手寫組建的代碼之前,可以先想一下那些不重要的功能邏輯可能會帶來額外的工作量,組件有哪些自己的狀態,可以支持哪些參數。有一份這樣的設計文檔在後續編碼時思路會比較清晰。
前置約定為了減低複雜度,根據具體需求場景可以做一些前置約定:
只支持多選。(單選呢?當然是用 Antd 的 Cascader 組件)
所有節點的 key 都是字符串,整顆樹範圍內唯一,方便做節點是否被選中的判斷
組件的 value 字符串數組類型,不支持數字或 Symbol 等類型,字符串就可以滿足絕大多數場景
一個節點需要提供的信息有 value(必須,用作唯一標記), title(必須,節點文本展示) 和 children(非必須,子節點數組)
State級聯多選組件大致需要以下幾個狀態:
忘了在哪裡看過的」能通過計算得到的狀態都應該通過計算得到「的狀態設計原則。在級聯多選組件中,由於最終是以 TreeSelect.SHOW_PARENT 的策略展示,所見即所得,可以讓 value 值也保持一致,下圖中 value 值為 ['深圳市', '荔灣區']。而廣州市,廣東省的部分選中「狀態」是通過計算出來的。
Props作為一個表單組件,必有的參數就那些,很容易就可以列出來。
PropsTypeDescriptionvaluestring[]數據綁定dataTreeNode[]節點數據 { title: string, value: string, children?: TreeNode }allowClearboolean是否允許清楚placeholderstringPlaceholderonChange(newVal) => voidvalue 變更回調函數classNameboolean額外的 CSS 類名styleReact.CSSProperties額外的樣式disabledboolean是否禁用支持 value 和 onChange props 就可以在 Antd 的 Form 中使用了。
實現細節Selector 樣式首先要做的是,表單輸入框,選中節點的標籤 🏷 樣式,由於交付時是作為 Antd 的表單控制項使用。所有要和其他 Select 組件樣式/行為一致。
需要考慮外觀盒模型,hover/ 高亮狀態樣式,支持 allowClear 時的樣式,disabled 的樣式等等。這部分樣式可以自己實現也可以直接從 antd Select 組件上扒樣式。
不過這種情況容易疏漏,擔心有一些沒考慮到的場景。最節省時間成本的方法是,使用 Antd Selector 的類名,直接復用其樣式,偽裝成一個 Selector 🤓 。
彈窗及展開動畫接下來處理 Selector 的事件,控制菜單的展開和收起。這一步大概要做以下這些事情
給 Selector 綁定監聽事件,當發生點擊時展示菜單
為了不受 Selector 所在的容器及樣式影響,需要使用 Portal 的形式將菜單渲染到整個 React Root Dom 節點的外部
監聽菜單 click outside 事件,事件觸發時關閉菜單
展示菜單和收起菜單時需要有動畫,保持和其他「彈出組件」的統一交互。
最小實現的工作量不大,但,在我們要做級聯多選組件這件事情上不是很重要,可以交給公共的庫去做。在 Antd 的使用的 rc-cascader 組件源碼中可以看到,它的最外層包裹了一個 rc-trigger 的組件,點過去看,果然這個抽象組件容器組件就幫我們做了👆 提到的功能。
return (
<Trigger
...
>
{React.cloneElement(children, {
onKeyDown: this.handleKeyDown,
tabIndex: disabled ? undefined : 0,
})}
</Trigger>
);
rc-xxx 是 Antd 開發團隊提供的基礎組件,我們平時用到的 Antd 組件是在 rc-xxx 組件上的包裝。
使用 rc-trigger,可以非常快速地完成彈窗基本功能。
import { Button, Empty } from 'antd'
import Trigger from 'rc-trigger'
const [popupVisible, setPopupVisible] = useState(false)
return (
<Trigger
action={!disabled ? ['click'] : []}
popup={
<Popup />
}
popupVisible={popupVisible}
onPopupVisibleChange={setPopupVisible}
popupStyle={{
position: 'absolute',
}}
// 對齊方式
popupAlign={{
points: ['tl', 'bl'],
offset: [0, 3]
}}
// 內置動畫
popupTransitionName="slide-up"
>
<Selector
{...props}
/>
</Trigger>
)
rc-trigger 不僅提供了彈窗,對齊方式,點擊觸發方式,甚至連動畫都內置了,只需要設置popupTransitionName="slide-up" 就可以得到和其他 Select 組件一樣的展開動畫。
對於 rc-trigger 組件內部具體是如何實現可以看這篇源碼解讀
Checkbox 🌲 狀態聯動接下來進入這個組件的重頭戲,Checkbox 🌲 狀態的維護。
以下面的例子來說,深圳市為選中狀態,那麼深圳市下的所有子節點都展示為選中狀態(但不體現在 value 中)。廣州市為部分選中狀態,因為 value 中包含部分廣州市下的區,同理廣東省的半選中狀態也是如此。
結構考慮到樹結構需要頻繁的向上向下遍歷的操作,我們可能需要的是一個雙向多叉樹的結構🌲 。從父 → 子的聯繫在 children 欄位中已經體現了,還需要給每一個子節點添加一個 parent 屬性指向父節點。
在使用引用計數的垃圾回收機制的語言,循環引用容易出現內存洩漏的問題。而現代的 Javascript 垃圾回收機制是標記清除法。
為了方便判斷通過 value 去獲取對應的節點,在關聯完父 → 子後將樹結構打平,得到一維數組,代碼如下:
export function flattenTree(root: TreeNode[]): TreeNode[] {
const res: TreeNode[] = []
function dfs(nodes: TreeNode[], parent: TreeNode | null = null) {
// ...
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
const { children } = node
const newNode = { ...node, parent }
res.push(newNode)
if (children) { dfs(children, newNode) }
}
// ...
}
dfs(root)
return res
}
當用戶點擊某一級的 Checkbox,需要向上遞歸遍歷所有直接父節點,判斷其子節點是否都為 checked 狀態。如果是,切換 value 為父節點的 value,並刪除其所有子節點 value。
當前 value
['深圳市', '天河區', '荔灣區']
點擊勾選」蘿崗區」 之後,需要加到 value 中
['深圳市', '天河區', '荔灣區', '蘿崗區']
向上遍歷,來到』廣州市『發現所有子節點都被選中了,刪除所有子節點 value,加入自身 value。
['深圳市', '廣州市']
繼續往上判斷,來到廣東省也是所有子節點都被選中了,刪除所有子節點 value,加入自身 value。
['廣東省']
由於沒有更上一層了,向上遍歷中止
代碼如下:
// 狀態提升
export function liftTreeState(
item: TreeNode,
curVal: ValueType[]
): ValueType[] {
const { value } = item
// 加入當前節點 value
const nextValue = curVal.concat(value)
let last = item
// eslint-disable-next-line no-constant-condition
while (true) {
// 如果父節點的所有子節點都已經 checked, 添加該節點 value,繼續嘗試提升
if (
last?.parent?.children!.every((child: TreeNode) => nextValue.includes(child.value))
) {
nextValue.push(last.parent.value)
last = last.parent
} else {
break
}
}
// 移除最後一個滿足 checked 的父節點的所有子孫節點 value
return removeAllDescendanceValue(last, nextValue)
}
當用戶點擊 Uncheck 時,邏輯基本是 Check 操作的反向處理。
取消勾選」荔灣區」 之後,先從該節點一路往上遍歷,一直到當前 value 中的父節點,並暫時保存這條路徑 parentPath ['荔灣區', '廣州市', '廣東省']。
再從"廣東省"一路向下遍歷,如果當前節點在 parentPath 上直接丟棄,如果不在則需要將其放到 nextValue 中。
當前 value
['廣東省', '湖南省']
廣東省在 parentPath,需要丟棄。
['湖南省']
繼續向下遞歸,深圳市不在 parentPath 上,需要加到 value 中,不在 parentPath 上,子節點可以不再遍歷。
['湖南省', '深圳市']
廣州市在 parentPath 上,不在 value 中,繼續遞歸。
['湖南省', '深圳市']
荔灣區在 parentPath 上,不在 value 中,直接忽略,天河區和蘿崗區不在 parentPath 上,需要加入到 value。
['湖南省', '深圳市', '天河區', '蘿崗區']
沒有更深的節點了,遍歷中止
代碼如下:
// 狀態下沉
export function sinkTreeState(root: TreeNode, value: ValueType[]): ValueType[] {
const parentValues: ValueType[] = []
const subTreeValues: ValueType[] = []
// 獲取 parentPath
function getCheckedParent(
node: TreeNode | null | undefined
): TreeNode | null {
if (!node) {
return null
}
parentValues.push(node.value)
if (value.includes(node.value)) {
return node
}
return getCheckedParent(node.parent)
}
const checkedParent = getCheckedParent(root)
if (!checkedParent) {
return value
}
// 遞歸遍歷所有子節點
function dfs(node: TreeNode) {
if (!node.children || node.value === root.value) {
return
}
node.children.forEach((item: TreeNode) => {
if (item.value !== root.value) {
if (parentValues.includes(item.value)) {
dfs(item)
} else {
subTreeValues.push(item.value)
}
}
})
}
dfs(checkedParent)
// 替換 checkedParent 下子樹的值
const nextValue = removeAllDescendanceValue(checkedParent, value).filter(
(item) => item !== checkedParent.value
)
return Array.from(new Set(nextValue.concat(subTreeValues)))
}
前面提到了我們只保存 Show_Parent 的值,部分選中的狀態和子節點選中的狀態是在運行時計算出來的。計算規則如下:
某個子節點是否為 checked 狀態 ⇒ 自己或本身的被 checked
某個父節點是否為 indeterminate 狀態 ⇒ 非 checked 狀態,並且有部分子節點被 checked
某個子節點是否為 unchecked 狀態 ⇒ 默認狀態
export const ConnectedCheckbox = React.memo(
(props: Pick<MenuItemProps, 'node'>) => {
const { node } = props
const { value: containerValue, handleSelectChange } = MultiCascader.useContainer()
const handleChange = useCallback(
(event: CheckboxChangeEvent) => {
const { checked } = event.target
handleSelectChange(node, checked)
},
[node]
)
// 自己或父節點為 checked
const checked = useMemo(() => hasParentChecked(node, containerValue), [
containerValue,
node,
])
// 自己沒有 checked,但是有子節點狀態 checked
const indeterminate = useMemo(
() => !checked && hasChildChecked(node, containerValue),
[checked, containerValue, node]
)
return (
<Checkbox
onChange={handleChange}
checked={checked}
indeterminate={indeterminate}
/>
)
}
)
hasParentChecked 和 hasChildChecked 方法就是簡單的向上或向下遍歷,代碼比較簡單這裡就省略了。
為了方便組件內狀態的管理(代碼中的 MultiCascader.useContainer),我引入了 unstated-next 這個庫,背後還是使用 Hooks + React Context,不過代碼更簡潔,Typescript 支持友好。
全選功能有了以上的級聯 Checkbox 聯動之後,在繼續實現【全選】的邏輯也變得非常簡單。只需在原來的 data 至上再增加一個 All 節點,將改節點和 Footer 上的 All Checkbox 綁定即可。當第一級的所有節點都被選中之後,會自動沿 parent 向上提升 value。
const flattenData = useMemo(() => {
// 如果需要支持全選,在原來的 data 之上添加一個 TreeNode 節點
if (selectAll) {
return flattenTree([
{
title: 'All',
value: All,
parent: null,
children: data,
},
])
}
return flattenTree(data || [])
}, [data, selectAll])
// 如果需要支持全選,在 Footer 渲染 ConnectedCheckbox,賦予 All 節點
{selectAll ? (
<div className={`${prefix}-popup-all`}>
<ConnectedCheckbox node={flattenData[0]} />
{selectAllText}
</div>
) : null}
默認情況展開菜單,需要列出所有第一級的父節點。如果支持全選,直接取第一個 flattenData 的 children,否則遍歷一遍 flattenData ,找到所有沒有 parent 的 children。
const [menuData, setMenuData] = useState([
selectAll
? flattenData[0].children!
: flattenData.filter((item) => !item.parent),
])
由於每個非葉子節點都保存了 children 的數據,級聯菜單其實就是在父節點被點擊之後,將其 children 添加到維護級聯狀態的數組中。
const addMenu = useCallback((menu: TreeNode[], index: number) => {
if (menu && menu.length) {
setMenuData((prevMenuData) => [...prevMenuData.slice(0, index), menu])
} else {
// 如果 children 也要更新 menu
// 比如當前展開了三級菜單,點擊了另一個二級葉子節點
setMenuData((prevMenuData) => [...prevMenuData.slice(0, index)])
}
}, [])
到這裡,整個級聯多選的核心邏輯已經開發完畢,剩下的一些細碎的邏輯不在這裡展開,感興趣的同學可以 github 上看源碼。
下一個部分介紹如何將用 Typescript 開發的 React 組件發布到 NPM。
Typescript NPM Package我們的組件的代碼是用 Typescript 編寫的,然而用戶不一定使用 Typescript,也不一定會編譯 node_modules 下的文件,所以發布到 NPM 上的代碼應該是 JS 的形式。
Typescript 項目,首先需要安裝 typescript 和 tslib 兩個包。
$ yarn add typescript tslib -D
修改 package.json,添加 tsc script 和 main 欄位。
main 欄位是指定當別人使用這個包時的入口文件。原來的 Typescript 代碼入口是在 src/index.tsx 。dev 裡執行了 link 操作,這樣可以在本地項目中先進行驗證。tsc watch 可以在每次代碼變更時立馬重新編譯。 prepublishOnly 的作用是每次準備發布前重新編譯 Typescript。
"main": "dist/index.js",
"scripts": {
"dev": "yarn link && yarn tsc --watch",
"tsc": "tsc",
"prepublishOnly": "rm -rf dist/ && npm run tsc"
},
在根目錄下添加 tsconfig.json 文件,指定輸出路徑,是否生成聲明文件,執行 jsx 等等。declaration 欄位設為 true,在編譯時,Typescript 會自動生成 d.ts 文件。提供這些聲明文件,在 VSCode 等編輯器中就可以有代碼聯想提示的功能。
{
"compilerOptions": {
//...
"outDir": "dist",
// ...
"declaration": true,
// ...
"jsx": "react"
},
"include": ["./src/**/*"]
}
組件中的樣式文件使用了 less 來編寫,同樣的不能要求使用這個包的用戶也必須用 less,我們需要提供一份 css 文件(Typescript 是沒辦法處理 less 文件的)。
// 在 less 文件中引入 antd 自帶的文件,以便使用其提供的變量
@import '../node_modules/antd/es/style/themes/default.less';
@prefix: ~'antd-multi-cascader';
.@{prefix} {
text-align: left;
&-hidden {
display: none;
}
//...
}
安裝 less
$ yarn add -D less
回到 package.json 文件,添加 lessc script,指定將 src/index.less 編譯到 dist/index.less。 —js 參數是因為引用了 antd 的 dfault.less 文件,裡面會用到了 inline javascript 的功能。
"scripts": {
"compile": "yarn tsc && yarn lessc",
"tsc": "tsc",
"lessc": "lessc src/index.less dist/index.css --js",
"prepublishOnly": "yarn test && rm -rf dist/ && npm run compile"
},
發布到 npm 之後,就可以在項目中應用組件和樣式文件了。✨
import MultiCascader from "antd-multi-cascader";
import "antd-multi-cascader/dist/index.css";
單元測試是成本較低卻能有效保障代碼質量的方法,除了能幫你找出代碼實際運行和預期不符的問題,還是排查問題的好工具。一般而言,單元測試覆蓋率越高,更容易獲得其他開發者的認可。
在前端領域,我們可以為方法,模塊,甚至一個組件編寫單元測試。編寫和運行單元測試首先需要安裝單元測試框架,以 facebook 的 jest 為例。
yarn add -D jest @types/jest ts-jest
項目根目錄下添加 jest.config.js 文件,讓 jest 知道如何解析,匹配哪些單元測試文件。
module.exports = {
preset: 'ts-jest',
testMatch: ['<rootDir>/src/**/__tests__/*.tsx'],
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
}
然後就可以編寫單元測試代碼了。全局的 describe 方法聲明一個單元測試分組,在這個組內可以定義單元測試生命周期的鉤子,before, after, beforeEach, afterEach 等等,在這些鉤子中可以為跑單元測試造數據。
it 方法則是具體某個用例執行的地方,會用到 jest 提供的 expect 方法,這個方法接受一個參數,然後返回一個具有很多斷言方法的對象。調用這些斷言方法即可起到驗證預期執行的效果。比如下面的例子中斷言 hasChildChecked(flattenValue[0], ['1']) 的返回值將為 true。給定輸入,驗證方法允許結果,如果下次不小心改壞了這個方法,單元測試就可以立即告訴你,及時修復避免雪球越滾越大。
import { hasChildChecked } from '..'
describe('src/components/MultiCascader/utils.tsx', () => {
describe('hasChildChecked', () => {
let flattenValue: TreeNode[]
beforeEach(() => {
flattenValue = createFlattenTree()
})
it('should tell has child checked or not', () => {
expect(hasChildChecked(flattenValue[0], ['1'])).toEqual(true)
})
})
})
編寫完單元測試,在 package.json scripts 中添加 test 命令,同時在 prepublishOnly 前也加上 test 任務,這樣每次打包前都能跑一遍單元測試。
"scripts": {
"test": "jest",
"prepublishOnly": "yarn test && rm -rf dist/ && npm run compile"
},
在持續集成的過程中,我們可以使用 Github Actions,Gitlab CI,Travis CI 等工具來跑單元測試、lint,sonarqube 等任務,保證代碼提交質量。
使用 Github Actions 很簡單,只需要在項目根目錄創建 .github/workflows/test.yml 文件。複製一下代碼,然後在每次提交代碼和 pr 時都會啟動一個 node 服務來運行單元測試了。
name: Test
on: [push, pull_request]
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-10.14]
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: '12.x'
- run: npm install
- run: npm run test -- --coverage
想要獲得展示 Coverage 的 Badge?,登錄 https://codecov.io/gh,選擇對應倉庫,將頁面上展示的 CODECOV_TOKEN 填到 Github 項目 Settings-Secrets 頁面上,然後把以下的代碼補充到 test.yml 文件中。
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: unittests
name: codecov-umbrella
fail_ci_if_error: true
然後,就可以通過 https://img.shields.io/codecov/c/github/[user]/[project]/master.svg 連結拿到 badge 啦。🎉
結語相信閱讀完本文之後,你也可以動手開發一個級聯多選選擇器了。如果閱讀過程中有疑問歡迎留言討論。
作為滿足產品需求的最小實現,本文內容僅供參考,在實際項目中使用還需慎重,因為至少還存在以下缺陷。
數據量大時的性能要求,筆者在項目中的數據量非常小,編碼的時候沒有過多考慮性能問題。數據量很大的時候需要上 Virtual List
不支持搜索,動態加載菜單內容,不支持自定義 render
沒有經過嚴格測試,使用過程中參數變化可能會帶來預期之外的結果
沒有支持按鍵操作,Web 無障礙等
關於Checkbox 🌲 狀態聯動部分的實現,參考另一個非常棒的開源組件庫 rsuitejs - multi-cascader 的源碼。由於樣式、生態以及為了靈活應對項目需求,最終沒有選擇它,而是自行實現。
最後,所有代碼都在 github 上可以找到,如果你想了解更多細節可以點過去👀 歡迎 star
有同樣需求的同學,也歡迎試用,使用過程中有問題也歡迎提 issue~
// npm
$ npm install antd-multi-cascader
// yarn
$ yarn add antd-multi-cascader
https://github.com/react-component/trigger
https://github.com/ant-design/ant-design/blob/master/components/cascader/index.tsx
https://rsuitejs.com/components/multi-cascader
1.看到這裡了就點個在看支持下吧,你的「點讚,在看」是我創作的動力。
2.關注公眾號
程式設計師成長指北,回復「1」加入高級前端交流群!「在這裡有好多 前端 開發者,會討論 前端 Node 知識,互相學習」!
3.也可添加微信【ikoala520】,一起成長。
「在看轉發」是最大的支持