【實用!】聊聊React元件狀態設計,一定能幫你避坑~

CelesteW發表於2021-11-04

前言

react函式元件寫法十分靈活,資料傳遞也非常方便,但如果對react的理解不夠深入,就會遇到很多問題,比如資料變了檢視沒變,父元件狀態變了子元件狀態沒有及時更新等等,對於複雜的元件來說,可能產生的問題會更多,混亂的程式碼也更容易出現。

隨著自己踩的坑多了,就越來越意識到資料狀態的合理設計對於React元件的重要性,大部分常見問題都是由於資料傳遞和修改混亂導致的。

依照開發經驗和官方文件,我對react元件狀態設計做了一些些總結,希望能幫大家理理思路。

元件的資料與狀態

在聊元件狀態設計之前,先聊聊元件的狀態與資料,因為重點是元件狀態設計,對部分前置知識只會做簡單介紹,如果想詳細瞭解,我在文中也有推薦文章,感興趣的同學可以自己瀏覽哈。

元件中的資料狀態來源有statepropsstate由元件自身維護的,可以呼叫setState進行修改。而props是外部傳入的,是隻讀的,要想修改只能由props傳入修改的方法。

元件的state

當我們呼叫setState修改state時,會觸發元件的重新渲染,同步資料和檢視。在這個過程中,我們可以思考幾個問題:

  1. 資料是怎樣同步到檢視的?
  2. state的狀態是怎樣儲存的?
  3. why重渲染後state沒有被重置為初始值?

一、資料是怎樣同步到檢視的?

先了解一下fiber

react16之前,react的虛擬dom樹樹結構的,演算法以深度優先原則,遞迴遍歷這棵樹,找出變化了的節點,針對變化的部分操作原生dom。因為是遞迴遍歷,缺點就是這個過程同步不可中斷,並且由於js是單執行緒的,大量的邏輯處理會佔用主執行緒過久,瀏覽器沒有時間進行重繪重排,就會有渲染卡頓的問題。

React16出現之後優化了框架,推出了時間片與任務排程的機制,js邏輯處理只佔用規定的時間,當時間結束後,不管邏輯有沒有處理完,都要把主執行緒的控制權交還給瀏覽器渲染程式,進行重繪和重排。

而非同步可中斷的更新需要一定的資料結構在記憶體中來儲存工作單元的資訊,這個資料結構就是Fiber。[1] (引用文章連結在文末,推薦)

fiber樹以連結串列結構儲存了元素節點的資訊,每個fiber節點儲存了足夠的資訊,樹對比過程可以被中斷,當前的fiber樹為current fiber,在renderer階段生成的fiber樹稱為workInProgress fiber,兩棵樹之間對應的節點alternate指標相連,react會diff對比這兩顆樹,最終再進行節點的複用、增加、刪除和移動。

呼叫setState之後發生了什麼?
  1. 首先生成呼叫函式生成一個更新物件,這個更新物件帶有任務的優先順序、fiber例項等。
  2. 再把這個物件放入更新佇列中,等待協調。
  3. react會以優先順序高低先後呼叫方法,建立Fiber樹以及生成副作用列表。
  4. 在這個階段會先判斷主執行緒是否有時間,有的話先生成workInProgress tree並遍歷之。
  5. 之後進入調教階段,將workInProgress treecurrent Fiber對比,並操作更新真實dom。

二、state的狀態是怎樣儲存的?

在fiber節點上,儲存了memoizedState,即當前元件的hooks按照執行順序形成的連結串列,這個連結串列上存著hooks的資訊,每種型別的hooks值並不相同,對於useState而言,值為當前state

(小貼士: 深入理解react hooks原理,推薦閱讀《React Hooks 原理》

函式元件每次state變化重渲染,都是新的函式,擁有自身唯一不變的state值,即memoizedState上儲存的對應的state值。(capture value特性)。

這也是為什麼明明已經setState卻拿不到最新的state的原因,渲染髮生在state更新之前,所以state是當次函式執行時的值,可以通過setState的回撥或ref的特性來解決這個問題。

二、why重渲染後state沒有被重置為初始值?

為什麼元件都重渲染了,資料不會重新初始化?

可以先從業務上理解,比如兩個select元件,初始值都是未選中,select_A選中選項後,select_B再選中,select_A不會重置為未選,只有重新整理頁面元件過載時,資料狀態才會初始化為未選。

知道state狀態是怎樣儲存的之後,其實就很好理解了。

來劃重點了——重渲染≠過載,元件並沒有被解除安裝,state值仍然存在在fiber節點中。並且useState只會在元件首次載入時初始化state的值。

常有小夥伴遇到元件沒正常更新的場景就納悶,父元件重渲染子元件也會重渲染,但為什麼子元件的狀態值不更新?就是因為rerender只是rerender,不是過載,你不人為更新它的state,它怎麼會重置/更新呢?

ps:面對有些非受控元件不更新狀態的情況,我們可以通過改變元件的key值,使之過載來解決。

元件的props

當元件的props變化時,也會發生重渲染,同時其子元件也會重渲染。

元件關係圖
[圖1-1]

如圖1-1所示,元件A的stateprops.data作為子元件1的props傳入,當元件A的props發生變化時,子元件1的props也發生變化,會重渲染,然而子元件2是非受控元件,父元件A重渲染後它也會重渲染,然而資料狀態沒有變化的它,本來不需要重新渲染的,這就造成了浪費。針對這樣不需要重複渲染的元件或狀態,優化元件的方式也有很多,比如官方提供的React.Memo,pureComponent,useMemo,useCallback等等。

react元件狀態設計

資料與檢視互相影響,複雜元件中往往有props也有state,怎樣規定元件使用的資料應該是元件自身狀態,還是由外部傳入props,怎樣規劃元件的狀態,是編寫優雅的程式碼的關鍵。

如何設計資料型別?props?state?常量?

先來看react官方文件中的段落:

通過問自己以下三個問題,你可以逐個檢查相應資料是否屬於 state:

  1. 該資料是否是由父元件通過 props 傳遞而來的?如果是,那它應該不是 state。
  2. 你能否根據其他 state 或 props 計算出該資料的值?如果是,那它也不是 state。
  3. 該資料是否隨時間的推移而保持不變?如果是,那它應該也不是 state。[2]

我們逐個把這些規則用程式碼具象化,聊聊不遵循規則寫出的程式碼可能會產生的陷阱。

1.該資料是否是由父元件通過 props 傳遞而來的?如果是,那它應該不是 state。

// 元件Test
import React, {useState} from 'React'

export default (props: {
    something: string // *是父元件維護的狀態,會被修改
}) => {

    const [stateOne, setStateOne] = useState(props.something)

    return (
        <>
            <span>{stateOne}</span>
        </>
    )
}

這段程式碼中useState的使用方式估計是很多新手小夥伴會寫出來的,把something作為props傳入,又重新將something作為初始值賦給了元件Test的狀態stateOne

這麼做會存在什麼問題?

  • 前文我們說過,props變化會引發元件及其子元件的重渲染,但是,重渲染不等於過載,useState的初始值只有在元件首次載入的時候才會賦給state,重渲染時,是不能重新賦值的,並且當前fiber樹上仍然儲存了hooks的資料,即當前的state狀態值, 所以無論props.something怎麼改變,頁面上展示的stateOne值不會隨之改變,一直是元件Test當前stateOne的值。

也就是說something從受控變成失控了。違背了我們傳值的本意。

那有什麼解決辦法呢?——可以在元件Test裡,通過useEffect監聽props.something的變化,重新setState

// 元件Test
import React, {useState} from 'React'

export default (props: {
    something: string
}) => {

    const [stateOne, setStateOne] = useState()
    
    useEffect(() => {
        setStateOne(props.something) // 如果沒有別的副作用,加一層state是不是看起來很多餘
    }, [props.something])

    return (
        <>
            <span>{stateOne}</span>
        </>
    )
}

可能有小夥伴會說,“我不是拿了props的值直接用呀,props的資料需要做一些修改後再使用,這不是需要一箇中間變數去接收嗎?用state難道不對嗎?”

這裡我們引入第二個規則 —— “你能否根據其他 state 或 props 計算出該資料的值?如果是,那它也不是 state。”

2.你能否根據其他 state 或 props 計算出該資料的值?如果是,那它也不是 state。

state相較props最大的不同,就是state是可修改的,props是隻讀的,所以我們想要對props的資料做些修改後再使用的時候,可能自然而然會想到用state作為中間變數去快取,但是,這種場景使用useState卻顯得大材小用了,因為只在props變化的時候,你需要用到setState去重置state值,沒有其他操作需要setState,這個時候我們就不需要用到state

所以這個場景可以直接使用變數去接收資料重新計算後的結果,或者,更好的辦法是使用useMemo,用新的變數去接收props.something計算後的值。

// 元件Test
import React, {useState} from 'React'

export default (props: {
    something: number[]
}) => {
    // 方式一 每次重渲染都會重新計算
    // const newSome = props.something.map((num) => (num + 1))

    // 方式二 props.something變化時會重新計算
    const newSome = useMemo(() = {
        return props.something.map((num) => (num + 1))
    }, [props.something])

    return (
        <>
            <span>
                {newSome.map((num) => num)}
            </span>
        </>
    )
}

還有一種情況,props傳遞的資料作為單純的常量,而非父元件維護的狀態,也就是說不會再次更新,子元件渲染需要這些資料,並且會操作這些資料,這時候是可以用state去接收的。

// 元件Test
import React, {useState} from 'React'

export default (props: {
    something: string // 在父元件中表現為不會改變的常量
}) => {

    const [stateOne, setStateOne] = useState()

    return (
        <>
            <span>{stateOne}</span>
        </>
    )
}

還有一種更復雜一些的情況,子元件需要父元件的狀態A,根據A進行資料的重組,並且又需要改動這些新的資料,父元件的對狀態A也有它自己的作用,不能直接被子元件改變為子元件需要的資料。這種情況也是可以用state去接收的,因為子元件是需要去修改state的,並不是僅僅依賴props的值得到新的值。

// 父元件
 export default (props) => {

    const [staffList, setStaffList] = useState([])

    // 非同步請求後setStaffList(請求結果)

    return (
       <div>
           {/* <div>{staffList相關的展示}</div> */}
           <Comp staffList={[{name: '小李'}, {name: '小劉'}, {name: '小明'}]} />
       </div>
    )
}

// 子元件
const Comp = ({staffList}) => {

    const [list, setList] = useState(staffList)

    useEffect(() => {
        const newStaffList = staffList.map((item) => ({
            ...item,
            isShow: true
        }))
        setList()
    }, [staffList])

    const onHide = useCallBack((index) => {
        // ... 為 克隆list隱藏下標為index項後的資料
        setList(...)
    }, []) // 寫的時候別忘記填入依賴

    return (
        <div>
            {
                list.map((staff, index) => (
                    staff.isShow && <div onClick={() => onHide(index)}>{staff.name}</div>
                ))
            }
        </div>
    )
}

3.該資料是否隨時間的推移而保持不變?如果是,那它應該也不是 state。

這條就非常好理解了,隨時間的推移而保持不變,就是指從元件載入的時候起,到元件解除安裝,都是一樣的值,對於這樣的資料,我們用一個常量去宣告就好了,放在元件外元件內都問題不大,元件內用useMemo包裹。

// 元件Test
import React, {useState} from 'React'

const writer = '蔚藍C'

export default () => {

    // const writer = useMemo(() => '蔚藍C', [])

    return (
        <>
            <span>
                {writer}
            </span>
        </>
    )
}

補充一點,用react的小夥伴應該多少對受控元件這個概念有了解(不瞭解的快去看文件),從我的理解簡而言之就是,當元件中有資料受父級元件的控制(資料的來源和修改的方式都由父級元件提供,作為props傳入),就是受控元件,反之當元件的資料完全由自身維護,父級元件即沒有提供資料也影響不了資料的變化,這樣的元件是非受控元件。

這是一個非常好的概念,我覺得從理解上來說,“受控物件”的顆粒度可以細分到單個變數會更好理解,因為複雜元件的狀態型別往往不止一種,有從父級傳遞的也有自身維護的。有時候思考元件狀態的時候,往往腦子裡會思考這個狀態是否應該受控。

react的資料傳遞是單向的,即從上至下傳遞,相對父子元件來說,只有子元件可以是受控的,子元件需要修改父元件的資料,一定是父元件提供給它修改資料的方法。

順便推薦一個可以讓父元件和子元件都可以控制同一個狀態的hooks,阿里的hooks庫——ahooks,裡面的useControllableValue

狀態應該放在哪一級元件?元件自身?父元件?祖先元件?

關於state狀態應該放在哪一級元件,在react官方文件中,有下面兩段話:

在 React 中,將多個元件中需要共享的 state 向上移動到它們的最近共同父元件中,便可實現共享 state。這就是所謂的“狀態提升”。[3]

對於應用中的每一個 state:

  • 找到根據這個 state 進行渲染的所有元件。
  • 找到他們的共同所有者(common owner)元件(在元件層級上高於所有需要該 state 的元件)。
  • 該共同所有者元件或者比它層級更高的元件應該擁有該 state。
  • 如果你找不到一個合適的位置來存放該 state,就可以直接建立一個新的元件來存放該 state,並將這一新元件置於高於共同所有者元件層級的位置。[2]

描述非常詳細,也很好理解。因為react資料流向是單向的,兄弟元件的通訊一定是要藉助父元件作為“中間層”,當兄弟元件需要用到同一個狀態時,比起各自維護狀態再通過父級互相通知這樣,麻煩切捨近求遠的方法當然是把元件共同狀態提到最近的共同父元件中,由父元件去管理狀態。

react的Context,就是層級較多的複雜元件的狀態管理方案,把狀態提到最頂層,使每一級元件都能獲取到頂層傳遞的資料和方法。

關於這個小標題,強烈推薦閱讀React官方文件-React哲學,非常新手友好,也適合複習和整理思路,我就不再贅述啦。

後記

以前我看同組大佬同事的程式碼,總是產生我好菜的感覺(現在也是,手動狗頭),覺得我怎麼就沒想到程式碼要這麼規劃呢。但其實寫程式碼的時候我們往往不能一步到位,都是邊寫邊思考,邊隨著需求改進的,比如一個複雜的元件初期,是一個簡單的元件,狀態的傳遞可能也就一兩層,隨著封裝的元件增多,層級增多,我們又自然而然會考慮使用Context... 即便是大佬也是一樣噠。

文章中很多坑都是我自己親身踩過的,我自己也有做筆記,這次整理的內容,其實重點還是在講述react函式元件的狀態管理,怎樣寫程式碼是較為規範的,能一定程度避免效能的浪費,避免可能的隱患,像是一個請求重複執行了兩次、資料互動弄得極其複雜、難以維護之類的。

都是經驗的分享,如果覺得有幫助的話,希望大家看完多點贊收藏轉發~

that's all,thank u~ 下篇文章見~

引用

[1]react原始碼解析7.Fiber架構

[2]React官方文件-React哲學

[2]React官方文件-React狀態提升

相關文章