React中Popstate事件在useEffect清理前未触发的问题排查
问题描述
我需要实现用户离开页面时的「确认离开」提示(避免未保存更改丢失),简化后的Hook示例如下:
import { useEffect } from "react"; const useUnsavedChangesAlert = () => { useEffect(() => { const handleRouteChange = (event) => { console.log("IN HANDLE ROUTE CHANGE!!"); }; console.log("ADD POPSTATE EVENT LISTENER!!"); window.addEventListener("popstate", handleRouteChange); return () => { console.log("REMOVE POPSTATE EVENT LISTENER!!"); window.removeEventListener("popstate", handleRouteChange); }; }, []); }; export default useUnsavedChangesAlert;
预期点击浏览器返回按钮时,popstate事件先触发handleRouteChange,再执行useEffect清理函数移除监听器,但实际handleRouteChange的日志完全没输出。
在create-react-app中的复现代码:
import React, { useEffect } from "react"; export default function Home() { useEffect(() => { const handleRouteChange = (event) => { console.log("IN HANDLE ROUTE CHANGE!!"); alert("Are you sure you want to leave this page?"); }; console.log("ADD POPSTATE EVENT LISTENER!!"); window.addEventListener("popstate", handleRouteChange); return () => { console.log("REMOVE POPSTATE EVENT LISTENER!!"); window.removeEventListener("popstate", handleRouteChange); }; }, []); return <h1>Home</h1>; }
该问题在Next.js 14和create-react-app中均存在,注释掉removeEventListener后,handleRouteChange可正常执行。推测是页面导航导致组件卸载,useEffect清理函数先移除了popstate监听器,导致事件无法触发。需要解决让事件先触发再执行清理的问题。
解决方案
方法一:使用beforeunload事件替代(推荐)
对于浏览器前进/后退、关闭标签页这类页面卸载场景,原生beforeunload事件是更适配的选择——它会在页面即将卸载前优先触发,不会被组件清理函数抢先执行。修改后的Hook如下:
import { useEffect, useState } from "react"; const useUnsavedChangesAlert = () => { // 用状态标记是否存在未保存更改 const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); useEffect(() => { const handleBeforeUnload = (event) => { if (hasUnsavedChanges) { // 部分浏览器需要通过preventDefault和returnValue触发提示 event.preventDefault(); event.returnValue = "您有未保存的更改,确定要离开吗?"; return "您有未保存的更改,确定要离开吗?"; } }; window.addEventListener("beforeunload", handleBeforeUnload); return () => { window.removeEventListener("beforeunload", handleBeforeUnload); }; }, [hasUnsavedChanges]); // 向外暴露修改未保存状态的方法 return { setHasUnsavedChanges }; }; export default useUnsavedChangesAlert;
使用时,在表单输入、数据修改等操作中调用setHasUnsavedChanges(true)即可标记未保存状态,触发离开确认提示。
方法二:针对单页应用路由跳转,使用框架路由监听API
在React/Next.js这类单页应用中,路由跳转由框架内部处理,直接监听popstate容易和框架逻辑冲突。可以改用框架提供的路由监听能力:
Next.js(App Router)方案
结合usePathname和useRouter拦截路由跳转,同时处理浏览器前进后退:
"use client"; import { useEffect, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; const useUnsavedChangesAlert = () => { const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const pathname = usePathname(); const router = useRouter(); useEffect(() => { // 处理浏览器前进后退 const handlePopState = () => { if (hasUnsavedChanges) { const confirmLeave = window.confirm("您有未保存的更改,确定要离开吗?"); if (!confirmLeave) { // 用户取消时,回退到当前路径 history.pushState(null, "", pathname); } } }; // 拦截编程式路由跳转 const originalPush = router.push; router.push = (...args) => { if (hasUnsavedChanges) { const confirmLeave = window.confirm("您有未保存的更改,确定要离开吗?"); if (!confirmLeave) return; } return originalPush.apply(router, args); }; window.addEventListener("popstate", handlePopState); return () => { // 恢复原路由方法并移除监听器 router.push = originalPush; window.removeEventListener("popstate", handlePopState); }; }, [hasUnsavedChanges, pathname, router]); return { setHasUnsavedChanges }; }; export default useUnsavedChangesAlert;
React Router方案
通过替换navigate方法拦截编程式跳转,同时监听popstate处理浏览器导航:
import { useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; const useUnsavedChangesAlert = () => { const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const navigate = useNavigate(); const location = useLocation(); useEffect(() => { // 处理浏览器前进后退 const handlePopState = () => { if (hasUnsavedChanges) { const confirmLeave = window.confirm("您有未保存的更改,确定要离开吗?"); if (!confirmLeave) { history.pushState(null, "", location.pathname); } } }; // 拦截编程式路由跳转 const originalNavigate = navigate; const guardedNavigate = (...args) => { if (hasUnsavedChanges) { const confirmLeave = window.confirm("您有未保存的更改,确定要离开吗?"); if (!confirmLeave) return; } return originalNavigate.apply(null, args); }; Object.assign(navigate, guardedNavigate); window.addEventListener("popstate", handlePopState); return () => { Object.assign(navigate, originalNavigate); window.removeEventListener("popstate", handlePopState); }; }, [hasUnsavedChanges, navigate, location]); return { setHasUnsavedChanges }; }; export default useUnsavedChangesAlert;
方法三:延迟清理监听器(应急方案,不推荐)
如果必须使用popstate,可以通过setTimeout延迟清理函数执行,确保事件先触发:
import { useEffect } from "react"; const useUnsavedChangesAlert = () => { useEffect(() => { const handleRouteChange = (event) => { console.log("IN HANDLE ROUTE CHANGE!!"); const confirmLeave = window.confirm("您有未保存的更改,确定要离开吗?"); if (confirmLeave) { // 用户确认后手动移除监听器 window.removeEventListener("popstate", handleRouteChange); } else { // 用户取消,恢复历史状态 history.pushState(null, "", window.location.pathname); } }; console.log("ADD POPSTATE EVENT LISTENER!!"); window.addEventListener("popstate", handleRouteChange); return () => { // 延迟100ms执行清理,确保事件先触发 setTimeout(() => { console.log("REMOVE POPSTATE EVENT LISTENER!!"); window.removeEventListener("popstate", handleRouteChange); }, 100); }; }, []); }; export default useUnsavedChangesAlert;
注意:这种方法依赖定时器延迟,存在时序兼容性问题,仅适合临时应急使用。
内容的提问来源于stack exchange,提问作者Luke Sharon

