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

源码

引用

useEvent RFC

Add useEvent PR

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

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

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