React Hooks實踐體會

1、前言react

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

 

2、怎麼替代以前的生命週期方法?數組

這個問題在筆者粗略地過了一遍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上多幾回小範圍的無心義的重渲染對性能影響不是很大,但在移動端的影響就很大,因此得看時機狀況來決定。app

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

 

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

先說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,這樣咱們就有了本身的種子項目生成器,當開新項目的時候,能夠進行快速的初始化。

 

4、性能優化

在類組件中,咱們給一個點擊事件指定一個事件的回調函數,而且指望在回調函數中訪問到該組件實例,一般採用如下作法:

export default class App extends React {
    constructor (){
        this.onClick = this.onClick.bind(this);
    }

    onClick (){
        console.log('點擊了按鈕');
    }

    render (){
        return <div>
            <button onClick={this.onClick}>點擊</button>
        </div>;  
    }  
}  

咱們不在render方法中button組件的 onClick 事件上直接寫箭頭函數的或者進行 bind 操做的緣由是:這兩種方式會在 render 方法每次執行的時候都執行一次,要不就是建立一個新的箭頭函數或者從新執行一次 bind 方法。但回調函數的內容卻從未改變過,所以這些重複的執行均爲非必要的,上嚴格上來說,存在有性能上的沒必要要的損耗。鑑於 constructor 只會執行一次,因此把 bind 操做放置於此是十分正確的處理方式。

對於上述例子,使用Hook方式應該如此:

export default function App (){
    const onClick = useCallback(() => {
        console.log('點擊了按鈕');
    }, []);    

    return <>
        <button onClick={onClick}>點擊</button> 
    </>;  
}

若是不用useCallback在每次App重渲染(調用)時, onClick 方法都會被從新建立一次。若是方法內部有依賴,能夠將依賴寫入 useCallback 的第二個參數的數組中,僅當依賴改變後, onClick  

方法纔會被從新建立一次。若是存在有依賴,必定不要忘記依賴,不然這個方法在組件初始化調用之後永遠都不會被改變。

對於一些組件內部永遠都不會改變,或者僅依賴於某些值而改變的值,可使用 useMemo 進行優化:

export default function App ({name, age}){
    const name = useMemo(() => <span>name</span>, [name]);
   
    const age = useMemo(() => <span>age</span>, [age]);

    return <>
        我叫{name},今年{age}歲
    </>;
}

若是一個值不可能改變,那麼則不須要爲期設置具體依賴,傳入一個空數組便可。

這樣處理後,能夠減小重渲染時必需要的工做,也能夠避免一個不須要改變的值在組件函數在每次調用時,都被從新建立的問題。

對於類組件中使用 shouldComponentUpdate 進行優化的地方,可使用 React.memo 包裹整個組件,對 props 進行淺比較來判斷。

 

針對嚴格意義上的極致性能優化,筆者有個體會就是:若要對每個函數組件內的方法或值進行 useCallback 、 useMemo 等操做來進行緩存優化,會出現不少模板式的代碼,彷佛又回到了被模板代碼支配的時代。是否嚴格執行這種代碼書寫約束,仍是要取決於應用的複雜程度和須要適配的機器,若是是僅須要支持PC端並且界面簡單的話,從實踐來看,一些模板代碼是能夠捨棄的,捨棄後也不會形成性能上的問題(用開發者工具Performance測試後的結果)。這一點就像在類組件時代,PC端的項目連 shouldComponentUpdate 都不須要判斷,依然能有一個不錯的性能同樣(是想通過了xx ms的判斷了,最後獲得的結果是依舊須要更新,那麼這就很扯淡的)。何況從應用和某個頁面的設計來說,每一次的更新基本都須要重繪界面,那麼確實沒有太大的必要去執行 shouldComponentUpdate 這個生命週期。但在移動端爲了低端機器的性能就必須判斷了,由於DOM的消耗至關於運行JS代碼來講實在是過高。總結就是:一切得根據實際的渲染結果來決定,不要過早的進行性能優化,不然不只沒有意義,還會拔苗助長(淺比較也有成本消耗)。

 

對於怎麼實現其餘類組件中的功能,好比 ref 、怎麼調用子函數組件內部的一個方法等等之類的問題,在官方Hook文檔中都有詳細的描述,這裏就再也不作過多講解了。

 

5、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帶來的限制。

 

6、Hook的優點與"坑"

在Hook的官方文檔和一些文章中也提到了類組件的一些很差的地方,好比:HOC的多層嵌套,HOC和Render Props也不是太理想的複用代碼邏輯,有關狀態管理的邏輯代碼很難在組件之間複用、一個業務邏輯的實現代碼被放到了不一樣的生命週期內、ES2015與類有關語法和this指向等困擾初級開發者的問題等都有提到,若是組件時間過多,在構造函數內經過 bind 進行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的方式來抽象邏輯有助於代碼的組織質量。

說了優點就來講說筆者認爲最容易掉進去的"坑":

1. 對於 useEffect 的使用,在當內部邏輯代碼存在有獲取數據或修改涉及到該hook的依賴的時候,必定要小心,譬如你在該hook內部的操做可能會觸發重渲染並會改變該hook的某個依賴的值,就會致使死循環的出現,切記要在hook內部加上條件判斷來避免死循環的出現。若是某個界面出現明顯的卡頓和動畫的掉幀等性能問題,那麼極可能是這個緣由致使的。能夠直接在函數組件內部打log或者使用performance工具進行檢測。

2. 使用hook後,代碼歸類不會像以前class組件時代的同樣有語法的強制規劃了,什麼意思呢?在class組件時代,redux的有關的代碼是放到connect裏的,state生命是放constructor裏的,其餘邏輯是放每一個有關的生命週期裏的。而在hook的時代,沒有這些東西了,一切都直接放在函數組件內部,若是寫得混亂,看起來就是一鍋粥,因此,制定組件的書寫規範和經過註釋來歸類不一樣功能的邏輯顯得尤其重要。這有助於後期的維護,也有助於一個團隊的書寫風格的一致性。

 

7、爲啥會推進Hook

筆者認爲上個段落中提到的函數式組件配合Hook相較於類組件配合生命週期方法是存在有必定優點的。再者,React團隊最開始發佈Hook的時候,應該是頂着壓力的,由於這對於開發者來講意味着之前的白學了,上層API所有變完。筆者最開始瞭解Hook後,最直接感覺就是這東西是否是在給後面的Async Render填坑用的,爲啥會這麼說呢?由於React的這種更新機制就是所有樹作Diff而後更新patch(一旦組件樹異常複雜,Dff過程當中主線程將被持續佔用形成阻塞)。而Vue是依賴收集方式的,數據變化後,哪些地方須要更新是明確的,因此更新是相對精準和少許的。React的這種設計機制,就致使更新的成本很高,即使有虛擬樹,可是一旦應用很龐大之後,遍歷新舊虛擬樹作Diff也是很耗時的,而且沒有Async Render前,一旦開啓協調的過程,就只能一條路走到底,咱們又不能在代碼層面上控制JS引擎的函數調用棧,在主線程長時間運行腳本又不歸還控制權,會阻塞線程而形成界面友好度降低,特別是當應用運行在移動端設備等性能不太強的計算機上時效果特別顯著。而基於Fiber的鏈表式樹結構能夠模擬出函數調用棧,並可以由代碼控制工做的開始和暫停,能夠有效解決上述問題,但它會破壞本來完整的生命週期方式,由於一個協調任務的執行,可能會放在不一樣的線程空閒時間內去完成,進而致使一個生命週期可能會被調用屢次,致使實際運行的結果並不像代碼書寫的那樣,這也是在16.3及之後版本將某些生命週期重命名爲unsafe的緣由。生命週期基本廢掉了,雖而後續更新的小版本(16.三、16.4)引入了一些靜態方法用來解決一些問題,但存在感過低了,基本都屬於過分階段的產物,筆者也沒有選擇升級到這些版本,而是直接從16.2跳躍至16.9的。既然生命週期廢了,就須要有東西來替代,並支持Async Render的實現,Hook這種模式就是一個不錯的選擇。固然筆者的這些說法可能並不全面,或者說的不絕對正確,但筆者認爲這些緣由或多或少是存在的。

 

8、單元測試

筆者目前的項目對穩定性要求高,屬於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,但對於函數組件來講,沒法從函數外面訪問函數做用域內的東西(useImperativeHandle除外)。

 

9、Vue的Hook

尤大對於Vue的hook和React的hook的總結以下:

1. 總體上更符合JavaScript的直覺;

2. 不受調用順序的限制,能夠有條件地被調用;

3. 不會在後續更新時不斷產生大量的內聯函數而影響引擎優化或是致使GC壓力;

4. 不須要老是使用 useCallback 來緩存傳給子組件的回調以防止過分更新;

5. 不須要擔憂傳了錯誤的依賴數組給useEffect/useMemo/useCallback從而致使回調中使用了過時的值 —— Vue 的依賴追蹤是全自動的。

結合以上段落自行體會吧。

 

10、總結

就像官方團隊的文章中寫道的同樣:「若是你太不可以接受Hook,咱們仍是可以理解的,但請你至少不要去噴它,能夠適當宣傳一下。」。咱們仍是能夠大膽嘗試一下Hook的,至少如今2019年年中的時候,由於在這個時間點,一切有關Hook的支持和文檔應該都比去年年末甚至是年初的時候更加完善了,雖然可能還不是太徹底,但至少官方還在繼續摸索,社區也很活躍,造輪子的人也不少。以前也有消息說Vue3.0大版本也會出Hook(某文章的標題:Vue最黑暗的一天=.=),哈哈,各大論壇有支持的,有反對的,又是一片腥風血雨。對於有開發經驗的人來講入門還算簡單,但完全地掌握這種思想方式和正確地、高水平地運用並總結一套最佳實踐的編碼方式,仍是須要時間和項目實踐的。但對於新人來講,無疑提升了入門的門檻,而且很難解釋清楚爲啥放着好理解的生命週期方式不用,而採用晦澀的函數式方式,因此,對於新人來講,仍是建議先嚐試16.2版本。

相關文章
相關標籤/搜索