精讀《React Hooks》

黃子毅發表於2019-03-04

1 引言

React Hooks 是 React 16.7.0-alpha 版本推出的新特性,想嘗試的同學安裝此版本即可。

React Hooks 要解決的問題是狀態共享,是繼 render-propshigher-order components 之後的第三種狀態共享方案,不會產生 JSX 巢狀地獄問題。

狀態共享可能描述的不恰當,稱為狀態邏輯複用會更恰當,因為只共享資料處理邏輯,不會共享資料本身。

不久前精讀分享過的一篇 Epitath 原始碼 – renderProps 新用法 就是解決 JSX 巢狀問題,有了 React Hooks 之後,這個問題就被官方正式解決了。

為了更快理解 React Hooks 是什麼,先看筆者引用的下面一段 renderProps 程式碼:

function App() {
  return (
    <Toggle initial={false}>
      {({ on, toggle }) => (
        <Button type="primary" onClick={toggle}> Open Modal </Button>
        <Modal visible={on} onOk={toggle} onCancel={toggle} />
      )}
    </Toggle>
  )
}
複製程式碼

恰巧,React Hooks 解決的也是這個問題:

function App() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button type="primary" onClick={() => setOpen(true)}>
        Open Modal
      </Button>
      <Modal
        visible={open}
        onOk={() => setOpen(false)}
        onCancel={() => setOpen(false)}
      />
    </>
  );
}
複製程式碼

可以看到,React Hooks 就像一個內建的打平 renderProps 庫,我們可以隨時建立一個值,與修改這個值的方法。看上去像 function 形式的 setState,其實這等價於依賴注入,與使用 setState 相比,這個元件是沒有狀態的

2 概述

React Hooks 帶來的好處不僅是 “更 FP,更新粒度更細,程式碼更清晰”,還有如下三個特性:

  1. 多個狀態不會產生巢狀,寫法還是平鋪的(renderProps 可以通過 compose 解決,可不但使用略為繁瑣,而且因為強制封裝一個新物件而增加了實體數量)。
  2. Hooks 可以引用其他 Hooks。
  3. 更容易將元件的 UI 與狀態分離。

第二點展開說一下:Hooks 可以引用其他 Hooks,我們可以這麼做:

import { useState, useEffect } from "react";

// 底層 Hooks, 返回布林值:是否線上
function useFriendStatusBoolean(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

// 上層 Hooks,根據線上狀態返回字串:Loading... or Online or Offline
function useFriendStatusString(props) {
  const isOnline = useFriendStatusBoolean(props.friend.id);

  if (isOnline === null) {
    return "Loading...";
  }
  return isOnline ? "Online" : "Offline";
}

// 使用了底層 Hooks 的 UI
function FriendListItem(props) {
  const isOnline = useFriendStatusBoolean(props.friend.id);

  return (
    <li style={{ color: isOnline ? "green" : "black" }}>{props.friend.name}</li>
  );
}

// 使用了上層 Hooks 的 UI
function FriendListStatus(props) {
  const statu = useFriendStatusString(props.friend.id);

  return <li>{statu}</li>;
}
複製程式碼

這個例子中,有兩個 Hooks:useFriendStatusBooleanuseFriendStatusString, useFriendStatusString 是利用 useFriendStatusBoolean 生成的新 Hook,這兩個 Hook 可以給不同的 UI:FriendListItemFriendListStatus 使用,而因為兩個 Hooks 資料是聯動的,因此兩個 UI 的狀態也是聯動的。

順帶一提,這個例子也可以用來理解 對 React Hooks 的一些思考 一文的那句話:“有狀態的元件沒有渲染,有渲染的元件沒有狀態”

  • useFriendStatusBooleanuseFriendStatusString 是有狀態的元件(使用 useState),沒有渲染(返回非 UI 的值),這樣就可以作為 Custom Hooks 被任何 UI 元件呼叫。
  • FriendListItemFriendListStatus 是有渲染的元件(返回了 JSX),沒有狀態(沒有使用 useState),這就是一個純函式 UI 元件,

利用 useState 建立 Redux

Redux 的精髓就是 Reducer,而利用 React Hooks 可以輕鬆建立一個 Redux 機制:

// 這就是 Redux
function useReducer(reducer, initialState) {
  const [state, setState] = useState(initialState);

  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}
複製程式碼

這個自定義 Hook 的 value 部分當作 redux 的 state,setValue 部分當作 redux 的 dispatch,合起來就是一個 redux。而 react-redux 的 connect 部分做的事情與 Hook 呼叫一樣:

// 一個 Action
function useTodos() {
  const [todos, dispatch] = useReducer(todosReducer, []);

  function handleAddClick(text) {
    dispatch({ type: "add", text });
  }

  return [todos, { handleAddClick }];
}

// 繫結 Todos 的 UI
function TodosUI() {
  const [todos, actions] = useTodos();
  return (
    <>
      {todos.map((todo, index) => (
        <div>{todo.text}</div>
      ))}
      <button onClick={actions.handleAddClick}>Add Todo</button>
    </>
  );
}
複製程式碼

useReducer 已經作為一個內建 Hooks 了,在這裡可以查閱所有 內建 Hooks

不過這裡需要注意的是,每次 useReducer 或者自己的 Custom Hooks 都不會持久化資料,所以比如我們建立兩個 App,App1 與 App2:

function App1() {
  const [todos, actions] = useTodos();

  return <span>todo count: {todos.length}</span>;
}

function App2() {
  const [todos, actions] = useTodos();

  return <span>todo count: {todos.length}</span>;
}

function All() {
  return (
    <>
      <App1 />
      <App2 />
    </>
  );
}
複製程式碼

這兩個例項同時渲染時,並不是共享一個 todos 列表,而是分別存在兩個獨立 todos 列表。也就是 React Hooks 只提供狀態處理方法,不會持久化狀態。

如果要真正實現一個 Redux 功能,也就是全域性維持一個狀態,任何元件 useReducer 都會訪問到同一份資料,可以和 useContext 一起使用。

大體思路是利用 useContext 共享一份資料,作為 Custom Hooks 的資料來源。具體實現可以參考 redux-react-hook

利用 useEffect 代替一些生命週期

在 useState 位置附近,可以使用 useEffect 處理副作用:

useEffect(() => {
  const subscription = props.source.subscribe();
  return () => {
    // Clean up the subscription
    subscription.unsubscribe();
  };
});
複製程式碼

useEffect 的程式碼既會在初始化時候執行,也會在後續每次 rerender 時執行,而返回值在析構時執行。這個更多帶來的是便利,對比一下 React 版 G2 呼叫流程:

class Component extends React.PureComponent<Props, State> {
  private chart: G2.Chart = null;
  private rootDomRef: React.ReactInstance = null;

  componentDidMount() {
    this.rootDom = ReactDOM.findDOMNode(this.rootDomRef) as HTMLDivElement;

    this.chart = new G2.Chart({
      container: document.getElementById("chart"),
      forceFit: true,
      height: 300
    });
    this.freshChart(this.props);
  }

  componentWillReceiveProps(nextProps: Props) {
    this.freshChart(nextProps);
  }

  componentWillUnmount() {
    this.chart.destroy();
  }

  freshChart(props: Props) {
    // do something
    this.chart.render();
  }

  render() {
    return <div ref={ref => (this.rootDomRef = ref)} />;
  }
}
複製程式碼

用 React Hooks 可以這麼做:

function App() {
  const ref = React.useRef(null);
  let chart: G2.Chart = null;

  React.useEffect(() => {
    if (!chart) {
      chart = new G2.Chart({
        container: ReactDOM.findDOMNode(ref.current) as HTMLDivElement,
        width: 500,
        height: 500
      });
    }

    // do something
    chart.render();

    return () => chart.destroy();
  });

  return <div ref={ref} />;
}
複製程式碼

可以看到將細碎的程式碼片段結合成了一個完整的程式碼塊,更維護。

現在介紹了 useState useContext useEffect useRef 等常用 hooks,更多可以查閱:內建 Hooks,相信不久的未來,這些 API 又會成為一套新的前端規範。

3 精讀

Hooks 帶來的約定

Hook 函式必須以 “use” 命名開頭,因為這樣才方便 eslint 做檢查,防止用 condition 判斷包裹 useHook 語句。

為什麼不能用 condition 包裹 useHook 語句,詳情可以見 官方文件,這裡簡單介紹一下。

React Hooks 並不是通過 Proxy 或者 getters 實現的(具體可以看這篇文章 React hooks: not magic, just arrays),而是通過陣列實現的,每次 useState 都會改變下標,如果 useState 被包裹在 condition 中,那每次執行的下標就可能對不上,導致 useState 匯出的 setter 更新錯資料。

雖然有 eslint-plugin-react-hooks 外掛保駕護航,但這第一次將 “約定優先” 理念引入了 React 框架中,帶來了前所未有的程式碼命名和順序限制(函式命名遭到官方限制,JS 自由主義者也許會暴跳如雷),但帶來的便利也是前所未有的(沒有比 React Hooks 更好的狀態共享方案了,約定帶來提效,自由的代價就是回到 renderProps or HOC,各團隊可以自行評估)。

筆者認為,React Hooks 的誕生,也許來自於這個靈感:“不如通過增加一些約定,徹底解決狀態共享問題吧!”

React 約定大於配置腳手架 nextjs umi 以及筆者的 pri 都通過有 “約定路由” 的功能,大大降低了路由配置複雜度,那麼 React Hooks 就像程式碼級別的約定,大大降低了程式碼複雜度。

狀態與 UI 的界限會越來越清晰

因為 React Hooks 的特性,如果一個 Hook 不產生 UI,那麼它可以永遠被其他 Hook 封裝,雖然允許有副作用,但是被包裹在 useEffect 裡,總體來說還是挺函式式的。而 Hooks 要集中在 UI 函式頂部寫,也很容易養成書寫無狀態 UI 元件的好習慣,踐行 “狀態與 UI 分開” 這個理念會更容易。

不過這個理念稍微有點蹩腳的地方,那就是 “狀態” 到底是什麼。

function App() {
  const [count, setCount] = useCount();
  return <span>{count}</span>;
}
複製程式碼

我們知道 useCount 算是無狀態的,因為 React Hooks 本質就是 renderProps 或者 HOC 的另一種寫法,換成 renderProps 就好理解了:

<Count>{(count, setCount) => <App count={count} setCount={setCount} />}</Count>;

function App(props) {
  return <span>{props.count}</span>;
}
複製程式碼

可以看到 App 元件是無狀態的,輸出完全由輸入(Props)決定。

那麼有狀態無 UI 的元件就是 useCount 了:

function useCount() {
  const [count, setCount] = useState(0);
  return [count, setCount];
}
複製程式碼

有狀態的地方應該指 useState(0) 這句,不過這句和無狀態 UI 元件 App 的 useCount() 很像,既然 React 把 useCount 成為自定義 Hook,那麼 useState 就是官方 Hook,具有一樣的定義,因此可以認為 useCount 是無狀態的,useState 也是一層 renderProps,最終的狀態其實是 useState 這個 React 內建的元件。

我們看 renderProps 巢狀的表達:

<UseState>
  {(count, setCount) => (
    <UseCount>
      {" "}
      {/**雖然是透傳,但給 count 做了去重,不可謂沒有作用 */}
      {(count, setCount) => <App count={count} setCount={setCount} />}
    </UseCount>
  )}
</UseState>
複製程式碼

能確定的是,App 一定有 UI,而上面兩層父級元件一定沒有 UI。為了最佳實踐,我們儘量避免 App 自己維護狀態,而其父級的 RenderProps 元件可以維護狀態(也可以不維護狀態,做個二傳手)。因此可以考慮在 “有狀態的元件沒有渲染,有渲染的元件沒有狀態” 這句話後面加一句:沒渲染的元件也可以沒狀態。

4 總結

把 React Hooks 當作更便捷的 RenderProps 去用吧,雖然寫法看上去是內部維護了一個狀態,但其實等價於注入、Connect、HOC、或者 renderProps,那麼如此一來,使用 renderProps 的門檻會大大降低,因為 Hooks 用起來實在是太方便了,我們可以抽象大量 Custom Hooks,讓程式碼更加 FP,同時也不會增加巢狀層級。

5 更多討論

討論地址是:精讀《React Hooks》 · Issue #111 · dt-fe/weekly

如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 – 幫你篩選靠譜的內容。

相關文章