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

Next.js与Chakra UI:实现页面跳转前未保存更改确认对话框

小磊小哥_3826

小磊小哥_3826

发布时间:2025-11-18 19:19:13

|

862人浏览过

|

来源于php中文网

原创

next.js与chakra ui:实现页面跳转前未保存更改确认对话框

本文详细介绍了在Next.js应用中,如何利用自定义React Hook和Chakra UI的AlertDialog组件,实现用户在离开带有未保存更改的页面时,弹出确认对话框的功能。通过巧妙地拦截Next.js路由事件并管理页面状态,确保用户在数据丢失前得到提示,并可选择取消跳转或继续导航至目标路由。

在现代Web应用中,提供良好的用户体验至关重要。其中一个常见场景是,当用户在表单中输入了数据但尚未保存时,意外点击其他链接或尝试返回上一页,导致数据丢失。为了避免这种情况,我们通常需要一个机制来拦截页面跳转,并弹出一个确认对话框,询问用户是否要放弃未保存的更改。本教程将深入探讨如何在Next.js应用中结合Chakra UI实现这一功能。

核心挑战:Next.js路由拦截

在React应用中,传统的浏览器API如window.onbeforeunload可以用于在用户关闭页面或刷新时触发确认。然而,对于单页应用(SPA)如Next.js,内部路由跳转并不会触发onbeforeunload。Next.js提供了router.events来监听路由生命周期事件,其中routeChangeStart是拦截路由跳转的关键。

直接在routeChangeStart事件处理器中调用event.preventDefault()并不能完全阻止Next.js的路由跳转。Next.js的内部路由机制更为复杂,它会继续尝试完成导航。因此,我们需要一个更巧妙的方法来“欺骗”Next.js,使其认为路由跳转失败,从而停止导航过程。

解决方案:自定义导航观察器Hook (useNavigationObserver)

为了优雅地解决Next.js的路由拦截问题,我们设计一个名为useNavigationObserver的自定义Hook。这个Hook将负责监听路由变化、阻止导航、显示确认对话框,并在用户确认后恢复导航。

1. useNavigationObserver Hook 的设计理念

该Hook的核心思想是:

Aria2 Json Rpc
Aria2 Json Rpc

通过 JSON‑RPC 2.0 与 aria2 下载管理器交互,使用自然语言命令管理下载、查询状态并控制任务。适用于 aria2、下载管理或种子操作。

下载
  • 在routeChangeStart事件发生时,如果存在未保存的更改,则阻止路由继续。
  • 通过抛出一个“假”错误并捕获它,来阻止Next.js的内部导航。
  • 将目标路由保存起来,以便用户确认后可以恢复导航。
  • 提供一个回调函数,用于触发外部的确认对话框。
// hooks/useNavigationObserver.ts
import { useRouter } from "next/router";
import { useCallback, useEffect, useRef } from "react";

// 定义一个特殊的错误消息,用于在Promise拒绝时识别并阻止默认行为
const errorMessage = "Please ignore this error.";

// 抛出一个假错误来中断Next.js的路由流程
const throwFakeErrorToFoolNextRouter = () => {
  // eslint-disable-next-line no-throw-literal
  throw errorMessage;
};

// 监听未处理的Promise拒绝事件,如果原因是我们的假错误,则阻止默认行为
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(() => {
    // 触发一个路由错误事件,告知Next.js路由更改失败
    router.events.emit("routeChangeError", "", "", { shallow: false });
    throwFakeErrorToFoolNextRouter(); // 抛出假错误,中断Promise链
  }, [router]);

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

    const onRouteChange = (url: string) => {
      // 如果当前路径与目标路径不同,且未确认导航,则进行拦截
      if (
        shouldStopNavigation &&
        url !== currentPath &&
        !navigationConfirmed.current
      ) {
        // 将浏览器的历史状态推回当前页面,以防止URL在地址栏中改变
        // 这一步很重要,因为Next.js在routeChangeStart时可能会更新URL
        window.history.pushState(null, "", router.basePath + currentPath);

        nextPath.current = url.replace(router.basePath, ""); // 存储目标路径
        onNavigate(); // 调用外部回调,通常用于打开确认对话框
        killRouterEvent(); // 阻止路由跳转
      }
    };

    // 监听路由开始变化事件
    router.events.on("routeChangeStart", onRouteChange);
    // 监听未处理的Promise拒绝事件,以捕获并处理我们的假错误
    window.addEventListener("unhandledrejection", rejectionHandler);

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

关键点解析:

  • throwFakeErrorToFoolNextRouter 和 rejectionHandler: 这是阻止Next.js路由的关键技巧。当routeChangeStart被触发且我们决定阻止导航时,killRouterEvent会抛出一个特殊的错误。由于Next.js的路由逻辑是基于Promise的,这个错误会导致Promise被拒绝。window.addEventListener("unhandledrejection", rejectionHandler)会捕获这个未处理的Promise拒绝,并通过event.preventDefault()阻止浏览器报告这个错误,从而避免应用崩溃。
  • window.history.pushState: 在拦截路由后,Next.js可能已经更新了浏览器的URL。为了保持URL与当前页面一致,我们需要手动将历史状态推回当前路径。
  • navigationConfirmed: 这是一个useRef变量,用于标记用户是否已经通过对话框确认了导航。如果已确认,下次routeChangeStart事件发生时就不会再次拦截。
  • confirmNavigation: 这个函数由Hook返回,当用户在确认对话框中选择“是”时调用,它会使用router.push将用户导航到之前保存的目标路径。

在组件中集成Hook和Chakra UI对话框

现在,我们将useNavigationObserver Hook集成到我们的Next.js页面组件中,并结合Chakra UI的AlertDialog来显示确认对话框。

// components/RecordEditing.tsx (示例组件)
import { useState, useEffect } from "react";
import {
  Box,
  Grid,
  GridItem,
  Input,
  Flex,
  Button,
  useColorModeValue,
  useDisclosure,
} from "@chakra-ui/react";
// 假设 AlertModal 是一个封装了 Chakra UI AlertDialog 的组件
import AlertModal from "./AlertModal"; // 自定义的对话框组件
import { useRouter } from "next/router";
import isDeepEqual from "deep-equal"; // 用于深度比较对象是否相等
import { useNavigationObserver } from "@/hooks/useNavigationObserver"; // 引入自定义Hook

// 假设的类型定义
interface IRecordEditData {
  title: string;
  url: string;
  username: string;
  password?: string;
}
interface IRecordEditingProps {
  type: "new" | "edit";
  record: IRecordEditData;
  user: { id: string };
}

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: record.password || "" };
  const title = type === "new" ? "New Record" : "Edit Record";
  const router = useRouter();
  const { isOpen, onOpen, onClose } = useDisclosure(); // Chakra UI对话框控制

  // 使用自定义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);
    }
  };

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

  // 处理表单提交
  const handleSubmit = () => {
    setIsDirty(false); // 保存后清除脏状态
    // ... (保存逻辑,例如调用API)
    // 假设保存成功后跳转到首页
    router.push("/");
  };

  // 在组件渲染时,如果 AlertModal 是一个独立的组件,需要确保它能接收到正确的props
  // 这里假设 AlertModal 内部处理了 AlertDialog 的渲染逻辑
  // 并且通过 callBackAction 属性接收 navigate 函数
  return (
    <Box py="60px">
      {/* ... 页面内容,例如输入框和保存按钮 */}
      <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 组件 */}
      <Box mt="20px">
        <Button
          type="submit"
          w="100%"
          onClick={handleSubmit}
        >
          Save Record
        </Button>
      </Box>

      {/* 确认离开对话框 */}
      <AlertModal
        type="leave"
        onClose={onClose}
        isOpen={isOpen}
        callBackAction={navigate} // 当用户确认离开时,调用 navigate 函数继续跳转
      />
    </Box>
  );
};

export default RecordEditing;

AlertModal 组件示例 (假设)

为了保持主组件的简洁性,我们可以将Chakra UI的AlertDialog封装到一个独立的AlertModal组件中。

// components/AlertModal.tsx
import {
  AlertDialog,
  AlertDialogBody,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogContent,
  AlertDialogOverlay,
  Button,
} from "@chakra-ui/react";
import React, { useRef } from "react";

interface AlertModalProps {
  isOpen: boolean;
  onClose: () => void;
  type: "leave" | "delete"; // 可以根据需要扩展类型
  callBackAction?: () => void; // 用户确认后执行的回调
}

const AlertModal: React.FC<AlertModalProps> = ({
  isOpen,
  onClose,
  type,
  callBackAction,
}) => {
  const cancelRef = useRef<HTMLButtonElement>(null);

  const handleConfirm = () => {
    if (callBackAction) {
      callBackAction(); // 执行确认操作,例如继续导航
    }
    onClose(); // 关闭对话框
  };

  const getHeader = () => {
    if (type === "leave") {
      return "确认离开此页面?";
    }
    // ... 其他类型
    return "确认操作?";
  };

  const getBody = () => {
    if (type === "leave") {
      return "您有未保存的更改。确定要离开此页面吗?未保存的更改将会丢失!";
    }
    // ... 其他类型
    return "您确定要执行此操作吗?";
  };

  return (
    <AlertDialog
      motionPreset="slideInBottom"
      leastDestructiveRef={cancelRef}
      onClose={onClose}
      isOpen={isOpen}
      isCentered
    >
      <AlertDialogOverlay />
      <AlertDialogContent>
        <AlertDialogHeader>{getHeader()}</AlertDialogHeader>
        <AlertDialogBody>{getBody()}</AlertDialogBody>
        <AlertDialogFooter>
          <Button ref={cancelRef} onClick={onClose}>
            否
          </Button>
          <Button colorScheme="red" onClick={handleConfirm} ml={3}>
            是
          </Button>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
};

export default AlertModal;

集成关键点:

  • isDirty 状态管理: RecordEditing 组件需要一个状态变量isDirty来跟踪表单数据是否与初始数据不同。setDirtyInputs函数负责在每次输入变化后更新此状态。
  • useDisclosure: Chakra UI的useDisclosure Hook用于方便地管理对话框的isOpen状态以及onOpen和onClose函数。
  • onNavigate 回调: useNavigationObserver Hook的onNavigate属性被设置为() => onOpen(),这意味着当Hook决定阻止导航时,它会调用onOpen()来显示确认对话框。
  • callBackAction 属性: AlertModal组件通过callBackAction属性接收useNavigationObserver返回的navigate函数。当用户在对话框中点击“是”时,handleConfirm会调用navigate(),从而恢复被阻止的路由跳转。

注意事项与最佳实践

  1. deep-equal 库: 在实际应用中,比较复杂对象(如表单数据)是否相等,通常不能简单地使用===。deep-equal或Lodash的isEqual等库可以进行深度比较,确保isDirty状态的准确性。
  2. 错误处理: throwFakeErrorToFoolNextRouter方法虽然有效,但本质上是利用了Next.js内部机制的一个“漏洞”。未来Next.js版本更新可能会改变其内部路由行为,导致此方法失效。在使用时需注意其潜在的维护风险。
  3. 用户体验: 确保确认对话框的文案清晰明了,告知用户未保存的更改将丢失,并提供明确的“是”和“否”选项。
  4. 性能优化: setDirtyInputs在每次输入变化时都会调用isDeepEqual。对于非常复杂的表单,这可能会有轻微的性能开销。可以考虑使用debounce或throttle来限制其调用频率,或者只在blur事件时检查。
  5. 全局处理: 如果应用中有多个页面需要此功能,可以考虑将useNavigationObserver和AlertModal封装成一个更高级别的组件或上下文提供者,以便在整个应用中统一管理未保存更改的提示逻辑。
  6. 保存操作: 当用户点击“保存”按钮时,记得调用setIsDirty(false)来清除脏状态,这样在保存后立即跳转就不会触发确认对话框。

总结

通过结合Next.js的路由事件监听机制、自定义React Hook以及Chakra UI的对话框组件,我们成功实现了一个健壮的“未保存更改”提示功能。这个方案不仅提升了用户体验,避免了数据意外丢失,也展示了在Next.js中处理复杂路由拦截场景的有效策略。虽然涉及了一些对Next.js内部机制的巧妙利用,但其提供了一种可行的、相对优雅的解决方案。

热门AI工具

更多
UpDream
UpDream Hot

一款AI视频创作工具,主要用于哔哩哔哩推出的自研AI视频创作工具,适合需要提升相关任务效率的用户。

WorkBuddy

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

讯飞智作

讯飞智作是一款AI视频创作工具,AI文本配音工具,数字人课程、营销视频制作。

火山引擎

火山引擎是一款面向企业的云计算与AI服务平台。

蛙蛙写作

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

豆包大模型

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

UP简历
UP简历 Hot

一款AI办公效率工具,主要用于基于AI技术的免费在线简历制作工具,适合需要提升相关任务效率的用户。

音述AI
音述AI Hot

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

DeepSeek

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

相关专题

更多
js正则表达式
js正则表达式

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

3836

2023.06.20

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

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

1215

2023.07.28

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

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

1558

2023.08.03

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

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

9283

2023.08.17

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

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

840

2023.09.01

js截取字符串的方法
js截取字符串的方法

js截取字符串的方法有substring()方法、substr()方法、slice()方法、split()方法和slice()方法。本专题为大家提供字符串相关的文章、下载、课程内容,供大家免费下载体验。

2264

2023.09.04

Js中concat和push的区别
Js中concat和push的区别

Js中concat和push的区别:1、concat用于将两个或多个数组合并成一个新数组,并返回这个新数组,而push用于向数组的末尾添加一个或多个元素,并返回修改后的数组的新长度;2、concat不会修改原始数组,是创建新的数组,而push会修改原数组,将新元素添加到原数组的末尾等等。本专题为大家提供concat和push相关的文章、下载、课程内容,供大家免费下载体验。

1429

2023.09.14

js截取字符串的方法介绍
js截取字符串的方法介绍

JavaScript字符串截取方法,包括substring、slice、substr、charAt和split方法。这些方法可以根据具体需求,灵活地截取字符串的不同部分。在实际开发中,根据具体情况选择合适的方法进行字符串截取,能够提高代码的效率和可读性 。

3829

2023.09.21

Buffalo框架数据库开发全教程
Buffalo框架数据库开发全教程

本专题围绕Buffalo框架数据库开发,讲解database.yml多环境配置、soda与fizz迁移生成回滚、模型结构体标签、增删改查与条件查询、一对多与多对多关联、数据校验、回调钩子、事务处理及原生SQL执行能力。

80

2026.09.23

热门下载

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

精品课程

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

共58课时 | 12万人学习

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

共12课时 | 1.4万人学习

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

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