You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.01 13:47:27