源碼分析:react hook 最佳實踐

源碼分析:react hook 最佳實踐

原文鏈接

前言

本文從 mini React —— Preact 源碼的角度,分析 React Hook 各個 API 的優點缺點。
從而理解爲什麼要用 hook,以及如何最佳使用。

2條規則

爲什麼?

  1. ✅只在最頂層使用 Hook,不要在循環,條件或嵌套函數中調用 Hook;
  2. ✅只在 React 函數中調用 Hook,不要在普通的 JavaScript 函數中調用 Hook。

源碼分析

let currentIndex; // 全局索引
let currentComponent; // 當前 hook 所在的組件

function getHookState(index) {
  const hooks =
    currentComponent.__hooks ||
    (currentComponent.__hooks = {_list: [], _pendingEffects: []});

  if (index >= hooks._list.length) {
    hooks._list.push({});
  }
  return hooks._list[index];
}
// 獲取註冊的 hook
const hookState = getHookState(currentIndex++);
  • hook 狀態都維護在數組結構中,執行 hook api 時,索引 currentIndex + 1 依次存入數組。
    當組件 render 之前,會先調用 hook render,重置索引和設置當前組件,hook 注入在 options 內。
options._render = vnode => {
  currentComponent = vnode._component;
  currentIndex = 0;
  // ...
};
  • 首先需要知道一點的是,函數組件 在每次 diff 時,整個函數都會重新執行,
    class組件 只會執行 this.render,因此 hook 在性能上會有些損耗,
    考慮到這一點 hook 爲那些聲明開銷很大的數據結構和函數,提供了 useMemouseCallback 優化。

  • hook 在每次 render 時,取上一次 hook state 時,
    如果在循環,條件或嵌套函數不確定的分支裏執行,就有可能取錯數據,導致混亂。

function Todo(props) {
  const [a] = useState(1);
  if(props.flag) {
    const [b] = useState(2);
  }
  const [c] = useState(3);
  // ...
}
<Todo flag={true} />
  • 此時 a = 1, b = 2, c = 3;
<Todo flag={false} />
  • 當條件被改變時,a = 1, c = 2c取錯了狀態!

第二條嘛,就顯而易見了,hook 寄生於 react 組件和生命週期。

  • Preact hookoptions 對象上聲明瞭 _render -> diffed -> _commit -> unmount 四個鉤子,
    分別會在對象組件的生命週期前執行,這樣侵入性較小。

在這裏插入圖片描述

useState

使用方式

// 聲明 hook
const [state, setState] = useState(initialState);
// 更新 state
setState(newState);

// 也可以函數式更新
setState(prevState => { // 可以拿到上一次的 state 值
  // 也可以使用 Object.assign
  return {...prevState, ...updatedValues};
});
  • 惰性初始 state。如果初始化 state 值開銷很大,可以傳入函數,初始化只會執行一次。
const [state, setState] = useState(() => {
  const initialState = someExpensiveComputation(props);
  return initialState;
});
  • 跳過 state 更新。設置相同的值(Object.is判斷),不會觸發組件更新。
const [state, setState] = useState(0);
// ...
// 更新 state 不會觸發組件重新渲染
setState(0);
setState(0);

爲什麼?

  • 坑:依賴 props.state === 1 初始化 hook,爲什麼 props.state === 2 時,hook state 不會變化?
function Component(props) {
  const [state, setState] = useState(props.state);
  // ...
}
  • 惰性初始的原理是什麼?
  • hook state 變更是怎麼驅動組件渲染的,爲什麼說可以當 class state 使用?

源碼分析

  • PreactuseState 是使用 useReducer 實現的,便於行文,代碼會略加修改。
function useState(initialState) {
  const hookState = getHookState(currentIndex++);
  if (!hookState._component) {
    hookState._component = currentComponent;

    hookState._value = [
      invokeOrReturn(undefined, initialState),

      action => {
        const nextValue = invokeOrReturn(hookState._value[0], action);
        if (hookState._value[0] !== nextValue) {
          hookState._value[0] = nextValue;
          hookState._component.setState({});
        }
      }
    ];
  }

  return hookState._value;
}
// 工具函數,用來支持函數式初始和更新
function invokeOrReturn(arg, f) {
  return typeof f === 'function' ? f(arg) : f;
}
  • 可以看出 useState 只會在組件首次 render 時初始化一次,以後由返回的函數來更新狀態。
  1. 坑:初始化(包括傳入的函數)只會執行一次,所以不應該依賴 props 的值來初始化 useState;
  2. 優化:可以利用傳入函數來性能優化開銷較大的初始化操作。
  • hookState._value[0] !== nextValue 比較新舊值避免不必要的渲染。
  • 可以看出,更新操作利用了組件實例的 this.setState 函數。這就是爲什麼 hook 可以代替 classthis.state 使用。

useEffect

使用方式

  • 例如,常用根據 query 參數,首次加載組件只發一次請求內容。
function Component(props) {
  const [state, setState] = useState({});
  
  useEffect(() => {
    ajax.then(data => setState(data));
  }, []); // 依賴項
  // ...
}
  • useState 有說到,props 初始 state 有坑,可以用 useEffect 實現。
function Component(props) {
  const [state, setState] = useState(props.state);
  
  useEffect(() => {
    setState(props.state);
  }, [props.state]); // props.state 變動賦值給 state
  // ...
}
  • 清除副作用,例如監聽改變瀏覽器窗口大小,之後清除副作用
function WindowWidth(props) {
  const [width, setWidth] = useState(0);

  function onResize() {
    setWidth(window.innerWidth);
  }
  // 只執行一次副作用,組件 unmount 時會被清除
  useEffect(() => {
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return <div>Window width: {width}</div>;
}
  • 注意:在 useEffect 在使用 state 時最好把它作爲依賴,不然容易產生 bug
function Component() {
  const [a, setA] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => console.log(a), 100);
    return () => clearInterval(timer)
  }, []);
  return <button onClick={() => setA(a+1)}>{a}</button>
}

當你點擊按鈕 a+=1 時,此時 console.log 依舊打印 0
這是因爲 useEffect 的副作用只會在組件首次加載時入 _pendingEffects 數組,形成閉包。

修改如下:

function Component() {
  const [a, setA] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => console.log(a), 100);
    return () => clearInterval(timer)
-  }, []);
+  }, [a]);
  return <button onClick={() => setA(a+1)}>{a}</button>
}

這段代碼在 React 裏運行,輸出會隨點擊按鈕而變化,而在 preact 中,之前定時器未被清除,
說明有 bug。-_-||

爲什麼?

  • useEffect 解決了什麼問題

一般發送數據請求 componentDidMount 中,之後 componentWillUnmount 在相關清理。
這就導致相互無關的邏輯夾雜在 componentDidMount,而對應的掃尾工作卻分配在 componentWillUnmount 中。

有了 useEffect ,你可以把相互獨立的邏輯寫在不同的 useEffect 中,他人擔心維護時,也不用擔心其他代碼塊裏還有清理代碼。

  • 在組件函數體內執行副作用(改變 DOM、添加訂閱、設置定時器、記錄日誌等)是不被允許的?

每次 diff 函數組件會被當做class 組件的 this.render 函數類似使用,
整體會被執行,在主體裏操作副作用是致命的。

  • useEffect 的機制?

源碼分析

function useEffect(callback, args) {
  const state = getHookState(currentIndex++);
  if (argsChanged(state._args, args)) {
    state._value = callback;
    state._args = args;

    currentComponent.__hooks._pendingEffects.push(state);
  }
}
  • 工具函數,依賴項爲 undefined 或依賴項數組中一個值變動,則 true
function argsChanged(oldArgs, newArgs) {
  return !oldArgs || newArgs.some((arg, index) => arg !== oldArgs[index]);
}
  • 可以看出副作用的回調函數會在 _pendingEffects 數組中維護,代碼有兩處執行
options._render = vnode => {
  currentComponent = vnode._component;
  currentIndex = 0;

  if (currentComponent.__hooks) { // 這裏爲什麼要清理了再執行!!!
    currentComponent.__hooks._pendingEffects.forEach(invokeCleanup);
    currentComponent.__hooks._pendingEffects.forEach(invokeEffect);
    currentComponent.__hooks._pendingEffects = [];
  }
};
function invokeCleanup(hook) {
  if (hook._cleanup) hook._cleanup();
}

function invokeEffect(hook) {
  const result = hook._value(); // 如果副作用函數有返回函數的,會被當成清理函數保存。
  if (typeof result === 'function') hook._cleanup = result;
}
options.diffed = vnode => {
  const c = vnode._component;
  if (!c) return;

  const hooks = c.__hooks;
  if (hooks) {
    if (hooks._pendingEffects.length) {
      afterPaint(afterPaintEffects.push(c));
    }
  }
};
function afterPaint(newQueueLength) {
  if (newQueueLength === 1 || prevRaf !== options.requestAnimationFrame) {
    prevRaf = options.requestAnimationFrame;
    (prevRaf || afterNextFrame)(flushAfterPaintEffects);
  }
}
function flushAfterPaintEffects() {
  afterPaintEffects.some(component => {
    if (component._parentDom) {
      try {
        component.__hooks._pendingEffects.forEach(invokeCleanup);
        component.__hooks._pendingEffects.forEach(invokeEffect);
        component.__hooks._pendingEffects = [];
      } catch (e) {
        options._catchError(e, component._vnode);
        return true;
      }
    }
  });
  afterPaintEffects = [];
}
  • 我很懷疑,options._render 的代碼是從 flushAfterPaintEffects 不假思索的拷過去。
    導致上面講到的一個 bug

  • afterPaint 利用 requestAnimationFramesetTimeout 來達到以下目的

componentDidMountcomponentDidUpdate 不同的是,在瀏覽器完成佈局與繪製之後,傳給 useEffect 的函數會延遲調用,不會在函數中執行阻塞瀏覽器更新屏幕的操作。
(勘誤:ReactuseEffect 能達到這效果,Preact 並沒有實現)

useMemo

使用方式

function Counter () {
  const [count, setCount] = useState(0);
  const [val, setValue] = useState('');
  const expensive = useMemo(() => {
    let sum = 0;
    for (let i = 0; i < count * 100; i++) {
      sum += i;
    }
    return sum
  }, [ count ]); // ✅ 只有 count 變化時,回調函數纔會執行

  return (
    <>
      <span>You Clicked {expensive} times</span>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <input value={val} onChange={event => setValue(event.target.value)} />
    </>
  )
}

爲什麼?

  • useMemo 解決了什麼問題

上面反覆強調了,函數組件體會被反覆執行,如果進行大的開銷的會喫性能。
所以 react 提供了 useMemo 來緩存函數執行返回結果,useCallback 來緩存函數。

源碼分析

function useMemo(factory, args) {
  const state = getHookState(currentIndex++);
  if (argsChanged(state._args, args)) {
    state._args = args;
    state._factory = factory;
    return (state._value = factory());
  }

  return state._value;
}
  • 可以看出,只是把傳入的函數根據依賴性執行了一遍把結果保存在內部的 hook state 中。

  • 記住,所有的 hook api 都一樣,不要在沒有傳入state 作爲依賴項的情況下,在副租用中
    使用 state

useCallback

使用方式

const onClick = useCallback(
  () => console.log(a, b),
  [a, b]
);

爲什麼?

  • useCallback 解決了什麼問題

上面提到了,用來緩存函數的

  • 例如,上面優化監聽窗口的例子。
function WindowWidth(props) {
  const [width, setWidth] = useState(0);

-  function onResize() {
-    setWidth(window.innerWidth);
-  }
  
+  const onResize = useCallback(() => {
+    setWidth(window.innerWidth);
+  }, []);

  useEffect(() => {
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return <div>Window width: {width}</div>;
}

上面說過,沒有依賴的時,不使要用 width,但可以使用 setWidth
函數是引用,閉包變量 setWidth 是同一個地址。

源碼分析

  • useMemo 的封裝
function useCallback(callback, args) {
  return useMemo(() => callback, args);
}

useRef

使用方式

  • 舉個例子,點擊按鈕開啓 60 秒倒計時,再次點擊停止。
function Counter() {
  const [start, setStart] = useState(false);
  const [time, setTime] = useState(60);

  useEffect(() => { // effect 函數,不接受也不返回任何參數
    let interval;
    if (start) {
      interval = setInterval(() => {
        setTime(time - 1); // ❌ time 在 effect 閉包函數裏是拿不到準確值的
      }, 1000);
    }
    return () => clearInterval(interval) // clean-up 函數,當前組件被註銷時調用
  }, [start]); // 依賴數組,當數組中變量變化時會調用 effect 函數

  return (
    <button onClick={() => setStart(!start)}>{time}</button>
  );
}
  • 在前面的分析中,由於閉包的原因,取到的 time 值不是最新的。
    可以用 time 的初始值來傳給 useRef,再來驅動 time 的更新。
function Counter() {
  const [start, setStart] = useState(false);
  const [time, setTime] = useState(60);
+  const currentTime = useRef(time); // 生成一個可變引用
	
  useEffect(() => { // effect 函數,不接受也不返回任何參數
    let interval;
    if (start) {
      interval = setInterval(() => {
+        setTime(currentTime.current--) // currentTime.current 是可變的
-        setTime(time - 1); // ❌ time 在 effect 閉包函數裏是拿不到準確值的
      }, 1000);
    }
    return () => clearInterval(interval) // clean-up 函數,當前組件被註銷時調用
  }, [start]); // 依賴數組,當數組中變量變化時會調用 effect 函數

  return (
    <button onClick={() => setStart(!start)}>{time}</button>
  );
}
  • useRef 生成一個對象 currentTime = {current: 60}currentTime 對象在組件的整個生命週期內保持不變。

  • 但這樣處理有點多此一舉,setTime 函數式更新不就好了嘛,current 可以用來替代 interval,這樣外部也能取消倒計時。

function Counter() {
  const [start, setStart] = useState(false);
  const [time, setTime] = useState(60);
-  const currentTime = useRef(time); // 生成一個可變引用
+  const interval = useRef() // interval 可以在這個作用域裏任何地方清除和設置
	
  useEffect(() => { // effect 函數,不接受也不返回任何參數
-    let interval;
    if (start) {
-      interval = setInterval(() => {
+      interval.current = setInterval(() => {
-        setTime(currentTime.current--) // currentTime.current 是可變的
+        setTime(t => t - 1) // ✅ 在 setTime 的回調函數參數裏可以拿到對應 state 的最新值
      }, 1000);
    }
-    return () => clearInterval(interval) // clean-up 函數,當前組件被註銷時調用
+    return () => clearInterval(interval.current) // clean-up 函數,當前組件被註銷時調用
  }, [start]); // 依賴數組,當數組中變量變化時會調用 effect 函數

  return (
    <button onClick={() => setStart(!start)}>{time}</button>
  );
}

這樣既能消滅 interval 變量的反覆創建,也能讓外部能夠清理定時器 interval.current

爲什麼?

  • useRef 返回的對象在組件的整個生命週期內保持不變,怎麼理解?
  • 爲什麼不能改變返回的對象,而是隻能改變對象 current 屬性?

源碼分析

function useRef(initialValue) {
  return useMemo(() => ({ current: initialValue }), []);
}
  • 內部使用了 useMemo 來實現,傳入一個生成一個具有 current 屬性對象的函數,
    空數組依賴,所以在整個生命週期該函數只執行一次。
  • 直接改變 useRef 返回的值,無法改變內部 hookState._value 值,只能通過 改變內部 hookState._value.current 來影響下次的使用。

useLayoutEffect

使用方式

  • useEffect 使用方式相同。

爲什麼?

  • useEffect 區別在哪裏?

源碼分析

  • useEffect 的回調在 option.diffed 階段,
    使用 requestAnimationFramesetTimeout(callback, 100) 來異步執行,由於作者都認爲
    this is not such a big deal ,所以代碼就不貼了,而且只是有一層 requestAnimationFrame 也達不到下一幀之前執行的效果。

  • useLayoutEffect 的回調在 option._commit 階段批量同步處理。

  • React 中估計使用了 requestIdleCallbackrequestAnimationFrame 來進行時間分片,以避免阻塞視覺更新。

  • 由於 react 自己內部使用了優先級調度,勢必會導致某些低優先級會延遲執行,只有你覺得優先級很高,在不管阻塞渲染的情況也要同步執行,
    那麼你可以用 useLayoutEffect

useReducer

使用方式

  • 數字±1與重置
const initialState = 0;
const reducer = (state, action) => {
  switch (action) {
    case 'increment': return state + 1;
    case 'decrement': return state - 1;
    case 'reset': return 0;
    default: throw new Error('Unexpected action');
  }
};

function Counter() {
  const [count, dispatch] = useReducer(reducer, initialState);
  return (
    <div>
      {count}
      <button onClick={() => dispatch('increment')}>+1</button>
      <button onClick={() => dispatch('decrement')}>-1</button>
      <button onClick={() => dispatch('reset')}>reset</button>
    </div>
  );
}
  • 第二個參數可以一個函數,返回 state 的初始值;
  • 第三個參數可以一個函數,以第二個參數爲入參,返回 state 的初始值。

爲什麼?

  • 什麼時候使用 useReducer

state 邏輯較複雜且包含多個子值,下一個 state 依賴於之前的 state。

使用 reducer 最好是個純函數,集中處理邏輯,修改源頭方便追溯,避免邏輯分散各處,也能避免不可預知的地方修改了狀態,導致 bug 難追溯。

源碼分析

  • 前面說到,useStateuseReducer 實現的。
function useReducer(reducer, initialState, init) {
  const hookState = getHookState(currentIndex++);
  if (!hookState._component) {
    hookState._component = currentComponent;

    hookState._value = [
      !init ? invokeOrReturn(undefined, initialState) : init(initialState),

      action => {
        const nextValue = reducer(hookState._value[0], action);
        if (hookState._value[0] !== nextValue) {
          hookState._value[0] = nextValue;
          hookState._component.setState({});
        }
      }
    ];
  }

  return hookState._value;
}
  • 上一次的 statereducer 的第一個參數,dispatch 接受的參數爲第二個參數,產生新的 state

useContext

使用方式

  • 例如設置全局主題 theme
// App.js
function App() {
  return <Toolbar theme="dark" />;
}

// Toolbar.js
function Toolbar(props) {
  // 很麻煩,theme 需層層傳遞所有組件。
  return (
    <div>
      <ThemedButton theme={props.theme} />
    </div>
  );
}

// ThemedButton.js
class ThemedButton extends React.Component {
  render() {
    return <Button theme={this.props.theme} />;
  }
}
  • 使用 Context
// context.js
+ const ThemeContext = React.createContext('light');

// App.js
function App() {
-  return <Toolbar theme="dark" />;
+  return (
+    <ThemeContext.Provider value="dark">
+      <Toolbar />
+    </ThemeContext.Provider>
   );
}

// Toolbar.js
function Toolbar(props) {
  return (
    <div>
-      <ThemedButton theme={props.theme} />
+      <ThemedButton />  // 無需傳遞
    </div>
  );
}

// ThemedButton.js
class ThemedButton extends React.Component {
+  static contextType = ThemeContext;   // 指定 contextType 讀取當前的 theme context。
  render() {
-    return <Button theme={this.props.theme} />;
+    return <Button theme={this.context} />; // React 會往上找到最近的 theme Provider,theme 值爲 “dark”。
  }
}
  • 使用 useContext
// context.js
const ThemeContext = React.createContext('light');

// App.js
function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Toolbar />
    </ThemeContext.Provider>
   );
}

// Toolbar.js
function Toolbar(props) {
  return (
    <div>
      <ThemedButton /> // 無需傳遞
    </div>
  );
}

// ThemedButton.js
- class ThemedButton extends React.Component {
-   static contextType = ThemeContext;   // 指定 contextType 讀取當前的 theme context。
-   render() {
-     return <Button theme={this.context} />; // React 會往上找到最近的 theme Provider,theme 值爲 “dark”。
-   }
- }
+ function ThemedButton() {
+   const theme = useContext(ThemeContext);
+ 
+   return <Button theme={theme} />;
+ }
  • useContext(MyContext) 相當於 class 組件 中的 static contextType = MyContext

  • 當組件上層最近的 <MyContext.Provider> 更新時,該 Hook 會觸發重渲染,並使用最新傳遞給 MyContext provider 的 context value 值。即使祖先使用 React.memo 或 shouldComponentUpdate,也會在組件本身使用 useContext 時重新渲染。

可以使用 React.memouseMemo hook性能優化

爲什麼?

  • useContext 怎麼拿到 context 的,然後驅動變化?

源碼分析

  • 在當前組件上,拿到 context,訂閱當前組件,當 context 發生變化時,發佈通知。
function useContext(context) {
  const provider = currentComponent.context[context._id];
  if (!provider) return context._defaultValue;
  const state = getHookState(currentIndex++);
  // This is probably not safe to convert to "!"
  if (state._value == null) {
    state._value = true;
    provider.sub(currentComponent);
  }
  return provider.props.value;
}

自定義 hook

使用方式

  • 常見的給組件添加防抖功能,例如使用 antd 的 SelectInput 組件,你可能分別對應使用他們來重新組合一個新的組件,把防抖實現在新組件內部。
  • 利用自定義 hook 可以更細粒度的來分離組件與防抖的關係。
// 防抖 hook
function useDebounce() {
  const time = useRef({lastTime: Date.now()});
  return (callback, ms) => {
    time.current.timer && clearTimeout(time.current.timer);
    time.current.timer = setTimeout(() => {
      const now = Date.now();
      console.log(now - time.current.lastTime);
      time.current.lastTime = now;
      callback();
    }, ms);
  }
}
function App() {
  const [val, setVal] = useState();
  const inputChange = useDebounce();
  // 可以多次使用
  // const selectChange = useDebounce();
  
  return (
    <>
      <input onChange={
        ({target: {value}}) => {
          inputChange(() => setVal(value), 500)
        }
      }/>{val}
    </>
  );
}

函數組件 hook 與 class 組件的對比

缺點

  1. 性能較差,但也只是瀏覽器解析JS級別的損耗。

優勢

  1. 減少了代碼量,相關邏輯更聚合,便於閱讀與維護;
  2. 不用理解 classthisclass 目前還只是語法糖,標準還在更改,沒有像傳統面向對象的多態、多繼承的概念,this 理解成本很高;
  3. 純函數有利於例如 ts 推導類型,等等。

參考

  1. React 文檔
  2. Preact 文檔
  3. Preact 源碼
  4. 使用 React Hooks 重構你的小程序
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章