2026年5月9日
React 浏览器标签页 UX:用标题、Favicon 和通知把用户拉回来
普通用户笔记本上随时开着三十个标签页,你的应用只是其中一个。用户打开它,切去看 Slack,十五分钟后回来,已经分不清哪一个标签页是你的。如果你的标签标题还停在”My App”,favicon 还是上线那天的灰色方块,那十五分钟就白白浪费了——其间来过新消息、构建完成、上传成功,用户却完全不知道。
浏览器其实给了你一块虽小但很有威力的”注意力表面”:标签标题、favicon、可见状态、聚焦事件,以及系统级通知。把它们接对了,一个非活动标签可以在标签栏里显示”(3) New messages — Acme Chat”,favicon 上闪一个红点,隐藏时停掉昂贵的轮询,回到前台时立刻刷新,紧急情况还能弹一条原生 OS 通知。接错了,这堆代码会泄漏事件监听器、跟 React 的渲染周期打架、首次 SSR 就抛 hydration 不一致。
本文走过六个在 React 中构建注意力感知 UI 的原语,每一个都用 ReactUse 中专门的 Hook 实现。我们先看手动写法、踩到的坑,再看 Hook 是怎么把它们藏起来的。最后把六个 Hook 合在一起,做出一个像原生 App 一样会”叫人”的聊天标签页。
1. 把标签标题当作通知通道
<title> 元素是 Web 上被低估得最严重的通知表面。Gmail、GitHub、Linear、Discord 都在用:开头的 (N) 计数或一个 • 圆点告诉你”出事了”,而你不必切回标签页确认。实现是一行——document.title = "..."——但放进 React 组件里写法不对,标题就会一直停在最后一次渲染设置的值上,连组件卸载之后都不会复原。
手动实现
import { useEffect, useState } from "react";
function ManualUnreadTitle({ count }: { count: number }) {
useEffect(() => {
const previous = document.title;
document.title = count > 0 ? `(${count}) Acme Chat` : "Acme Chat";
return () => {
document.title = previous;
};
}, [count]);
return null;
}
肉眼不太容易抓到的 bug 在这里:previous 捕获的是 effect 运行那一刻的标题,意味着如果父组件在两次渲染之间也改了标题,cleanup 会把一个过时的值再写回去。修法要么是给标题选一个唯一的真值来源,要么干脆别 cleanup,让下一次渲染覆盖。多数应用走第二条路,然后忘了写 cleanup,半年之后接进 React StrictMode、effect 跑两次,标题就卡死在某个旧值上。
ReactUse 写法:useTitle
useTitle 接受一个字符串,每当字符串变化就同步到 document.title:
import { useTitle } from "@reactuses/core";
function UnreadTitle({ count }: { count: number }) {
useTitle(count > 0 ? `(${count}) Acme Chat` : "Acme Chat");
return null;
}
整个组件就这么多。Hook 订阅的是它自己的输入,而不是上一次的 DOM 值,所以不可能出现”清理写回旧值”的 bug。把它丢在树里任何位置——通常是页面根部,或者持有未读数的那个组件——标题就会随着数据变。
一个常见的搭配是把它和聊天 store 中派生出的未读数组合起来:
import { useTitle } from "@reactuses/core";
import { useChatStore } from "./store";
function ChatTitle() {
const unread = useChatStore((s) => s.unreadCount);
const channel = useChatStore((s) => s.activeChannel?.name ?? "Chat");
useTitle(unread > 0 ? `(${unread}) ${channel} — Acme` : `${channel} — Acme`);
return null;
}
这个组件不渲染任何视觉元素,存在的唯一理由就是把 store 同步到标题上。在应用顶部挂一次就好。
2. 状态化的 Favicon
Favicon 比标题占的位置还要小——十六像素见方——但它是标题被截断时用户在标签栏里唯一能看到的东西。根据状态切换 favicon(idle 灰、attention 红、error 橙、success 绿)是浏览器里最廉价的 UX 之一。
手动实现
import { useEffect } from "react";
function ManualFavicon({ status }: { status: "idle" | "alert" | "error" }) {
useEffect(() => {
const link = document.querySelector<HTMLLinkElement>("link[rel='icon']");
if (!link) return;
link.href =
status === "idle"
? "/favicon.ico"
: status === "alert"
? "/favicon-alert.ico"
: "/favicon-error.ico";
}, [status]);
return null;
}
正常路径下能跑,坏在三种情况下:根本没有 <link rel="icon">(有些打包器会把它去掉)、有多个不同尺寸的 icon link(Apple touch icon、manifest icon)、SSR 渲染的 icon 和客户端要的不一样。最后会写成一堆分支。
ReactUse 写法:useFavicon
useFavicon 把这三种情况都照顾了。它会更新所有匹配 link[rel*="icon"] 的标签,找不到就自己创建一个,同时支持 base URL 前缀(用于 CDN 资源)。
import { useFavicon } from "@reactuses/core";
function StatusFavicon({ status }: { status: "idle" | "alert" | "error" }) {
const href =
status === "idle"
? "/favicon.ico"
: status === "alert"
? "/favicon-alert.ico"
: "/favicon-error.ico";
useFavicon(href);
return null;
}
一个有意思的玩法是把它和未读数结合,做出”带角标的 favicon”。预先生成几张 PNG(favicon-1.png 到 favicon-9.png,再加 favicon-9plus.png),按数量挑一张:
import { useFavicon } from "@reactuses/core";
function BadgedFavicon({ count }: { count: number }) {
const variant =
count === 0 ? "" : count > 9 ? "-9plus" : `-${count}`;
useFavicon(`/favicon${variant}.png`);
return null;
}
这样即使标题被截断,标签栏里也能看到带数字的 favicon。
3. 标签隐藏时暂停昂贵的工作
每个应用至少有一个不该在用户看不到时还在跑的轮询、动画或视频。浏览器会节流后台标签,但节流不等于停止——一个原本 1 秒的轮询变成 60 秒,仍然在打服务器、解析 JSON、改 state、触发一次没人看到的渲染。Page Visibility API 让你能干净地暂停。
手动实现
import { useEffect, useState } from "react";
function ManualVisibility() {
const [hidden, setHidden] = useState(document.hidden);
useEffect(() => {
const onChange = () => setHidden(document.hidden);
document.addEventListener("visibilitychange", onChange);
return () => document.removeEventListener("visibilitychange", onChange);
}, []);
return hidden ? "hidden" : "visible";
}
两个问题。一是服务器端 document 是 undefined,初始 state 直接把 SSR 弄崩。二是 visibilitychange 在首次绘制时不会触发——如果用户进站时你的页面就是后台标签,初次的 document.hidden 是对的,但等到聚焦回来你就不会再读它一次。
ReactUse 写法:useDocumentVisibility
useDocumentVisibility 用一个 defaultValue 参数处理 SSR,并在挂载之后再同步一次。
import { useEffect } from "react";
import { useDocumentVisibility } from "@reactuses/core";
function PriceTicker() {
const visibility = useDocumentVisibility("visible");
const [price, setPrice] = useState<number | null>(null);
useEffect(() => {
if (visibility === "hidden") return;
const id = setInterval(async () => {
const r = await fetch("/api/price");
setPrice((await r.json()).price);
}, 1000);
return () => clearInterval(id);
}, [visibility]);
return <span>${price ?? "—"}</span>;
}
Tab 可见时挂 interval、隐藏时卸载、回来再重新挂。没有”被节流但还在跑”的轮询,没有浪费的带宽,用户切回来那一刻就能看到最新价格。
Hook 返回的是真正的 DocumentVisibilityState('visible' | 'hidden'),而不是布尔值,跟规范保持一致,将来规范扩出 'prerender' 这种状态也能直接接入。
4. 聚焦时刷新
visibilitychange 在标签从隐藏变成可见时触发,但”可见”不等于”被聚焦”——画中画、左右分屏、或者你的标签是后台窗口里的前景标签都属于这种情况。如果你想要的是”用户刚刚切回我”,那应该用 window focus,而不是 visibility。
手动实现
import { useEffect, useState } from "react";
function ManualFocus() {
const [focused, setFocused] = useState(false);
useEffect(() => {
setFocused(document.hasFocus());
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);
};
}, []);
return focused ? "focused" : "blurred";
}
跟前面一样的故事——三个事件监听器、一次初始读取、一个 SSR 的坑。
ReactUse 写法:useWindowsFocus
useWindowFocus(导出名是 useWindowsFocus,遗留命名保留了下来)返回一个布尔值,并在挂载时再同步一次。
import { useEffect } from "react";
import { useWindowsFocus } from "@reactuses/core";
function FreshFeed() {
const focused = useWindowsFocus();
const [items, setItems] = useState<Item[]>([]);
useEffect(() => {
if (!focused) return;
fetch("/api/feed").then((r) => r.json()).then(setItems);
}, [focused]);
return <Feed items={items} />;
}
每次用户切回这个窗口,feed 就重新拉取一次。和 useDocumentVisibility 配合:隐藏时停掉轮询,重新聚焦时拉一次新数据,“长时间离开”和”快速一瞥”这两种情况都被覆盖。
5. 在用户离开之前抓住他
usePageLeave 在鼠标移出视口时触发——通常是朝着标签栏或地址栏移动,往往是用户准备切走的先兆。这是”离开意图”浮层的基础。这种模式被广告弹窗用滥了名声不太好,但用在”你有未保存的改动”提示或”走之前看看你错过了什么”上是有用的。
import { usePageLeave } from "@reactuses/core";
function UnsavedHint({ dirty }: { dirty: boolean }) {
const isLeaving = usePageLeave();
if (!dirty || !isLeaving) return null;
return (
<div className="toast">
你有未保存的改动。按 ⌘S 保存。
</div>
);
}
Hook 监听 mouseout、mouseleave、mouseenter,光标越过视口边缘时翻转布尔值。用得节制一点——每一个在你出门时塞过”等等,再看一眼!“模态框的网站,都是在提醒:这个模式从有用变到讨厌只需要一步。
更克制的版本:和”表单是否脏”配合,只有真正有东西要丢失时才提示。
6. 原生通知——先看权限
Notification API 是这一切表面里唯一彻底逃出浏览器的:原生 OS 通知即使你的标签被埋在最深处、窗口被最小化、用户在另一个 App 里,都能弹出。它也是唯一一个明确需要用户授权的,把授权 UX 做错就是把”deny”刻在浏览器设置里最快的捷径。
这里成对使用的两个 Hook 是 usePermission 和 useWebNotification。
在请求之前先看状态
usePermission 包装了 Permissions API,针对任意权限名返回当前状态——'granted'、'denied'、'prompt',或者 API 不支持时返回空。用它来决定是渲染”开启通知”按钮(状态是 'prompt')、“已开启”指示('granted'),还是”通知被禁用——去浏览器设置修复”链接('denied')。
import { usePermission } from "@reactuses/core";
function NotificationStatus() {
const state = usePermission("notifications");
if (state === "granted") return <span>通知:已开启</span>;
if (state === "denied") return <a href="#help">通知被禁用——前往修复</a>;
return null;
}
仅在用户主动操作时再请求
useWebNotification 返回 isSupported、show、close 和 ensurePermissions。Notification API 的铁律:不要在页面加载时就调 Notification.requestPermission()。浏览器把权限提示作为标签级 chrome 弹窗显示,在用户跟你的页面发生交互之前就弹出来,是教科书级的”反射性拒绝”UX。
放到一个按钮点击里再触发:
import { useWebNotification } from "@reactuses/core";
function EnableButton() {
const { isSupported, ensurePermissions, show } = useWebNotification();
if (!isSupported) return null;
return (
<button
onClick={async () => {
const granted = await ensurePermissions();
if (granted) {
show("已开启", {
body: "我们会在这里通知你新消息。",
icon: "/favicon.ico",
});
}
}}
>
开启桌面通知
</button>
);
}
一旦用户授权,从应用的任何地方调用 show(title, options) 就能弹原生通知。Hook 在卸载时会关掉当前通知,所以触发后立刻卸载的组件不会留下永久挂着的通知。
全部组合:一个注意力感知的聊天标签页
把六个原语都接上之后,一个聊天标签页大致是这样的:未读数同时更新标题和 favicon;轮询在隐藏时暂停、在重新聚焦时刷新;草稿未保存时触发离开提示;后台来新消息时弹原生通知。
import { useEffect, useRef } from "react";
import {
useTitle,
useFavicon,
useDocumentVisibility,
useWindowsFocus,
usePageLeave,
useWebNotification,
} from "@reactuses/core";
import { useChatStore } from "./store";
export function AttentionAwareChat() {
const unread = useChatStore((s) => s.unreadCount);
const channel = useChatStore((s) => s.activeChannel?.name ?? "Chat");
const draftDirty = useChatStore((s) => s.composer.length > 0);
const latest = useChatStore((s) => s.latestMessage);
const fetchFeed = useChatStore((s) => s.fetchFeed);
// 1 + 2: 标题和 favicon 反映未读数
useTitle(unread > 0 ? `(${unread}) ${channel} — Acme` : `${channel} — Acme`);
const variant = unread === 0 ? "" : unread > 9 ? "-9plus" : `-${unread}`;
useFavicon(`/favicon${variant}.png`);
// 3: 隐藏时暂停轮询
const visibility = useDocumentVisibility("visible");
useEffect(() => {
if (visibility === "hidden") return;
const id = setInterval(fetchFeed, 5000);
return () => clearInterval(id);
}, [visibility, fetchFeed]);
// 4: 聚焦时全量刷新
const focused = useWindowsFocus();
useEffect(() => {
if (focused) fetchFeed();
}, [focused, fetchFeed]);
// 5: 有未保存草稿时给离开提示
const isLeaving = usePageLeave();
// 6: 后台收到新消息时弹原生通知
const { show, ensurePermissions, isSupported } = useWebNotification();
const lastNotifiedId = useRef<string | null>(null);
useEffect(() => {
if (!isSupported || !latest || visibility === "visible") return;
if (lastNotifiedId.current === latest.id) return;
lastNotifiedId.current = latest.id;
show(`${latest.author} 在 ${channel}`, {
body: latest.text,
icon: "/favicon.ico",
tag: "chat-message",
});
}, [latest, visibility, channel, show, isSupported]);
return (
<>
<ChatPane />
{draftDirty && isLeaving && (
<Toast>你有一条未保存的草稿。</Toast>
)}
{!isSupported || (
<button onClick={ensurePermissions}>开启桌面通知</button>
)}
</>
);
}
六个 Hook,一个组件,没有手写的事件监听器,没有 SSR 崩溃,没有泄漏的 timer。每一行注意力管理逻辑都和它服务的聊天功能贴在一起,下一个读这个文件的人一眼就知道去哪儿改。
小结
| Hook | 用途 | 何时需要 |
|---|---|---|
useTitle | 把字符串同步到 document.title | 未读数、构建状态、文档名 |
useFavicon | 响应式切换 favicon href | 状态徽标、提醒红点、品牌化状态 |
useDocumentVisibility | 跟踪标签隐藏/可见 | 暂停轮询、动画、视频 |
useWindowFocus | 跟踪窗口焦点 | 回来时刷新、失焦时暂停 |
usePageLeave | 检测光标离开视口 | 离开意图提示、未保存草稿警告 |
usePermission | 读取 Permissions API 状态 | 通知/定位等条件化 CTA |
useWebNotification | 显示原生 OS 通知 | 后台消息提醒、构建完成提示 |
浏览器标签页 UX 是那种”好应用”和”出色应用”之间差距很小、感受差距很大的领域。六个 Hook、二十行胶水代码,你的应用就开始有了那些跟它争夺注意力的原生应用的”行为感”。在 reactuse.com 浏览完整目录——明天上线了哪一个,给我们扔张截图。