使用React Hook后的一些體會

一、前言

距離React Hook發布已經有一段時間了,筆者在之前也一直在等待機會來嘗試一下Hook,這個嘗試不是像文檔中介紹的可以先在已有項目中的小組件和新組件上嘗試,而是嘗試用Hook的方式構建整個項目,正好新的存儲項目啟動了,需要一個新的基於web的B/S管理系統,機會來了。在項目未進入正式開發前的時間里,筆者和小夥伴們對官方的Hook和Dan以及其他優秀開發者的關於Hook的文檔和文章都過了至少一遍,當時的感覺就是:之前學的又沒用了,新的一套又來了。目前這個項目已經成功搭起來了,主要組件和業務已具規模,UT也對應完成了。是時候寫一下對Hook使用后的初步體會了,在這裏,筆者不會做太多太深入的Hook API和原理講解,因為很多其他優秀的文章可以已經講得足夠多了。再者因為雖然重構了項目,但代碼組織方式可能還不是最Hook的方式。本文內容大多為筆者認為使用Hook最需要明白的地方。

 

二、怎麼替代之前的生命周期方法?

這個問題在筆者粗略地過了一遍Hook的API后自然而然地產生了,因為畢竟大多數關注Hook新特性的開發者們,都是從生命周期的開發方式方式過來的,從 createClass 到ES2015的 class ,再到Hook。很少有人是從Hook出來才使用React的。這也就是說,大家在使用初期,都會首先用生命周期的思維模式來探究Hook的使用,就像我們對英語沒熟練使用之前,英文對話都是先在心裏準備出中文語句,在心裏翻譯出英文語句再說出來。筆者已有3年的生命周期方式的開發經驗,慣性的思維改變起來最為困難。

筆者在之前使用生命周期的方式開發組件時,使用最多的、對要實現業務最依賴的生命周期是 componentDidMount 、 componentWillReceiveProps 、 shouldComponentUpdate 。

對於 componentDidMount 的替代方式很簡單: useEffect(() => {/* code */}, []); ,使用 useEffect hook,依賴給空數組就行,空數組在這裏表示有依賴的存在,但依賴實際上又為空,會是這個hook在初次render完成的時候調用一次足矣。如果有需要在組件卸載的生命周期內 componentWillUnmount 乾的事情,只需要在 useEffect 內部返回一個函數,並在這個函數內部做這些事情即可。但要記住的時候,考慮到函數的Capture Value的特性,對值的獲取等情況與生命周期方法的表現並非完全一致。

對於 componentWillReceiveProps 這個生命周期。首先這裏說說筆者自己的歷史原因。在React16.3版本以後,生命周期API被大幅修改,16.4又在16.3上改了一把,為了後期的Async Render的出現,原有的 componentWillReceiveProps 被預先重命名為unsafe方法,並引入了 getDerivedStateFromPorps 的靜態方法,為了不重構項目,筆者把React和對應打包工具都停留在了16.2和適配16.2的版本。現有的Hook文檔也忽略了怎麼替代 componentWillReceiveProps 。其實這個生命周期的替代方式最為簡單,因為像 useEffect 、 useCallback 、 useMemo 等hook都可以指定依賴,當依賴變化后,回調函數會重新執行,或者返回一個根據依賴產生的新的函數,或者返回一個根據依賴產生的新的值。

對於 shouldComponentUpdate 來說,它和 componentWillReceiveProps 的替換方式其實差不多。說實話,筆者在項目中,至少是在目前跑在PC瀏覽器的項目中,不太經常使用這個生命周期。因為在目前的業務中,從redux導致的props更新基本都有數據變化進而導致有視圖更新的需要,可能從觸發父到子的prop更新的時候,會出現不太必要的沖渲染需要,這個時候可能需要這個生命周期對當前和歷史狀態進行判斷。也就是說,如果對於某個組件來說,差不多每次的props變化大概率可能是值真的變了,其實做比較是無意義的,因為比較也需要耗時,特別是數據量較大的情況。最後耗時去比較了,結果還是數據發生了變化,需要衝渲染,那麼這是很操蛋的。所有說不能濫用 shouldComponentUpdate ,真的要看業務情況而定,在PC上多幾次小範圍的無意義的重渲染對性能影響不是很大,但在移動端的影響就很大,所以得看時機情況來決定。

Hook帶來的改變,最重要的應該是在組織一個組件代碼的時候,在思維方式上的變化,這也是官方文章中有提到的:”忘記你已經學會的東西”,所以我們在熟悉Hook以後,在書寫組件邏輯的時候應該不要先考慮生命周期是怎麼實現這個業務的,再轉成Hook的實現,這樣一來,一是還停留在生命周期的方式上,二是即便實現了業務功能,可能也不是很Hook的最優方式。所以,是時候用Hook的方式來思考組件的設計了。

 

三、不要忘記依賴、不要打亂Hook的順序

先說Hook的順序,在很多文章中,都有介紹Hook的基本實現或模擬實現原理,筆者這裏不再多講,有興趣可以自行查看。總結來說就是,Hook實現的時候依賴於調用索引,當某個Hook在某一次渲染時因條件不滿足而未能被調用,就會造成調用索引的錯位,進而導致結果出錯。這是和Hook的實現方式有關的原因,只要記住Hook不能書寫在 if 等條件判斷語句內部即可。

對於某個hook的依賴來說,一定要記住寫,因為函數式組件是沒有 componentWillReceive 、 shouldComponentUpdate 生命周期的。任何在重渲染時,一個函數是否需要重新創建、一個值是否需要重新計算,都和依賴有關係,如果依賴變了,就需要計算,沒變就不需要計算,以節省重渲染的成本。這裏特別需要注意的是函數依賴,因為函數內部可能會使用到 state 和 props 。比如,當你在 useEffect 內部引用了某些 state 和 props ,你可能會很容易的查看到,但是不太容易查看到其內部調用的其他函數是否也用到了 state 和 props 。所以函數的依賴一定不要忘記寫。當然官方的CRA工具已經集成了ESlint配置,來幫我們檢測某個hook是否存在有遺漏的依賴沒有寫上。PS. 這裏我也推薦大家使用CRA進行項目初始化,並eject出配置文件,這樣可以按照我們的業務要求自定義修改配置,然後將一些框架代碼通過yeoman打包成generator,這樣我們就有了自己的種子項目生成器,當開新項目的時候,可以進行快速的初始化。

 

四、Cpature Value特性

捕獲值的這個特性並非函數式組件特有,它是函數特有的一種特性,函數的每一次調用,會產生一個屬於那一次調用的作用域,不同的作用域之前不受影響。筆者看過的有關Hook的文檔中,大多都引述過這個經典的例子:

function App (){
    const [count, setCount] = useState(0);
    
    function increateCount (){
        setCount(count + 1);
    }
    
    function showCount (){
        setTimeout(() => console.log(`你點擊了${count}次`), 3000);
    }
    
    return (
        <div>
            <p>點擊了{count}次</p>
            <button onClick={increateCount}>增加點擊次數</button>
            <button onClick={showCount}>显示點擊次數</button>
        </div>
    );
}

當我們點擊了一次”增加點擊次數”按鈕后,再點擊”显示點擊次數”按鈕,在大約3s后,我們可以看到點擊次數會在控制台輸上出來,在這之前我們再次點擊”增加點擊次數”按鈕。3s后,我們看到控制台上輸出的是1,而我們期望的是2。當你第一次接觸Hook的時候看到這個結果,你一定會大吃一驚,WTF?

可以驚,但不要慌,聽我細細道來:

1. 當App函數組件初次渲染完后,生成了第一個scope。在這個scope中, count 的值為0。

2. 我們第一次點擊”增加點擊次數”按鈕的時候,調用了 setCount 方法,並將 count 的值加1,觸發了重渲染,App組件函數因重渲染的需要而被重新調用,生成了第二個scope。在這個scope中,count為1。頁面也更新到最新的狀態,显示”點擊了1次”。

3. 緊接着我們點擊了”显示點擊次數”按鈕,將調用 showCount 方法,延遲3s后显示 count 的值。請注意這裏,我們這次操作是在第二次渲染生成的這個scope(第二個scope)中進行的,而在這個scope中, count 的值為1。

4. 在3s的異步宏任務還未被推進主線程執行之前,我們又再次點擊了”增加點擊次數”按鈕,再次調用了 setCount 方法,並加 count 的值再次加1,又觸發了重渲染,App組件函數因重渲染的需要而被重新調用,生成了第三個scope。在這個scope中,count為2。頁面也更新到最新的狀態,显示”點擊了2次”。

5. 3s到了以後,主線程也出於空閑狀態,之前壓入異步隊列的宏任務被推入主線程中執行,重要的地方來了,這個異步任務所處的作用域是屬於第二個scope,也就是說它會使用那一次渲染scope的 count 值,也就是1。而不是和界面最新的渲染結果2一樣。

當你使用類組件來實現這個小功能並進行相同操作的時候,在控制台得到的結果都不同,但是在界面上最終的結果是一致的。在類組件中,我們在是生命周期方法 componentDidMount 、 componentDidUpdate 通過 this.state 去獲取狀態,得到的一定是其最新的值。這就是最大的不同之處,也是讓初學者很困惑,很容易踩入坑中的地方,當然這個坑並不是說函數式組件和Hook設計上的問題,而是我們對其的不了解,進而導致使用上的錯誤和對結果的誤判,進而導致代碼出現BUG。

Capture Value這個特性在Hook的編碼中一定要記住,並且理解。

如果說想要跳出每個重渲染產生的scope會固化自己的狀態和值的特性,可以使用Hook API提供的 useRef hook,讓所有的渲染scope中的某個狀態,都指向一個統一的值的一個Key(API中採用current)。這個對象是引用傳遞的,ref的值記錄在這個Key中,我們並不直接改變這個對象本身,而是通過修改其的一個Key來修記錄的值。讓每次重渲染生成的scope都保持對同一個對象的引用,來跳出Cpature Value帶來的限制。

 

五、Hook的優勢

在Hook的官方文檔和一些文章中也提到了類組件的一些不好的地方,比如:HOC的多層嵌套,HOC和Render Props也不是太理想的復用代碼邏輯,有關狀態管理的邏輯代碼很難在組件之間復用、一個業務邏輯的實現代碼被放到了不同的生命周期內、ES2015與類有關語法和this指向等困擾初級開發者的問題等都有提到。還有像上一段落中提到的一些問題一樣。這些都是需要改革和推動的地方。

這裏筆者對HOC的多層嵌套確實覺得很噁心,因為筆者之前的項目就是這樣的,一旦進入開發者工具的React Dev Tool的Tab,犹如地獄般的connect、asyncLoad就出現了,你會發現每個和Redux有關的組件都有一個connect,做了代碼分割以後,異步加載的組件都有一個asyncLoad(雖然後面可以用原生的 lazy 和 suspense 替代),很多因使用HOC而帶來的負面影響,對強迫症患者來說這不可接受,只能不看了之。

而對於類組件生命周期的開發方式來說,一個業務邏輯的實現,需要多個生命周期的配合,也就是邏輯代碼會被放到多個生命周期內部,在一個組件比較稍微龐大和複雜以後,維護起來較為困難,有些時候可能會忘記修改某個地方,而採用Hook的方式來實現就比較好,可以完全封裝在一個自定hook內部,需要的組件引入這個hook即可,還可以做到邏輯的復用。比如這個簡單的需求:在頁面渲染完成后監聽一個瀏覽器網絡變化的事件,並給出對應提示,在組件卸載后,我們再移除這個監聽,通常使用生命周期的實現方式為:

class App (){
    browserOnline () {
        notify('瀏覽器網絡已恢復正常!');  
    }   

    browserOffline () {
        notify('瀏覽器發生網絡異常!');  
    }  

    componentDidMount (){
        window.addEventListener('online', this.browserOnline);
        window.addEventListener('offline', this.browserOffline);
    }  

    componentWillUnmount (){
        window.removeEventListener('online', this.browserOnline);
        window.removeEventListener('offline', this.browserOffline);
    }
}

使用Hook方式實現:

function useNetworkNotification (){
    const browserOnline = () => notify('瀏覽器網絡已恢復正常!');

    const browserOffline = () => notify('瀏覽器發生網絡異常!');

    useEffect(() => {
        window.addEventListener('online', browserOnline);
        window.addEventListener('offline', browserOffline);

        return () => {
            window.removeEventListener('online', browserOnline);
            window.removeEventListener('offline', browserOffline);
        };
    }, []);
}
function App (){
    useNetworkNotification();
}    

function AnotherComp (){
    useNetworkNotification();
}

所以,採用Hook實現的代碼不僅管理起來方便(無需將相關的代碼散布到不同的生命周期方法內),可以封裝成自定義的hook,便於邏輯的在不同組件間復用,組件在使用的時候也不需要關注其內部的實現方式。這僅僅是實現了一個很簡單功能的例子,如果項目變得更加複雜和難以維護,通過自定義Hook的方式來抽象邏輯有助於代碼的組織質量。

 

六、為啥會推動Hook

筆者認為上個段落中提到的函數式組件配合Hook相較於類組件配合生命周期方法是存在有一定優勢的。再者,React團隊最開始發布Hook的時候,其實是頂着很大的壓力的,因為這對於開發者來說實在就是以前的白學了,除了底層某些思想不變外,上層API全部變完。筆者最開始了解Hook后,最直接感受就是這東西是不是在給後面的Async Render填坑用的,為啥會這麼說呢?因為React的這種更新機制就是全部樹做Diff然後更新patch。而Vue是依賴收集方式的,數據變化后,哪些地方需要更新是明確的,所以更新是精準的。React的這種設計機制,就導致更新的成本很高,即便有虛擬樹,但是一旦應用很龐大以後,遍歷新舊虛擬樹做Diff也是很耗時的,並且沒有Async Render前,一旦開啟協調,就只能一條路走到底,代碼又不能控制JS引擎的函數調用棧,在主線程長時間運行腳本又不歸還控制權,會阻塞線程造成界面友好度下降,特別是當應用運行在移動端設備等性能不太強的計算機上時效果特別顯著。而基於Fiber的鏈表式樹結構可以模擬出函數調用棧,並能夠由代碼控制工作的開始和暫停,可以有效解決上述問題,但它會破壞原本完整的生命周期方式,因為一個協調的任務,可能會放在不同的線程空閑時間內去完成,進而導致一個生命周期可能會被調用多次,導致實際運行的結果並不像代碼書寫的那樣,這也是在16.3及以後版本將某些生命周期重命名為unsafe的原因。生命周期基本廢掉了,雖然後來引入了一些靜態方法用來解決一些問題,但存在感太低了,基本都屬於過度階段的產物。生命周期廢了,就需要有東西來替代,並支持Async Render的實現,Hook這種模式就是一個不錯的選擇。當然這可能並不全面,或者說的不絕對正確,但筆者認為是有這個原因的。

 

七、單元測試

筆者目前的項目對穩定性要求高,屬於LTS類型,不像創業型的互聯網項目,可能上線幾個月就下了,所以UT是必須的。筆者給新項目的模塊寫單元測試的時候,比較完好的支持Hook的Enzyme3.10版本在8天前才發布:(。從目前測試的體驗來看,相對於類組件時代確實有進步。在類組件時代,除了生命周期外,其他的一切基本都靠HOC來完成,這就造成了我們在測試的時候,必須套上HOC,而當測試組件業務邏輯的時候,又必須扒開之前套上的HOC,找到裏面的真實組件,再進行各種模擬和打樁操作。而函數式組件是沒有這個問題的,有Hook加持后,一切都是扁平化的,總之就是比之前好測了。有一點稍微麻煩點的就是:

1. 涉及到會觸發重渲染,會執行useEffect 和 useState 的操作,需要放入 react-dom/test-utils 的act 方法內,並且還需要注意源代碼是同步還是異步執行,並且在 act 方法執行后,需要執行wrapper的 update 來更新wrapper。遇到這類問題不難解決,到React、Enzyme的Github上搜對應issue即可。

2. 測試中,Capture Value的特性也會存在,所以有些之前緩存的東西,並不是最新的:(。

當然類組件時代也有好處,就是能夠訪問instance,但對於函數組件來說,無法從函數外面訪問函數作用域內的東西。

 

八、總結

就像官方團隊的文章中寫道的一樣:“如果你太不能夠接受Hook,我們還是能夠理解的,但請你至少不要去噴它,可以適當宣傳一下。”。我們還是可以大膽嘗試一下Hook的,至少現在2019年年中的時候,因為在這個時間點,一切有關Hook的支持和文檔應該都比去年年底甚至是年初的時候更加完善了,雖然可能還不是太完全,但至少官方還在繼續摸索,社區也很活躍,造輪子的人也很多。之前也有消息說Vue3.0大版本也會出Hook,哈哈,又是一片腥風血雨。總之,風口來了,能折騰的、喜歡折騰的就跟着風吹唄。入門簡單,但完全、徹底地掌握和熟練運用,還是需要時間的。

 

【精選推薦文章】

自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

評比前十大台北網頁設計台北網站設計公司知名案例作品心得分享

台北網頁設計公司這麼多,該如何挑選?? 網頁設計報價省錢懶人包"嚨底家"

您可能也會喜歡…