所有文章

重磅:React Hooks

React v16.7.0-alpha 後引入了「Hooks」此一向後兼容之概念。

背景介紹

隨著 React 應用長期擴展和維護,終會引致以下所謂「unconnected」問題,而 Hooks 因應而生:

  • 難以復用組件間的富狀態邏輯:React 無法為組件連結可復用的行為。Render props 和 HOC 是解決之道,但你需要重構代碼以使用之。Hooks 便是 React 所亟需的、一個用以共享富狀態(stateful)邏輯的原生手段。
  • 複雜組件變得難以理解:互有關聯的邏輯分散支離於不同生命週期,而同一生命週期又充斥著互無關聯的代碼。Hooks 能讓一組件基於關係地分割成小函式(譬如基於抓取數據一塊),你或可使用一 reducer 來使組件本地 state 更加可預測。
  • 令人困惑的 class:囿於 JavaScript 本身特性,class 組件常令人困惑,尤其是涉及對class 組件或函式組件的選擇時。Class 組件對機器層面亦有影響,譬如難以 minify、使熱加載變慢或不穩定,還會影響極有潛力的 ahead-of-time compilation 的速度。Hooks 擁抱函式,能在沒有 class 的情況下,充分利用 React 特性。

React 不鼓勵開發者急於改寫現有代碼,而是逐漸轉換思想,即「thinking in Hooks」。Hooks 能覆蓋 class 的所有使用場景,但仍會保留 class。

Hooks 用法

以下代碼示例片段,默認發生於此函式之內:

import { useState, useEffect } from 'react';

function Example() {
  // ...
}

State Hooks

useState 是一個 Hook。在函式組件內調用它(傳參為初始 state 值),會返回新增的本地 state、以及用以更新該 state 的函式:

const [count, setCount] = useState(0);  // 初始值為 0 的新 state,返回該 state 和更新函式

上例之 setCount 和 class 裏的 this.setState 相似,除了它不合併新舊 state,而是直接替代——此即為 Hooks 的初衷,即提倡基於哪些 state 會一道改變來分割 state(並因之分割 Hooks),故直接替代 state 值而非合併一龐雜的 state 對象。

Effect Hooks

諸如抓取數據、訂閱和手動操作 DOM 這些行為,皆是「副作用」(簡稱「effects」)。我們通常想副作用發生於 React 更新 DOM 之後,譬如把基於 state 的 DOM 手動操作放在 componentDidMountcomponentDidUpdate 生命週期中,而這導致重複代碼。useEffect 此一 Hook,發揮並結合了 class 裏的各生命週期的用處,能在函式組件發生渲染之後執行副作用。

useEffect(() => {
  document.title = `You clicked ${count} times`;
})

副作用還能透過返回一個函式,以指定 React 於組件卸載時「清理」之:

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
});

Effects 實踐貼士

在一函式組件內結合調用多個 useStateuseEffect,將無關的邏輯分離至多個代碼塊:

const [count, setCount] = useState(0);
useEffect(() => {
  document.title = `You clicked ${count} times`;
});

const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
});

useEffect 在每次重渲染時都觸發的原因是:在 class 組件中,使用到 props 的副作用在 props 變動時會引致 bug,除非正確引入 componentDidUpdate(which is 一常見的 bug 來源)以監測 props。

為優化性能,可擇機取消重渲染後觸發 useEffect。不同於 componentDidUpdate 中比對 prevProps 參數和當前 props(或 prevState 和當前 state),useEffect 內建可選的第二個參數:包含 state 名的數組,於是僅在該 state 變化時才重跑 effect。

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]);

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
}, [props.friend.id]);

Hooks 之規則

  • 只在頂層調用 Hooks。勿在循環、條件或嵌套函式內調用。
  • 只在 React 函式組件內調用 Hooks。勿在常規 JavaScript 函式內調用(除了 custom Hooks,後文將述及)。

官方提供一 eslint-plugin-react-hooks 來確保這些規則。

Custom Hooks

自訂 Hooks 讓你能復用富狀態邏輯,而不似 render props 和 HOC 那般徒增新組件。

將訂閱好友在線狀態的邏輯抽取出來成為一自訂 Hook:

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);
  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }
  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => { /* unsubscribe */ };
  });
  return isOnline;
}

然後就可在多個組件中使用之:

function FriendStatus(props) {
  const isOnline = useFriendStatus(props.friend.id);
  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

function FriendListItem(props) {
  const isOnline = useFriendStatus(props.friend.id);
  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}

須知自訂 Hooks 只復用邏輯而非 state,調用者仍有完全孤立的 state。自訂 Hooks 更似一個自然流露的 convention 而非 feature,若一個函式以「use」冠名並其內部調用了其他 Hooks,則為一自訂 Hook。諸如表單處理、動畫、訂閱聲明、定時器等行為,皆可利用自訂 Hooks。

其他 Hooks

useContext

此 Hook 讓你毋須引入嵌套即可訂閱 React context:

const locale = useContext(LocaleContext);
const theme = useContext(ThemeContext);

開發者通常不樂意手動地在組件樹中層層傳遞回調函式。可以結合 useReducer(下文會述及)和 context 來往下傳遞一 dispatch

const TodosDispatch = React.createContext(null);

function TodosApp() {
  // Tip: `dispatch` won't change between re-renders
  const [todos, dispatch] = useReducer(todosReducer);

  return (
    <TodosDispatch.Provider value={dispatch}>
      <DeepTree todos={todos} />
    </TodosDispatch.Provider>
  );
}

TodosApp 內的任意子裔都可以讀取使用 dispatch 函數並發起 actions 至 TodosApp

function DeepChild(props) {
  // 從 context 處取用 dispatch 來執行 action
  const dispatch = useContext(TodosDispatch);

  function handleClick() {
    dispatch({ type: 'add', text: 'hello' });
  }

  return (
    <button onClick={handleClick}>Add todo</button>
  );
}

useReducer

此 Hook 是 useState 的替代,可用一 reducer 管理複雜組件的本地 state。它接收一 reducer 和一可選的 initialState,返回當前 state 併以一 dispatch 方法。

const initialState = { count: 0 };

function reducer(state, action) {
 switch(action.type) {
   case 'reset': return initialState;
   case 'increment': return { count: state.count + 1 };
   case 'decrement': return { count: state.count - 1 };
 }
}

function Counter({ initialCount }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'reset'})}>
        Reset
      </button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
    </>
  )
}

useMemo

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

返回一個記憶化的(memoized)值。只會在輸入參數時發生改變時觸發重計算,避免每次渲染後的計算開銷。

useMemo 甚至可用來跳過對子組件的重渲染:

function Parent({ a, b }) {
  // 僅當 `a` 變化時重渲染:
  const child1 = useMemo(() => <Child1 a={a} />, [a]);
  // 僅當 `b` 變化時重渲染:
  const child2 = useMemo(() => <Child2 b={b} />, [b]);
  return (
    <>
      {child1}
      {child2}
    </>
  )
}

useCallback

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);

返回一個記憶化的回調函式,它只會在輸入參數發生改變時觸發。引用比較的子組件會引致不必要的渲染,useCallback 即能用於優化此情境(類似 shouldComponentUpdate)。

useRef

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` 指向加載的輸入框元素
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

一個小技巧:useRef 可用以保存實例變量,因為「ref」對象是一個通用的容器,而其 current 成員是可變的(mutable)並能貯存任意值,類似 class 的實例變量。

function Timer() {
  const intervalRef = useRef();
  useEffect(() => {
    const id = setInterval(() => {
      // ...
    });
    intervalRef.current = id;
    return () => {
      clearInterval(intervalRef.current);
    };
  });
  function handleCancelClick() {
    clearInterval(intervalRef.current);
  }
}

完 :)

發佈於 28 Oct 2018

慎獨|純亦不已
Jason Lam on Twitter