源碼解析 React Hook 構建過程

2018 年的 React Conf 上 Dan Abramov 正式對外介紹了React Hook,這是一種讓函數組件支持狀態和其餘 React 特性的全新方式,並被官方解讀爲這是下一個 5 年 React 與時俱進的開端。從中細品,能夠窺見React Hook的重要性。今年 2 月 6 號,React Hook 新特性隨 React v16.8.0 版本正式發佈,整個上半年 React 社區都在積極努力地擁抱它,學習並解讀它。雖然官方聲明,React Hook還在快速的發展和更新迭代過程當中,不少Class Component支持的特性,React Hook還並未支持,但這絲絕不影響社區的學習熱情。html

React Hook上手很是簡單,使用起來也很容易,但相比咱們已經熟悉了 5 年的類組件寫法,React Hook仍是有一些理念和思想上的轉變。React 團隊也給出了使用 Hook 的一些規則和eslint 插件來輔助下降違背規則的機率,但規則並非僅僅讓咱們去記憶的,更重要的是要去真正理解設計這些規則的緣由和背景。前端

文章中的代碼不少只是僞代碼,重點在解讀設計思路,所以並不是完整的實現。不少鏈表的構建和更新邏輯也一併省略了,但並不影響你們瞭解整個 React Hook 的設計。事實上React Hook的大部分代碼都在適配React Fiber架構的理念,這也是源碼晦澀難懂的主要緣由。不過不要緊,咱們徹底能夠先屏蔽掉React Fiber的存在,去一點點構建純粹的 React Hook 架構。react

設計的背景和初衷

React Hook 的產生主要是爲了解決什麼問題呢?官方的文檔裏寫的很是清楚,這裏只作簡單的提煉,不作過多陳述,沒讀過文檔的同窗能夠先移步閱讀React Hook 簡介ajax

總結一下要解決的痛點問題就是:npm

  1. 在組件之間複用狀態邏輯很難
    • 以前的解決方案是:render props 和高階組件。
    • 缺點是難理解、存在過多的嵌套造成「嵌套地獄」。

  1. 複雜組件變的難以理解編程

    • 生命週期函數中充斥着各類狀態邏輯和反作用。
    • 這些反作用難以複用,且很零散。
  2. 難以理解的 Classredux

    • this 指針問題。數組

    • 組件預編譯技術(組件摺疊)會在 class 中遇到優化失效的 case。瀏覽器

    • class 不能很好的壓縮。緩存

    • class 在熱重載時會出現不穩定的狀況。

設計方案

React 官網有下面這樣一段話:

爲了解決這些問題,Hook 使你在 == 非 class 的狀況下可使用更多的 React 特性 ==。 從概念上講,React 組件一直更像是函數。而 Hook 則擁抱了函數,同時也沒有犧牲 React 的精神原則。Hook 提供了問題的解決方案,無需學習複雜的函數式或響應式編程技術

設計目標和原則

對應第一節所拋出的問題,React Hook 的設計目標即是要解決這些問題,總結起來就如下四點:

  • 無 Class 的複雜性

  • 無生命週期的困擾

  • 優雅地複用

  • 對齊 React Class 組件已經具有的能力

設計方案

無 Class 的複雜性(去 Class) React 16.8 發佈以前,按照是否擁有狀態的維護來劃分的話,組件的類型主要有兩種:

  1. 類組件 Class Component: 主要用於須要內部狀態,以及包含反作用的複雜的組件

    class App extends React.Component{
        constructor(props){
            super(props);
            this.state = {
                //...
            }
        }
        //...
    }
    複製代碼
  2. 函數組件 Function Component:主要用於純組件,不包含狀態,至關於一個模板函數

    function Footer(links){
        return (
            <footer>
                <ul>
                {links.map(({href, title})=>{
                    return <li><a href={href}>{title}</a></li>
                })}
                </ul>
            </footer>
        )
    }
    複製代碼

若是設計目標是 == 去 Class== 的話,彷佛選擇只能落在改造Function Component,讓函數組件擁有Class Component同樣的能力上了。

咱們不妨暢想一下最終的支持狀態的函數組件代碼:

// 計時器
function Counter(){
    let state = {count:0}
    
    function clickHandler(){
        setState({count: state.count+1})   
    }
    
    return (
        <div>
            <span>{count}</span>
            <button onClick={clickHandler}>increment</button>
        </div>
    )
}
複製代碼

上述代碼使用函數組件定義了一個計數器組件Counter,其中提供了狀態state,以及改變狀態的setState函數。這些 API 對於Class component來講無疑是很是熟悉的,但在Function component中卻面臨着不一樣的挑戰:

  1. class 實例能夠永久存儲實例的狀態,而函數不能,上述代碼中 Counter 每次執行,state 都會被從新賦值爲 0;
  1. 每個Class component的實例都擁有一個成員函數this.setState用以改變自身的狀態,而Function component只是一個函數,並不能擁有this.setState這種用法,只能經過全局的 setState 方法,或者其餘方法來實現對應。

以上兩個問題即是選擇改造Function component所須要解決的問題。

解決方案

在 JS 中,能夠存儲持久化狀態的無非幾種方法:

  • 類實例屬性

    class A(){
        constructor(){
            this.count = 0;
        }
        increment(){
            return this.count ++;
        }
    }
    const a = new A();
    a.increment();
    複製代碼
  • 全局變量

    const global = {count:0};
    
     function increment(){
         return global.count++;
     }
    複製代碼
  • DOM

    const count = 0;
      const $counter = $('#counter');
      $counter.data('count', count);
      
      funciton increment(){
          const newCount = parseInt($counter.data('count'), 10) + 1;
          $counter.data('count',newCount);
          return newCount;
      }
    複製代碼
  • 閉包

    const Counter = function(){
          let count = 0;
          return {
              increment: ()=>{
                  return count ++;
              }
          }
      }()
      
      Counter.increment();
    
    複製代碼
  • 其餘全局存儲:indexDB、LocalStorage 等等 Function component對狀態的訴求只是能存取,所以彷佛以上全部方案都是可行的。但做爲一個優秀的設計,還須要考慮到如下幾點:

    • 使用簡單

    • 性能高效

    • 可靠無反作用

方案 2 和 5 顯然不符合第三點;方案 3 不管從哪一方面都不會考慮;所以閉包就成爲了惟一的選擇了。

閉包的實現方案

既然是閉包,那麼在使用上就得有所變化,假設咱們預期提供一個名叫useState的函數,該函數可使用閉包來存取組件的 state,還能夠提供一個 dispatch 函數來更新 state,並經過初始調用時賦予一個初始值。

function Counter(){
    const [count, dispatch] = useState(0)
    
    return (
        <div>
            <span>{count}</span>
            <button onClick={dispatch(count+1)}>increment</button>
        </div>
    )
}
複製代碼

若是用過 redux 的話,這一幕必定很是眼熟。沒錯,這不就是一個微縮版的 redux 單向數據流嗎?

給定一個初始 state,而後經過 dispatch 一個 action,再經由 reducer 改變 state,再返回新的 state,觸發組件從新渲染。

知曉這些,useState的實現就一目瞭然了:

function useState(initialState){
    let state = initialState;
    function dispatch = (newState, action)=>{
        state = newState;
    }
    return [state, dispatch]
}

複製代碼

上面的代碼簡單明瞭,但顯然仍舊不知足要求。Function Component在初始化、或者狀態發生變動後都須要從新執行useState函數,而且還要保障每一次useState被執行時state的狀態是最新的。

很顯然,咱們須要一個新的數據結構來保存上一次的state和這一次的state,以即可以在初始化流程調用useState和更新流程調用useState能夠取到對應的正確值。這個數據結構能夠作以下設計,咱們假定這個數據結構叫 Hook:

type Hook = {
  memoizedState: any,   // 上一次完整更新以後的最終狀態值
  queue: UpdateQueue<any, any> | null, // 更新隊列
};

複製代碼

考慮到第一次組件mounting和後續的updating邏輯的差別,咱們定義兩個不一樣的useState函數的實現,分別叫作mountState和updateState。

function useState(initialState){
    if(isMounting){
        return mountState(initialState);
    }
    
    if(isUpdateing){
        return updateState(initialState);
    }
}

// 第一次調用組件的 useState 時實際調用的方法
function mountState(initialState){
    let hook = createNewHook();
    hook.memoizedState = initalState;
    return [hook.memoizedState, dispatchAction]
}

function dispatchAction(action){
    // 使用數據結構存儲全部的更新行爲,以便在 rerender 流程中計算最新的狀態值
    storeUpdateActions(action);
    // 執行 fiber 的渲染
    scheduleWork();
}

// 第一次以後每一次執行 useState 時實際調用的方法
function updateState(initialState){
    // 根據 dispatchAction 中存儲的更新行爲計算出新的狀態值,並返回給組件
    doReducerWork();
    
    return [hook.memoizedState, dispatchAction];
}   

function createNewHook(){
    return {
        memoizedState: null,
        baseUpdate: null
    }
}

複製代碼

上面的代碼基本上反映出咱們的設計思路,但還存在兩個核心的問題須要解決:

  1. 調用storeUpdateActions後將以什麼方式把此次更新行爲共享給doReducerWork進行最終狀態的計算。

  2. 同一個 state,在不一樣時間調用mountState和updateState時,如何實現hook對象的共享。

更新邏輯的共享

更新邏輯是一個抽象的描述,咱們首先須要根據實際的使用方式考慮清楚一次更新須要包含哪些必要的信息。實際上,在一次事件 handler 函數中,咱們徹底能夠屢次調用dispatchAction:

function Count(){
    const [count, setCount] = useState(0);
    const [countTime, setCountTime] = useState(null);
    
    function clickHandler(){
        // 調用屢次 dispatchAction
        setCount(1);
        setCount(2);
        setCount(3);
        //...
        setCountTime(Date.now())
    }
    
    return (
    <div>
        <div>{count} in {countTime}</div>
        <button onClick={clickHandler} >update counter</button>
    </div>
    )
}

複製代碼

在執行對setCount的 3 次調用中,咱們並不但願 Count 組件會所以被渲染 3 次,而是會按照調用順序實現最後調用的狀態生效。所以若是考慮上述使用場景的話,咱們須要同步執行完clickHandler中全部的dispatchAction後,並將其更新邏輯順序存儲,而後再觸發 Fiber 的 re-render 合併渲染。那麼屢次對同一個dispatchAction的調用,咱們如何來存儲這個邏輯呢?

比較簡單的方法就是使用一個隊列Queue來存儲每一次更新邏輯Update的基本信息:

type Queue{
    last: Update,   // 最後一次更新邏輯
    dispatch: any,
    lastRenderedState: any  // 最後一次渲染組件時的狀態
}

type Update{
    action: any,    // 狀態值
    next: Update    // 下一次 Update
}

複製代碼

這裏使用了單向鏈表結構來存儲更新隊列,有了這個數據結構以後,咱們再來改動一下代碼:

function mountState(initialState){
    let hook = createNewHook();
    hook.memoizedState = initalState;
    
    // 新建一個隊列
    const queue = (hook.queue = {
        last: null,
        dispatch: null,
        lastRenderedState:null
    });
    
    // 經過閉包的方式,實現隊列在不一樣函數中的共享。前提是每次用的 dispatch 函數是同一個
    const dispatch = dispatchAction.bind(null, queue);
    return [hook.memoizedState, dispatch]
}


function dispatchAction(queue, action){
    // 使用數據結構存儲全部的更新行爲,以便在 rerender 流程中計算最新的狀態值
    const update = {
        action,
        next: null
    }
    
    let last = queue.last;
    if(last === null){
        update.next = update;
    }else{
        // ... 更新循環鏈表
    }
    
    // 執行 fiber 的渲染
    scheduleWork();
}

function updateState(initialState){
    // 獲取當前正在工做中的 hook
    const hook = updateWorkInProgressHook();
    
    // 根據 dispatchAction 中存儲的更新行爲計算出新的狀態值,並返回給組件
    (function doReducerWork(){
        let newState = null;
        do{
            // 循環鏈表,執行每一次更新
        }while(...)
        hook.memoizedState = newState;
    })();
     
    return [hook.memoizedState, hook.queue.dispatch];
} 

複製代碼

到這一步,更新邏輯的共享,咱們就已經解決了。

Hook 對象的共享

Hook 對象是相對於組件存在的,因此要實現對象在組件內屢次渲染時的共享,只須要找到一個和組件全局惟一對應的全局存儲,用來存放全部的 Hook 對象便可。對於一個 React 組件而言,惟一對應的全局存儲天然就是 ReactNode,在React 16x 以後,這個對象應該是FiberNode。這裏爲了簡單起見,咱們暫時不研究 Fiber,咱們只須要知道一個組件在內存裏有一個惟一表示的對象便可,咱們姑且把他叫作fiberNode:

type FiberNode {
    memoizedState:any  // 用來存放某個組件內全部的 Hook 狀態
}

複製代碼

如今,擺在咱們面前的問題是,咱們對Function component的指望是什麼?咱們但願的是用Function component的useState來徹底模擬Class component的this.setState嗎?若是是,那咱們的設計原則會是:

一個函數組件全局只能調用一次 useState,並將全部的狀態存放在一個大 Object 裏

若是僅僅如此,那麼函數組件已經解決了去 Class的痛點,但咱們並無考慮優雅地複用狀態邏輯的訴求。

試想一個狀態複用的場景:咱們有多個組件須要監聽瀏覽器窗口的resize事件,以即可以實時地獲取clientWidth。在Class component裏,咱們要麼在全局管理這個反作用,並藉助 ContextAPI 來向子組件下發更新;要麼就得在用到該功能的組件中重複書寫這個邏輯。

resizeHandler(){
    this.setState({
        width: window.clientWidth,
        height: window.clientHeight
    });
}

componentDidMount(){
    window.addEventListener('resize', this.resizeHandler)
}

componentWillUnmount(){
    window.removeEventListener('resize', this.resizeHandler);
}


複製代碼

ContextAPI 的方法無疑是不推薦的,這會給維護帶來很大的麻煩;ctrl+c ctrl+v就更是無奈之舉了。

若是Function component能夠爲咱們帶來一種全新的狀態邏輯複用的能力,那無疑會爲前端開發在複用性和可維護性上帶來更大的想象空間。

所以理想的用法是:

const [firstName, setFirstName] = useState('James');
const [secondName, setSecondName] = useState('Bond');

// 其餘非 state 的 Hook,好比提供一種更靈活更優雅的方式來書寫反作用
useEffect()
複製代碼

綜上所述,設計上理應要考慮一個組件對應多個 Hook 的用法。帶來的挑戰是:

咱們須要在fiberNode上存儲全部 Hook 的狀態,並確保它們在每一次re-render時均可以獲取到最新的正確的狀態

要實現上述存儲目標,直接想到的方案就是用一個 hashMap 來搞定:

{
    '1': hook1,
    '2': hook2,
    //...
}
複製代碼

若是用這種方法來存儲,會須要爲每一次 hook 的調用生成惟一的 key 標識,這個 key 標識須要在 mount 和 update 時從參數中傳入以保證能路由到準確的 hook 對象。

除此方案以外,還可使用 hook.update 採用的單向鏈表結構來存儲,給 hook 結構增長一個 next 屬性便可實現:

type Hook = {
    memoizedState: any,                     // 上一次完整更新以後的最終狀態值
    queue: UpdateQueue<any, any> | null,    // 更新隊列
    next: any                               // 下一個 hook
}


const fiber = {
    //...
    memoizedState: {
        memoizedState: 'James', 
        queue: {
            last: {
                action: 'Smith'
            },  
            dispatch: dispatch,
            lastRenderedState: 'Smith'
        },
        next: {
            memoizedState: 'Bond',
            queue: {
                // ...
            },
            next: null
        }
    },
    //...
}
複製代碼

這種方案存在一個問題須要注意:

整個鏈表是在mount時構造的,因此在update時必需要保證執行順序才能夠路由到正確的 hook。

咱們來粗略對比一下這兩種方案的優缺點:

方案 優勢 缺點
hashMap 查找定位 hook 更加方便對 hook 的使用沒有太多規範和條件的限制 影響使用體驗,須要手動指定 key
鏈表 API 友好簡潔,不須要關注 key 須要有規範來約束使用,以確保能正確路由

很顯然,hashMap 的缺點是沒法忍受的,使用體驗和成本都過高了。而鏈表方案缺點中的規範是能夠經過 eslint 等工具來保障的。從這點考慮,鏈表方案無疑是勝出了,事實上這也正是React團隊的選擇。

到這裏,咱們能夠了解到爲何 React Hook 的規範裏要求:

只能在函數組件的頂部使用,不能再條件語句和循環裏使用

function Counter(){
    const [count, setCount] = useState(0);
    if(count >= 1){
        const [countTime, setCountTime] = useState(Date.now());
    }
}

// mount 階段構造的 hook 鏈爲
{
    memoizedState: {
        memoizedState: '0', 
        queue: {},
        next: null
}

// 調用 setCount(1) 以後的 update 階段,則會找不到對應的 hook 對象而出現異常

複製代碼

至此,咱們已經基本實現了 React Hooks 去 Class的設計目標,如今用函數組件,咱們也能夠經過useState這個 hook 實現狀態管理,而且支持在函數組件中調用屢次 hook。

無生命週期的困擾

上一節咱們藉助閉包、兩個單向鏈表(單次 hook 的 update 鏈表、組件的 hook 調用鏈表)、透傳 dispatch 函數實現了 React Hook 架構的核心邏輯:如何在函數組件中使用狀態。到目前爲止,咱們尚未討論任何關於生命週期的事情,這一部分也是咱們的設計要解決的重點問題。咱們常常會須要在組件渲染以前或者以後去作一些事情,譬如:

  • 在Class component的componentDidMount中發送ajax請求向服務器端拉取數據。

  • 在Class component的componentDidMount和componentDidUnmount中註冊和銷燬瀏覽器的事件監聽器。

這些場景,咱們一樣須要在 React Hook 中予以解決。React 爲Class component設計了一大堆生命週期函數:

  • 在實際的項目開發中用的比較頻繁的,譬如渲染後期的:componentDidMount、componentDidUpdate、componentWillUnmount;

  • 不多被使用的渲染前期鉤子componentWillMount、componentWillUpdate;

  • 一直以來被濫用且有爭議的componentWillReceiveProps和最新的getDerivedStateFromProps;

  • 用於性能優化的shouldComponentUpdate;

React 16.3 版本已經明確了將在 17 版本中廢棄componentWillMount、componentWillUpdate和componentWillReceiveProps這三個生命週期函數。設計用來取代componentWillReceiveProps的getDerivedStateFromProps也並不被推薦使用。

真正被重度使用的就是渲染後和用於性能優化的幾個,在 React hook 以前,咱們習慣於以 render 這種技術名詞來劃分組件的生命週期階段,根據名字componentDidMount咱們就能夠判斷如今組件的 DOM 已經在瀏覽器中渲染好了,能夠執行反作用了。這顯然是技術思惟,那麼在 React Hook 裏,咱們可否拋棄這種思惟方式,讓開發者無需去關注渲染這件事兒,只須要知道哪些是反作用,哪些是狀態,哪些須要緩存便可呢?

根據這個思路咱們來設計 React Hook 的生命週期解決方案,或許應該是場景化的樣子:

// 用來替代 constructor 初始化狀態
useState()

// 替代 componentDidMount 和 componentDidUpdate 以及 componentWillUnmount
// 統一稱爲處理反作用
useEffect()

// 替代 shouldComponent
useMemo()
複製代碼

這樣設計的好處是開發者再也不須要去理清每個生命週期函數的觸發時機,以及在裏面處理邏輯會有哪些影響。而是更關注去思考哪些是狀態,哪些是反作用,哪些是須要緩存的複雜計算和沒必要要的渲染。

useEffect

effect的全稱應該是Side Effect,中文名叫反作用,咱們在前端開發中常見的反作用有:

  • dom 操做

  • 瀏覽器事件綁定和取消綁定

  • 發送 HTTP 請求

  • 打印日誌

  • 訪問系統狀態

  • 執行 IO 變動操做

在 React Hook 以前,咱們常常會把這些反作用代碼寫在componentDidMount、componentDidUpdate和componentWillUnmount裏,好比

componentDidMount(){
    this.fetchData(this.props.userId).then(data=>{
        //... setState
    })
    
    window.addEventListener('resize', this.onWindowResize);
    
    this.counterTimer = setInterval(this.doCount, 1000);
}

componentDidUpdate(prevProps){
   if (this.props.userID !== prevProps.userID) {
    this.fetchData(this.props.userID);
  }
}

componentWillUnmount(){
    window.removeEventListener('resize', this.onWindowResize);
    clearInterval(this.counterTimer);
}
複製代碼

這種寫法存在一些體驗的問題:

  1. 同一個反作用的建立和清理邏輯分散在多個不一樣的地方,這不管是對於新編寫代碼仍是要閱讀維護代碼來講都不是一個上佳的體驗。

  2. 有些反作用可能要再多個地方寫多份。

第一個問題,咱們能夠經過 thunk 來解決:將清理操做和新建操做放在一個函數中,清理操做做爲一個 thunk 函數被返回,這樣咱們只要在實現上保障每次 effect 函數執行以前都會先執行這個 thunk 函數便可:

useEffect(()=>{
    // do some effect work
    return ()=>{
        // clean the effect
    }
})
複製代碼

第二個問題,對於函數組件而言,則再簡單不過了,咱們徹底能夠把部分通用的反作用抽離出來造成一個新的函數,這個函數能夠被更多的組件複用。

function useWindowSizeEffect(){
    const [size, setSize] = useState({width: null, height: null});
    
    function updateSize(){
        setSize({width: window.innerWidth, height: window.innerHeight});
    }
    
    useEffect(()=>{
        window.addEventListener('resize', updateSize);
        
        return ()=>{
            window.removeEventListener('resize', updateSize);
        }
    })
    
    return size;
}

複製代碼

useEffect 的執行時機 既然是設計用來解決反作用的問題,那麼最合適的時機就是組件已經被渲染到真實的 DOM 節點以後。由於只有這樣,才能保證全部反作用操做中所須要的資源(dom 資源、系統資源等)是 ready 的。

上面的例子中描述了一個在 mount 和 update 階段都須要執行相同反作用操做的場景,這樣的場景是廣泛的,咱們不能假定只有在 mount 時執行一次反作用操做就能知足全部的業務邏輯訴求。因此在 update 階段,useEffect 仍然要從新執行才能保證知足要求。

這就是 useEffect 的真實機制:

Function Component函數(useState、useEffect、…)每一次調用,其內部的全部 hook 函數都會再次被調用。

這種機制帶來了一個顯著的問題,就是:

父組件的任何更新都會致使子組件內 Effect 邏輯從新執行,若是 effect 內部存在性能開銷較大的邏輯時,可能會對性能和體驗形成顯著的影響。

React 在PureComponent和底層實現上都有過相似的優化,只要依賴的 state 或者 props 沒有發生變化(淺比較),就不執行渲染,以此來達到性能優化的目的。useEffect一樣能夠借鑑這個思想:

useEffect(effectCreator: Function, deps: Array)

// demo
const [firstName, setFirstName] = useState('James');
const [count, setCount] = useState(0);

useEffect(()=>{
    document.title = `${firstName}'s Blog`; }, [firstName]) 複製代碼

上面的例子中,只要傳入的firstName在先後兩次更新中沒有發生變化,effectCreator函數就不會執行。也就是說,即使調用屢次setCount(*),組件會重複渲染屢次,但只要 firstName 沒有發生變化,effectCreator函數就不會重複執行。

useEffect 的實現

useEffect 的實現和 useState 基本類似,在mount時建立一個 hook 對象,新建一個 effectQueue,以單向鏈表的方式存儲每個 effect,將 effectQueue 綁定在 fiberNode 上,並在完成渲染以後依次執行該隊列中存儲的 effect 函數。核心的數據結構設計以下:

type Effect{
    tag: any,           // 用來標識 effect 的類型,
    create: any,        // 反作用函數
    destroy: any,       // 取消反作用的函數,
    deps: Array,        // 依賴
    next: Effect,       // 循環鏈表指針
}

type EffectQueue{
    lastEffect: Effect
}

type FiberNode{
    memoizedState:any  // 用來存放某個組件內全部的 Hook 狀態
    updateQueue: any  
}
複製代碼

deps 參數的優化邏輯就很簡單了:

let componentUpdateQueue = null;
function pushEffect(tag, create, deps){
    // 構建更新隊列
    // ...
}

function useEffect(create, deps){
    if(isMount)(
        mountEffect(create, deps)
    )else{
        updateEffect(create, deps)
    }
}

function mountEffect(create, deps){
    const hook = createHook();
    hook.memoizedState = pushEffect(xxxTag, create, deps);
    
}

function updateEffect(create, deps){
    const hook = getHook();
    if(currentHook!==null){
        const prevEffect = currentHook.memoizedState;
        if(deps!==null){
            if(areHookInputsEqual(deps, prevEffect.deps)){
                pushEffect(xxxTag, create, deps);
                return;
            }
        }
    }
    
    hook.memoizedState = pushEffect(xxxTag, create, deps);
}
複製代碼

useEffect 小結

  • 執行時機至關於componentDidMount和componentDidUpdate,有 return 就至關於加了componentWillUnmount。

  • 主要用來解決代碼中的反作用,提供了更優雅的寫法。

  • 多個 effect 經過一個單向循環鏈表來存儲,執行順序是按照書寫順序依次執行。

  • deps 參數是經過循環淺比較的方式來判斷和上一次依賴值是否徹底相同,若是有一個不一樣,就從新執行一遍 Effect,若是相同,就跳過本次 Effect 的執行。

  • 每一次組件渲染,都會完整地執行一遍清除、建立 effect。若是有 return 一個清除函數的話。

  • 清除函數會在建立函數以前執行。

useMemo

在useEffect中咱們使用了一個deps參數來聲明 effect 函數對變量的依賴,而後經過areHookInputsEqual函數來比對先後兩次的組件渲染時deps的差別,若是淺比較的結果是相同,那麼就跳過 effect 函數的執行。

仔細想一想,這不就是生命週期函數shouldComponentUpdate要作的事情嗎?何不將該邏輯抽取出來,做爲一個通用的 hook 呢,這就是useMemo這個 hook 的原理。

function mountMemo(nextCreate,deps) {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

function updateMemo(nextCreate,deps){
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  // 上一次的緩存結果
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}
複製代碼

但 useMemo 和shouldComponentUpdate的區別在於 useMemo 只是一個通用的無反作用的緩存 Hook,並不會影響組件的渲染與否。因此從這點上講,useMemo 並不能替代shouldComponentUpdate,但這絲絕不影響 useMemo 的價值。useMemo 爲咱們提供了一種通用的性能優化方法,對於一些耗性能的計算,咱們能夠用 useMemo 來緩存計算結果,只要依賴的參數沒有發生變化,就達到了性能優化的目的。

const result = useMemo(()=>{
    return doSomeExpensiveWork(a,b);
}, [a,b])
複製代碼

那麼要完整實現shouldComponentUpdate的效果應該怎麼辦呢?答案是藉助React.memo:

const Button = React.memo((props) => {
  // 你的組件
});
複製代碼

這至關於使用了 PureComponent。

到目前爲止,除了getDerivedStateFromProps,其餘經常使用的生命週期方法在 React Hook 中都已經有對應的解決方案了,componentDidCatch官方已經聲明正在實現中。這一節的最後,咱們再來看看getDerivedStateFromProps的替代方案。

這個生命週期的做用是根據父組件傳入的 props,按需更新到組件的 state 中。雖然不多會用到,但在 React Hook 組件中,仍然能夠經過在渲染時調用一次"setState"來實現:

function ScrollView({row}) {
  let [isScrollingDown, setIsScrollingDown] = useState(false);
  let [prevRow, setPrevRow] = useState(null);

  if (row !== prevRow) {
    // Row 自上次渲染以來發生過改變。更新 isScrollingDown。
    setIsScrollingDown(prevRow !== null && row > prevRow);
    setPrevRow(row);
  }

  return `Scrolling down: ${isScrollingDown}`;
}
複製代碼

若是在渲染過程當中調用了 "setState",組件會取消本次渲染,直接進入下一次渲染。因此這裏必定要注意 "setState" 必定要放在條件語句中執行,不然會形成死循環。

React 中的代碼複用

使用過早期版本 React 的同窗可能知道Mixins API,這是官方提供的一種比組件更細粒度的邏輯複用能力。在 React 推出基於 ES6 的Class Component的寫法後,就被逐漸’拋棄’了。Mixins雖然能夠很是方便靈活地解決AOP類的問題,譬如組件的性能日誌監控邏輯的複用:

const logMixin = {
    componentWillMount: function(){
        console.log('before mount:', Date.now());
    }
    
    componentDidMount: function(){
        console.log('after mount:', Date.now())
    }
}

var createReactClass = require('create-react-class');
const CompA = createReactClass({
    mixins: [logMixin],
    render: function(){
        //... 
    }
})

const CompB = createReactClass({
    mixins: [logMixin],
    render: function(){
        //... 
    }
})
複製代碼

但這種模式自己會帶來不少的危害,具體能夠參考官方的一片博文:《Mixins Considered Harmful》

React 官方在 2016 年建議擁抱HOC,也就是使用高階組件的方式來替代mixins的寫法。minxins API 僅能夠在create-react-class手動建立組件時才能使用。這基本上宣告了 mixins 這種邏輯複用的方式的終結。

HOC很是強大,React 生態中大量的組件和庫都使用了HOC,好比react-redux的connect API:

class MyComp extends Component{
    //...
}
export default connect(MyComp, //...)

複製代碼

用HOC實現上面的性能日誌打印,代碼以下:

function WithOptimizeLog(Comp){
    return class extends Component{
        constructor(props){
            super(props);
           
        }
        
        componentWillMount(){
            console.log('before mount:', Date.now());
        }
        
        componentDidMount(){
            console.log('after mount:', Date.now());
        }
        
        render(){
            return (
                <div>
                    <Comp {...props} />
                </div>
            )
        }
    }
} 

// CompA
export default WithOptimizeLog(CompA)

//CompB
export defaultWithOptimizeLog(CompB);

複製代碼

HOC雖然強大,但因其自己就是一個組件,僅僅是經過封裝了目標組件提供一些上層能力,所以難以免的會帶來嵌套地獄的問題。而且由於HOC是一種將可複用邏輯封裝在一個 React 組件內部的高階思惟模式,因此和普通的React組件相比,它就像是一個魔法盒子同樣,勢必會更難以閱讀和理解。

能夠確定的是HOC模式是一種被普遍承認的邏輯複用模式,而且在將來很長的一段時間內,這種模式仍將被普遍使用。但隨着React Hook架構的推出,HOC模式是否仍然適合用在Function Component中?仍是要尋找一種新的組件複用模式來替代HOC呢?

React 官方團隊給出的答案是後者,緣由是在React Hook的設計方案中,藉助函數式狀態管理以及其餘 Hook 能力,邏輯複用的粒度能夠實現的更細、更輕量、更天然和直觀。畢竟在 Hook 的世界裏一切都是函數,而非組件。

來看一個例子:

export default function Article() {
    const [isLoading, setIsLoading] = useState(false);
    const [content, setContent] = useState('origin content');
    
    function handleClick() {
        setIsLoading(true);
        loadPaper().then(content=>{
            setIsLoading(false);
            setContent(content);
        })
    }

    return (
        <div>
            <button onClick={handleClick} disabled={isLoading} >
                {isLoading ? 'loading...' : 'refresh'}
            </button>
            <article>{content}</article>
        </div>
    )
}
複製代碼

上面的代碼中展現了一個帶有 loading 狀態,能夠避免在加載結束以前反覆點擊的按鈕。這種組件能夠有效地給予用戶反饋,而且避免用戶因爲得不到有效反饋帶來的不斷嘗試形成的性能和邏輯問題。

很顯然,loadingButton 的邏輯是很是通用且與業務邏輯無關的,所以徹底能夠將其抽離出來成爲一個獨立的LoadingButton組件:

function LoadingButton(props){
    const [isLoading, setIsLoading] = useState(false);
    
    function handleClick(){
        props.onClick().finally(()=>{
            setIsLoading(false);
        });    
    }
    
    return (
        <button onClick={handleClick} disabled={isLoading} >
            {isLoading ? 'loading...' : 'refresh'}
        </button>
    )
}

// 使用
function Article(){
    const {content, setContent} = useState('');
    
    clickHandler(){
       return fetchArticle().then(data=>{
           setContent(data);
       })
    }
    
    return (
        <div>
            <LoadingButton onClick={this.clickHandler} />
            <article>{content}</article>
        </div>
    )
}
複製代碼

上面這種將某一個通用的 UI 組件單獨封裝並提取到一個獨立的組件中的作法在實際業務開發中很是廣泛,這種抽象方式同時將狀態邏輯和 UI 組件打包成一個可複用的總體。

很顯然,這仍舊是組件複用思惟,並非邏輯複用思惟。試想一下另外一種場景,在點擊了 loadingButton 以後,但願文章的正文也一樣展現一個 loading 狀態該怎麼處理呢?

若是不對 loadingButton 進行抽象的話,天然能夠很是方便地複用 isLoading 狀態,代碼會是這樣:

export default function Article() {
    const [isLoading, setIsLoading] = useState(false);
    const [content, setContent] = useState('origin content');
    
    function handleClick() {
        setIsLoading(true);
        loadArticle().then(content=>{
            setIsLoading(false);
            setContent(content);
        })
    }

    return (
        <div>
            <button onClick={handleClick} disabled={isLoading} >
                {isLoading ? 'loading...' : 'refresh'}
            </button>
            {
                isLoading
                    ? <img src={spinner}  alt="loading" />
                    : <article>{content}</article>
            }
        </div>
    )
}
複製代碼

但針對抽象出 LoadingButton 的版本會是什麼樣的情況呢?

function LoadingButton(props){
    const [isLoading, setIsLoading] = useState(false);
    
    function handleClick(){
        props.onClick().finally(()=>{
            setIsLoading(false);
        });    
    }
    
    return (
        <button onClick={handleClick} disabled={isLoading} >
            {isLoading ? 'loading...' : 'refresh'}
        </button>
    )
}

// 使用
function Article(){
    const {content, setContent} = useState('origin content');
    const {isLoading, setIsLoading} = useState(false);
    
    clickHandler(){
       setIsLoading(true);
       return fetchArticle().then(data=>{
           setContent(data);
           setIsLoading(false);
       })
    }
    
    return (
        <div>
            <LoadingButton onClick={this.clickHandler} />
            {
                isLoading
                    ? <img src={spinner}  alt="loading" />
                    : <article>{content}</article>
            }
        </div>
    )
}
複製代碼

問題並無由於抽象而變的更簡單,父組件 Article 仍然要自定一個 isLoading 狀態才能夠實現上述需求,這顯然不夠優雅。那麼問題的關鍵是什麼呢?

答案是耦合。上述的抽象方案將isLoading狀態和button標籤耦合在一個組件裏了,這種複用的粒度只能總體複用這個組件,而不能單獨複用一個狀態。解決方案是:

// 提供 loading 狀態的抽象
export function useIsLoading(initialValue, callback) {
    const [isLoading, setIsLoading] = useState(initialValue);

    function onLoadingChange() {
        setIsLoading(true);

        callback && callback().finally(() => {
            setIsLoading(false);
        })
    }

    return {
        value: isLoading,
        disabled: isLoading,
        onChange: onLoadingChange, // 適配其餘組件
        onClick: onLoadingChange,  // 適配按鈕
    }
}

export default function Article() {
    const loading = useIsLoading(false, fetch);
    const [content, setContent] = useState('origin content');

    function fetch() {
       return loadArticle().then(setContent);
    }

    return (
        <div>
            <button {...loading}>
                {loading.value ? 'loading...' : 'refresh'}
            </button>
           
            {
                loading.value ? 
                    <img src={spinner} alt="loading" />
                    : <article>{content}</article>
            }
        </div>
    )
}
複製代碼

如此便實現了更細粒度的狀態邏輯複用,在此基礎上,還能夠根據實際狀況,決定是否要進一步封裝 UI 組件。譬如,仍然能夠封裝一個 LoadingButton:

// 封裝按鈕
function LoadingButton(props){
    const {value, defaultText = '肯定', loadingText='加載中...'} = props;
    return (
        <button {...props}>
            {value ? loadingText: defaultText}
        </button>
    )
}

// 封裝 loading 動畫
function LoadingSpinner(props) {
    return (
        < >
            { props.value && <img src={spinner} className="spinner" alt="loading" /> }
        </>
    )
}
// 使用

return (
    <div>
        <LoadingButton {...loading} />
        <LoadingSpinner {...loading}/>
        { loading.value || <article>{content}</article> }
    </div>
)
複製代碼

狀態邏輯層面的複用爲組件複用帶來了一種全新的能力,這徹底有賴於React Hook基於Function的組件設計,一切皆爲函數調用。而且,Function Component也並不排斥HOC,你仍然可使用熟悉的方法來提供更高階的能力,只是如今,你的手中擁有了另一種武器。

對齊 React Class 組件已經具有的能力

在本文撰寫的時間點上,仍然有一些Class Component具有的功能是React Hook沒有具有的,譬如:生命週期函數componentDidCatch,getSnapshotBeforeUpdate。還有一些第三方庫可能還沒法兼容 hook,官方給出的說法是:

咱們會盡快補齊

將來可期,咱們只需靜靜地等待。

參考文檔

相關文章
相關標籤/搜索