React 团队发布了一个useEvent RFC。
它用于定义始终稳定的函数引用的事件处理 Hook,解决事件回调中的闭包陷阱问题。
能够解决部分 useCallback 的痛点,简化心智负担。
该 RFC 仍处于 React 团队早期讨论阶段,落地前建议结合当前 React 版本与发布说明评估。
动机
在 onClick 事件处理中需要读取当前键入的 text:
function Chat() {
const [text, setText] = useState("");
// 永远是新的函数引用
const onClick = () => {
sendMessage(text);
};
return <SendButton onClick={onClick} />;
}
为了性能优化,我们通常都会把 SendButton 包裹在 React.memo 中。如果想要它有效、不重复渲染,需要 props 通过浅比较。但是 onClick 函数每次渲染都会产生不同的函数引用,因此会使性能优化失效。
export interface SendButtonProps {
children: ReactNode;
onClick: () => void;
}
const SendButton = ({ children, onClick }: SendButtonProps) => {
return <button onClick={onClick}>{children}</button>;
};
export default memo(SendButton);
解决此类问题的常用方法是将 onClick 函数包装成 useCallback,以保留函数引用。
const onClick = useCallback(() => {
sendMessage(text);
}, []);
初学时常会忘记添加依赖,sendMessage 函数一直获取不到最新值。(闭包陷阱)
const onClick = useCallback(() => {
sendMessage(text);
}, [text]);
依赖项 text 变化时,useCallback 返回一个全新的 onClick 函数引用,这样 sendMessage 函数就可以获取到最新值。
问题是只要 text 改变,onClick 函数引用也会发生变化,这样 useCallback 就失去了缓存函数的作用。
useEventCallback
事实上,官网文档如何从 useCallback 读取一个经常变化的值?中已经有解决方案了。
如果想要保留函数引用,并能够在函数内部获取最新的值,可以使用 ref,把它当作实例变量,并且不会触发渲染。
const [text, setText] = useState("");
const textRef = useRef<string>();
useEffect(() => {
textRef.current = text; // 每次都把最新的text写入ref中
});
const sendMessage = (text?: string) => {
console.log("text", text);
};
const onClick = useCallback(() => {
sendMessage(textRef.current); // 从ref中获取最新值
}, [textRef]); // 这个并不是重新生成函数引用。
return (
<>
<input value={text} onChange={(event) => setText(event.target.value)} />
<SendButton onClick={onClick}>按钮Ref</SendButton>
</>
);
这种模式一直比较麻烦,每次都要写一堆模板代码。可以把它抽取成一个自定义 Hook:
const [text, updateText] = useState("");
// 即使 `text` 变了也会被记住:
const handleSubmit = useEventCallback(() => {
alert(text);
}, [text]);
return (
<>
<input value={text} onChange={(e) => updateText(e.target.value)} />
<SendButton onClick={handleSubmit}>useEventCallback按钮</SendButton>
</>
);
useEventCallback Hook 的实现:
type Fn = () => void;
export function useEventCallback(fn: Fn, dependencies: DependencyList) {
const ref = useRef<Fn>(() => {
throw new Error("Cannot call an event handler while rendering.");
});
// 每次渲染就把最新的函数存入到ref中
useEffect(() => {
ref.current = fn;
}, [fn, ...dependencies]);
// 函数引用永远不会发生变化。但是可以从ref中获取最新的值(函数)
return useCallback(() => {
const fn = ref.current;
return fn();
}, [ref]);
}
官网文档中强烈不建议使用这种做法,更倾向于使用 context 向下传递 dispatch。
不过现在看起来官方团队妥协了。
useEvent
useEvent 没有依赖数组,并且返回相同的函数引用,还能够获取到最新的 text。
const [text, updateText] = useState("");
// 即便 text 变了也会被记住:
const handleSubmit = useEvent(() => {
alert(text);
});
return (
<>
<input value={text} onChange={(e) => updateText(e.target.value)} />
<SendButton onClick={handleSubmit}>useEvent按钮</SendButton>
</>
);
useEvent 模拟实现:
export function useEvent(handler: Fn) {
const handlerRef = useRef<Fn | null>(null);
// 视图渲染后更新handlerRef.current, 函数触发时始终是最新的引用。
useLayoutEffect(() => {
handlerRef.current = handler;
});
// 没有依赖项,每次render时函数的引用一致
return useCallback((...args) => {
const fn = handlerRef.current;
return fn?.(...args);
}, []);
}
场景
当事件函数变化时,useEffect不应该触发
Chat 连接到所选房间,加入房间或收到消息时会输出日志,并根据 muted 决定是否播放声音。
export const Chat = ({ selectedRoom }: ChatProps) => {
const [muted, setMuted] = useState();
const theme = useContext(ThemeContext);
useEffect(() => {
const socket = createSocket("/chat/" + selectedRoom);
socket.on("connected", async () => {
await checkedConnection(selectedRoom);
console.log(theme, "Connected to" + selectedRoom);
});
socket.on("message", (message) => {
console.log(theme, "New message" + message);
if (!muted) {
playSound();
}
});
socket.connect();
return () => socket.close();
}, [selectedRoom, theme, muted]);
};
更改 theme 和 muted 会导致 socket 销毁后重新创建并重新连接。这是因为 useEffect 中使用了 theme 和 muted,必须在依赖中指定它们。
如果忽略 Lint 或从依赖中移除 theme 和 muted,那么 connected 和 message 永远获取不到最新值。(闭包陷阱)
为什么不能禁用 Lint?
禁用 Lint 可能导致更大的问题,例如在 React 18 中 useEffect(() => {}, []) 可能会触发多次。
使用 useEvent 解决这类问题:
export const Chat = ({ selectedRoom }: ChatProps) => {
const [muted, setMuted] = useState();
const theme = useContext(ThemeContext);
const onConnected = useEvent(async (selectedRoom) => {
await checkedConnection(selectedRoom);
console.log(theme, "Connected to" + selectedRoom);
});
const onMessage = useEvent((message: string) => {
console.log(theme, "New message" + message);
if (!muted) {
playSound();
}
});
useEffect(() => {
const socket = createSocket("/chat/" + selectedRoom);
socket.on("connected", () => onConnected(selectedRoom));
socket.on("message", onMessage);
socket.connect();
return () => socket.close();
}, [selectedRoom]); // 只有当房间号改变时才会销毁socket。重新创建socket并连接
};
将参数传递给事件
在 onConnected 和 onMessage 中可以获取到最新的 theme 和 muted 值。
但是 selectedRoom 可能是之前的值,因此需要以参数的方式将它传递给 onConnected 函数。
自定义hook封装
export const useRoom = (room: string, events: {}) => {
const onConnected = useEvent(events.onConnected);
const onMessage = useEvent(events.onMessage);
useEffect(() => {
const socket = createSocket(room);
socket.on("connected", onConnected);
socket.on("message", onMessage);
socket.connect();
return () => socket.close();
}, [room]); // 只有当房间号改变时才会销毁socket。重新创建socket并连接
};
传递的事件回调被包装在 useEvent 中,保证了函数引用的唯一性,useEffect 永远不会重新触发。
const [muted, setMuted] = useState();
const theme = useContext(ThemeContext);
const onConnected = useEvent(async (connectedRoom) => {
await checkedConnection(selectedRoom);
console.log(theme, "Connected to" + selectedRoom);
});
const onMessage = useEvent((message: string) => {
console.log(theme, "New message" + message);
if (!muted) {
playSound();
}
});
useRoom(selectedRoom, { onConnected, onMessage });
埋点
埋点依赖于 url 和 name,只有 url 改变时才需要重新埋点,name 的改变并不需要重新触发埋点。
export const Tracking = ({ url, name }: TrackingProps) => {
useEffect(() => {
logAnalytics("visit_page", url, name);
}, [url, name]);
};
现有方案
const urlRef = useRef<string>();
useEffect(() => {
if (urlRef.current === url) {
return;
}
logAnalytics("visit_page", url, name);
urlRef.current = url; // 保证urlRef引用最新的url值。
}, [url, name]);
基于useEvent
注意 url 必须以参数形式传入:
const onVisit = useEvent((url: string) => {
logAnalytics("visit_page", url, name);
});
useEffect(() => {
onVisit(url);
}, [url]);
定时器
在做邮箱系统时,需要定时保存邮件内容到草稿箱中。
useEffect(() => {
const id = setInterval(() => {
saveDraft(content);
}, 1000);
return () => clearInterval(id);
}, [content]);
每次 content 变化都会导致定时器重新触发。
删除依赖后,saveDraft 获取不到最新的 content。
现有方案
const [content, setContent] = useState();
const handleSaveDraftRef = useRef<() => void>();
const handleSaveDraft = () => {
saveDraft(content);
};
useEffect(() => {
handleSaveDraftRef.current = handleSaveDraft;
});
useEffect(() => {
const id = setInterval(() => {
handleSaveDraftRef.current?.();
}, 1000);
return () => clearInterval(id);
}, []);
看起来就特别繁琐。
基于useEvent
const [content, setContent] = useState();
const handleSaveDraft = useEvent(() => {
saveDraft(content);
});
useEffect(() => {
const id = setInterval(handleSaveDraft, 1000);
return () => clearInterval(id);
}, []);
源码
引用
https://twitter.com/dan_abramov/status/1522218410695794694
https://overreacted.io/making-setinterval-declarative-with-react-hooks/
DISCUSSION
讨论与反馈