讲师中心 微信公众号
AI工具推荐 视频效率加速

Next.js 与 Chakra UI:实现页面未保存修改离页提示与导航控制

小磊小哥_3826

小磊小哥_3826

发布时间:2025-11-18 19:45:16

|

913人浏览过

|

来源于php中文网

原创

next.js 与 chakra ui:实现页面未保存修改离页提示与导航控制

本文详细介绍了在 Next.js 应用中,如何结合 Chakra UI 实现用户离页时未保存修改的提示功能。通过自定义 useNavigationObserver Hook,巧妙地拦截 Next.js 路由跳转事件,阻止默认导航行为,并提供弹窗询问用户是否继续。用户确认后,再手动导航至目标页面,确保数据完整性与用户体验。

背景与问题分析

在现代 Web 应用中,当用户在表单页面进行编辑但尚未保存时,如果尝试离开当前页面(例如点击导航链接或使用浏览器返回按钮),通常需要一个提示来防止数据丢失。在 Next.js 应用中,虽然浏览器提供了 beforeunload 事件来处理页面卸载,但它无法直接控制 Next.js 的客户端路由跳转。Next.js 的 router.events.on('routeChangeStart') 事件可以监听路由变化,但默认情况下,它并不能直接阻止路由的完成,这导致即使检测到未保存的更改并尝试打开弹窗,路由仍可能继续,弹窗也无法正常显示。

核心挑战在于:

  1. 阻止默认路由行为:在 routeChangeStart 阶段有效阻止 Next.js 的路由跳转。
  2. 保留目标路径:在阻止路由后,能够记住用户最初想要跳转到的目标路径。
  3. 恢复导航:当用户确认离开后,能够重新触发到目标路径的导航。
  4. UI 交互:结合 UI 组件库(如 Chakra UI)显示模态对话框。

解决方案:自定义 useNavigationObserver Hook

为了解决上述问题,我们可以创建一个自定义的 useNavigationObserver Hook。这个 Hook 的核心思想是利用 Next.js 路由事件的特性,通过“抛出假错误”的方式来中断路由跳转,并在用户确认后,手动恢复导航。

useNavigationObserver Hook 代码解析

import { useRouter } from "next/router";
import { useCallback, useEffect, useRef } from "react";

// 定义一个独特的错误消息,用于识别并阻止路由错误
const errorMessage = "Please ignore this error.";

// 抛出一个假错误以欺骗 Next.js 路由
const throwFakeErrorToFoolNextRouter = () => {
  // eslint-disable-next-line no-throw-literal
  throw errorMessage;
};

// 拦截并阻止 Next.js 内部的 PromiseRejectionEvent,防止假错误被报告
const rejectionHandler = (event: PromiseRejectionEvent) => {
  if (event.reason === errorMessage) {
    event.preventDefault(); // 阻止默认行为,即阻止控制台报告错误
  }
};

interface Props {
  shouldStopNavigation: boolean; // 是否需要阻止导航的标志
  onNavigate: () => void; // 当导航被阻止时触发的回调,用于打开提示弹窗
}

const useNavigationObserver = ({ shouldStopNavigation, onNavigate }: Props) => {
  const router = useRouter();
  const currentPath = router.asPath; // 当前页面路径
  const nextPath = useRef(""); // 存储用户尝试跳转的目标路径
  const navigationConfirmed = useRef(false); // 标记用户是否已确认离开

  // 阻止路由事件并抛出假错误
  const killRouterEvent = useCallback(() => {
    // 触发 'routeChangeError' 事件,Next.js 会认为路由失败
    router.events.emit("routeChangeError", "", "", { shallow: false });
    throwFakeErrorToFoolNextRouter(); // 抛出假错误以中断 Promise 链
  }, [router]);

  useEffect(() => {
    navigationConfirmed.current = false; // 每次组件挂载或依赖更新时重置确认状态

    const onRouteChange = (url: string) => {
      // 如果 URL 已经改变,但我们想阻止导航,需要将浏览器历史状态推回当前路径
      // 这是因为在 routeChangeStart 发生时,浏览器地址栏可能已经更新了
      if (currentPath !== url) {
        window.history.pushState(null, "", router.basePath + currentPath);
      }

      // 只有当满足以下条件时才阻止导航:
      // 1. shouldStopNavigation 为 true (即有未保存的更改)
      // 2. 目标 URL 与当前 URL 不同
      // 3. 用户尚未确认导航
      if (
        shouldStopNavigation &&
        url !== currentPath &&
        !navigationConfirmed.current
      ) {
        nextPath.current = url.replace(router.basePath, ""); // 存储目标路径
        onNavigate(); // 调用传入的回调函数,通常用于打开弹窗
        killRouterEvent(); // 阻止路由继续
      }
    };

    router.events.on("routeChangeStart", onRouteChange);
    window.addEventListener("unhandledrejection", rejectionHandler); // 监听未处理的 Promise 拒绝

    return () => {
      router.events.off("routeChangeStart", onRouteChange);
      window.removeEventListener("unhandledrejection", rejectionHandler);
    };
  }, [
    currentPath,
    killRouterEvent,
    onNavigate,
    router.basePath,
    router.events,
    shouldStopNavigation,
  ]);

  // 用户确认离开后,调用此函数以继续导航
  const confirmNavigation = () => {
    navigationConfirmed.current = true; // 标记已确认
    router.push(nextPath.current); // 导航到之前存储的目标路径
  };

  return confirmNavigation;
};

export { useNavigationObserver };

Hook 关键点解释:

  1. throwFakeErrorToFoolNextRouter & rejectionHandler:

    • Next.js 内部的路由跳转是基于 Promise 实现的。当 routeChangeStart 触发后,如果后续的 Promise 链被中断(例如抛出错误),Next.js 会认为路由失败并停止导航。
    • 我们通过 router.events.emit("routeChangeError") 配合 throw errorMessage 来模拟一个路由错误,从而中断正常的路由流程。
    • rejectionHandler 监听全局的 unhandledrejection 事件,捕获并阻止我们抛出的假错误被浏览器控制台报告,保持控制台的整洁。
  2. window.history.pushState:

    • 在 routeChangeStart 事件触发时,Next.js 可能会在内部更新浏览器地址栏的 URL,即使路由尚未完成。
    • 为了在阻止导航后将地址栏 URL 恢复到当前页面的路径,我们使用 window.history.pushState(null, "", router.basePath + currentPath)。这确保了用户在看到提示弹窗时,浏览器地址栏仍然显示当前页面的 URL。
  3. nextPath 和 navigationConfirmed:

    Teams Delegate
    Teams Delegate

    将Microsoft Teams收件箱管理委托给AI代理,实现自动回复、消息摘要、优先级筛选及情境感知响应。

    下载
    • nextPath useRef 用于存储用户最初尝试访问的目标路径。
    • navigationConfirmed useRef 是一个标志,当用户在弹窗中选择“是”时,将其设置为 true。这使得 confirmNavigation 函数可以绕过 shouldStopNavigation 检查,直接进行导航。
  4. confirmNavigation:

    • 这是 useNavigationObserver Hook 返回的函数。当用户在提示弹窗中点击“是”时,调用此函数。它将 navigationConfirmed 设置为 true,然后使用 router.push(nextPath.current) 重新发起导航到用户最初选择的页面。

在 Next.js 组件中集成

现在,我们将 useNavigationObserver Hook 集成到 Next.js 组件中,以实现离页提示功能。

import { useState } from "react";
import {
  Box,
  Grid,
  GridItem,
  Input,
  Flex,
  Button,
  useColorModeValue,
  useDisclosure, // Chakra UI Hook for managing modal state
} from "@chakra-ui/react";
// ... 其他导入,如 PasswordEditor, TopNav, services, showMsg, deep-equal 等

import { useNavigationObserver } from "@/hooks/useNavigationObserver"; // 导入自定义 Hook

const RecordEditing: React.FC<IRecordEditingProps> = ({
  type,
  record,
  user,
}) => {
  const [recordObj, setRecordObj] = useState<IRecordEditData>(record);
  const [password, setPassword] = useState<string>(record.password);
  const [isDirty, setIsDirty] = useState<boolean>(false); // 跟踪表单是否有未保存的更改
  const defaultRecord = { ...record, password }; // 初始记录状态,用于比较
  const title = type === "new" ? "New Record" : "Edit Record";
  const router = useRouter();
  const { recordId } = router.query;
  const buttonBg = useColorModeValue("#dbdbdb", "#2a2c38");

  // 使用 Chakra UI 的 useDisclosure 管理弹窗的打开/关闭状态
  const { isOpen, onOpen, onClose } = useDisclosure();

  // 使用自定义的 useNavigationObserver Hook
  const navigate = useNavigationObserver({
    shouldStopNavigation: isDirty, // 当 isDirty 为 true 时阻止导航
    onNavigate: () => onOpen(), // 导航被阻止时打开 Chakra UI 弹窗
  });

  // 检查表单数据是否与初始数据不同,更新 isDirty 状态
  const setDirtyInputs = () => {
    if (!isDeepEqual(defaultRecord, { ...recordObj, password })) {
      setIsDirty(true);
    } else {
      setIsDirty(false);
    }
  };

  // 处理输入变化,并更新 isDirty 状态
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setRecordObj((prevState) => ({
      ...prevState,
      [e.target.id]: e.target.value,
    }));
    setDirtyInputs(); // 每次输入变化后检查脏状态
  };

  // 处理表单提交
  const handleSubmit = () => {
    setIsDirty(false); // 提交后重置脏状态
    // ... 保存逻辑 ...
    if (type === "new") {
      postMethod(`/api/user/${user.id}/records`, {
        ...recordObj,
        password,
      })
        .then(() => router.push("/"))
        .then(() => showMsg("Record saved", { type: "success" }))
        .catch(() => showMsg("Something went wrong", { type: "error" }));
    } else {
      updateMethod(`/api/user/${user.id}/records/${recordId}`, {
        ...recordObj,
        password,
      })
        .then(() => router.push("/"))
        .then(() => showMsg("Record updated", { type: "success" }))
        .catch(() => showMsg("Something went wrong", { type: "error" }));
    }
  };

  return (
    <Box py="60px">
      <TopNav title={title} type="backAndTitle" />
      <Box>
        {/* 表单输入字段 */}
        <Grid gridTemplateColumns="3fr 6fr" gap="10px" py="10px">
          <GridItem w="100%" h="10">
            <Flex align="center" h="100%">Title</Flex>
          </GridItem>
          <GridItem w="100%" h="10">
            <Input
              id="title"
              value={recordObj.title}
              placeholder="Record title"
              onChange={handleInputChange}
              _focusVisible={{ border: "2px solid", borderColor: "teal.200" }}
            />
          </GridItem>
          {/* 其他输入字段 ... */}
        </Grid>
      </Box>
      <PasswordEditor password={password} setPassword={setPassword} />
      <Box mt="20px">
        <Button
          type="submit"
          w="100%"
          background={buttonBg}
          _focus={{ background: buttonBg }}
          onClick={handleSubmit}
        >
          Save Record
        </Button>
      </Box>

      {/* Chakra UI 弹窗组件 */}
      {/* 假设 AlertModal 是一个自定义组件,内部封装了 Chakra UI 的 AlertDialog */}
      <AlertModal
        type="leave"
        onClose={onClose} // 关闭弹窗的回调
        isOpen={isOpen} // 控制弹窗是否显示
        callBackAction={navigate} // 用户确认离开时调用 navigate 函数
      />
    </Box>
  );
};

export default RecordEditing;

组件集成关键点:

  1. isDirty 状态管理:

    • isDirty 是一个布尔状态,用于指示表单数据是否与初始加载的数据不同。
    • setDirtyInputs 函数负责比较当前表单数据 (recordObj 和 password) 与 defaultRecord (初始数据) 的深层相等性,并更新 isDirty 状态。
    • handleInputChange 在每次输入变化时调用 setDirtyInputs。
    • handleSubmit 在数据保存成功后将 isDirty 重置为 false。
  2. useDisclosure:

    • Chakra UI 提供的 useDisclosure Hook 简化了模态框、抽屉等组件的打开/关闭状态管理,提供了 isOpen、onOpen 和 onClose。
  3. useNavigationObserver 的使用:

    • 通过 const navigate = useNavigationObserver({ shouldStopNavigation: isDirty, onNavigate: () => onOpen() }); 初始化 Hook。
    • shouldStopNavigation 被设置为 isDirty,这意味着只有当表单有未保存的更改时,Hook 才会尝试阻止导航。
    • onNavigate 回调设置为 onOpen(),当导航被阻止时,它会触发 Chakra UI 弹窗的显示。
  4. AlertModal (或 AlertDialog):

    • AlertModal 是一个用于显示提示信息的自定义组件,它接收 isOpen、onClose 和 callBackAction 作为 props。
    • 当用户点击“是”(确认离开)时,AlertModal 会调用 callBackAction,即我们从 useNavigationObserver Hook 返回的 navigate 函数,从而恢复到目标路径的导航。
    • 当用户点击“否”(取消离开)时,AlertModal 会调用 onClose() 关闭弹窗,用户停留在当前页面。

注意事项与总结

  • “假错误”机制: 这种通过抛出错误来阻止 Next.js 路由的方式,虽然有效,但本质上是一种利用框架内部机制的“hack”。在未来的 Next.js 版本中,其内部路由实现可能会发生变化,从而影响此方法的兼容性。
  • 浏览器 beforeunload: 这种方法主要针对 Next.js 的客户端路由跳转。对于用户直接关闭浏览器标签页或输入新 URL 的情况,beforeunload 事件仍然是更合适的选择(尽管它只能显示一个浏览器内置的确认提示,无法自定义 UI)。
  • 用户体验: 确保提示信息清晰明了,让用户明白离开页面会丢失未保存的更改。
  • 性能: deep-equal 库在处理大型对象时可能会有性能开销。如果表单数据非常复杂,可以考虑更优化的脏检查策略。

通过 useNavigationObserver Hook,我们成功地在 Next.js 应用中实现了一个健壮的离页提示功能,极大地提升了用户体验和数据安全性。这种模式提供了一个可复用的解决方案,可以轻松集成到任何需要此功能的表单组件中。

热门AI工具

更多
WorkBuddy

一款AI办公效率工具,主要用于腾讯云推出的AI原生桌面智能体工作台,适合需要提升相关任务效率的用户。

DeepSeek

DeepSeek是一款面向对话、写作、编程和推理场景的AI大模型工具。

SkildArt
SkildArt Hot

SkildArt是一款AI文本写作工具,一站式 AI 视觉创作平台。

讯飞绘文

讯飞绘文是一款由科大讯飞推出的一站式 AIGC 内容运营平台。

蛙蛙写作

一款AI论文写作工具,主要用于超级AI智能写作助手,适合需要提升相关任务效率的用户。

Laper
Laper Hot

Laper是专为编剧、导演和制片人推出的 AI 原生剧本创作工具。

豆包大模型

豆包大模型是一款由字节跳动推出的企业级大语言模型服务平台。

PixTV
PixTV Hot

PixTV是一款面向AIGC内容创作的AI视频生成工具。

音述AI
音述AI Hot

一款AI音频处理工具,主要用于音述AI是一个以“用声音述说故事”为核心的 AI 音乐创作与声音分享社区,适合需要提升相关任务效率的用户。

相关专题

更多
c语言中null和NULL的区别
c语言中null和NULL的区别

c语言中null和NULL的区别是:null是C语言中的一个宏定义,通常用来表示一个空指针,可以用于初始化指针变量,或者在条件语句中判断指针是否为空;NULL是C语言中的一个预定义常量,通常用来表示一个空值,用于表示一个空的指针、空的指针数组或者空的结构体指针。

549

2023.09.22

java中null的用法
java中null的用法

在Java中,null表示一个引用类型的变量不指向任何对象。可以将null赋值给任何引用类型的变量,包括类、接口、数组、字符串等。想了解更多null的相关内容,可以阅读本专题下面的文章。

1698

2024.03.01

c语言const用法
c语言const用法

const是关键字,可以用于声明常量、函数参数中的const修饰符、const修饰函数返回值、const修饰指针。详细介绍:1、声明常量,const关键字可用于声明常量,常量的值在程序运行期间不可修改,常量可以是基本数据类型,如整数、浮点数、字符等,也可是自定义的数据类型;2、函数参数中的const修饰符,const关键字可用于函数的参数中,表示该参数在函数内部不可修改等等。

2378

2023.09.20

js正则表达式
js正则表达式

php中文网为大家提供各种js正则表达式语法大全以及各种js正则表达式使用的方法,还有更多js正则表达式的相关文章、相关下载、相关课程,供大家免费下载体验。

4796

2023.06.20

js获取当前时间
js获取当前时间

JS全称JavaScript,是一种具有函数优先的轻量级,解释型或即时编译型的编程语言;它是一种属于网络的高级脚本语言,主要用于Web,常用来为网页添加各式各样的动态功能。js怎么获取当前时间呢?php中文网给大家带来了相关的教程以及文章,欢迎大家前来学习阅读。

1415

2023.07.28

js 字符串转数组
js 字符串转数组

js字符串转数组的方法:1、使用“split()”方法;2、使用“Array.from()”方法;3、使用for循环遍历;4、使用“Array.split()”方法。本专题为大家提供js字符串转数组的相关的文章、下载、课程内容,供大家免费下载体验。

1878

2023.08.03

js是什么意思
js是什么意思

JS是JavaScript的缩写,它是一种广泛应用于网页开发的脚本语言。JavaScript是一种解释性的、基于对象和事件驱动的编程语言,通常用于为网页增加交互性和动态性。它可以在网页上实现复杂的功能和效果,如表单验证、页面元素操作、动画效果、数据交互等。

10343

2023.08.17

js删除节点的方法
js删除节点的方法

js删除节点的方法有:1、removeChild()方法,用于从父节点中移除指定的子节点,它需要两个参数,第一个参数是要删除的子节点,第二个参数是父节点;2、parentNode.removeChild()方法,可以直接通过父节点调用来删除子节点;3、remove()方法,可以直接删除节点,而无需指定父节点;4、innerHTML属性,用于删除节点的内容。

1020

2023.09.01

DBeaver怎么导入数据
DBeaver怎么导入数据

DBeaver数据导入专题,整理Excel、CSV、SQL、TXT等常见文件的数据导入方法,涵盖目标数据库选择、数据表创建、字段映射、数据类型设置及导入参数配置等操作,帮助用户将本地文件中的数据写入数据库,并了解批量导入、重复数据处理和导入失败等常见问题的解决方法。

0

2026.11.05

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
相关推荐
/
热门推荐
/
最新课程
React 教程
React 教程

共58课时 | 12.4万人学习

国外Web开发全栈课程全集
国外Web开发全栈课程全集

共12课时 | 1.4万人学习

关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号
PHP中文网订阅号
每天精选资源文章推送

Copyright 2014-2026 https://www.php.cn/ All Rights Reserved | php.cn