首页 > web前端 > js教程 > 正文

Next.js 与 Chakra UI:构建优雅的未保存更改导航防护

心靈之曲
发布: 2025-11-18 17:36:28
原创
770人浏览过

next.js 与 chakra ui:构建优雅的未保存更改导航防护

本文详细介绍了如何在 Next.js 应用中,结合 Chakra UI 实现一个健壮的页面导航防护机制。当用户在包含未保存更改的表单页面尝试离开时,系统将通过一个自定义 React Hook 拦截路由跳转,并弹出一个 Chakra UI 警告对话框,询问用户是否确认离开。该方案通过巧妙地利用 Next.js 路由事件和浏览器历史 API,确保用户在确认前不会丢失数据,并能准确地导航到其最初选择的路由。

在现代 Web 应用中,用户体验至关重要。当用户在表单中输入数据但尚未保存时,意外的页面导航可能导致数据丢失,从而带来糟糕的用户体验。为了解决这一问题,我们需要一种机制来检测未保存的更改,并在用户尝试离开页面时提供确认提示。本教程将指导您如何在 Next.js 应用中,利用 Chakra UI 的 AlertDialog 组件和自定义 Hook 来实现这一功能。

核心挑战:Next.js 路由拦截与用户确认

Next.js 提供了 router.events 来监听路由变化,例如 routeChangeStart。然而,仅仅监听这个事件并不能直接“阻止”路由跳转。当 routeChangeStart 事件触发时,Next.js 已经开始处理导航,并且通常会立即更新浏览器的 URL。为了真正阻止导航并等待用户确认,我们需要更高级的策略。

主要的挑战点在于:

  1. 停止路由跳转:在 routeChangeStart 发生后,如何有效地中断 Next.js 的路由进程。
  2. 保留目标路由:在用户确认离开后,如何让应用继续导航到用户最初点击的目标路由。
  3. URL 同步:防止在对话框显示期间,浏览器地址栏短暂显示目标路由,然后又跳回当前路由。

解决方案:自定义 useNavigationObserver Hook

为了优雅地解决上述挑战,我们将创建一个名为 useNavigationObserver 的自定义 React Hook。这个 Hook 将负责监听路由事件、阻止默认导航行为、保存目标路由,并提供一个回调函数来允许组件在用户确认后恢复导航。

知我AI·PC客户端
知我AI·PC客户端

离线运行 AI 大模型,构建你的私有个人知识库,对话式提取文件知识,保证个人文件数据安全

知我AI·PC客户端 35
查看详情 知我AI·PC客户端

useNavigationObserver Hook 实现

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

// 用于欺骗 Next.js 路由的假错误信息
const errorMessage = "Please ignore this error.";

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

// 拦截并阻止处理我们的假错误,防止其被报告到控制台
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(); // 抛出错误以强制中断
  }, [router]);

  useEffect(() => {
    // 每次组件重新渲染时重置确认状态
    navigationConfirmed.current = false;

    const onRouteChange = (url: string) => {
      // 如果当前路径与目标路径不同,且导航未被确认
      if (currentPath !== url) {
        // 立即将浏览器历史记录的 URL 恢复到当前路径
        // 这是为了防止在对话框显示之前,URL 短暂地变为目标路径
        window.history.pushState(null, "", router.basePath + currentPath);
      }

      // 如果有未保存的更改 (shouldStopNavigation 为 true)
      // 且目标路径与当前路径不同,且导航未被用户确认
      if (
        shouldStopNavigation &&
        url !== currentPath &&
        !navigationConfirmed.current
      ) {
        nextPath.current = url.replace(router.basePath, ""); // 存储目标路径
        onNavigate(); // 调用回调函数,通常用于打开确认对话框
        killRouterEvent(); // 阻止 Next.js 导航
      }
    };

    // 监听路由开始变化事件
    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 };
登录后复制

Hook 工作原理详解

  1. shouldStopNavigation: 这是一个布尔值,表示当前页面是否有未保存的更改,决定是否需要阻止导航。
  2. onNavigate: 当导航被阻止时,此回调函数会被调用。它通常用于触发 Chakra UI 的 AlertDialog 显示。
  3. router.events.on("routeChangeStart", onRouteChange): 这是核心监听器。当用户点击链接或使用浏览器前进/后退按钮时,Next.js 会触发此事件。
  4. window.history.pushState(null, "", router.basePath + currentPath): 关键一步!在 routeChangeStart 触发后,Next.js 会立即更新浏览器 URL。为了防止用户在看到对话框之前看到 URL 变化,我们立即将 URL 恢复到当前页面的路径。
  5. killRouterEvent():
    • router.events.emit("routeChangeError", "", "", { shallow: false });:通过手动触发一个路由错误事件,我们告诉 Next.js 路由系统发生了一个错误,从而有效地阻止了当前的路由跳转。
    • throwFakeErrorToFoolNextRouter():为了确保路由彻底中断,我们抛出一个自定义的错误。这个错误并不会真正导致应用崩溃,因为它会被 rejectionHandler 捕获并阻止。
  6. rejectionHandler: 这是一个全局的 unhandledrejection 事件监听器,它的作用是捕获我们抛出的“假错误”,并通过 event.preventDefault() 阻止浏览器将这个错误报告到控制台,从而保持控制台的清洁。
  7. nextPath.current: 当导航被阻止时,目标 URL 会被存储在这个 useRef 中。
  8. confirmNavigation(): 这个函数由 Hook 返回,并在用户在对话框中选择“是”时被调用。它会将 navigationConfirmed.current 设置为 true,然后使用 router.push(nextPath.current) 手动导航到之前存储的目标路径。

在组件中集成导航防护

现在,我们将在一个 Next.js 页面组件中集成 useNavigationObserver Hook 和 Chakra UI 的 AlertDialog。

假设我们有一个 RecordEditing 组件,用于编辑记录。

import { useState, useEffect } from "react";
import {
  Box,
  Grid,
  GridItem,
  Input,
  Flex,
  Button,
  useColorModeValue,
  useDisclosure,
  AlertDialog,
  AlertDialogBody,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogContent,
  AlertDialogOverlay,
} from "@chakra-ui/react";
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 postMethod = async (url: string, data: any) => { /* ... */ };
const updateMethod = async (url: string, data: any) => { /* ... */ };
const showMsg = (message: string, options: { type: string }) => { /* ... */ };
const TopNav = ({ title, type }: { title: string; type: string }) => { /* ... */ return <Box>{title}</Box>; };
const PasswordEditor = ({ password, setPassword }: { password: string; setPassword: (p: string) => void }) => { /* ... */ return <Input value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" />; };


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 Hook 管理对话框状态
  const { isOpen, onOpen, onClose } = useDisclosure();

  // 使用自定义导航观察者 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,
    }));
  };

  // 在 recordObj 或 password 变化时检查脏状态
  useEffect(() => {
    setDirtyInputs();
  }, [recordObj, password]);


  // 处理表单提交
  const handleSubmit = async () => {
    setIsDirty(false); // 提交后清除脏状态
    try {
      if (type === "new") {
        await postMethod(`/api/user/${user.id}/records`, {
          ...recordObj,
          password,
        });
        showMsg("Record saved", { type: "success" });
      } else {
        await updateMethod(`/api/user/${user.id}/records/${recordId}`, {
          ...recordObj,
          password,
        });
        showMsg("Record updated", { type: "success" });
      }
      router.push("/"); // 保存成功后导航到主页
    } catch (error) {
      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>
          <GridItem w="100%" h="10">
            <Flex align="center" h="100%">
              Url
            </Flex>
          </GridItem>
          <GridItem w="100%" h="10">
            <Input
              id="url"
              value={recordObj.url}
              placeholder="Website url (optional)"
              onChange={handleInputChange}
              _focusVisible={{ border: "2px solid", borderColor: "teal.200" }}
            />
          </GridItem>
          <GridItem w="100%" h="10">
            <Flex align="center" h="100%">
              Username
            </Flex>
          </GridItem>
          <GridItem w="100%" h="10">
            <Input
              id="username"
              value={recordObj.username}
              placeholder="Username or email"
              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 AlertDialog for unsaved changes */}
      <AlertDialog
        motionPreset="slideInBottom"
        leastDestructiveRef={undefined} // 可以指向 "No" 按钮的 ref
        onClose={onClose}
        isOpen={isOpen}
        isCentered
      >
        <AlertDialogOverlay />
        <AlertDialogContent>
          <AlertDialogHeader>
            您确定要离开此页面吗?
          </AlertDialogHeader>
          <AlertDialogBody>
            您有未保存的更改。如果您离开此页面,所有未保存的更改都将丢失!
          </AlertDialogBody>
          <AlertDialogFooter>
            <Button onClick={onClose}>
              否 (留在当前页)
            </Button>
            <Button
              onClick={() => {
                onClose(); // 关闭对话框
                navigate(); // 调用 Hook 返回的函数,继续导航到目标页
              }}
              colorScheme="red"
              ml={3}
            >
              是 (离开此页)
            </Button>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </Box>
  );
};

export default RecordEditing;
登录后复制

组件集成详解

  1. isDirty 状态: useState<boolean>(false) 用于跟踪表单数据是否与初始加载的数据不同。
  2. defaultRecord: 在组件挂载时,将初始的 record 和 password 组合成一个 defaultRecord 对象。后续通过 isDeepEqual 库将其与当前表单状态进行深度比较,以准确判断 isDirty 状态。
  3. useDisclosure(): Chakra UI 提供的 Hook,用于管理 AlertDialog 的 isOpen、onOpen 和 onClose 状态。
  4. useNavigationObserver 的使用:
    • shouldStopNavigation: isDirty:我们将 isDirty 状态传递给 Hook,只有当 isDirty 为 true 时,Hook 才会阻止导航。
    • onNavigate: () => onOpen():当 Hook 阻止导航时,它会调用 onNavigate 回调,我们在这里触发 AlertDialog 的打开。
    • const navigate = useNavigationObserver(...):Hook 返回一个 navigate 函数,当用户确认离开页面时,我们将调用此函数来恢复路由跳转。
  5. setDirtyInputs(): 此函数负责比较 defaultRecord 和当前 recordObj 及 password 的状态,并更新 isDirty。
  6. useEffect 监听脏状态: 通过 useEffect 监听 recordObj 和 password 的变化,并调用 setDirtyInputs 来实时更新 isDirty 状态。
  7. handleSubmit(): 当用户点击“保存”按钮并成功提交表单后,需要将 isDirty 状态重置为 false,因为此时更改已经保存。
  8. AlertDialog 交互:
    • “否 (留在当前页)”按钮:直接调用 onClose() 关闭对话框,用户停留在当前页面。
    • “是 (离开此页)”按钮:首先调用 onClose() 关闭对话框,然后调用 navigate() 函数。这个 navigate 函数就是 useNavigationObserver Hook 返回的 confirmNavigation,它会执行 router.push(nextPath.current),将用户导航到他们最初尝试前往的页面。

注意事项与最佳实践

  • deep-equal 库: 在判断对象是否“脏”时,简单地比较引用是不够的。deep-equal 这样的库可以帮助您进行深层比较,确保所有嵌套属性都被考虑在内。
  • 用户体验: 确保 AlertDialog 的提示信息清晰明了,让用户清楚了解离开页面将带来的后果。
  • 错误处理: useNavigationObserver 中的“假错误”机制是一种高级的路由拦截技巧。理解其工作原理(routeChangeError 事件和 unhandledrejection 监听)对于调试和维护至关重要。
  • 表单提交: 在表单成功提交后,务必将 isDirty 状态重置为 false,否则用户保存后仍然会收到离开页面的提示。
  • leastDestructiveRef: 在 AlertDialog 中,leastDestructiveRef 通常指向一个在对话框打开时获得焦点的元素,通常是“取消”或“否”按钮,以增强可访问性。在我们的例子中,由于没有特定的取消按钮,可以暂时设置为 undefined 或指向“否”按钮的 useRef。

总结

通过结合 Next.js 的路由事件、浏览器历史 API 以及 Chakra UI 的模态框组件,我们成功构建了一个强大且用户友好的导航防护系统。useNavigationObserver Hook 提供了一个可复用的解决方案,能够优雅地处理未保存更改的场景,从而显著提升 Next.js 应用的用户体验。这种模式不仅适用于表单页面,也可以扩展到任何需要用户确认才能离开的复杂交互场景。

以上就是Next.js 与 Chakra UI:构建优雅的未保存更改导航防护的详细内容,更多请关注php中文网其它相关文章!

最佳 Windows 性能的顶级免费优化软件
最佳 Windows 性能的顶级免费优化软件

每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。

下载
来源:php中文网
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
最新问题
开源免费商场系统广告
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板
关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新 English
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送
PHP中文网APP
随时随地碎片化学习

Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号