跳到主要内容

useEvent

· 阅读需 9 分钟

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]);
};

更改thememuted会导致socket销毁并重新创建与连接。这是因为useEffect中使用了thememuted,必须在依赖中指定他们。

如果忽略linter或从依赖中移除thememuted。那么connectedmessage永远获取不到最新值。闭包陷阱

为什么不能禁用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并连接
};

将参数传递给事件

onConnectedonMessage中可以获取到最新的thememuted值。

但是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 });

埋点

埋点依赖了urlname,只有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);
}, []);

源码

引用

useEvent RFC

Add useEvent PR

如何从 useCallback 读取一个经常变化的值?

https://twitter.com/dan_abramov/status/1522218410695794694

https://overreacted.io/making-setinterval-declarative-with-react-hooks/