揭秘 antd mobile “IndexBar” 的實(shí)現(xiàn)思路
“文章由我同事卡晨(antd mobile 作者)投稿,揭秘 antd mobile IndexBar 的實(shí)現(xiàn)過程。原文鏈接:https://www.yuque.com/awmleer/rgdap2/gffgeh
”
這是 antd-mobile 中的 IndexBar[1] 組件,它由兩部分組成,一部分是主體,是一個帶分組的列表(List),另外一部分是一個在右側(cè)懸浮的索引條(Sidebar)。如果說要找出這個組件中邏輯最難的部分,你覺得會是哪里呢?可能很多人都會覺得是手勢和滾動邏輯,當(dāng)然,這塊確實(shí)可能需要一點(diǎn)小技巧,不過在這篇文章中,我們來分析另一個隱藏的難點(diǎn):索引項(xiàng)的生成和在 Sidebar 中的渲染。IndexBar 在實(shí)際項(xiàng)目中的用法是這樣的:
<IndexBar>
??<IndexBar.Panel?key='A'?index='A'?title='標(biāo)題?A'>
????...
??IndexBar.Panel>
??<IndexBar.Panel?key='A'?index='B'?title='標(biāo)題?B'>
????...
??IndexBar.Panel>
??...
IndexBar>
IndexBar 本身沒有屬性,每一個分組是渲染在其 children 中的 Panel 組件,而 Panel 組件有兩個屬性 index 和 title。index 是索引的唯一標(biāo)識,也是渲染在右側(cè) Sidebar 中的小字,而 title 則對應(yīng)著左側(cè)列表的標(biāo)題。
似乎簡單到不能再簡單了。
如果只考慮主內(nèi)容區(qū)的列表,那思路想必非常明了:IndexBar 渲染一個最外層的容器,Panel 渲染一個帶標(biāo)題的列表。邏輯各自都是封閉的,互不依賴和干擾。
但是如果我們還需要渲染 Sidebar,那就會遇到一個很令人頭疼的問題—— Sidebar 中的數(shù)據(jù)從哪里拿到?讓我們來聊一聊幾種可能的實(shí)現(xiàn)方案,以及他們各自存在的問題。
React.Children.forEach
在 IndexBar 中,我們可以通過 React 提供的 React.Children.forEach 方法對子節(jié)點(diǎn)進(jìn)行遍歷,從而很輕松的就可以生成一個 indexes 數(shù)組。
function?IndexBar()?{
??const?indexes:?string[]?=?[]
??React.Children.forEach(props.children,?child?=>?{
????indexes.push(child.props.index)
??})
}
但是,如果 IndexBar.Panel 不是 IndexBar 的直接一級子節(jié)點(diǎn)呢?那意味著 React.Children.forEach 無法遍歷到它,也就會導(dǎo)致 indexes 的數(shù)據(jù)缺失。
遍歷整個 children 節(jié)點(diǎn)樹
當(dāng)然,我們可以基于 React.Children.forEach 實(shí)現(xiàn)一個樹形遍歷方法,去遍歷子節(jié)點(diǎn)的子節(jié)點(diǎn)的子節(jié)點(diǎn)。
但這樣做同樣還存在一些問題:
性能 遍歷整個子節(jié)點(diǎn)樹在極端情況下可能會有性能影響,當(dāng)然絕大多數(shù)情況下,性能問題是可以忽略不計(jì)的。 子節(jié)點(diǎn)可能是被隱藏在其他組件之中的 考慮一下下面這種情況,我們封裝了一個 FooPanel 組件,這樣在 IndexBar 的 children中是完全不存在,也無法感知到 Panel 組件的:
function FooPanel(props) {
return
}
當(dāng)子節(jié)點(diǎn)變化時,父節(jié)點(diǎn)可能不會觸發(fā)重渲染,也就無法重新計(jì)算 indexes從而導(dǎo)致數(shù)據(jù)的過期
顯然,這種方案存在著非常多的弊端,而且這些弊端會導(dǎo)致隱蔽的、不容易被人察覺的 bug,這是無法容忍的。
Context
React Context 為跨層級的組件通信提供了非常便捷的途徑,所以我們可以換一種思路,遍歷節(jié)點(diǎn)行不通的話,就直接通過 Context 來讓父子組件通信和聯(lián)動。把原本位于 IndexBar 內(nèi)部的 const [indexes, setIndexes] = useState 上提到 Context 中:
export?const?IndexBarContext?=?createContext<{
??indexes:?string[]
??setIndexes:?React.Dispatchstring[]>>
}>({
??indexes:?[],
??setIndexes:?()?=>?{},
})
然后在 IndexBar.Panel 中去更新 indexes 數(shù)據(jù):
export?const?Panel:?React.FC?=?props?=>?{
??const?{?setIndexes?}?=?useContext(IndexBarContext)
??useEffect(()?=>?{
????setIndexes(val?=>?val.concat([props.index]))
????return?()?=>?{
??????setIndexes(val?=>?val.filter(x?=>?x?!==?props.index))
????}
??},?[props.index])
??return?(...)
}
當(dāng)組件創(chuàng)建時,把當(dāng)前 index 添加到 indexes 中;當(dāng)組件卸載時,把 index 從 indexes 中移除掉;當(dāng) index 變化時,移除掉舊的 index,添加新的 index 屬性.
這其實(shí)就是 antd-mobile 曾經(jīng)的實(shí)現(xiàn)思路,但是細(xì)心的讀者朋友們或許可以發(fā)現(xiàn)這其中的思維誤區(qū)。是的,當(dāng) index 變化時,我們是通過 val.concat() 把新的 index 添加到數(shù)組的末尾的,而這一項(xiàng)很有可能并不應(yīng)該在數(shù)組末尾。舉個例子:
//?之前
'A'?/>
'B'?/>
'C'?/>
對應(yīng)的?indexes?為?[A,?B,?C]
-----------------
//?之后
'D'?/>
'B'?/>
'C'?/>
我們移除了?A,再把?D?添加到數(shù)組末尾,所以此時對應(yīng)的?indexes?為?[B,?C,?D]
很顯然是不符合預(yù)期的
這是個非常頭疼的問題,仔細(xì)、嚴(yán)謹(jǐn)?shù)厮伎家幌?,我們會發(fā)現(xiàn)一個結(jié)論:當(dāng)一個 Panel 組件渲染時,我們是無法得知它在 IndexBar 是第幾個 Panel 的。(如果我推斷有誤,歡迎大家指正~)
Hacky Context
基于 Conext 的思路也不真的就到了死胡同,我們可以用一些比較“黑”的方式去達(dá)到目的。
首先,我們可以讓任何一個 Panel 的 index 變更時,都強(qiáng)制觸發(fā)所有 Panel 的重渲染,然后在這次的重渲染過程中,按照順序重新構(gòu)建出 indexes 數(shù)組。且不說這個方案是否能夠?qū)崿F(xiàn)(我覺得是可行的,但是坦白講我沒有真的去寫出來),單是性能問題就令人卻步了。
那再換一種思路,我們可以讓 Panel 渲染出一個帶有特殊標(biāo)記的 DOM 節(jié)點(diǎn),例如這樣:
function?Panel(props)?{
??return?(
????<div?data-panel-index={props.index}>...div>
??)
}
接下來,我們可以在 IndexBar 的 useEffect 中,通過 DOM 節(jié)點(diǎn)查詢,查找到所有帶 data-panel-index 屬性的子 DOM 節(jié)點(diǎn)(注意這里時 DOM 節(jié)點(diǎn)而非 React 節(jié)點(diǎn)了),從而可以構(gòu)建出 indexes 數(shù)據(jù)。最后,我們需要要
我們的選擇
剛剛提到的 Hack Context 的確很好,已經(jīng)滿足了我們的各項(xiàng)標(biāo)準(zhǔn)和要求。性能不至于很差,至少是可以優(yōu)化到性能達(dá)標(biāo)的;魯棒性雖然算不上特別的健壯,但也完全說得過去。但是在 antd-mobile 中,我們最終還是選擇了返璞歸真:React.Children.forEach。是的,我們選擇了使用 React.Children.forEach 來遍歷一級子節(jié)點(diǎn),同時限制用戶在使用的時候,必須把 IndexBar.Panel 直接渲染在 IndexBar 下。
看起來明明有著更好的方案,為什么我們不用呢?其實(shí)主要是以下兩點(diǎn)考慮:
是否真的有必要支持如此強(qiáng)的靈活性 用戶如果這樣寫:
-?IndexBar
??-?div
????-?IndexBar.Panel
這種情況其實(shí)已經(jīng)不是預(yù)期之中的用法了,甚至是應(yīng)該被禁止的用法 是的,理論上講,在 React 的虛擬 DOM 節(jié)點(diǎn)樹中,IndexBar.Panel 可以不是 IndexBar 的直接 child,但是在瀏覽器的真實(shí) DOM 結(jié)構(gòu)中,前者必須是后者的直接 child
避免未來再次調(diào)整 API 造成 break change 在正式發(fā)布之前,先采用對用戶來說更嚴(yán)格的方案,是更保險的一種做法,先收集一下用戶的反饋,如果真的有很多場景有很難解決的問題,那么再組件庫調(diào)整成現(xiàn)在的這種 Context 方案,這樣后面是不會產(chǎn)生 break change 的,反過來就 break change 了
不得不說的是,Context + data-panel-index 屬性仍然是一種有趣而且可行的方案,也正是因此,才有了這篇文章的記錄。
“附:感謝 @GOWxx[2] 發(fā)現(xiàn) antd-mobile 中 IndexBar 組件存在的問題,感謝 @zzzgydi[3] 和 @p697[4] 參與討論和貢獻(xiàn)思路。相關(guān) issue 和 PR:#4439[5] #4443[6]
”
參考資料
IndexBar: https://mobile.ant.design/components/index-bar
[2]@GOWxx: https://github.com/GOWxx
[3]@zzzgydi: https://github.com/zzzgydi
[4]@p697: https://github.com/p697
[5]#4439: https://github.com/ant-design/ant-design-mobile/issues/4439
[6]#4443: https://github.com/ant-design/ant-design-mobile/pull/4443
