Hooks與事件綁定

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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章