如何在 React 中優雅的使用 addEventListener

uccs發表於2023-01-30

React Hooks 中使用第三方庫的事件時,很多人會寫成這樣(指的就是我):

const [count, setCount] = useState(0);
useEffect(() => {
  const library = new Library();
  library.on("click", () => {
    console.log(count); // 拿不到最新的 count
  });
}, []);

這樣寫會有問題:

它只會在這個元件載入時,繫結事件,如果這個事件中用到了其他的 state,那麼這個狀態發生變化時事件中是拿不到最新的 state

你會想到,我把 state 放到依賴項中:

const [count, setCount] = useState(0);
useEffect(() => {
  const library = new Library();
  // click 事件會重複繫結
  library.on("click", () => {
    console.log(count);
  });
}, [count]);

這樣做又會有新問題:click 事件會重複繫結

這時候你說那我先解除安裝 click 事件,在繫結事件:

const [count, setCount] = useState(0);
useEffect(() => {
  const library = new Library();
  library.on("click", handleClick);
  return () => {
    // 解除安裝不掉事件,還是會重複繫結
    handleClick && library.un("click", handleClick);
  };
}, [count]);

const handleClick = () => {
  console.log(count);
};

你驚奇的發現,居然解除安裝不掉之前的事件,還是會重複繫結事件。

如何解決這個問題呢?

使用 addEventListener 代替第三方庫的事件

這裡使用 addEventListener 代替第三方庫的事件,初始程式碼

const Test = (props) => {
  const ref = useRef();
  const [count, setCount] = useState(0);

  useEffect(() => {
    const handleClick = (event) => {
      console.log("clicked");
      console.log("count", count);
    };
    const element = ref.current;
    element.addEventListener("click", handleClick);
    return () => {
      element.removeEventListener("click", handleClick);
    };
  }, []);

  const onClickIncrement = () => {
    setCount(count + 1);
  };
  return (
    <>
      <h2>Test</h2>
      <button onClick={onClickIncrement}>點選 +1</button>
      <div>count: {count}</div>
      <button ref={ref}>Click Test Button</button>
    </>
  );
};

方法一:state 變化,解除安裝/繫結事件

state 放在依賴項中,就要解決 state 變化時,事件重複繫結的問題

解決事件重複繫結問題,首先想到的是事件解除安裝

你很容易就會想到這樣寫

useEffect(() => {
  handleClick && ref.current.removeEventListener("click", handleClick);
  ref.current.addEventListener("click", handleClick);
}, [count]);

const handleClick = () => {
  console.log(count);
};

這在 React Hooks 中是一個坑,state 變化後會 handleClick 事件函式會重新宣告,新的 handleClick 和之前的 handleClick 不是一個事件函式,導致 removeEventListener 移除的事件函式不是之前的事件函式

那你又會想到,我給 handleClick 加個 useCallback

useEffect(() => {
  handleClick && ref.current.removeEventListener("click", handleClick);
  ref.current.addEventListener("click", handleClick);
}, [count]);

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

這樣寫的話還是會有同一個問題:依賴項為空陣列,就拿不到最新的 state;依賴項中放入 statestate 變化後就不是同一個事件函式了,無法移除事件

如何解決這個問題呢?

把事件函式儲存為狀態:

  1. count 變化時,掛載事件,同時將事件函式儲存為 state
  2. eventFn.fn 變化時,在 useEffect return 中解除安裝之前的事件函式(這裡利用的是閉包)

具體的程式碼:

const Test = () => {
  const ref = useRef();
  const [count, setCount] = useState(0);
  const [eventFn, setEventFn] = useState({ fn: null });

  useEffect(() => {
    mountEvent();
  }, [count]);

  const mountEvent = () => {
    if (!ref.current) return;
    //  eventFn.fn && ref.current.removeEventListener("click", eventFn.fn);  // 下面看不懂的話,也可以這樣寫
    ref.current.addEventListener("click", handleClick);
    setEventFn({ fn: handleClick });
  };

  useEffect(() => {
    return () => {
      eventFn.fn && ref.current.removeEventListener("click", eventFn.fn); // 這裡用的是閉包,和上面註釋部分任選其一
    };
  }, [eventFn.fn]);

  const handleClick = () => {
    console.log(count);
  };

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

  return (
    <>
      <h2>Test</h2>
      <button onClick={onClickIncrement}>點選 +1</button>
      <div>count: {count}</div>
      <button ref={ref}>Click Test Button</button>
    </>
  );
};

方法二:使用閉包的方式解除安裝事件

利用閉包,可以將方法一簡化

const Test = () => {
  const ref = useRef();
  const [count, setCount] = useState(0);

  useEffect(() => {
    const element = ref.current;
    element.addEventListener("click", handleClick);
    return () => {
      element.removeEventListener("click", handleClick);
    };
  }, [count]);

  const handleClick = () => {
    console.log(count);
  };

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

  return (
    <>
      <h2>Test</h2>
      <button onClick={onClickIncrement}>點選 +1</button>
      <div>count: {count}</div>
      <button ref={ref}>Click Test Button</button>
    </>
  );
};

useEffect return 中的變數用的是閉包,這點剛開始學的時候不好理解

方法三:使用 ref 儲存狀態

ref 儲存的資料雖然不能用於頁面渲染,但可以作為 state 備份,在 state 變化時更新 ref

在事件函式中就能拿到最新的 stateRef

const Test = () => {
  const ref = useRef();
  const [count, setCount] = useState(0);

  const countRef = useRef(count);
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const element = ref.current;
    element.addEventListener("click", handleClick);
  }, []);

  const handleClick = () => {
    console.log(countRef.current);
  };

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

  return (
    <>
      <h2>Test</h2>
      <button onClick={onClickIncrement}>點選 +1</button>
      <div>count: {count}</div>
      <button ref={ref}>Click Test Button</button>
    </>
  );
};

最佳化 state 手動維護

上面三種方法,都有個問題,state 需要手動維護

這一步如何最佳化呢?

方法一和方法二,最佳化的方式都一樣:將依賴項是 count 改為 state

const [state, setState] = useState({ count: 0 });

useEffect(() => {
  // ...
}, [state]);

方法三的最佳化是,用 stateRef 儲存 ref 物件,當 state 變化時,遍歷 statestateRef 賦值

事件函式中使用 stateRef

const [state, setState] = useState({ count: 0 });
const stateRef = useRef({});
useEffect(() => {
  Object.keys(state).forEach((key) => {
    stateRef.current[key] = state[key];
  });
}, [state]);

方案四:useEvent

當前還處於 RFC 階段

const useEvent = (handler) => {
  const handlerRef = useRef(null);
  useLayoutEffect(() => {
    handlerRef.current = handler;
  });
  return useCallback((...args) => {
    const fn = handlerRef.current;
    return fn(...args);
  }, []);
};

方案五:useMemoizedFn

ahook 實現

function useMemoizedFn(fn) {
  const fnRef = useRef(fn);

  fnRef.current = useMemo(() => fn, [fn]);
  const memoizedFn = useRef();
  if (!memoizedFn.current) {
    memoizedFn.current = function (_this, ...args) {
      return fnRef.current.apply(_this, args);
    };
  }

  return memoizedFn.current;
}

寫在最後

這個問題困擾了我很久,寫業務時,我一直用方法一,隨著依賴項越來越多,維護是個噩夢(方法三也是噩夢)

我一直在想如何在 addEventListener 中拿到最新的 state

今天本來想記錄下方法一和方法三的

在一點點寫下筆記的時候,發現了方法二,可以利用閉包解決事件解除安裝問題,從而又發現了最佳化維護依賴項的方法

如果今天不寫筆記,這個問題還會繼續困擾著我

相關文章