2026年3月31日

React 空闲检测与会话管理实战

凡是涉及敏感数据的应用——银行后台、医疗信息系统、运维管理面板——都绕不开一个看似简单的问题:用户还在吗? 如果他离开电脑去倒了杯咖啡,屏幕上还挂着一份病历,你应该锁定会话。如果他在等待数据导出时切到了别的标签页,你可以暂停轮询来节省带宽。如果他正在看培训视频,屏幕不应该自动息屏。这些场景本质上是同一个问题:感知用户是否在场,并做出相应处理。

本文将从零开始构建四个实用模式,先展示手动实现的痛点,再用 ReactUse 的 Hook 一一替换。读完之后,你将掌握会话超时提醒、后台标签页暂停、屏幕常亮控制,以及用户回归通知这四种生产级方案。

1. 会话超时警告:空闲检测

手动实现

检测空闲意味着你要监听所有能表明用户活跃的信号——鼠标移动、键盘输入、触摸事件、滚动——然后在任一事件触发时重置计时器。一个朴素的实现大概长这样:

import { useCallback, useEffect, useRef, useState } from "react";

function useManualIdle(timeoutMs: number) {
  const [idle, setIdle] = useState(false);
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  const resetTimer = useCallback(() => {
    setIdle(false);
    clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => setIdle(true), timeoutMs);
  }, [timeoutMs]);

  useEffect(() => {
    const events = ["mousemove", "keydown", "touchstart", "scroll"];
    events.forEach((evt) => window.addEventListener(evt, resetTimer));
    resetTimer(); // 启动计时器

    return () => {
      events.forEach((evt) => window.removeEventListener(evt, resetTimer));
      clearTimeout(timerRef.current);
    };
  }, [resetTimer]);

  return idle;
}

这段代码在 demo 里能跑,但放到生产环境就捉襟见肘了:你漏掉了 mousedownpointerdownwheelvisibilitychange;每次鼠标移动都会调用 setIdle(false),即使当前已经不是空闲状态,白白触发重渲染;想区分”空闲 5 分钟”和”空闲 30 秒”就得再加一组计时器;超时时长也没法在运行时动态修改。

Hook 方案:useIdle

useIdle 一行搞定:

import { useIdle } from "@reactuses/core";

function SessionManager() {
  const idle = useIdle(5 * 60 * 1000); // 5 分钟

  return idle ? <SessionWarningDialog /> : null;
}

它在内部监听了完整的 DOM 事件集合,自带防抖,返回一个稳定的布尔值。不用自己维护定时器,不用担心遗漏事件类型。

完整的会话超时对话框

useIdle 和倒计时结合起来,构建一个真实可用的会话超时警告:

import { useCallback, useEffect, useState } from "react";
import { useIdle } from "@reactuses/core";

const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 分钟
const WARNING_DURATION = 60; // 60 秒倒计时

function SessionTimeoutGuard({ onLogout }: { onLogout: () => void }) {
  const idle = useIdle(IDLE_TIMEOUT);
  const [countdown, setCountdown] = useState(WARNING_DURATION);

  useEffect(() => {
    if (!idle) {
      setCountdown(WARNING_DURATION);
      return;
    }

    const interval = setInterval(() => {
      setCountdown((prev) => {
        if (prev <= 1) {
          clearInterval(interval);
          onLogout();
          return 0;
        }
        return prev - 1;
      });
    }, 1000);

    return () => clearInterval(interval);
  }, [idle, onLogout]);

  if (!idle) return null;

  return (
    <div className="session-overlay">
      <div className="session-dialog">
        <h2>还在吗?</h2>
        <p>
          由于长时间未操作,您的会话将在 <strong>{countdown}</strong> 秒后过期。
        </p>
        <p>移动鼠标或按任意键即可保持登录状态。</p>
        <div className="session-progress">
          <div
            className="session-progress-bar"
            style={{ width: `${(countdown / WARNING_DURATION) * 100}%` }}
          />
        </div>
      </div>
    </div>
  );
}

因为 useIdle 在用户动鼠标的瞬间就会返回 false,对话框会自动消失——甚至不需要”保持登录”按钮(当然你也可以加一个)。用户重新活跃时,倒计时也会干净地重置。

2. 标签页切换时暂停后台任务

手动实现

很多应用会定时轮询 API。当用户切到别的标签页时,这些请求纯属浪费。手动检测标签页可见性需要用到 Page Visibility API:

import { useEffect, useState } from "react";

function useManualDocumentVisibility() {
  const [visibility, setVisibility] = useState<DocumentVisibilityState>(
    typeof document !== "undefined" ? document.visibilityState : "visible"
  );

  useEffect(() => {
    const handler = () => setVisibility(document.visibilityState);
    document.addEventListener("visibilitychange", handler);
    return () => document.removeEventListener("visibilitychange", handler);
  }, []);

  return visibility;
}

代码不长,但你得记得处理 SSR 的情况,而且一旦需要把可见性和窗口焦点等其他信号组合起来用,条件判断就会散落在组件各处。

Hook 方案:useDocumentVisibility

useDocumentVisibility 封装了 Page Visibility API,并内置了 SSR 安全检查:

import { useDocumentVisibility } from "@reactuses/core";

function PollingDashboard() {
  const visibility = useDocumentVisibility();

  useEffect(() => {
    if (visibility === "hidden") return;

    const interval = setInterval(() => {
      fetch("/api/metrics").then(/* 更新状态 */);
    }, 10_000);

    return () => clearInterval(interval);
  }, [visibility]);

  return <Dashboard />;
}

用户切走标签页时 visibility 变为 "hidden",effect 清理函数执行,轮询停止。用户切回来时 effect 重新运行,轮询恢复。零浪费请求。

更智能的数据暂停模式

更稳健的做法是把可见性和数据新鲜度指标结合起来:

import { useCallback, useEffect, useRef, useState } from "react";
import { useDocumentVisibility } from "@reactuses/core";

interface DashboardData {
  metrics: Record<string, number>;
  updatedAt: number;
}

function SmartPollingDashboard() {
  const visibility = useDocumentVisibility();
  const [data, setData] = useState<DashboardData | null>(null);
  const [stale, setStale] = useState(false);
  const lastFetchRef = useRef(0);

  const fetchData = useCallback(async () => {
    const res = await fetch("/api/dashboard");
    const json = await res.json();
    setData(json);
    setStale(false);
    lastFetchRef.current = Date.now();
  }, []);

  useEffect(() => {
    if (visibility === "hidden") {
      // 后台停留超过 30 秒则标记数据过期
      const staleTimer = setTimeout(() => setStale(true), 30_000);
      return () => clearTimeout(staleTimer);
    }

    // 标签页可见——如果数据过期则立即刷新
    if (stale || Date.now() - lastFetchRef.current > 30_000) {
      fetchData();
    }

    // 恢复正常轮询
    const interval = setInterval(fetchData, 10_000);
    return () => clearInterval(interval);
  }, [visibility, stale, fetchData]);

  return (
    <div>
      {stale && <div className="stale-banner">数据可能已过时</div>}
      {data && <MetricsGrid metrics={data.metrics} />}
    </div>
  );
}

这个模式的好处是:后台不做无用请求、用户切回来后立即刷新、长时间离开还会显示过期提示。

3. 保持屏幕常亮

手动实现

Screen Wake Lock API 可以阻止设备屏幕变暗或锁定。视频播放器、演示文稿、菜谱查看器等场景都离不开它——用户在看屏幕但不触碰设备的时候,你不希望屏幕自己灭掉:

import { useCallback, useEffect, useRef, useState } from "react";

function useManualWakeLock() {
  const [isActive, setIsActive] = useState(false);
  const wakeLockRef = useRef<WakeLockSentinel | null>(null);

  const request = useCallback(async () => {
    try {
      wakeLockRef.current = await navigator.wakeLock.request("screen");
      setIsActive(true);

      wakeLockRef.current.addEventListener("release", () => {
        setIsActive(false);
      });
    } catch (err) {
      console.error("Wake Lock 请求失败:", err);
    }
  }, []);

  const release = useCallback(async () => {
    await wakeLockRef.current?.release();
    wakeLockRef.current = null;
    setIsActive(false);
  }, []);

  // 标签页重新可见时需要重新获取锁
  useEffect(() => {
    const handleVisibility = () => {
      if (document.visibilityState === "visible" && isActive) {
        request();
      }
    };
    document.addEventListener("visibilitychange", handleVisibility);
    return () =>
      document.removeEventListener("visibilitychange", handleVisibility);
  }, [isActive, request]);

  return { isActive, request, release };
}

Wake Lock API 有个坑:浏览器会在标签页隐藏时自动释放锁。你必须在标签页重新可见时重新获取,这恰恰是生产环境中最容易遗漏的边界情况。

Hook 方案:useWakeLock

useWakeLock 自动处理重新获取、错误处理和清理工作:

import { useWakeLock } from "@reactuses/core";

function PresentationMode() {
  const { isActive, request, release } = useWakeLock();

  return (
    <button onClick={() => (isActive ? release() : request("screen"))}>
      {isActive ? "屏幕将保持常亮" : "允许屏幕息屏"}
    </button>
  );
}

视频应用的”保持常亮”开关

下面是一个视频或演示应用的完整组件:

import { useWakeLock, useDocumentVisibility } from "@reactuses/core";
import { useEffect } from "react";

function VideoPlayer({ src }: { src: string }) {
  const { isActive, request, release } = useWakeLock();
  const visibility = useDocumentVisibility();

  // 播放时自动请求屏幕常亮
  const handlePlay = () => {
    if (!isActive) request("screen");
  };

  const handlePause = () => {
    if (isActive) release();
  };

  return (
    <div className="video-container">
      <video
        src={src}
        onPlay={handlePlay}
        onPause={handlePause}
        controls
      />
      <div className="video-controls">
        <span className={`wake-indicator ${isActive ? "active" : ""}`}>
          {isActive ? "屏幕已锁定常亮" : "屏幕可能自动息屏"}
        </span>
        {visibility === "hidden" && (
          <span className="background-notice">
            视频正在后台标签页播放
          </span>
        )}
      </div>
    </div>
  );
}

用户点击播放时屏幕保持常亮,暂停或切走标签页时锁定释放。Hook 会在标签页回来后自动重新获取锁——手动实现的话,这又是额外十几行代码。

4. 用户切回标签页时发送通知

手动实现

假设你的应用在用户切到别的标签页后完成了一项耗时任务,你想发一条浏览器通知提醒他回来。手动实现需要把 Notification API 和焦点检测拼在一起:

import { useCallback, useEffect, useRef, useState } from "react";

function useManualNotifyOnReturn() {
  const [focused, setFocused] = useState(true);
  const pendingRef = useRef<string | null>(null);

  useEffect(() => {
    const onFocus = () => setFocused(true);
    const onBlur = () => setFocused(false);
    window.addEventListener("focus", onFocus);
    window.addEventListener("blur", onBlur);
    return () => {
      window.removeEventListener("focus", onFocus);
      window.removeEventListener("blur", onBlur);
    };
  }, []);

  const notify = useCallback(
    (title: string, body: string) => {
      if (focused) return; // 用户已经在看了

      if (Notification.permission === "granted") {
        new Notification(title, { body });
      } else if (Notification.permission !== "denied") {
        Notification.requestPermission().then((perm) => {
          if (perm === "granted") {
            new Notification(title, { body });
          }
        });
      }
    },
    [focused]
  );

  return { focused, notify };
}

这段代码遗漏了一些边界情况:用户拒绝了通知权限怎么办?移动端的 focus/blur 行为不一致怎么处理?用户回来后旧通知要不要自动清除?

Hook 方案:useWindowFocus + useWebNotification

useWindowFocususeWebNotification 组合使用,代码清晰且声明式:

import { useWindowFocus, useWebNotification } from "@reactuses/core";

function TaskRunner() {
  const focused = useWindowFocus();
  const { isSupported, show, close } = useWebNotification({
    title: "",
    dir: "auto",
    lang: "zh",
    tag: "task-complete",
  });

  const runTask = async () => {
    await performLongRunningTask();

    // 仅在用户不在当前标签页时发送通知
    if (!focused) {
      show({
        title: "任务完成",
        body: "您的数据导出已就绪,可以下载了。",
      });
    }
  };

  return (
    <div>
      <button onClick={runTask}>开始导出</button>
      {!isSupported && (
        <p className="warning">
          当前浏览器不支持通知功能。
        </p>
      )}
    </div>
  );
}

完整的通知中心

下面构建一个更贴近真实场景的通知中心:用户离开时将事件排队,回来后汇总通知:

import { useCallback, useEffect, useRef, useState } from "react";
import { useWindowFocus, useWebNotification } from "@reactuses/core";

interface AppEvent {
  id: string;
  title: string;
  body: string;
  timestamp: number;
}

function NotificationCenter() {
  const focused = useWindowFocus();
  const { isSupported, show } = useWebNotification({
    title: "",
    dir: "auto",
    lang: "zh",
    tag: "app-notification",
  });
  const [missedEvents, setMissedEvents] = useState<AppEvent[]>([]);
  const focusedRef = useRef(focused);

  // 保持 ref 同步以便在回调中使用
  useEffect(() => {
    focusedRef.current = focused;
  }, [focused]);

  // 模拟服务端推送事件(替换为你的 WebSocket/SSE 处理逻辑)
  const onServerEvent = useCallback((event: AppEvent) => {
    if (!focusedRef.current) {
      setMissedEvents((prev) => [...prev, event]);
    }
  }, []);

  // 用户回来时,发送一条汇总通知
  useEffect(() => {
    if (focused && missedEvents.length > 0) {
      if (isSupported) {
        show({
          title: `您离开期间有 ${missedEvents.length} 条更新`,
          body: missedEvents.map((e) => e.title).join("、"),
        });
      }
      // 清空队列——用户已经看到了
      setMissedEvents([]);
    }
  }, [focused, missedEvents, isSupported, show]);

  return (
    <div className="notification-center">
      {missedEvents.length > 0 && (
        <div className="missed-badge">{missedEvents.length}</div>
      )}
    </div>
  );
}

这个模式对协同应用(比如在线文档、聊天工具)尤其有价值——用户不在的时候总会发生各种事情。

组合拳:感知用户状态的应用外壳

真正的威力在于把这些 Hook 组合到一起。下面是一个统一处理会话管理、后台优化和用户通知的应用外壳:

import { useEffect, useCallback } from "react";
import {
  useIdle,
  useDocumentVisibility,
  useWindowFocus,
  useWakeLock,
  useWebNotification,
} from "@reactuses/core";

function AppShell({ children }: { children: React.ReactNode }) {
  const idle = useIdle(5 * 60 * 1000);
  const visibility = useDocumentVisibility();
  const focused = useWindowFocus();
  const { request: requestWakeLock, release: releaseWakeLock } = useWakeLock();
  const { show: showNotification } = useWebNotification({
    title: "",
    dir: "auto",
    lang: "zh",
    tag: "app-shell",
  });

  // 会话超时
  useEffect(() => {
    if (idle) {
      // 开始登出倒计时或锁定屏幕
    }
  }, [idle]);

  // 后台时暂停高开销操作
  useEffect(() => {
    if (visibility === "hidden") {
      // 暂停动画、轮询、降低 WebSocket 心跳频率
    }
  }, [visibility]);

  // 用户回来时刷新数据
  useEffect(() => {
    if (focused) {
      // 检查待处理的通知,刷新过期数据
    }
  }, [focused]);

  const userState = idle
    ? "idle"
    : visibility === "hidden"
      ? "background"
      : "active";

  return (
    <div className="app-shell" data-user-state={userState}>
      {idle && <SessionTimeoutOverlay />}
      {children}
    </div>
  );
}

五个 Hook,各司其职,组合在一起就构成了一个感知用户状态的应用。不用手写事件监听器,不用维护定时器,不用操心 SSR 兼容。

使用场景速查

场景Hook检测目标
会话超时useIdle用户无操作达 N 毫秒
暂停后台任务useDocumentVisibility标签页隐藏/可见
检测标签页切换useWindowFocus窗口获得/失去焦点
保持屏幕常亮useWakeLockScreen Wake Lock API
浏览器通知useWebNotificationNotification API

安装

npm install @reactuses/core
# 或
pnpm add @reactuses/core
# 或
yarn add @reactuses/core

相关 Hook

ReactUse 提供了 100+ 个 React Hook。去看看完整列表 →