React 团队发布了一个useEvent RFC。
用于定义始终稳定的函数引用的事件处理hook,解决hook存在的闭包陷阱问题。
能够代替部分useEffect,简化心智负担。
目前还没有发布。
动机
在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
,可以把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
,必须在依赖中指定他们。
如果忽略linter或从依赖中移除theme
和muted
。那么connected
和message
永远获取不到最新值。闭包陷阱
为什么不能禁用Lint?
禁用可能导致更大的问题,例如React18 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
中,保证了唯一的函数引用。永远不会重新触发。
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/