close

前言

秋意漸濃了,有點涼涼了。今日前端早讀課文章由螞蟻金服 @卡晨分享,公號:前端桃園授權。

正文從這開始~~

曾經,我每次面試時幾乎都會問一個問題: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期】淺談低代碼平台遠程組件加載方案

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()