可控組件?不可控組件?讓我們來討論一下下~

NO IMAGE

首發於我的 blog

前言:本人入職之後算是第一次真正去寫 React,發現了 React 的組件系統和其他框架的組件系統有些許的不同,這也觸發了我對其中組件的可控性的一些思考和總結。

可控組件?不可控組件?

自從前端有了組件系統之後,有一個很常見但是卻又被大家忽視的概念,就是可控組件(Controlled Component)和不可控組件(Uncontrolled Component)。

什麼是可控和不可控?

官方詳細講解了什麼事可控和不可控組件,雖然只是針對 input 組件的 value 屬性來講的。但是對於很多第三方組件庫來講,一個組件不止有一個數據屬於可控。比如 Ant Design 的 Select 組件,valueopen 都屬於可控的數據,如果你讓 value 可控 open 不可控,那這到底是可控組件還是不可控組件呢?

所以從廣義來講使用可控/不可控組件其實不是很恰當,這裡使用可控數據不可控數據更加合理一點。一個組件可能可能同時有可控的數據和不可控的數據。

可控數據是指組件的數據被使用者所控制。不可控數據是指組件的數據不由使用者來控制而是由組件內部控制。

之所以會有可控和不可控,主要是跟人奇怪的心理有關。如果把框架比作一個公司,組件比作人,組件之間的關係比作上下級。那麼上級對下級的期望就是你既能自己做好分內的事情,也可以隨時聽從我的命令。這本身就是一件矛盾的事情,一邊撒手不管,一邊又想全權掌控。遇到這樣的上級,下級肯定會瘋了吧。

為啥要區分呢?

在 Vue 中,其實都忽視了這兩者的區別,我們來看下面這個例子。

<input/>

上面是一個最簡單 Input 組件,我們來思考一下如下幾種使用場景:

  • 如果我只關心最後的結果,也就是輸入的值,中間的過程不關心,最簡單的方式是用 v-model 或者自己在 change 事件裡面獲取值並保存下來。

    <input v-model="value"/>
    <!-- OR -->
    <input @change="change"/>
    

    這種場景是非常普遍,Vue 可以很好的完成,結果也符合人們的預期。

  • 如果我也只是關心結果,但是想要一個初始值。
    也很簡單,通過 value 傳入一個靜態字符串不就好了,或者傳入一個變量,因為 Vue 的 props 是單向的。

    <input v-model="value"/> <!-- value 有初始值 -->
    <input value="init string" @change="change"/>
    <input :value="initValue" @change="change"/>
    

    其中第三個方案並不是非常正確的方式,如果 initValue 在用戶輸入期間發生了更新,那麼他將覆蓋用戶的數據,且不會觸發 change 事件。

  • 我不僅僅關心結果,還關心過程,我需要對過程進行控制。比如說把輸入的字符串全部大小寫,或者鎖定某些字符串。
    熟練的工程師肯定可以寫出下面的代碼。

    <input v-model="value"/> <!-- watch "value",做修改 -->
    <input :value="value" @change="change"/> <!-- 在 change 中修改數據 -->
    

    但是這會有問題:

    1. 數據的修改都是在渲染 dom 之後,也就是說你不管怎麼處理,都會出現輸入的抖動。
    2. 如果通過第二種方法,恰巧你做的工作是限制字符串長度,那麼你這樣寫 change(e) {this.value = e.target.slice(0, 10)} 函數會發現沒有效果。這是因為當超過 10 字符之後,value 的值長度一直是 10,vue 沒有檢測到 value 的變化,從而不會更新 input.value。

出現這個問題最關鍵的是因為沒有很好的區分可控組件和不可控組件,我們來回顧一下上面的某一段代碼:

<input :value="value" @change="change"/>

你能從這塊代碼能看出來使用這個組件的用戶的意圖是什麼呢?他是想可控的使用組件還是說只是想設置一個初始值?你無法得知。我們人類都無法得知,那麼代碼層面就不可能得知的了。所以 vue 對這一塊的處理其實是睜一隻眼閉一隻眼。用戶用起來方便,

用一個例子來簡單描述一下:上級讓你去做一項任務,你詢問了上級關於這些任務的信息(props),然後你就開始(初始化組件)工作了,並且你隔一段時間就會向上級彙報你的工作進度(onChange),上級根據你反饋的進度,合理安排其他的事情。看起來一切都很完美。但是有的上級會有比較強的控制慾,當你提交了你的工作進度之後,他還會瞎改你的工作,然後告訴你,按照我的繼續做。然後下級就懵逼,當初沒說好我要接受你的修改的呀(value props),我這裡也有一份工作進度呀(component state),我應該用我自己的還是你的?

對於人來說,如何處理上級的要求(props)和自身工作(state)是一個人情商的表現,這個邏輯很符合普通人的想法,但是對於計算機來說,它沒有情商也無法判斷究竟應該聽誰的。為了克服這個問題,你需要多很多的判斷和處理才可以,而且對於一些不變的值,你需要先清空再 nextTick 之後賦值才可以出發組件內部的更新。

最近入職之後,公司用到了 React,我才真正的對這個有所理解。

value? defaultValue? onChange?

如果對 React 可控組件和不可控組件有了解了可以跳過這塊內容了。

讓我們來看一下 React 如何處理這個的?我們還是拿上面的那三種情況來說:

  • 如果我只關心最後的結果,也就是輸入的值,中間的過程不關心
    <input onChange={onChange}/>
    
  • 如果我也只是關心結果,但是想要一個初始值
    <input defaultValue="init value" onChange={onChange}/>
    <input defaultValue={initValue} onChange={onChange}/>
    
  • 我不僅僅關心結果,還關心過程,我需要對過程進行控制
    <input value={value} onChange={onChange}/>
    

當看完了這段你會很清楚的知道什麼樣的結構是可控,什麼結構是不可控:

  • 如果有 value 那麼就屬於可控數據,永遠使用 value 的值
  • 否則屬於不可控數據,由組件使用內部 value 的值,並且通過 defaultValue 設置默認值

不論什麼情況修改都會觸發 onChange 事件。

React 對可控和不可控的區分其實對於計算機來說是非常合理的,而且也會讓整個流程變的非常清晰。當然,不僅僅只有這一種設置的方式,你可以按照一定的規則也同樣可以區分,但是保證可控和不可控之間清晰的界限是一個好的設計所必須要滿足的

propName in this.props?

瞭解上面的概念之後,我們進入到實戰環節,我們怎麼從代碼的層面來判斷當前組件是可控還是不可控呢?

根據上面的判斷邏輯來講:

const isControlled1 = 'value' in this.props // approval 1
const isControlled2 = !!this.props.value // approval 2
const isControlled3 = 'value' in this.props && this.props.value !== null && this.props.value !== undefined // approval 3

我們來觀察上面幾個判斷的方式,分別對應一下下面幾個模板(針對第三方組件):

<Input value={inputValue} /> // element 1,期望可控
<Input value="" /> // element 2,期望可控
<Input /> // element 3,期望不可控
<Input value={null} /> // element 4,期望???

可以得到如下表格

是否可控approval 1approval 2approval 3
element1truetruetrue
element2truefalsetrue
element3falsefalsefalse
element4truefalsefalse

大家第一眼就應該能看出來方法二其實是不正確的,他無法很好的區分這兩種狀態,所以直接 pass 掉。

眼尖的同學也會發現為什麼 element 4 的期望沒有填寫呢?這是因為有一條官方的規則沒有講,這條規則是這樣的:當設置了 value 屬性之後,組件就變成了可控組件,會阻止用戶修改 input 的內容。但是如果你想在設置了 value prop 的同時還想讓用戶可以編輯的話,只可以通過設置 valueundefinednull

在官方的這種規則下面,element 4 期望是不可控組件,也就是說 approval 3 是完全符合官方的定義的。但是這樣會導致可控和不可控之間的界限有些模糊。

<Input value={inputValue} />
// 如果 inputValue 是 string,組件是什麼狀態?如果是 null 又是什麼狀態?

所以這裡其實我推薦使用 approval 1 的方式,這也是 antd 所採用的。雖然不符合官方的定義,但是我覺得符合人們使用組件的一種直覺。第六感,=逃=

Independence

有了判斷的方法,那麼我們可以畫出一個簡單的流程圖(Input 組件為例):

可控組件?不可控組件?讓我們來討論一下下~

圖片有點複雜,簡單來講就是每一次需要獲取可控數據或者更新可控數據的時候,都需要檢測一下當前組件的狀態,並根據狀態選擇是從 props 中獲取數據還是從 state 中獲取數據已經更新的時候調用的是那個函數等等。圖中有一些箭頭的方向不是很正確,而且部分細節未畫出,大家見諒。

如果只是添加這一個可控的屬性 value ,這樣寫未嘗不可,但是如果我們要同時考慮很多屬性呢?比如說 Antd Select 組件就同時有 valueopen 兩個可控屬性,那麼整個代碼量是以線性方式增長的。這很明顯是無法接受的。

於是這裡我引入了 Independence 裝飾器來做這件事情。架構如下:

可控組件?不可控組件?讓我們來討論一下下~

我們可以這麼理解,一個支持可控和不可控的組件本質上可以拆分成內部一個展示型的無狀態受控的組件和外面的包裝組件,通過包裝(也就是高階組件的方式)讓內部受控組件支持不可控。

這樣寫其實有如下幾個好處:

  1. 組件邏輯複雜度降低,只需要將組件的受控情況
  2. 可以將任意受控組件包裝成不受控組件,尤其是對第三方組件的封裝上
  3. 組件複雜度降低,代碼冗餘變少
  4. 非常方便的添加和刪除受控屬性,只需要修改裝飾器即可

如何使用?

目前我簡單實現了 Independence 裝飾器,代碼在網易猛獁開源的組件庫 bdms-ui(建設中,組件不全、文檔不全、時間不夠,敬請期待)中,代碼在此

他遵循這樣的規範:假如屬性名稱為 value,那麼默認值為 defaultValue,change 事件為 onValueChange。支持通過 onChangeName 修改 change 事件名稱,通過 defaultName 修改默認值名稱。

另外最簡單的使用方式就是通過裝飾器了,拿 Select 組件舉例。

@Independence({
value: {
onChangeName: 'onChange'
},
open: {} // 使用默認值
})
export default class Select extends Component {
// blahblah,你就可以當受控組件來編寫了
}

從此編寫可控和不可控的數據從未如此簡單。另外 Independence 還實現了 forward ref 的功能。

不過現在功能還比較薄弱,需要經過時間的檢驗,等完備之後可以封裝成一個庫。

總結

本文簡單講解了一下什麼是可控和不可控,以及提出了一個 React 的解決方案。

這些只是基於我的經驗的總結,歡迎大家積極交流。

相關文章

Spring理論基礎控制反轉和依賴注入

面向對象設計原則依賴倒置

如何使用自簽名CA和證書來保護個人在公網上的內容

如何讓你的React『變慢』?探析ArrayDiff的一些邊角特性