前言
秋意漸濃了,有點涼涼了。今日前端早讀課文章由螞蟻金服 @卡晨分享,公號:前端桃園授權。
正文從這開始~~
曾經,我每次面試時幾乎都會問一個問題:antd 中的 Input 組件是受控組件還是非受控組件?
有些人會毫不猶豫的回答:是受控組件,因為有 value 和 onChange,而另外也有一些人會比較猶豫,因為的確似乎說 Input 是受控組件或非受控組件都說得過去。當然,實際上 Input 組件既可以是受控組件,也可以是非受控組件,這完全取決於業務項目中怎麼去使用它。
在這篇文章,我們將一起聊聊怎麼去讓一個組件像 antd 的 Input 組件這樣,既支持受控模式,又支持非受控模式。讓我們從最簡單和基礎的部分出發,一點點來分析和演進,看看會遇到哪些問題,又如何一步步解決。
什麼是受控組件?什麼又是非受控組件?
讓我們先來看一個簡單的例子,這個 Input 組件有一個內部的狀態(State)value,而且它沒有任何屬性,因此很顯然,它是一個非受控的組件,它的組件狀態並不受外部環境控制,而是封閉在組件內部。
而如果我們稍微對它做一點調整,把原本的內部狀態 value 去掉,放到 props 上去,它就變成了受控組件:
很顯然,此時輸入框的值是取決於外部傳遞進來的 props。
如果我們畫個圖,那可以很清楚的看到受控和非受控的區別:
圖中藍色的方框表示組件,黃色的圓圈表示組件內的狀態。
既受控組件又非受控?
儘管在業務項目中,我們寫的組件都是明確的受控或者非受控,但對於組件庫來說,有非常多的組件需要做到既支持受控模式,又支持非受控模式。以 antd-mobile 現在的 5.17 版本為例,幾乎全部的涉及到輸入值、切換、展開收起的組件,都是需要做到既受控又非受控的。
儘管聽起來似乎不難,但實際寫起來還是會遇到一些困難的,讓我們來試一試。
如何實現最簡單的方案:內外兩個狀態,手動同步
考慮到實現成本的複雜度,我們需要讓組件邏輯在兩種模式下,儘可能的保持一致,減少邏輯分支意味着更好的可維護性和可讀性。所以,自然而然的,我們可以很容易想到這個方案:
Child 組件內部始終存在一個狀態,不管它處於哪種模式,它都直接使用自己內部的狀態。而當它處於受控模式時,我們讓它的內部狀態和 Parent 組件中的狀態手動保持同步。
下面的示意圖中加上了兩個對勾標記,被勾選的狀態表示 Child 組件實際在使用哪個狀態
這套方案聽起來是可行的,我們把它寫成代碼:
仔細看上面的代碼,我們會發現在受控模式下存在兩個問題:
原子性:Child 內部狀態的更新會比 Parent 組件晚一個渲染周期,存在 tearing 的問題
性能:因為是在 useEffect 中通過 setState 來做的狀態同步,所以會額外的觸發一次渲染,存在性能問題
明確問題之後,我們來逐個解決:
解決問題 1:原子性
這個問題其實很好解決,我們其實並不需要 Child 和 Parent 的狀態保持非常嚴格的每時每刻都一致,我們只需要判斷,如果組件此時處於受控模式,那麼直接使用來自外部的狀態就可以了:
這樣,即便狀態的同步是存在延遲的,但是 Child 組件所真正使用到的值一定是最新的。
代碼如下:
解決問題 2:性能
因為我們是在 useEffect 去做狀態同步的,所以自然會額外的多觸發一次 Child 組件的重渲染。如果 Child 組件比較簡單的話,那出現的性能影響可以忽略不計。但是對於一些複雜的組件(例如 Picker),多渲染一次帶來的性能問題是比較嚴重的。
那有沒有辦法在 Child 組件的 render 階段就直接更新 value 狀態呢?
並不可以,React 不允許我們在 render 過程中調用 setState。
似乎進入了死胡同,但我們可以停下來,重新考慮一下這行 useState 的代碼:
當我們創建這個 State 時?我們的目的是什麼?State 的本質是什麼?
如果比較簡單粗暴的分析,我們可以把 State 拆成兩部分:
State 是用來存放數據的,它讓我們在組件的渲染函數之外,可以 「持久化」 一些數據
State 的更新可以觸發重新渲染,因為 React 會感知 State 的更新
如果寫一個公式的話,可以寫成:
State = 存放數據 + 觸發重新渲染
而但就存放數據來看,我們可以直接使用 Ref;同樣,如果只是需要觸發重新渲染,我們可以使用類似於setFlag({})或者setCount(v => v + 1)這樣的強制方式(雖然很蠢,但想必 90% 的 React 開發者都曾經這麼寫過)。
那我們根據這個推斷來調整一下上面的公式:
State = Ref + forceUpdate()
我們已經非常接近了,根據這個公式,我們可以把 Child 組件中的 State 拆成一個 Ref 和一個 forceUpdate 函數:
下圖中的虛線淺色圓圈表示 ref,刷新圖標表示 forceUpdate 函數
這樣一來,我們就可以直接在 render 階段直接更新 ref 的值了:
再回頭看下代碼,會發現,為什麼還需要判斷根據受控和非受控模式來使用不同的值呢?(上面代碼塊中的第 12 行)。既然stateRef.current一定是最新的值,那麼完全可以簡化成 Child 組件永遠使用內部存放的數據(Ref):
除此之外,我們還可以把手動實現的 forceUpdate 替換成 ahooks 的 useUpdate:
抽象與復用:usePropsValue
到這裡,我們已經基本實現了所有的功能,但我們只是實現了一個 Input 組件,在 antd-mobile 這樣的組件庫中,會有很多很多組件都需要支持能夠切換受控和非受控模式。所以,為了更好的可復用性,我們把上面的邏輯抽離成一個自定義 Hook:
這樣,在各種組件中,我們可以直接使用 usePropsValue,用法和 useState 非常接近:
不過,我們忽略了 defaultValue,在 antd-mobile 中,value onChange defaultValue 總是成組出現的:
接下來,讓我們對它再做一點優化,讓它變得更像 useState。useState 得到的 setState 函數,支持傳入一個更新函數,而 usePropsValue 目前還不支持這種用法,所以我們來改造一下:
一個隱藏的小 bug
我本以為已經完工了,直到某天在 GitHub 上收到了一條 issue:TabBar 的 onChange 為什麼在同 key 的情況也會觸發 #5409。
這條 issue 揭示了一個隱藏已久的 bug,舉個例子:
假如當前的 state 為 1,如果我們用的是 React 的 useState,那執行 setState (1) 不會有任何效果,React 會幫我們過濾掉這次的更新。而 usePropsValue 不會。
對用戶來說,點擊同一個 Tab 並沒有觸發切換,也因此不應該觸發 onChange 事件,所以我們還需要額外的增加一點判斷,來解決這個 bug:
在 antd-mobile 中,我們也有一個這樣的 usePropsValue 工具 Hook,和上面文章中所描述的幾乎是一樣的,如果你想了解更多,可以去這裡翻閱代碼。
勘誤
上面 「解決問題 2:性能」 章節中提到 「React 不允許我們在 render 過程中調用 setState」,但經評論區@fenoob 指正,其實是 React 是允許我們在 render 函數中調用 setState 的,只是限制了只能觸發當前組件自己的 state 更新。我在這裡寫了一個 demo 驗證了一下。
關於本文作者:@卡晨原文:https://zhuanlan.zhihu.com/p/536322574
關於【組件】相關推薦,歡迎讀者自薦投稿,前端早讀課等你來,+v: zhgb_f2er【第2704期】網易嚴選多端組件庫OSSA正式開源【第2697期】淺談低代碼平台遠程組件加載方案