新闻中心

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

2025-11-18
浏览次数:
返回列表

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

本文详细介绍了在 Next.js 应用中,如何结合 Chakra UI 实现用户离页时未保存修改的提示功能。通过自定义 useN*igationObserver 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)显示模态对话框。

解决方案:自定义 useN*igationObserver Hook

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

useN*igationObserver 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 {
  shouldStopN*igation: boolean; // 是否需要阻止导航的标志
  onN*igate: () => void; // 当导航被阻止时触发的回调,用于打开提示弹窗
}

const useN*igationObserver = ({ shouldStopN*igation, onN*igate }: Props) => {
  const router = useRouter();
  const currentPath = router.asPath; // 当前页面路径
  const nextPath = useRef(""); // 存储用户尝试跳转的目标路径
  const n*igationConfirmed = useRef(false); // 标记用户是否已确认离开

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

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

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

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

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

    return () => {
      router.events.off("routeChangeStart", onRouteChange);
      window.removeEventListener("unhandledrejection", rejectionHandler);
    };
  }, [
    currentPath,
    killRouterEvent,
    onN*igate,
    router.basePath,
    router.events,
    shouldStopN*igation,
  ]);

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

  return confirmN*igation;
};

export { useN*igationObserver };

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 和 n*igationConfirmed:

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

    Docky AI Docky AI

    多合一AI浏览器助手,解答问题、绘制图片、阅读文档、强化搜索结果、辅助创作

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

在 Next.js 组件中集成

现在,我们将 useN*igationObserver 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, TopN*, services, showMsg, deep-equal 等

import { useN*igationObserver } from "@/hooks/useN*igationObserver"; // 导入自定义 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();

  // 使用自定义的 useN*igationObserver Hook
  const n*igate = useN*igationObserver({
    shouldStopN*igation: isDirty, // 当 isDirty 为 true 时阻止导航
    onN*igate: () => 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 s*ed", { 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">
      <TopN* 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}
        >
          S*e Record
        </Button>
      </Box>

      {/* Chakra UI 弹窗组件 */}
      {/* 假设 AlertModal 是一个自定义组件,内部封装了 Chakra UI 的 AlertDialog */}
      <AlertModal
        type="le*e"
        onClose={onClose} // 关闭弹窗的回调
        isOpen={isOpen} // 控制弹窗是否显示
        callBackAction={n*igate} // 用户确认离开时调用 n*igate 函数
      />
    </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. useN*igationObserver 的使用:

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

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

注意事项与总结

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

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

以上就是Next.js 与 Chakra UI:实现页面未保存修改离页提示与导航控制的详细内容,更多请关注其它相关文章!


# 是一个  # 益阳网站建设与维护题库  # 合肥抖音seo费用  # 色彩营销推广策略  # 辽宁seo外包怎么引流  # 龙华关键词排名优化公司  # 红桥区眼镜网站建设  # 公司网站建设地址格式  # 怎么做博客营销推广  # 北京seo优化作用  # 南宁网站建设推荐谁好呢  # 最初  # 提示信息  # 设置为  # 回调  # react  # 抛出  # 跳转  # 自定义  # 表单  # gate  # 表单提交  # 数据丢失  # win  # 路由  # 回调函数  # 浏览器  # js  # html  # word 


相关栏目: 【 科技资讯46185 】 【 网络学院92790


相关推荐: 如何创建没有密码的Windows本地账户_跳过微软账户登录的技巧【教程】  俄罗斯方块最新版入口 俄罗斯方块在线玩官网入口  在J*a中如何开发简易仓库管理与库存统计_仓库管理库存统计项目实战解析  C++的std::mdspan是什么_C++23中用于操作多维数组的非拥有视图  sublime怎么预览Markdown渲染效果_Markdown Preview插件 for sublime教程  Yandex官网搜索引擎免登录_俄罗斯Yandex一键直达入口  J*aScript中在Map循环中检测并处理空数组元素  我的世界mc.js免费游戏直接能玩 我的世界mc.js小游戏免费秒玩入口  Win10系统怎么查看已安装更新_Win10卸载有问题的更新补丁  c++中为什么推荐使用using替代typedef_c++现代化类型别名  AO3最新官网入口公告_2025AO3镜像站实时查询方法  不会效仿卡普空!《铁拳》制作人澄清:不采取赛事付费|直播|  蛙漫安全无毒 官方认证的绿色入口  CSS子选择器:如何区分并样式化嵌套列表的子层级  Promise错误处理:在catch后终止链式then执行的策略  Log4j Console Appender性能瓶颈与高并发优化策略  如何在 Excel Online 和 Google 表格中更改日期格式  Safari自带网页翻译功能怎么用 无需插件轻松看懂外文网站【方法】  QQ邮箱官网登录入口 QQ邮箱网页版邮箱快速登录  Composer的 "conflict" 字段有什么用_如何声明不兼容的包以避免依赖冲突  QQ邮箱网页版入口页面 QQ邮箱在线登录入口官网  composer 和 npm/yarn 在管理依赖方面有什么核心思想差异?  c++20的std::jthread是什么_c++可中断线程与RAII式管理  Lar*el Form Request中唯一性验证在更新操作中的正确实现  《刺客信条4:黑旗》重制版新细节曝光:无缝加载 地图更细致!  sublime如何优雅地处理行尾空格_sublime自动清理多余空白字符配置  J*aScript异步迭代器_j*ascript异步遍历  c++如何实现单例设计模式_c++线程安全的单例模式写法  b站怎么看视频的弹幕数量_b站弹幕数量查看方法  蛙漫官网漫画入口地址_蛙漫在线畅读无广告弹窗  漫蛙漫画官方首页 漫蛙2漫画在线阅读入口  Lar*el如何生成PDF或Excel文件_Lar*el文档导出工具与使用教程  word邮件合并后日期格式不对怎么改_Word邮件合并日期格式修改方法  12306选座怎么选到商务座_12306商务座选择与配置说明  C++编译期如何执行复杂计算_C++模板元编程(TMP)技巧与应用  知乎APP怎么管理已购盐选内容_知乎APP盐选内容购买记录与查看方法  EMS快递官网app_中国邮政速递物流手机客户端  微博网页版怎么开启两步验证_微博网页版账号安全两步验证设置方法  如何有效阻止外部脚本意外修改内联样式的高度属性  php源码怎么在电脑上测试_电脑测试php源码方法步骤【教程】  MAC怎么在地图App里使用“四处看看”_MAC体验部分城市的3D实景街景  QQ邮箱官方登录入口_QQ邮箱网页版快捷使用平台  可靠CSGO开箱平台解析 CSGO开箱网合集  DLsite中文平台入口 DLsite官网内容在线查看  如何将HTML表格多行数据保存到Google Sheet  sublime怎么覆盖插件的默认快捷键_sublime快捷键优先级与设置  如何在离线环境中使用Composer_Composer离线安装依赖包的技巧与策略  12306选座如何查看座位示意图_12306座位示意图解读与使用  Composer中的^和~符号代表什么_精通Composer版本号语义化约束  如何在复杂的电商平台中优雅地管理共享资源并确保正确重定向,使用spryker-shop/resource-share-page模块助你一臂之力 

搜索