當談論 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 的部落格(http://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,元件是不會再次例項化的。

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

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

就像這個例子(http://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?

先看這個例子(http://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 元件夾雜在一起了,上面的例子(http://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 很強大,掌握一門強大的技藝從來都是需要不斷磨練和時間沉澱的,希望這篇文章,能稍稍解答一些你曾經有過的疑問。

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