匿名函数无法移除事件监听?
基础知识
- EventTarget .addEventListener() 方法将指定的监听器注册到 EventTarget 上,当该对象触发指定的事件时,指定的回调函数就会被执行。事件目标可以是一个文档上的元素 Element、Document 和 Window。
- addEventListener() 的工作原理是将实现 EventListener 的函数或对象添加到调用它的 EventTarget 上的指定事件类型的事件侦听器列表中。如果要绑定的函数或对象已经被添加到列表中,该函数或对象不会被再次添加。
- EventTarget 的 removeEventListener() 方法可以删除使用 EventTarget.addEventListener() 方法添加的事件。可以使用事件类型,事件侦听器函数本身,以及可能影响匹配过程的各种可选择的选项的组合来标识要删除的事件侦听器。
- 调用 removeEventListener() 时,若传入的参数不能用于确定当前注册过的任何一个事件监听器,该函数不会起任何作用。 如果一个 EventTarget 上的事件监听器在另一监听器处理该事件时被移除,那么它将不能被事件触发。不过,它可以被重新绑定。
问题描述
- 代码如下,可以手动添加/移除监听事件;在操作过程中,添加监听事件成功,移除监听事件失败。监听的函数仍然生效,会不断打印出"The user is scrolling!"和index的值。
- 移除监听器失效的原因在于,useEffect 中为 scroll 事件添加的监听器是一个匿名函数:() => handleScroll(index)。 由于匿名函数的引用不同,removeEventListener 无法匹配到正确的函数引用,因此无法有效移除监听器。
import React, { useState, useEffect } from "react";
const App = () => {
const [isListening, setIsListening] = useState(false);
const handleScroll = useCallback((index) => {
console.log("The user is scrolling!", index);
}, []);
// 使用useEffect添加和移除事件监听器
useEffect(() => {
if (isListening) {
let index = 1;
console.log("设置事件监听!");
window.addEventListener("scroll", () => handleScroll(index));
return () => {
window.removeEventListener("scroll", handleScroll);
};
}
}, [isListening, handleScroll]);
// 手动控制监听器的添加和移除
const toggleListener = () => {
setIsListening((prevIsListening) => !prevIsListening);
};
// 手动移除监听器
const removeListener = () => {
if (isListening) {
console.log("移除事件监听!");
window.removeEventListener("scroll", handleScroll);
setIsListening(false); // 更新状态以反映监听器已被移除
}
};
return (
<div style={{ height: "1000vh" }}>
<p>滚动页面以查看控制台日志。</p>
<button onClick={toggleListener}>
{isListening ? "停止监听滚动" : "开始监听滚动"}
</button>
<button onClick={removeListener}>手动移除滚动监听</button>
</div>
);
};
export default App;
追根溯源
- MDN文档示例:
const els = document.getElementsByTagName("*");
// 例一
for (let i = 0; i < els.length; i++) {
els[i].addEventListener(
"click",
(e) => {
/* 处理点击事件 */
},
false,
);
}
// 例二
function processEvent(e) {
/* 处理同样的点击事件 */
}
for (let i = 0; i < els.length; i++) {
els[i].addEventListener("click", processEvent, false);
}
- 在上面的第一个例子中,一个新的(匿名)函数在每次循环中被创建一次。在第二个例子中,与之前的匿名函数功能相同的函数被用作事件监听器,但后者所带来的内存开销要更小一点,因为函数只被声明过一次。
- 此外,在第一个例子中,我们不能调用 removeEventListener(),因为我们没有保留任何对匿名函数的引用(在例子的情况中,是没有保存对循环中创建的多个匿名函数的引用)。而在第二个例子中,processEvent 是一个可被引用的函数,因此可以调用 myElement.removeEventListener(“click”, processEvent, false)。
- 实际上,真正影响内存的并不是没有保持函数引用,而是没有 保持静态的函数引用 – 在整个程序生命周期内保持不变的函数引用。
问题解决
若希望在 useEffect 中通过 () => handleScroll(index) 设置的监听器能够正确移除,需要确保 removeEventListener 能准确匹配添加的监听器。可以通过为匿名函数显式引用来解决。
import React, { useState, useEffect, useCallback } from "react";
const App = () => {
const [isListening, setIsListening] = useState(false);
const handleScroll = useCallback((index) => {
console.log("The user is scrolling!", index);
}, []);
// 用于保存事件监听器函数的引用
const scrollHandlerRef = React.useRef(null);
useEffect(() => {
if (isListening) {
let index = 1;
console.log("设置事件监听!");
// 将匿名函数赋值给 ref
scrollHandlerRef.current = () => handleScroll(index);
window.addEventListener("scroll", scrollHandlerRef.current);
return () => {
console.log("移除事件监听!");
if (scrollHandlerRef.current) {
window.removeEventListener("scroll", scrollHandlerRef.current);
}
};
}
}, [isListening, handleScroll]);
// 手动控制监听器的添加和移除
const toggleListener = () => {
setIsListening((prevIsListening) => !prevIsListening);
};
const removeListener = () => {
if (isListening && scrollHandlerRef.current) {
console.log("手动移除事件监听!");
window.removeEventListener("scroll", scrollHandlerRef.current);
setIsListening(false); // 更新状态以反映监听器已被移除
}
};
return (
<div style={{ height: "1000vh" }}>
<p>滚动页面以查看控制台日志。</p>
<button onClick={toggleListener}>
{isListening ? "停止监听滚动" : "开始监听滚动"}
</button>
<button onClick={removeListener}>手动移除滚动监听</button>
</div>
);
};
export default App;
- 使用 scrollHandlerRef(一个 ref)来存储动态生成的事件监听函数,确保在移除事件监听时可以引用到正确的函数。
- scrollHandlerRef.current 是一个闭包,可以捕获当前的 index 值,并在 addEventListener 和 removeEventListener 中使用。
- 在 useEffect 的清理函数中,通过 scrollHandlerRef 来移除正确的事件监听器。
多个场景
- 同样,可以通过循环为多个事件监听器动态地添加和移除,关键是要为每个事件监听器函数保留一个稳定的引用,同时确保每个监听器的管理逻辑清晰。以下是实现方案:
import React, { useState, useEffect, useCallback, useRef } from "react";
const App = () => {
const [isListening, setIsListening] = useState(false);
const handleScroll = useCallback((index) => {
console.log(`滚动事件触发,Index: ${index}`);
}, []);
const eventHandlers = useRef([]); // 保存所有事件监听器的引用
useEffect(() => {
if (isListening) {
console.log("设置多个事件监听器!");
// 动态创建监听器
[1, 2].forEach((index) => {
const listener = () => handleScroll(index);
eventHandlers.current[index] = listener; // 保存每个监听器引用
window.addEventListener("scroll", listener);
});
return () => {
console.log("移除所有事件监听器!");
[1, 2].forEach((index) => {
const listener = eventHandlers.current[index];
if (listener) {
window.removeEventListener("scroll", listener);
}
});
eventHandlers.current = []; // 清空监听器引用
};
}
}, [isListening, handleScroll]);
// 控制监听器的添加和移除
const toggleListener = () => {
setIsListening((prevIsListening) => !prevIsListening);
};
const removeAllListeners = () => {
console.log("手动移除所有事件监听器!");
[1, 2].forEach((index) => {
const listener = eventHandlers.current[index];
if (listener) {
window.removeEventListener("scroll", listener);
}
});
eventHandlers.current = []; // 清空监听器引用
setIsListening(false); // 更新状态
};
return (
<div style={{ height: "1000vh" }}>
<p>滚动页面以查看控制台日志。</p>
<button onClick={toggleListener}>
{isListening ? "停止监听滚动" : "开始监听滚动"}
</button>
<button onClick={removeAllListeners}>手动移除所有滚动监听</button>
</div>
);
};
export default App;
- eventHandlers 用于保存每个监听器的引用:通过一个数组 eventHandlers.current 保存每个监听器的引用,索引与动态参数(如 index)关联。这样可以在移除监听器时通过索引访问对应的函数。
- 动态添加多个监听器:使用 forEach 遍历要添加监听器的参数(如 [1, 2]),为每个参数生成对应的事件监听器函数,并将它添加到 window 和 eventHandlers 中。
- 移除所有监听器:在 useEffect 的清理函数和手动移除函数中,通过遍历 eventHandlers.current 来移除所有监听器。
- 清空监听器引用:移除监听器后,清空 eventHandlers.current 以防止遗留无效引用。