Hooks與事件繫結

2023-04-16 12:00:34

Hooks與事件繫結

React中,我們經常需要為元件新增事件處理常式,例如處理表單提交、處理點選事件等。通常情況下,我們需要在類元件中使用this關鍵字來繫結事件處理常式的上下文,以便在函數中使用元件的範例屬性和方法。React HooksReact 16.8引入的一個新特性,其出現讓React的函陣列件也能夠擁有狀態和生命週期方法。Hooks的優勢在於可以讓我們在不編寫類元件的情況下,複用狀態邏輯和副作用程式碼,Hooks的一個常見用途是處理事件繫結。

描述

React中使用類元件時,我們可能會被大量的this所困擾,例如this.propsthis.state以及呼叫類中的函數等。此外,在定義事件處理常式時,通常需要使用bind方法來繫結函數的上下文,以確保在函數中可以正確地存取元件範例的屬性和方法,雖然我們可以使用箭頭函數來減少bind,但是還是使用this語法還是沒跑了。

那麼在使用Hooks的時候,可以避免使用類元件中的this關鍵字,因為Hooks是以函數的形式來組織元件邏輯的,我們通常只需要定義一個普通函陣列件,並在函陣列件中使用useStateuseEffectHooks來管理元件狀態和副作用,在處理事件繫結的時候,我們也只需要將定義的事件處理常式傳入JSX就好了,也不需要this也不需要bind

那麼問題來了,這個問題真的這麼簡單嗎,我們經常會聽到類似於Hooks的心智負擔很重的問題,從我們當前要討論的事件繫結的角度上,那麼心智負擔就主要表現在useEffectuseCallback以及依賴陣列上。其實類比來看,類元件類似於引入了thisbind的心智負擔,而Hooks解決了類元件的心智負擔,又引入了新的心智負擔,但是其實換個角度來看,所謂的心智負擔也只是需要接受的新知識而已,我們需要了解React推出新的設計,新的元件模型,當我們掌握了之後那就不會再被稱為心智負擔了,而應該叫做語法,當然其實叫做負擔也不是沒有道理的,因為很容易在不小心的情況下出現隱患。那麼接下來我們就來討論下Hooks與事件繫結的相關問題,所有範例程式碼都在https://codesandbox.io/s/react-ts-template-forked-z8o7sv

事件繫結

使用Hooks進行普通的合成事件繫結是一件很輕鬆的事情,在這個例子中,我們使用了普通的合成事件onClick來監聽按鈕的點選事件,並在點選時呼叫了add函數來更新count狀態變數的值,這樣每次點選按鈕時,count就會加1

// https://codesandbox.io/s/hooks-event-z8o7sv
import { useState } from "react";

export const CounterNormal: React.FC = () => {
  const [count, setCount] = useState(0);
  const add = () => {
    setCount(count + 1);
  };
  return (
    <div>
      {count}
      <div>
        <button onClick={add}>count++</button>
      </div>
    </div>
  );
};

這個例子看起來非常簡單,我們就不再過多解釋了,其實從另一個角度想一下,這不是很類似於原生的DOM0事件流模型,每個物件只能繫結一個DOM事件的話,就不需要像DOM2事件流模型一樣還得保持原來的處理常式參照才能進行解除安裝操作,否則是解除安裝不了的,如果不能保持參照的地址是相同的,那就會造成無限的繫結,進而造成記憶體漏失,如果是DOM0的話,我們只需要覆蓋即可,而不需要去保持之前的函數參照。實際上我們接下來要說的一些心智負擔,就與參照地址息息相關。

另外有一點我們需要明確一下,當我們點選了這個count按鈕,React幫我們做了什麼。其實對於當前這個<CounterNormal />元件而言,當我們點選了按鈕,那麼肯定就是需要重新整理檢視,React的策略是會重新執行這個函數,由此來獲得返回的JSX,然後就是常說的diff等流程,最後才會去渲染,只不過我們目前關注的重點就是這個函陣列件的重新執行。Hooks實際上無非就是個函數,React通過內建的use為函數賦予了特殊的意義,使得其能夠存取Fiber從而做到資料與節點相互繫結,那麼既然是一個函數,並且在setState的時候還會重新執行,那麼在重新執行的時候,點選按鈕之前的add函數地址與點選按鈕之後的add函數地址是不同的,因為這個函數實際上是被重新定義了一遍,只不過名字相同而已,從而其生成的靜態作用域是不同的,那麼這樣便可能會造成所謂的閉包陷阱,接下來我們就來繼續探討相關的問題。

原生事件繫結

雖然React為我們提供了合成事件,但是在實際開發中因為各種各樣的原因我們無法避免的會用到原生的事件繫結,例如ReactDOMPortal傳送門,其是遵循合成事件的事件流而不是DOM的事件流,比如將這個元件直接掛在document.body下,那麼事件可能並不符合看起來DOM結構應該遵循的事件流,這可能不符合我們的預期,此時可能就需要進行原生的事件繫結了。此外,很多庫可能都會有類似addEventListener的事件繫結,那麼同樣的此時也需要在合適的時機去新增和解除事件的繫結。由此,我們來看下邊這個原生事件繫結的例子:

// https://codesandbox.io/s/react-ts-template-forked-z8o7sv?file=/src/counter-native.tsx
import { useEffect, useRef, useState } from "react";

export const CounterNative: React.FC = () => {
  const ref1 = useRef<HTMLButtonElement>(null);
  const ref2 = useRef<HTMLButtonElement>(null);
  const [count, setCount] = useState(0);

  const add = () => {
    setCount(count + 1);
  };

  useEffect(() => {
    const el = ref1.current;
    const handler = () => console.log(count);
    el?.addEventListener("click", handler);
    return () => {
      el?.removeEventListener("click", handler);
    };
  }, []);

  useEffect(() => {
    const el = ref2.current;
    const handler = () => console.log(count);
    el?.addEventListener("click", handler);
    return () => {
      el?.removeEventListener("click", handler);
    };
  }, [count]);

  return (
    <div>
      {count}
      <div>
        <button onClick={add}>count++</button>
        <button ref={ref1}>log count 1</button>
        <button ref={ref2}>log count 2</button>
      </div>
    </div>
  );
};

在這個例子中,我們分別對ref1ref2兩個button進行了原生事件繫結,其中ref1的事件繫結是在元件掛載的時候進行的,而ref2的事件繫結是在count發生變化的時候進行的,看起來程式碼上只有依賴陣列[][count]的區別,但實際的效果上差別就很大了。在上邊線上的CodeSandbox中我們首先點選三次count++這個按鈕,然後分別點選log count 1按鈕和log count 2按鈕,那麼輸出會是如下的內容:

0 // log count 1
3 // log count 2

此時我們可以看出,頁面上的count值明明是3,但是我們點選log count 1按鈕的時候,輸出的值卻是0,只有點選log count 2按鈕的時候,輸出的值才是3,那麼點選log count 1的輸出肯定是不符合我們的預期的。那麼為什麼會出現這個情況呢,其實這就是所謂的React Hooks閉包陷阱了,其實我們上邊也說了為什麼會發生這個問題,我們再重新看一下,Hooks實際上無非就是個函數,React通過內建的use為函數賦予了特殊的意義,使得其能夠存取Fiber從而做到資料與節點相互繫結,那麼既然是一個函數,並且在setState的時候還會重新執行,那麼在重新執行的時候,點選按鈕之前的add函數地址與點選按鈕之後的add函數地址是不同的,因為這個函數實際上是被重新定義了一遍,只不過名字相同而已,從而其生成的靜態作用域是不同的,那麼在新的函數執行時,假設我們不去更新新的函數,也就是不更新函數作用域的話,那麼就會保持上次的count參照,就會導致列印了第一次繫結的資料。

那麼同樣的,useEffect也是一個函數,我們那麼我們定義的事件繫結那個函數也其實就是useEffect的引數而已,在state發生改變的時候,這個函數雖然也被重新定義,但是由於我們的第二個引數即依賴陣列的關係,其陣列內的值在兩次render之後是相同的,所以useEffect就不會去觸發這個副作用的執行。那麼實際上在log count 1中,因為依賴陣列是空的[],兩次render或者說兩次執行依次比較陣列內的值沒有發生變化,那麼便不會觸發副作用函數的執行;那麼在log count 2中,因為依賴的陣列是[count],在兩次render之後依次比較其值發現是發生了變化的,那麼就會執行上次副作用函數的返回值,在這裡就是清理副作用的函數removeEventListener,然後再執行傳進來的新的副作用函數addEventListener。另外實際上也就是因為React需要返回一個清理副作用的函數,所以第一個函數不能直接用async裝飾,否則執行副作用之後返回的就是一個Promise物件而不是直接可執行的副作用清理函數了。

useCallback

在上邊的場景中,我們通過為useEffect新增依賴陣列的方式似乎解決了這個問題,但是設想一個場景,如果一個函數需要被多個地方引入,也就是說類似於我們上一個範例中的handler函數,如果我們需要在多個位置參照這個函數,那麼我們就不能像上一個例子一樣直接定義在useEffect的第一個引數中。那麼如果定義在外部,這個函數每次re-render就會被重新定義,那麼就會導致useEffect的依賴陣列發生變化,進而就會導致副作用函數的重新執行,顯然這樣也是不符合我們的預期的。此時就需要將這個函數的地址保持為唯一的,那麼就需要useCallback這個Hook了,當使用React中的useCallback Hook時,其將返回一個memoized記憶化的回撥函數,這個回撥函數只有在其依賴項發生變化時才會重新建立,否則就會被快取以便在後續的渲染中複用。通過這種方式可以幫助我們在React元件中優化效能,因為其可以防止不必要的重渲染,當將這個memoized回撥函數傳遞給子元件時,就可以避免在每次渲染時重新創它,這樣可以提高效能並減少記憶體的使用。由此,我們來看下邊這個使用useCallback進行事件繫結的例子:

// https://codesandbox.io/s/react-ts-template-forked-z8o7sv?file=/src/counter-callback.tsx
import { useCallback, useEffect, useRef, useState } from "react";

export const CounterCallback: React.FC = () => {
  const ref1 = useRef<HTMLButtonElement>(null);
  const ref2 = useRef<HTMLButtonElement>(null);
  const [count, setCount] = useState(0);

  const add = () => {
    setCount(count + 1);
  };

  const logCount1 = () => console.log(count);

  useEffect(() => {
    const el = ref1.current;
    el?.addEventListener("click", logCount1);
    return () => {
      el?.removeEventListener("click", logCount1);
    };
  }, []);

  const logCount2 = useCallback(() => {
    console.log(count);
  }, [count]);

  useEffect(() => {
    const el = ref2.current;
    el?.addEventListener("click", logCount2);
    return () => {
      el?.removeEventListener("click", logCount2);
    };
  }, [logCount2]);

  return (
    <div>
      {count}
      <div>
        <button onClick={add}>count++</button>
        <button ref={ref1}>log count 1</button>
        <button ref={ref2}>log count 2</button>
      </div>
    </div>
  );
};

在這個例子中我們的logCount1沒有useCallback包裹,每次re-render都會重新定義,此時useEffect也沒有定義陣列,所以在re-render時並沒有再去執行新的事件繫結。那麼對於logCount2而言,我們使用了useCallback包裹,那麼每次re-render時,由於依賴陣列是[count]的存在,因為count發生了變化useCallback返回的函數的地址也改變了,在這裡如果有很多的狀態的話,其他的狀態改變了,count不變的話,那麼這裡的logCount2便不會改變,當然在這裡我們只有count這一個狀態,所以在re-render時,useEffect的依賴陣列發生了變化,所以會重新執行事件繫結。在上邊線上的CodeSandbox中我們首先點選三次count++這個按鈕,然後分別點選log count 1按鈕和log count 2按鈕,那麼輸出會是如下的內容:

0 // log count 1
3 // log count 2

那麼實際上我們可以看出來,在這裡如果的log count 1與原生事件繫結例子中的log count 1一樣,都因為沒有及時更新而保持了上一次render的靜態作用域,導致了輸出0,而由於log count 2及時更新了作用域,所以正確輸出了3,實際上這個例子並不全,我們可以很明顯的發現實際上應該有其他種情況的,我們同樣先點選count++三次,然後再分情況看輸出:

  • logCount函數不用useCallback包裝。
    • useEffect依賴陣列為[]: 輸出0
    • useEffect依賴陣列為[count]: 輸出3
    • useEffect依賴陣列為[logCount]: 輸出3
  • logCount函數使用useCallback包裝,依賴為[]
    • useEffect依賴陣列為[]: 輸出0
    • useEffect依賴陣列為[count]: 輸出0
    • useEffect依賴陣列為[logCount]: 輸出0
  • logCount函數使用useCallback包裝,依賴為[count]
    • useEffect依賴陣列為[]: 輸出0
    • useEffect依賴陣列為[count]: 輸出3
    • useEffect依賴陣列為[logCount]: 輸出3

雖然看起來情況這麼多,但是實際上如果接入了react-hooks/exhaustive-deps規則的話,發現其實際上是會建議我們使用3.3這個方法來處理依賴的,這也是最標準的解決方案,其他的方案要不就是存在不必要的函數重定義,要不就是存在應該重定義但是依然存在舊的函數作用域參照的情況,其實由此看來React的心智負擔確實是有些重的,而且useCallback能夠完全解決問題嗎,實際上並沒有,我們可以接著往下聊聊useCallback的缺陷。

useMemoizedFn

同樣的,我們繼續來看一個例子,這個例子可能相對比較複雜,因為會有一個比較長的依賴傳遞,然後導致看起來比較麻煩。另外實際上這個例子也不能說useCallback是有問題的,只能說是會有相當重的心智負擔。

const getTextInfo = useCallback(() => { // 獲取一段資料
  return [text.length, dep.length];
}, [text, dep]);

const post = useCallback(() => { // 傳送資料
  const [textLen, depLen] = getTextInfo();
  postEvent({ textLen, depLen });
}, [getTextInfo, postEvent]);

useEffect(() => {
  post();
}, [dep, post]);

在這個例子中,我們希望達到的目標是僅當dep發生改變的時候,觸發post函數,從而將資料進行傳送,在這裡我們完全按照了react-hooks/exhaustive-deps的規則去定義了函數。那麼看起來似乎並沒有什麼問題,但是當我們實際去應用的時候,會發現當text這個狀態發生變化的時候,同樣會觸發這個post函數的執行,這是個並不明顯的問題,如果text這個狀態改變的頻率很低的話,甚至在迴歸的過程中都可能無法發現這個問題。此外,可以看到這個依賴的鏈路已經很長了,如果函數在複雜一些,那複雜性越來越高,整個狀態就會變的特別難以維護。

那麼如何解決這個問題呢,一個可行的辦法是我們可以將函數定義在useRef上,那麼這樣的話我們就可以一直拿到最新的函數定義了,實際效果與直接定義一個函數呼叫無異,只不過不會受到react-hooks/exhaustive-deps規則的困擾了。那麼實際上我們並沒有減緩複雜性,只是將複雜性轉移到了useRef上,這樣的話我們就需要去維護這個useRef的值,這樣的話就會帶來一些額外的心智負擔。

const post = useRef(() => void 0);

post.current = () => {
  postEvent({ textLen, depLen });
}

useEffect(() => {
  post.current();
}, [dep]);

那麼既然我們可以依靠useRef來解決這個問題,我們是不是可以將其封裝為一個自定義的Hooks呢,然後因為實際上我們並沒有辦法阻止函數的建立,那麼我們就使用兩個ref,第一個ref保證永遠是同一個參照,也就是說返回的函數永遠指向同一個函數地址,第二個ref用來儲存當前傳入的函數,這樣發生re-render的時候每次建立新的函數我們都將其更新,也就是說我們即將呼叫的永遠都是最新的那個函數。這樣通過兩個ref我們就可以保證兩點,第一點是無論發生多少次re-render,我們返回的都是同一個函數地址,第二點是無論發生了多少次re-render,我們即將呼叫的函數都是最新的。由此,我們就來看下ahooks是如何實現的useMemoizedFn

type noop = (this: any, ...args: any[]) => any;

type PickFunction<T extends noop> = (
  this: ThisParameterType<T>,
  ...args: Parameters<T>
) => ReturnType<T>;

function useMemoizedFn<T extends noop>(fn: T) {
  const fnRef = useRef<T>(fn);

  // why not write `fnRef.current = fn`?
  // https://github.com/alibaba/hooks/issues/728
  fnRef.current = useMemo(() => fn, [fn]);

  const memoizedFn = useRef<PickFunction<T>>();
  if (!memoizedFn.current) {
    memoizedFn.current = function (this, ...args) {
      return fnRef.current.apply(this, args);
    };
  }

  return memoizedFn.current as T;
}

那麼使用的時候就很簡單了,可以看到我們使用useMemoizedFn時是不需要依賴陣列的,並且雖然我們在useEffect中定義了post函數的依賴,但是由於我們上邊保證了第一點,那麼這個在這個元件被完全解除安裝之前,這個依賴的函數地址是不會變的,由此我們就可以保證只可能由於dep發生的改變才會觸發useEffect,而且我們保證的第二點,可以讓我們在re-render之後拿到的都是最新的函數作用域,也就是textLendepLen是能夠保證是最新的
,不會存在拿到了舊的函數作用域裡邊值的問題。

const post = useMemoizedFn(() => {
  postEvent({ textLen, depLen });
});

useEffect(() => {
  post.current();
}, [dep, post]);

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://juejin.cn/post/7194368992025247804
https://juejin.cn/post/7098137024204374030
https://react.dev/reference/react/useCallback