在 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
;依賴項中放入 state
,state
變化後就不是同一個事件函式了,無法移除事件
如何解決這個問題呢?
把事件函式儲存為狀態:
- 當
count
變化時,掛載事件,同時將事件函式儲存為state
- 當
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
變化時,遍歷 state
給 stateRef
賦值
事件函式中使用 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
今天本來想記錄下方法一和方法三的
在一點點寫下筆記的時候,發現了方法二,可以利用閉包解決事件解除安裝問題,從而又發現了最佳化維護依賴項的方法
如果今天不寫筆記,這個問題還會繼續困擾著我