當談論 React hook,我們究竟説的是什麼?

語言: CN / TW / HK

這個標題很大,但是落點很小,只是我,一個開發者在學習和使用 hooks 中的一點感受和總結。

React hook 的由來

React hook 的由來,其實也可以看作是前端技術不斷演進的結果。

在 world wide web 剛剛誕生的洪荒時代,還沒有 js,Web 頁面也都是靜態的,更沒有所謂的前端工程師,頁面的內容與更新完全由後端生成。這就使得頁面的任意一點更新,都要刷新頁面由後端重新生成,體驗非常糟糕。隨後就有了 Brendan 十天創世、網景微軟瀏覽器之爭、HTML 的改進、W3C 小組的建立等等。

後來 ajax 技術被逐漸重視,頁面按鈕提交/獲取信息終於不用再刷新頁面了,交互體驗升級。隨後又迎來了 jquery 的時代,可以方便地操作 DOM 和實現各種效果,大大降低了前端門檻。前端隊伍的壯大,也催生了越發複雜的交互,Web page 也逐漸向着 Web App 的方向進化。

jquery 可以將一大段的 HTML 結構的字符串通過 $.html、$.append、$.before 的方式插入到頁面上,雖然可以幫助我們以更舒服的方式來操作 DOM,但不能從根本上解決當 DOM 操作量過多時的前端側壓力大的問題。

隨着頁面內容和交互越來越複雜,如何解決這些繁瑣、巨量的 DOM 操作帶來的前端側壓力的問題,並能夠把各個 HTML 分散到不同的文件中,然後根據實際情況渲染出相應的內容呢?

這時候的前端們借鑑了後端技術,歸納出一個公式:html = template(data),也就帶來了模板引擎方案。模板引擎方案傾向於點對點地解決繁瑣 DOM 操作問題,它並沒有也不打算替換掉 jquery,兩者是共存的。

隨後陸續誕生了不少模板引擎,像 handlebars、Mustache 等等。無論是選用了哪種模板引擎,都離不開 html = template(data) 的模式,模板引擎的本質都是簡化了拼接字符串的過程,通過類 HTML 的語法快速搭建起各種頁面結構,通過變更數據源 data 來對同一個模板渲染出不同的效果。

這成就了模板引擎,但也限制住了它,它立足於「實現高效的字符串拼接」,但也侷限於此,你不能指望模板引擎去做太複雜的事情。早期的模板引擎在性能上也不如人意,由於不夠智能,它更新 DOM 的方式是將已經渲染好的 DOM 註銷,然後重新渲染,如果操作 DOM 頻繁,體驗和性能都會有問題。

雖然模板引擎有其侷限性,但是 html=template(data) 的模式還是很有啟發性,一批前端先驅也許是從中汲取了靈感,決定要繼續在 「數據驅動視圖」的方向上深入摸索。模板引擎的問題在於對真實 DOM 的修改過於粗暴,導致了 DOM 操作的範圍太大,進而影響了性能。

既然真實的 DOM 性能耗費太大了,那操作假的 DOM 好了。既然修改的範圍太大,那每次修改的範圍變小。

於是,模板引擎方案中的本來是“數據+模板”形成真實 DOM 的過程中加入了虛擬 DOM 這一層。

注意上圖, 右側的“模板”是打引號的,因為這裏不一定是真的模板,起到類似模板的作用即可。比如 JSX 並不是模板,只是一種類似模板語法的 js 拓展,它擁有完全的 js 能力。加入了虛擬 DOM 這一層後,能做的事情就很多了,首先是可以 diff,也可以實現同一套代碼跨平台了。

現在假的 DOM 有了,通過 diff(找不同)+ patch(使一致)的協調過程,也可以一種較為精確地方式修改 DOM 了。我們來到了 15.x 版本的 React。

我們這時候有了 class 組件,有了函數式組件,但是為什麼還需要加入 hook 呢?

官方的説法是這樣的:

  • 在組件之間複用狀態邏輯很難
  • 複雜組件變得難以理解
  • 難以理解的 class

誠然這些都是 class 的痛點所在,我們在使用 class 編寫一個簡單組件會遇到哪些問題:

  • 難以琢磨的 this
  • 關聯的邏輯被拆分
  • 熟練記憶眾多的生命週期,在合適的生命週期裏做適當的事情
  • 代碼量相對更多,尤其是寫簡單組件時
class FriendStatus extends React.Component {
  constructor(props) {
    super(props);
    this.state = { isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this); // 要手動綁定this
  }

  componentDidMount() {
    ChatAPI.subscribeToFriendStatus( // 訂閲和取消訂閲邏輯的分散
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentWillUnmount() { // 要熟練記憶並使用各種生命週期,在適當的生命週期裏做適當的事情
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }

  render() {
    if (this.state.isOnline === null) {
      return 'Loading...';
    }
    return this.state.isOnline ? 'Online' : 'Offline';
  }
}

React 的理念表達 UI = f(data) 中, React 組件的定位也更像是函數,像上面的示例其實就是 UI = render(state),關鍵的是 render 方法,其餘的都是些通過 this 傳參和一些外圍支持函數。

針對上述問題,React 決定給函數組件帶來新的能力。

過去的函數組件都是無狀態組件,它們只能被動從外部接受數據。我希望組件在更新後仍舊保留我上次輸入的值或者選中與否的狀態,這些值或者狀態最終會反應到宿主實例上(對瀏覽器來説就是 DOM)展示給用户,這是太常見的需求了。為了實現這種維護自身局部狀態的功能,React 給函數組件綁定了一個局部狀態。

這種綁定局部狀態的方式,就是 React hook useState。

React 為了讓函數能夠更好地構建組件,還使用了很多特性來增強函數,綁定局部狀態就是這些增強函數的特性之一。

所謂 React hook,就是這些增強函數組件能力特性的鈎子,把這些特性「鈎」進純函數。 純函數組件可以通過useState獲得綁定局部狀態的能力,通過useEffect來獲得頁面更新後執行副作用的能力,甚至通過你的自定義 hook useCounter 來獲得加、減數值、設置數值、重置計數器等一整套管理計數器的邏輯。這些都是 hook 所賦予的能力。

React 官方説沒有計劃將 Class 從 React 中移除,但現在重心在增強函數式組件上。作為開發者的我們,只要還在使用 React,就無法完全拒絕 hooks。

雖然 hooks 並不完美,也有很多人吐槽,我們嘗試去擁抱它吧。

React hook 的實現

前面我們提到了,React hook 是有益於構建 UI 的一系列特性,是用來增強函數式組件的。更具體的來説,hook 是一類特殊的函數。那麼這類增強普通無狀態組件的特殊函數究竟是何方神聖?我們既然要探尋 hook,就繞不開它的實現原理。

我們拿 useState 為例,它是怎麼做到在一次次函數組件被執行時,保持住過去的 state 呢?

雖然源碼是複雜的,但原理可能是簡單的。簡單地説,之所以能保持住 state,是在一個函數組件之外的地方,保存了一個「對象」,這個對象裏記錄了之前的狀態。

那究竟如何實現?我們從一段簡略版的useState的實現裏來一窺究竟。

首先我們是這麼用 useState 的:

function App () {
  const [num, setNum] = useState(0);
  const [age, setAge] = useState(18);
  const clickNum = () => {
    setNum(num =>  num + 1);
    // setNum(num =>  num + 1);  // 是可能調用多次的
  }
  const clickAage = () => {
    setNum(age =>  age + 3);
    // setNum(num =>  num + 1);  // 是可能調用多次的
  }
  return <div>
    <button onClick={clickNum}>num: {num}</button>
    <button onClick={clickAage}>age:{age}</button>
  </div>
}

因為 jsx 需要 babel 的支持,我們的簡略版 demo 為了 UI 無關更簡單的展示、使用 useState,我們將上述常見使用方式稍稍改造為:

當執行了 App() 時,將返回一個對象,我們調用對象的方法,就是模擬點擊。

function App () {
    const [num, setNum] = useState(0);
    const [age, setAge] = useState(10);
    console.log(isMount ? '初次渲染' : '更新');
    console.log('num:', num);
    console.log('age:', age);
    const clickNum = () => {
      setNum(num =>  num + 1);
    //   setNum(num =>  num + 1);  // 是可能調用多次的
    }
    const clickAge = () => {
      setAge(age =>  age + 3);
      // setNum(num =>  num + 1);  // 是可能調用多次的
    }
    return {
      clickNum,
      clickAge
    }
  }

那我們就從這個函數開始。

首先,組件要掛載到頁面上,App 函數肯定是需要執行的。執行一開始,我們就遇到了 useState 這個函數。現在請暫時忘記 useState 是個 React hook, 它只是一個函數,跟其他函數沒有任何不同。

在開始 useState 函數之前,先簡單瞭解下鏈表 這種數據結構。

u1 -> u2 -> u3 -> u1,這是環狀鏈表。

u1 -> u2 -> u3 -> null,這是單向鏈表。

我們使用的鏈表,所需要的預備知識只有這些,它能保證我們按照一定順序方便的讀取數據。

在之前表述的 useState 原理中,我們提到:

之所以能保持住state,是在一個函數組件之外的地方,保存了一個「對象」,這個對象裏記錄了之前的狀態。

那我們在函數之外,先聲明一些必要的東西:

// 組件是分初次渲染和後續更新的,那麼就需要一個東西來判斷這兩個不同階段,簡單起見,我們是使用這個變量好了。
let isMount = true;  // 最開始肯定是true

// 我們在組件中,經常是使用多個useState的,那麼需要一個變量,來記錄我們當前實在處理那個hook。
let workInProgressHook = null; // 指向當前正在處理的那個hook

// 針對App這個組件,我們需要一種數據結構來記錄App內所使用的hook都有哪些,以及記錄App函數本身。這種結構我們就命名為fiber
const fiber = {
  stateNode: App, // 對函組件來説,stateNode就是函數本身
  memorizedState: null // 鏈表結構。用來記錄App裏所使用的hook的。
}

// 使用 setNum是會更新組件的, 那麼我們也需要一種可以更新組件的方法。這個方法就叫做 schedule
function schedule () {
  // 每次執行更新組件時,都需要從頭開始執行各個useState,而fiber.memorizedState記錄着鏈表的起點。即workInProgressHook重置為hook鏈表的起點
  workInProgressHook = fiber.memorizedState;
  // 執行 App()
  const app = fiber.stateNode(); 
  // 執行完 App函數了,意味着初次渲染已經結束了,這時候標誌位該改變了。
  isMount = false;
  return app;
}

外面的東西準備好了,開始 useState 這個函數的內部。

在開始之前,我們對 useState 有幾個疑問:

  • useState 究竟怎麼保持住之前的狀態的?

  • 如果多次調用 setNum 這類更新狀態的函數,該怎麼處理這些函數呢?

  • 如果這個 useState 執行完了,怎麼知道下一個 hook 該去哪裏找呢?

帶着這些疑問,我們進入 useState 的內部:

// 計算新狀態,返回改變狀態的方法
function useState(initialState) {
    // 聲明一個hook對象,hook對象裏將有三個屬性,分別用來記錄一些東西,這些東西跟我們上述的三個疑問相關
    // 1. memorizedState, 記錄着state的初始狀態 (疑問1相關)
    // 2. queue, queue.pending 也是個鏈表,像上面所説,setNum是可能被調用多次的,這裏的鏈表,就是記錄這些setNum。 (疑問2相關)
    // 3. next, 鏈表結構,表示在App函數中所使用的下一個useState (疑問3相關)
      let hook; 
    if (isMount) {
      // 首次渲染,也就是第一次進入到本useState內部,每一個useState對應一個自己的hook對象,所以這時候本useState還沒有自己的的hook數據結構,創建一個
      hook = {
        memorizedState: initialState,
        queue: {
          pending: null // 此時還是null的,當我們以後調用setNum時,這裏才會被改變
        },
        next: null
      }
      // 雖然現在是在首次渲染階段,但是,卻不一定是進入的第一個useState,需要判斷
      if (!fiber.memorizedState) {
        // 這時候才是首次渲染的第一個useState. 將當前hook賦值給fiber.memorizedState
        fiber.memorizedState = hook; 
      } else {
        // 首次渲染進入的第2、3、4...N 個useState
        // 前面我們提到過,workInProgressHook的用處是,記錄當前正在處理的hook (即useState),當進入第N(N>1)個useState時,workInProgressHook已經存在了,並且指向了上一個hook
        // 這時候我們需要把本hook,添加到這個鏈表的結尾
        workInProgressHook.next = hook;
      }
      // workInProgressHook指向當前的hook
      workInProgressHook = hook;
    } else {
      // 非首次渲染的更新階段
      // 只要不是首次渲染,workInProgressHook所在的這條記錄hook順序的鏈表肯定已經建立好了。而且 fiber.memorizedState 記錄着這條鏈表的起點。
      // 組件更新,也就是至少經歷了一次schedule方法,在schedule方法裏,有兩個步驟:
      // 1. workInProgressHook = fiber.memorizedState,將workInProgressHook置為hook鏈表的起點。初次渲染階段建立好了hook鏈表,所以更新時,workInProgressHook肯定是存在的
      // 2. 執行App函數,意味着App函數裏所有的hook也會被重新執行一遍
      hook = workInProgressHook; // 更新階段此時的hook,是初次渲染時已經建立好的hook,取出來即可。 所以,這就是為什麼不能在條件語句中使用React hook。
      // 將workInProgressHook往後移動一位,下次進來時的workInProgressHook就是下一個當前的hook
      workInProgressHook = workInProgressHook.next;
    }
    // 上述都是在建立、操作hook鏈表,useState還要處理state。
    let state = hook.memorizedState; // 可能是傳參的初始值,也可能是記錄的上一個狀態值。新的狀態,都是在上一個狀態的基礎上處理的。
    if (hook.queue.pending) {
      let firstUpdate = hook.queue.pending.next; // hook.queue.pending是個環裝鏈表,記錄着多次調用setNum的順序,並且指向着鏈表的最後一個,那麼hook.queue.pending.next就指向了第一個
      do {
        const action = firstUpdate.action;
        state = action(state); // 所以,多次調用setNum,state是這麼被計算出來的
        firstUpdate.next = firstUpdate.next
      } while (firstUpdate !== hook.queue.pending.next) // 一直處理action,直到回到環狀鏈表第一位,説明已經完全處理了
      hook.queue.pending = null;
    }
    hook.memorizedState = state; // 這就是useState能保持住過去的state的原因
    return [state, dispatchAction.bind(null, hook.queue)]
  }

在 useState 中,主要是做了兩件事:

  • 建立 hook 的鏈表。將所有使用過的 hook 有序連接在一起,並通過移動指針,使鏈表裏記錄的 hook 和當前真正被處理的 hook 能夠一一對應。

  • 處理 state。在上一個 state 的基礎上,通過 hook.queue.pending 鏈表來不斷調用 action 函數,直到計算出最新的 state。

在最後,返回了 diapatchAction.bind(null, hook.queue), 這才是 setNum 的真正本體,可見在 setNum 函數中,是隱藏攜帶着hook.queue的。

接下來我們來看看 dispatchAction 的實現。

function dispatchAction(queue, action) {
    // 每次dispatchAction觸發的更新,都是用一個update對象來表述
    const update = {
      action,
      next: null // 記錄多次調用該dispatchAction的順序的鏈表
    }
    if (queue.pending === null) {
      // 説明此時,是這個hook的第一次調用dispatchAction
      // 建立一個環狀鏈表
      update.next = update;
    } else {
      // 非第一調用dispatchAction
      // 將當前的update的下一個update指向queue.pending.next 
      update.next = queue.pending.next;        
      // 將當前update添加到queue.pending鏈表的最後一位
      queue.pending.next = update;
      }
    queue.pending = update; // 把每次dispatchAction 都把update賦值給queue.pending, queue.pending會在下一次dispatchAction中被使用,用來代表上一個update,從而建立起鏈表
    // 每次dispatchAction都觸發更新
    schedule();
  }
  

上面這段代碼裏,7 -18 行不太好理解,我來簡單解釋一下。

假設我們調用了 3 次setNum函數,產生了 3 個 update, A、B、C。

當產生第一個 update A 時:

A:此時 queue.pending === null,

執行 update.next = update, 即 A.next = A;

然後 queue.pending = A;

建立 A -> A 的環狀鏈表

B:此時queue.pending 已經存在了,

update.next = queue.pending.next 即 B.next = A.next 也就是 B.next = A

queue.pending.next = update; 即 A.next = B, 破除了A->A的鏈條,將A->B

queue.pending = update 即 queue.pending = B

建立 B -> A -> B 的環狀鏈表

C: 此時queue.pending 也已經存在了

update.next = queue.pending.next, 即 C.next = B.next, 而B.next = A , C.next = A

queue.pending.next = update, 即 B.next = C

queue.pending = update, 即 queue.pending = C

由於 C -> A , B -> C,而第二步中,A是指向B的,即

建立起 C -> A -> B -> C 環狀鏈表

現在,我們已經完成了簡略 useState 的代碼了,可以操作試試看,全部代碼如下:

let isMount = true;
let workInProgressHook = null;
const fiber = {
  stateNode: App,
  memorizedState: null
}

function schedule () {
  workInProgressHook = fiber.memorizedState;
  const app = fiber.stateNode(); 
  isMount = false;
  return app;
}

function useState(initialState) {
      let hook; 
    if (isMount) {
      hook = {
        memorizedState: initialState,
        queue: {
          pending: null
        },
        next: null
      }
      if (!fiber.memorizedState) {
        fiber.memorizedState = hook; 
      } else {
        workInProgressHook.next = hook;
      }
      workInProgressHook = hook;
    } else {
      hook = workInProgressHook;
      workInProgressHook = workInProgressHook.next;
    }
    let state = hook.memorizedState;
    if (hook.queue.pending) {
        let firstUpdate = hook.queue.pending.next
        do {
            const action = firstUpdate.action;
            state = action(state);
            firstUpdate.next = firstUpdate.next
        } while (firstUpdate !== hook.queue.pending.next)
      hook.queue.pending = null;
    }
    hook.memorizedState = state;
    return [state, dispatchAction.bind(null, hook.queue)]
  }

  function dispatchAction(queue, action) {
    const update = {
      action,
      next: null
    }
    if (queue.pending === null) {
      update.next = update;
    } else {
      update.next = queue.pending.next;        
      queue.pending.next = update;
      }
    queue.pending = update;
    schedule();
  }

  function App () {
    const [num, setNum] = useState(0);
    const [age, setAge] = useState(10);
    console.log(isMount ? '初次渲染' : '更新');
    console.log('num:', num);
    console.log('age:', age);
    const clickNum = () => {
      setNum(num =>  num + 1);
    //   setNum(num =>  num + 1);  // 是可能調用多次的
    }
    const clickAge = () => {
      setAge(age =>  age + 3);
      // setNum(num =>  num + 1);  // 是可能調用多次的
    }
    return {
      clickNum,
      clickAge
    }
  }

  window.App = schedule();

複製然後瀏覽器控制枱粘貼,試試 App.clickNum() , App.clickAge() 吧。

由於我們是每次更新都調用了 schedule,所以 hook.queue.pending只要存在就會被執行,然後將 hook.queue.pending = null, 所以在我們的簡略版 useState 裏,queue.pending 所建立的環狀鏈表沒有被使用到。而在真實的 React 中,batchedUpdates會將多次 dispatchAction執行完後,再觸發一次更新。這時候就需要環狀鏈表了。

相信通過上面詳細的代碼註釋講解,對於前面我們對 useState 的 3 個疑問,應該已經有了答案。

- useState 究竟怎麼保持住之前的狀態?

每個 hook 都記錄了上一次的 state,然後根據 queue.pending 鏈表中保存的 action,重新計算一遍,得到新的 state 返回。並記錄此時的 state 供下一次狀態更新使用。

- 如果我多次調用 setNum 這類 dispatch 函數,該怎麼處理這些函數呢?

多次調用 dispatchAction 函數,會被存儲在 hook.queue.pending 中,作為更新依據。每次組件更新完,hook.queue.pending 置 null。如果在之後再有 dispatchAction,則繼續添加到 hook.queue.pending,並在 useState 函數中被依次執行 action,然後再次置 null。

- 如果這個 useState 執行完了,下一個 hook 該去哪裏找呢?

在初次渲染的時候,所有組件內使用的 hook 都按順序以鏈表的形式存在於組件對應的 fiber.memorizedState 中,並用一個 workInProgress 來標記當前正在處理的 hook。每處理完一個,workInProgress 將移動到 hook 的下一位,保證處理的 hook 的順序和初次渲染時收集的順序嚴格對應。

React hook 的理念

根據 Dan 的博客(https://overreacted.io/zh-hans/algebraic-effects-for-the-rest-of-us/), React hook 是在踐行代數效應(algebraic effects)。我對代數效應不太懂,只能模糊的將「代數效應」理解為「使用某種方式(表達式/語法)來獲得某種效果」,就像通過 useState 這種方式,讓組件獲得狀態,對使用者來説,不必再關心究竟是如何實現的,React 會幫我們處理,而我們可以專注於用這些效應來做什麼。

但為什麼 hook 選擇了函數式組件?是什麼讓函數式組件和類組件這麼不同?

從寫法上,過去的 class 組件的寫法,基本是命令式的,當滿足某個條件,去做一些事情。

class Box extends React.components {
  componentDidMount () {
    // fetch data
  }
  componentWillReceiveProps (props, nextProps) {
    if (nextProps.id !== props.id) {
      // this.setState
    }
  }
}

而 hook 的寫法,則變成了聲明式,先聲明一些依賴,當依賴變化,自動執行某些邏輯。

function Box () {
  useEffect(() => {
    // fetch data
  }, [])
  useEffect(() => {
    // setState
  }, [id])
}

這兩種哪種更好,可能因人而異。但我覺得對於第一次接觸 React 的人來説,funciton 肯定是更親切的。

兩者更大的差別是更新方式不同,class 組件是通過改變組件實例的 this 中的 props、state 內的值,然後重新執行render 來拿到最新的 props、state,組件是不會再次實例化的。

而函數式組件,則在更新時重新執行了函數本身。每一次執行的函數,都可以看做相互獨立的。

我覺得這種更新方式的區別,是不習慣函數式組件的主要原因。函數組件捕獲了渲染所需要的值,所以有些情況下結果總是出人意料。

就像這個例子(https://codesandbox.io/s/react-hooks-playground-forked-ktf4uw?file=/src/index.tsx),當我們點擊加號,然後嘗試拖動窗口,看看控制枱打印了什麼?

沒錯,count is 0。 儘管你點擊了很多次按鈕,儘管最新的 count 已經變成了 N,但是每一次 App 的 context 都是獨立的,在 handleWindowResize 被定義的時候,它看到的 count 是0, 然後被綁定事件。此後 count 變化,跟這一個 App 世界裏的 handleWindowResize 已經沒有關係了。會有新的 handleWindowResize 看到了新的 count,但是被綁定事件的,只有最初那個。

而 Class 版本是這樣的:

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
    this.handleWindowResize = this.handleWindowResize.bind(this);
    this.handleClick = this.handleClick.bind(this);
  }
  handleWindowResize() {
    console.log(`count is ${this.state.count}`);
  }
  handleClick() {
    this.setState({
      count: this.state.count + 1
    });
  }
  componentDidMount() {
    window.addEventListener("resize", this.handleWindowResize);
  }
  componentWillUnmount () {
    window.removeEventListener('resize', this.handleWindowResize)
  }
  render() {
    const { count } = this.state;
    return (
      <div className="App">
        <button onClick={this.handleClick}>+</button>
        <h1>{count}</h1>
      </div>
    );
  }
}

在 Class 的版本里,App 的實例並不會再次創建,無論更新多少次 this.handleWindowResize、this.handleClick 還是一樣,只是裏面的this.state被 React 修改了。

從這個例子,我們多少能夠看出 「函數捕獲了渲染所需要的值」的意思,這也是函數組件和類組件的主要不同。

但為什麼是給函數式組件增加 hook?

給函數式組件增加 hook,除了是要解決 class 的幾個缺陷:

  • 在組件之間複用狀態邏輯很難

  • 複雜組件變得難以理解

  • 難以理解的 class

之外,從 React 理念的角度,UI = f(state)也説明了,組件應該只是數據的通道而已,組件本質上更貼近函數。從個人使用角度,其實很多情況下,我們本不需要那麼重的 class,只是苦於無狀態組件沒法setState而已。

React hook 的意義

前面説了這麼多 hook,都是在説它如何實現,和 Class 組件有何不同,但還有一個最根本的問題沒有回答,hook 給我們開發者帶來了什麼?

React 官網裏提到了,Hook可以讓組件之間狀態可用邏輯更簡單,並用高階組件來對比,但是首要問題是,我們為什麼要用 HOC?

先看這個例子(https://codesandbox.io/s/modest-visvesvaraya-v0i9fy?file=/src/Counter.jsx):

import React from "react";

function Count({ count, add, minus }) {
  return (
    <div style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
      <div>You clicked {count} times</div>
      <button
        onClick={add}
        title={"add"}
        style={{ minHeight: 20, minWidth: 100 }}
      >
        +1
      </button>
      <button
        onClick={minus}
        title={"minus"}
        style={{ minHeight: 20, minWidth: 100 }}
      >
        -1
      </button>
    </div>
  );
}

const countNumber = (initNumber) => (WrappedComponent) =>
  class CountNumber extends React.Component {
    state = { count: initNumber };
    add = () => this.setState({ count: this.state.count + 1 });
    minus = () => this.setState({ count: this.state.count - 1 });
    render() {
      return (
        <WrappedComponent
          {...this.props}
          count={this.state.count}
          add={this.add.bind(this)}
          minus={this.minus.bind(this)}
        />
      );
    }
  };
export default countNumber(0)(Count);

效果就是展示當前數值,點擊產生加減效果。

之所以要使用這種方式,是為了在被包裹組件外部提供一套可複用的狀態和方法。本例中即 state、add、minus,從而使後面如果有其他功能相似的但樣式不同的WrappedComponent,可以直接用 countNumber 包裹一下就行。

從這個例子中,其實可以看到我們使用 HOC 本質是想做兩件事,傳值和同步。

傳值是將外面得到的值傳給我們被包裹的組件。而同步,則是讓被包裹的組件利用新值重新渲染。

從這裏我們大致可以看到 HOC 的兩個弊端:

  • 因為我們想讓子組件重新渲染的方式有限,要麼高階組件 setState,要麼 forceUpdate,而這類方法都是 React 組件內的,無法獨立於 React 組件使用,所以add\minus 這種業務邏輯和展示的 UI 邏輯,不得不粘合在一起。

  • 使用 HOC 時,我們往往是多個 HOC 嵌套使用的。而 HOC 遵循透傳與自身無關的 props 的約定,導致最終到達我們的組件時,有太多與組件並不太相關的 props,調試也相當複雜。我們沒有一種很好的方法來解決多層 HOC 嵌套所帶來的麻煩。

基於這兩點,我們才能説 hooks 帶來比 HOC 更能解決邏輯複用難、嵌套地獄等問題所謂的“優雅”方式。

HOC 裏的業務邏輯不能抽到組件外的某個函數,只有組件內才有辦法觸發子組件重新渲染。而現在自定義 hook 帶來了在組件外觸發組件重新渲染的能力,那麼難題就迎刃而解。

使用 hook,再也不用把業務邏輯和 UI 組件夾雜在一起了,上面的例子(https://codesandbox.io/s/modest-visvesvaraya-v0i9fy?file=/src/CounterWithHook.jsx),用 hook 的方式是這樣實現的:


// 業務邏輯拆分到這裏了
import { useState } from "react";

function useCounter() {
  const [count, setCount] = useState(0);
  const add = () => setCount((count) => count + 1);
  const minus = () => setCount((count) => count - 1);
  return {
    count,
    add,
    minus
  };
}
export default useCounter;
// 純UI展示組件
import React from "react";
import useCounter from "./counterHook";

function Count() {
  const { count, add, minus } = useCounter();
  return (
    <div style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
      <div>You clicked {count} times</div>
      <button
        onClick={add}
        title={"add"}
        style={{ minHeight: 20, minWidth: 100 }}
      >
        +1
      </button>
      <button
        onClick={minus}
        title={"minus"}
        style={{ minHeight: 20, minWidth: 100 }}
      >
        -1
      </button>
    </div>
  );
}
export default Count;

這種拆分,讓我們終於可以把業務和 UI 分離開了。如果想獲取類似之前嵌套 HOC 那樣的能力,只需要再引入一行 hook 就行了。


function Count() {
  const { count, add, minus } = useCounter();
  const { loading } = useLoading();
  return loading ? (
    <div>loading...please wait...</div>
  ) : (
    <div style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
      ...
    </div>
  );
}
export default Count;

useCounter、useLoading 各自維護各的,而我們所引入的的東西,一目瞭然。

在這個計數器的例子上,我們可以想的更遠一些。既然現在邏輯和 UI 是可以拆分的,那如果提取出一個計數器的所有邏輯,是不是就可以套用任何 UI 庫了?

從這個假設出發,如果我讓 hook 提供這些能力:

  • 可以設置計數器的初始值、每次加減值、最大值最小值、精度

  • 可以通過返回的方法,直接獲得超出最大最小值時按鈕變灰無法點擊等等效果。

  • 可以通過返回的方法,直接獲取中間輸入框只能輸入數字,不能輸入文字等等功能。

而開發人員要做的,就是將這些放在任何 UI 庫或原生的 button 與 input 之上,僅此而已。


function HookUsage() {
  const { getInputProps, getIncrementButtonProps, getDecrementButtonProps } =
    useNumberInput({
      step: 0.01,
      defaultValue: 1.53,
      min: 1,
      max: 6,
      precision: 2,
    })

  const inc = getIncrementButtonProps()
  const dec = getDecrementButtonProps()
  const input = getInputProps()

  return (
    <HStack maxW='320px'>
      <Button {...inc}>+</Button>
      <Input {...input} />
      <Button {...dec}>-</Button>
    </HStack>
  )
}

只需要這麼幾行代碼,就可以得到這樣的效果:

我們可以展望一個與 Antd、elementUI 這些完全不同的組件庫。它可以只提供提煉後的業務組件邏輯,而不必提供 UI, 你可以把這些業務邏輯應用在任何 UI 庫上。

Hooks 的出現可以讓UI library轉向logic library,而這種將組件狀態、邏輯與UI展示關注點分離的設計理念,也叫做Headless UI。

Headless UI 着力於提供組件的交互邏輯,而 UI 部分讓允許使用者自由選擇,在滿足 UI 定製拓展的前提下,還能實現邏輯的交互邏輯的複用,業界已經有一些這方面的探索,比如組件庫 chakra。前面的例子,其實就是 chakra 提供的能力。chakra 底層是對 Headless UI 的實踐,提供對外的 hook,上層則提供了自帶 UI 的組件,總之就是讓開發者既可以快速複用人家已經提煉好的而且是最難寫的交互邏輯部分,又不至於真的從頭去給 button、input 寫樣式。另外 chakra 提供了組成一個大組件的原子組件,比如 Table 組件,chakra 會提供:

Table,
  Thead,
  Tbody,
  Tfoot,
  Tr,
  Th,
  Td,
  TableCaption,
  TableContainer,

這讓使用者可以根據實際需要自由組合,提供原子組件的還有 Material-UI,對於用習慣了 Antd、element UI 的這種整體組件的我們來説,提供了一種不同的方式,十分值得嘗試。

在實際開發時,基於 hooks 甚至可以讓經驗豐富的人負責寫邏輯,而新手來寫 UI,分工合作,大大提升開發效率。

React hook 的侷限

React hook的提出,在前端裏是意義非凡的,但世界上沒有完美的東西,hook 仍然存在着一些問題。當然這只是我個人在使用中的一點感受與困惑。

被強制的順序

第一次接觸 hook 時,對不能在嵌套或者條件裏使用 hook 感到很奇怪,這非常反直覺。

useState、useEffect 明明就一個函數而已,居然限制我在什麼地方使用?只要我傳入的參數是正確的,你管我在哪兒用呢?

我們平時在項目裏寫一些工具函數時,會限制別人在什麼地方使用麼?頂多是判斷一下宿主環境,但是對使用順序,肯定是沒有限制的。一個好的函數,應該像純函數,同樣的輸入帶來同樣的輸出,沒有副作用,不需要使用者去思考在什麼環境、什麼層級、什麼順序下調用,對使用者的認知負擔最小。

後來知道了,React 是通過調用時序來保證組件內狀態正確的。

當然這並不是什麼大問題,曾經 jsx 剛出的時候,js 夾雜 html 的語法同樣被人詬病,現在也都真香了。開發者只要記住並遵守這些規則,就沒什麼負擔了。

複雜的useEffct

相信很多同學在遇到這個 API 的時候,第一個問題就是被其描述「執行副作用」所迷惑。

啥是執行副作用?

React 説數據獲取,設置訂閲、手動更改 DOM 這些就是副作用。

為什麼這些叫副作用?

因為理想狀態下,Class 組件也好,函數組件也好,最好都是無任何副作用的純函數,同樣的輸入永遠是同樣的輸出,穩定可靠能預測。但是實際上在組件渲染完成後去執行一些邏輯是很常見的需求,就像組件渲染完了要修改 DOM、要請求接口。所以為了滿足需求,儘管 Class 組件裏的 render 是純函數,還是將 Class 組件的副作用放在了 componentDidMount 生命週期中。儘管曾經的無狀態組件是純函數,還是增加了useEffect來在頁面渲染之後做一些事情。所謂副作用,就是componentDidMount 讓 Class 的 render 變的不純。useEffect讓無狀態函組件變的不純而已。

我們即使理解了副作用,接下來要理解 useEffect 本身。

先接觸 React 生命週期的同學,在學習 useEffct 的時候,或多或少會用 componentDidMount 來類比 useEffct。如果你熟悉 Vue,可能會覺得 useEffct 類似 watcher ,然而當用多了以後,會發現 useEffect 都似是而非。

首先 useEffect 每次渲染完成後都會執行,只是根據依賴數組去判斷是否要執行你的effect。它並不是componentDidMount,但為了實現componentDidMount的效果,我們需要使用空數組來模擬。這時候useEffect 可以看做 componentDidMount。當依賴數組為空時,effect 裏返回的清除方法,等同於 componentWillUnmount。

useEffect 除了實現componentDidMount、componentWillUnmount 之外,還可以在依賴數組裏設置需要監聽的變量,這時看起來又像是 Vue 的 watcher。但是 useEffect 實際上是在頁面更新後才會執行的。舉個例子:


function App () {
  let varibaleCannotReRender; // 普通變量,改變它並不會觸發組件重新渲染
  useEffect(() => {
          // some code
        }, [varibaleCannotReRender])
  // 比如在一次點擊事件中改變了varibaleCannotReRender
  varibaleCannotReRender = '123'
}

頁面不會渲染,effect 肯定不會執行,也不會有任何提示。所以 useEffect 也不是一個變量的 watcher。事實上只要頁面重新渲染了,你的 useEffect 的依賴數組裏即使有非 props/state 的本地變量也可以觸發effect。

像這樣,每次點擊後,effect 都會執行,儘管我沒有監聽 num,b 也只是個普通變量。


function App() {
  const [num, setNum] = useState(0);
  let b = 1;
  useEffect(() => {
    console.log('effefct', b);
  }, [b]);
  const click = () => {
    b = Math.random();
    set((num) => num + 1);
  };

  return <div onClick={click}>App {get}</div>;
}

所以在理解 useEffect 上,過去的 React 生命週期, Vue 的 watcher 的經驗都不能很好地遷移過來,可能最好的方式反而是忘記過去的那些經驗,從頭開始學習。

當然,即使你已經清楚了不同情況下 useEffect 都能帶來什麼效果,也不意味着就可以用好它。對於曾經重度使用 Class 組件的開發人員來説尤其如此,摒棄掉生命週期的還不夠。

在 Class 組件中,UI渲染是props或者state在render函數中確定的,render可以是個無副作用的純函數,每次調用了this.setState,新的props、state渲染出新的UI,UI與props、state之間保持了一致性。而 componentDidMount裏的那些副作用,是不參與更新過程的,失去了與更新的同步。這是Class的思維模式。 而在 useEffect思維模式中,useEffect 是與每一次更新同步的,這裏沒有 mount 與 update,第一次渲染和第十次渲染一視同仁。你願意的話,每一次渲染都可以去執行你的 effect。

這種思維模式上的區別,我認為是 useEffect 讓人覺得困惑的原因。

useEffect 與更新同步,而實際業務中並不一定每一次更新都去執行 effect,所以需要用依賴數組來決定什麼時候執行副作用,什麼時候不執行。而依賴數組填寫不當,又可能造成無限執行 effect、或者 effect 裏拿到過時數值等情況。

在寫業務的時候,我們總是不由自主的把功能、組件越寫越大,useEffect 越來越複雜。當依賴數組越來越長的時候,就該考慮是不是設計上出了問題。我們應該儘量遵循單一性原則,讓每個 useEffect,只做一件儘可能簡單的事情。當然,這也並不容易。

函數的純粹性

在 hook 出現之前的函數式組件,沒有局部狀態,信息都是外部傳入的,它本身就是像個純函數,一旦函數重新執行,你在組件裏聲明的變量、方法全都是新的,簡單純粹。那時候我們還是把這類組件叫做 SFC 的(staless function component)。

但引入了 hook 之後,無狀態函數組件擁有了局部狀態的能力,成了 FC 了。嚴格説這時候擁有了局部狀態的函數, 是不能看做是純函數了,但為了減輕一點思維上的負擔,可以把 useState 理解成類似函數組件之外地方所聲明的一種數據,這個數據每次變化了都傳給你的函數組件。

// 把這種
function YourComponent () {
  const [num, setNum] = useState(0);
  return <span>{num}</span>
}


// 理解成這種形式,使用了useState,React就自動給你生成AutoContainer包裹你的函數。這樣你的組件仍可以看成是純函數。
 function AutoContainer () {
   const [num, setNum] = useState(0);
   return <YourComponent num={num} />
 }
function YourComponent (props) {
  return <span>{props.num}</span>
}

每一次函數組件更新,就像一次快照一樣捕獲了當時環境,每次更新都擁有獨一份的 context,更新之間互不干擾,充分利用了閉包特性,似乎也很純粹。

如果一直是這樣,也還好,一次次更新就像一個個平行宇宙,相似但不相同。props、state 等等context決定渲染,渲染之後是新的 context,各過各的,互不打擾。

但實際上,useRef 的出現,打破了這種純粹性,useRef 讓組件在每次渲染時都返回了同一個對象,就像一個空間寶石,在平行宇宙之間穿梭。而 useRef 的這一特性,使之成為了 hooks 的閉包救星,也造成了“遇事不決,useRef”的局面。説好的每次渲染都是獨一份的 context,怎麼還能拿到幾次前更新的數據呢?

去掉 useRef 行不行?還真不行,有些場景就是需要一些值能夠穿越多次渲染。

但是這樣不相當於在 class 組件的 this 里加了個字段用來存數據嗎?看得出來 React 是想擁抱函數式編程,但是,useRef卻讓它變得不那麼“函數式”了。

寫在最後

我真正開始使用 hooks 已經比較晚了,這篇囉嗦了 1 萬多字的文章,其實是我在學習和使用中曾經問過自己的問題,在此我嘗試給出這些問題的回答。能力有限,只能從一些局部角度來描述一些我所理解的東西,不全面也不夠深刻,隨着繼續學習,可能會在未來有不一樣的感悟。

hooks 很強大,掌握一門強大的技藝從來都是需要不斷磨練和時間沉澱的,希望這篇文章,能稍稍解答一些你曾經有過的疑問。

參考文檔: https://overreacted.io/ https://zh-hans.reactjs.org/docs/thinking-in-react.html https://zh-hans.reactjs.org/docs/hooks-effect.html https://react.iamkasong.com/ https://juejin.cn/post/6944863057000529933