Antd Mobile 作者教你寫 React 受控組件和非受控組件
曾經(jīng),我每次面試時(shí)幾乎都會(huì)問一個(gè)問題:antd 中的 Input 組件是受控組件還是非受控組件?
有些人會(huì)毫不猶豫的回答:是受控組件,因?yàn)橛?nbsp;value 和 onChange,而另外也有一些人會(huì)比較猶豫,因?yàn)榈拇_似乎說 Input 是受控組件或非受控組件都說得過去。當(dāng)然,實(shí)際上 Input 組件既可以是受控組件,也可以是非受控組件,這完全取決于業(yè)務(wù)項(xiàng)目中怎么去使用它。
在這篇文章,我們將一起聊聊怎么去讓一個(gè)組件像 antd 的 Input 組件這樣,既支持受控模式,又支持非受控模式。讓我們從最簡單和基礎(chǔ)的部分出發(fā),一點(diǎn)點(diǎn)來分析和演進(jìn),看看會(huì)遇到哪些問題,又如何一步步解決。
什么是受控組件?什么又是非受控組件?
讓我們先來看一個(gè)簡單的例子,這個(gè) Input 組件有一個(gè)內(nèi)部的狀態(tài)(State)value,而且它沒有任何屬性,因此很顯然,它是一個(gè)非受控的組件,它的組件狀態(tài)并不受外部環(huán)境控制,而是封閉在組件內(nèi)部。
而如果我們稍微對(duì)它做一點(diǎn)調(diào)整,把原本的內(nèi)部狀態(tài) value? 去掉,放到 props 上去,它就變成了受控組件:
很顯然,此時(shí)輸入框的值是取決于外部傳遞進(jìn)來的 props。
如果我們畫個(gè)圖,那可以很清楚的看到受控和非受控的區(qū)別:
圖中藍(lán)色的方框表示組件,黃色的圓圈表示組件內(nèi)的狀態(tài)。
既受控組件又非受控?
盡管在業(yè)務(wù)項(xiàng)目中,我們寫的組件都是明確的受控或者非受控,但對(duì)于組件庫來說,有非常多的組件需要做到既支持受控模式,又支持非受控模式。以 antd-mobile 現(xiàn)在的 5.17 版本為例,幾乎全部的涉及到輸入值、切換、展開收起的組件,都是需要做到既受控又非受控的。
盡管聽起來似乎不難,但實(shí)際寫起來還是會(huì)遇到一些困難的,讓我們來試一試。
如何實(shí)現(xiàn)
最簡單的方案:內(nèi)外兩個(gè)狀態(tài),手動(dòng)同步
考慮到實(shí)現(xiàn)成本的復(fù)雜度,我們需要讓組件邏輯在兩種模式下,盡可能的保持一致,減少邏輯分支意味著更好的可維護(hù)性和可讀性。所以,自然而然的,我們可以很容易想到這個(gè)方案:
Child 組件內(nèi)部始終存在一個(gè)狀態(tài),不管它處于哪種模式,它都直接使用自己內(nèi)部的狀態(tài)。而當(dāng)它處于受控模式時(shí),我們讓它的內(nèi)部狀態(tài)和 Parent 組件中的狀態(tài)手動(dòng)保持同步。
下面的示意圖中加上了兩個(gè)對(duì)勾標(biāo)記,被勾選的狀態(tài)表示 Child 組件實(shí)際在使用哪個(gè)狀態(tài)。?
這套方案聽起來是可行的,我們把它寫成代碼:
仔細(xì)看上面的代碼,我們會(huì)發(fā)現(xiàn)在受控模式下存在兩個(gè)問題:
- ?原子性:Child 內(nèi)部狀態(tài)的更新會(huì)比 Parent 組件晚一個(gè)渲染周期,存在 tearing 的問題。
- 性能:因?yàn)槭窃趗seEffect? 中通過setState 來做的狀態(tài)同步,所以會(huì)額外的觸發(fā)一次渲染,存在性能問題。
明確問題之后,我們來逐個(gè)解決:
解決問題 1:原子性
這個(gè)問題其實(shí)很好解決,我們其實(shí)并不需要 Child 和 Parent 的狀態(tài)保持非常嚴(yán)格的每時(shí)每刻都一致,我們只需要判斷,如果組件此時(shí)處于受控模式,那么直接使用來自外部的狀態(tài)就可以了:
這樣,即便狀態(tài)的同步是存在延遲的,但是 Child 組件所真正使用到的值一定是最新的。
代碼如下:
解決問題 2:性能
因?yàn)槲覀兪窃?nbsp;useEffect 去做狀態(tài)同步的,所以自然會(huì)額外的多觸發(fā)一次 Child 組件的重渲染。如果 Child 組件比較簡單的話,那出現(xiàn)的性能影響可以忽略不計(jì)。但是對(duì)于一些復(fù)雜的組件(例如 Picker),多渲染一次帶來的性能問題是比較嚴(yán)重的。
那有沒有辦法在 Child 組件的 render 階段就直接更新 value 狀態(tài)呢?
并不可以,React 不允許我們?cè)?render 過程中調(diào)用 setState。
似乎進(jìn)入了死胡同,但我們可以停下來,重新考慮一下這行 useState 的代碼:
當(dāng)我們創(chuàng)建這個(gè) State 時(shí)?我們的目的是什么?State 的本質(zhì)是什么?
如果比較簡單粗暴的分析,我們可以把 State 拆成兩部分:
- State 是用來存放數(shù)據(jù)的,它讓我們?cè)诮M件的渲染函數(shù)之外,可以“持久化”一些數(shù)據(jù)。
- State 的更新可以觸發(fā)重新渲染,因?yàn)?React 會(huì)感知 State 的更新。
如果寫一個(gè)公式的話,可以寫成:
State = 存放數(shù)據(jù) + 觸發(fā)重新渲染
而但就存放數(shù)據(jù)來看,我們可以直接使用 Ref;同樣,如果只是需要觸發(fā)重新渲染,我們可以使用類似于 setFlag({}) 或者 setCount(v => v + 1) 這樣的強(qiáng)制方式(雖然很蠢,但想必 90% 的 React 開發(fā)者都曾經(jīng)這么寫過)。
那我們根據(jù)這個(gè)推斷來調(diào)整一下上面的公式:
State = Ref + forceUpdate()
我們已經(jīng)非常接近了,根據(jù)這個(gè)公式,我們可以把 Child 組件中的 State 拆成一個(gè) Ref 和一個(gè) forceUpdate 函數(shù):
下圖中的虛線淺色圓圈表示 ref,刷新圖標(biāo)表示 forceUpdate 函數(shù)”。
這樣一來,我們就可以直接在 render 階段直接更新 ref 的值了:
再回頭看下代碼,會(huì)發(fā)現(xiàn),為什么還需要判斷根據(jù)受控和非受控模式來使用不同的值呢?(上面代碼塊中的第 12 行)。既然 stateRef.current 一定是最新的值,那么完全可以簡化成 Child 組件永遠(yuǎn)使用內(nèi)部存放的數(shù)據(jù)(Ref):
除此之外,我們還可以把手動(dòng)實(shí)現(xiàn)的 forceUpdate 替換成 ahooks 的 useUpdate:
抽象與復(fù)用:usePropsValue
到這里,我們已經(jīng)基本實(shí)現(xiàn)了所有的功能,但我們只是實(shí)現(xiàn)了一個(gè) Input 組件,在 antd-mobile 這樣的組件庫中,會(huì)有很多很多組件都需要支持能夠切換受控和非受控模式。所以,為了更好的可復(fù)用性,我們把上面的邏輯抽離成一個(gè)自定義 Hook:
這樣,在各種組件中,我們可以直接使用 usePropsValue,用法和 useState 非常接近:
不過,我們忽略了 defaultValue,在 antd-mobile 中,value onChange defaultValue 總是成組出現(xiàn)的:
接下來,讓我們對(duì)它再做一點(diǎn)優(yōu)化,讓它變得更像 useState。useState 得到的 setState 函數(shù),支持傳入一個(gè)更新函數(shù),而 usePropsValue 目前還不支持這種用法,所以我們來改造一下:
一個(gè)隱藏的小 bug
我本以為已經(jīng)完工了,直到某天在 GitHub 上收到了一條 issue:TabBar 的 onChange 為什么在同 key 的情況也會(huì)觸發(fā) #5409[1]。
這條 issue 揭示了一個(gè)隱藏已久的 bug,舉個(gè)例子:
假如當(dāng)前的 state 為 1,如果我們用的是 React 的 useState,那執(zhí)行 setState(1) 不會(huì)有任何效果,React 會(huì)幫我們過濾掉這次的更新。而 usePropsValue 不會(huì)。
對(duì)用戶來說,點(diǎn)擊同一個(gè) Tab 并沒有觸發(fā)切換,也因此不應(yīng)該觸發(fā) onChange 事件,所以我們還需要額外的增加一點(diǎn)判斷,來解決這個(gè) bug:
在 antd-mobile 中,我們也有一個(gè)這樣的 usePropsValue 工具 Hook,和上面文章中所描述的幾乎是一樣的,如果你想了解更多,可以去這里[2]翻閱代碼。
勘誤
上面“解決問題 2:性能”章節(jié)中提到“React 不允許我們?cè)?render 過程中調(diào)用 setState”,但經(jīng)評(píng)論區(qū)@fenoob[3]。
指正,其實(shí)是 React 是允許我們?cè)?render 函數(shù)中調(diào)用 setState 的,只是限制了只能觸發(fā)當(dāng)前組件自己的 state 更新。我在這里寫了一個(gè) demo[4] 驗(yàn)證了一下。
參考資料
[1]TabBar 的 onChange 為什么在同 key 的情況也會(huì)觸發(fā) #5409:https://github.com/ant-design/ant-design-mobile/issues/5409。
[2]這里:https://github.com/ant-design/ant-design-mobile/blob/fae45549bcadb2b3c7f1dea27462543230e3b795/src/utils/use-props-value.ts。
[3]@fenoob://www.zhihu.com/people/05bdf67112572afd5f3526f2eaa425c8。
[4]demo:https://codesandbox.io/s/condescending-pare-1utvlt?file=/src/App.js。