Next.js 15 App Router中如何在用户离开特定页面时显示自定义弹窗
问题描述
我正在使用Next.js 15(App Router)搭建一个发布平台,用户发布文章后会进入/share/[id]页面。我希望当用户从该页面导航至平台内其他页面时,显示一个自定义促销弹窗(非浏览器原生beforeunload对话框)。
目前遇到的问题是找不到在App Router中检测“离开页面”的正确方式。
已尝试的方法:
- 在分享页面内渲染弹窗,但导航发生时组件立即卸载,弹窗在用户看到前就被销毁。
beforeunload事件:仅在用户关闭标签页或刷新时触发,不会在客户端导航时触发,且仅显示浏览器原生对话框(我需要自定义UI)。useEffect清理函数:本想通过检测卸载来显示弹窗,但清理函数执行时组件已从DOM中移除。
布局结构:
app/ [lang]/ (dashboard)/ layout.jsx share/ [id]/ page.jsx ← 要检测离开的页面 feed/ page.jsx settings/ page.jsx
自定义弹窗组件代码:
"use client"; import React from "react"; import { useMediaQuery } from "@/hooks/use-media-query"; import { Dialog, DialogContent, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { Drawer, DrawerContent, DrawerTitle, DrawerDescription, } from "@/components/ui/drawer"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Check, Zap, Search, Pen, BadgeCheck } from "lucide-react"; interface NudgeUserProps { open: boolean; onOpenChange: (open: boolean) => void; articleCount?: number; onSubscribe?: () => void; onDismiss?: () => void; } const benefits = [ { icon: Zap, text: "100 ضعف المشاهدات لمقالاتك", iconColor: "text-yellow-800", }, { icon: Search, text: "الظهور في اقتراحات المنصة ونتائج البحث", iconColor: "text-gray-800", }, { icon: Pen, text: "أدوات رفيق كتابة للتصميم والتحرير دون حدود", iconColor: "text-gray-800", }, { icon: BadgeCheck, text: "توثيق حسابك ضمن قائمة الكتّاب المميّزين", iconColor: "text-gray-800", }, ]; function NudgeContent({ articleCount = 3, onSubscribe, onDismiss, }: Pick<NudgeUserProps, "articleCount" | "onSubscribe" | "onDismiss">) { return ( <div className="flex flex-col items-center text-center px-4 pb-6 pt-2" dir="rtl"> <Badge className="rounded-full self-start bg-[#d2e2fa] text-[#3544bf] font-bold px-4 py-1.5 text-sm mb-4"> <Check className="w-4 h-4" /> مقال رقم {articleCount} </Badge> <h2 className="text-2xl font-bold mb-2 self-start"> نشرت {articleCount} مقالات! </h2> <p className="text-muted-foreground text-sm mb-6 max-w-xs text-right text-gray-800 self-start"> أنت ضمن أنشط 10% من الكتّاب على منصة كتابة - باقة الكاتب تناسبك وتمنحك: </p> <div className="w-full space-y-0"> {benefits.map((benefit, index) => ( <div key={index} className="flex items-center justify-between gap-3 py-3.5 border-b border-border last:border-b-0 px-2 " > <span className="text-sm font-medium text-right flex-1"> {benefit.text} </span> <benefit.icon className={`w-10 h-10 shrink-0 bg-[#d2e2fa] p-2 rounded-[11px] ${benefit.iconColor}`} /> </div> ))} </div> <Button onClick={onSubscribe} className="w-full mt-6 bg-brandNew hover:bg-brandNew/80 text-white rounded-full h-14 text-lg font-bold" > انضم إلى باقة الكاتب </Button> <button onClick={onDismiss} className="mt-3 text-sm text-muted-foreground hover:text-foreground transition-colors" > لاحقاً </button> </div> ); } export default function NudgeUser({ open, onOpenChange, articleCount = 3, onSubscribe, onDismiss, }: NudgeUserProps) { const isDesktop = useMediaQuery("(min-width: 768px)"); const handleDismiss = () => { onDismiss?.(); onOpenChange(false); }; if (isDesktop) { return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="sm:max-w-md p-6 rounded-2xl"> <DialogTitle className="sr-only">باقة الكاتب</DialogTitle> <DialogDescription className="sr-only"> انضم إلى باقة الكاتب للحصول على مزايا إضافية </DialogDescription> <NudgeContent articleCount={articleCount} onSubscribe={onSubscribe} onDismiss={handleDismiss} /> </DialogContent> </Dialog> ); } return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent> <DrawerTitle className="sr-only">باقة الكاتب</DrawerTitle> <DrawerDescription className="sr-only"> انضم إلى باقة الكاتب للحصول على مزايا إضافية </DrawerDescription> <NudgeContent articleCount={articleCount} onSubscribe={onSubscribe} onDismiss={handleDismiss} /> </DrawerContent> </Drawer> ); }
解决方案
核心思路是将弹窗放在更高层级的布局组件中(比如(dashboard)/layout.jsx),避免页面卸载时弹窗被销毁,同时通过Next.js的路由钩子拦截客户端导航事件,判断是否从/share/[id]页面离开,再触发弹窗显示。
步骤1:在父布局中添加弹窗组件
修改(dashboard)/layout.jsx,引入NudgeUser组件并维护弹窗状态与导航拦截逻辑:
"use client"; import React, { useState, useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; import NudgeUser from "@/components/nudge-user"; // 根据实际组件路径调整 export default function DashboardLayout({ children }) { const [showNudge, setShowNudge] = useState(false); const pathname = usePathname(); const router = useRouter(); let pendingNavigation = null; // 处理弹窗确认后的导航 const handleConfirmNavigation = () => { if (pendingNavigation) { router.push(pendingNavigation); pendingNavigation = null; } setShowNudge(false); }; useEffect(() => { // 拦截浏览器回退/前进导航 const unsubscribePopState = router.beforePopState(({ url }) => { if (pathname.startsWith("/share/")) { pendingNavigation = url; setShowNudge(true); return false; // 阻止默认导航行为 } return true; }); // 拦截next/link组件的点击导航 const handleLinkClick = (e) => { const targetLink = e.target.closest("a"); if (!targetLink || !pathname.startsWith("/share/")) return; const targetUrl = new URL(targetLink.href); if (targetUrl.origin !== window.location.origin) return; e.preventDefault(); pendingNavigation = targetUrl.pathname + targetUrl.search; setShowNudge(true); }; document.addEventListener("click", handleLinkClick); return () => { unsubscribePopState(); document.removeEventListener("click", handleLinkClick); }; }, [pathname, router]); return ( <> {children} <NudgeUser open={showNudge} onOpenChange={setShowNudge} articleCount={3} // 根据实际用户文章数传递 onSubscribe={handleConfirmNavigation} onDismiss={handleConfirmNavigation} /> </> ); }
步骤2:调整/share/[id]/page.jsx逻辑
无需在该页面内渲染弹窗,保持原有内容展示逻辑即可。若需传递用户文章数量等动态数据,可通过状态管理工具或布局props传递给父布局的弹窗组件。
关键原理
- 弹窗挂载在父布局:父布局不会随子页面卸载而销毁,确保弹窗能正常显示。
- 双重导航拦截:
beforePopState:处理浏览器自带的回退/前进按钮导航。- 点击事件监听:处理
next/link组件触发的客户端导航。
- 延迟导航执行:拦截导航时记录目标路径,等用户处理完弹窗后再执行跳转。
注意事项
- 仅在用户从
/share/[id]页面离开时触发弹窗,避免全局触发。 - 放行外部链接:若用户点击的是外部网站链接,不进行拦截。
- 状态清理:弹窗关闭后清空
pendingNavigation,避免内存泄漏。
内容的提问来源于stack exchange,提问作者Farhan Asghar
相关产品推荐
相关产品推荐

