在这个多任务处理的时代,用户的浏览器标签栏早已成为了一片拥挤的战场。你的Web应用,不过是用户同时打开的三十个标签页中的一个微小切片。当用户在Slack和邮件之间穿梭,十五分钟后再次面对那一排被截断的标题和千篇一律的默认图标时,如果你的应用不能在视觉噪音中发出自己的声音,那么这十五分钟里发生的一切——新消息、状态更新、任务完成——都将石沉大海。
真正的应用不仅仅存在于用户注视的那一刻,它应该具备“存在感”。这种存在感,源于对浏览器提供的微小但极具威力的“注意力表面”的精妙运用:动态的标题、状态化的Favicon、智能的可见性控制、精准的焦点感知、预判性的离开提示,以及突破浏览器边界的原生通知。将这些元素编织在一起,你的应用就能在后台静默时暂停呼吸,在收到新消息时让图标闪烁,在用户切回的瞬间立刻焕发生机。
本文将带你深入六个构建这种“生命力”的核心原语,我们将利用@reactuses/core中的专用Hooks,将这些原本充满陷阱(如事件监听器泄漏、SSR不一致)的底层逻辑封装起来,最终组合出一个像原生应用一样敏锐、流畅的React组件。
1. 标题:被忽视的强力通知栏
<title>标签是Web上被严重低估的通知渠道。Gmail和GitHub早已证明,一个简单的(3)或•就能在不打扰用户的情况下传递关键信息。在React中,直接操作document.title看似简单,却极易陷入状态管理的泥潭。
手动实现时,一个常见的陷阱是清理函数(cleanup)会错误地将过时的标题写回。这是因为清理函数捕获了Effect运行时的快照,而非DOM的当前值。如果父组件或其他逻辑修改了标题,你的清理操作就会造成覆盖。
// 手动实现的陷阱:清理函数写回过时的值
useEffect(() => {
const previous = document.title; // 捕获了旧值
document.title = newTitle;
return () => { document.title = previous; }; // 可能覆盖掉其他更新
}, [newTitle]);
useTitle Hook完美地解决了这个问题。它将输入的字符串作为唯一真值来源,直接同步到document.title,无需你手动处理清理逻辑,也完全避免了StrictMode下Effect执行两次导致的标题卡死问题。
import { useTitle } from "@reactuses/core";
function ChatTitle({ unreadCount }) {
useTitle(unreadCount > 0 ? `(${unreadCount}) Chat` : "Chat");
return null;
}
2. Favicon:16x16像素的UX革命
当标签页被挤压得只剩下一个图标时,Favicon就成了你唯一的阵地。根据应用状态(空闲、提醒、错误、成功)动态切换图标,是成本最低却效果最显著的UX优化。手动实现需要处理<link rel="icon">标签的查询、创建和更新,还要应对多尺寸图标和SSR环境下的document缺失问题。
useFavicon Hook将这些复杂性一并封装。它会自动更新所有匹配的图标标签,如果不存在则创建一个新的,并支持CDN前缀。
import { useFavicon } from "@reactuses/core";
function StatusFavicon({ status }) {
const href = status === "alert" ? "/favicon-alert.ico" : "/favicon.ico";
useFavicon(href);
return null;
}
更进一步,你可以预先生成一组带数字角标的Favicon(如favicon-1.png到favicon-9plus.png),结合未读数动态切换,即使标题被完全截断,用户也能在标签栏里一眼看到未读消息的数量。
3. 可见性感知:节省资源的智能休眠
当用户切换到其他标签页时,你的应用是否还在后台徒劳地轮询服务器、播放动画、消耗电量?Page Visibility API是你的救星。手动实现需要处理SSR的undefined问题,以及首次挂载时的状态同步。
useDocumentVisibility Hook返回'visible'或'hidden'状态,并妥善处理了服务端渲染(通过defaultValue参数)和客户端的首次同步。
import { useDocumentVisibility } from "@reactuses/core";
function PriceTicker() {
const visibility = useDocumentVisibility("visible");
useEffect(() => {
if (visibility === "hidden") return;
const id = setInterval(fetchPrice, 1000);
return () => clearInterval(id);
}, [visibility]);
// ...
}
利用这个Hook,你可以让轮询在标签页隐藏时暂停,在用户切回时立即恢复,既节省了服务器带宽,又保证了用户看到的是最新数据。
4. 焦点控制:比可见性更精准的刷新时机
“可见”并不等于“被关注”。在分屏模式或画中画场景下,你的标签页可能处于可见状态,但用户实际上正在操作另一个窗口。如果你希望在用户“真正”切回应用时刷新数据(例如刷新动态流),你应该监听窗口焦点(Window Focus),而不是文档可见性。
useWindowFocus Hook返回一个布尔值,指示当前窗口是否获得焦点。它同样处理了SSR的兼容性问题。
import { useWindowFocus } from "@reactuses/core";
function FreshFeed() {
const focused = useWindowFocus();
useEffect(() => {
if (focused) fetchFeed();
}, [focused]);
// ...
}
将useDocumentVisibility和useWindowFocus结合使用,可以构建出极其细腻的行为:隐藏时暂停轮询,切回前台时(即使在分屏中)不立即刷新,只有当用户真正点击回你的窗口时,才触发一次全量刷新。
5. 离开意图:在用户走之前挽留
usePageLeave Hook监听鼠标移出视口的事件,用于检测用户的“离开意图”。这是实现“你有未保存改动”提示或“走之前看看新内容”弹窗的基础。
import { usePageLeave } from "@reactuses/core";
function UnsavedHint({ dirty }) {
const isLeaving = usePageLeave();
if (!dirty || !isLeaving) return null;
return <div className="toast">你有未保存的改动!</div>;
}
这个Hook非常敏感,使用时必须克制。最佳实践是将其与“表单脏检查”等逻辑结合,只有在用户确实有数据可能丢失时才触发提示,避免成为烦人的广告弹窗。
6. 原生通知:打破浏览器的边界
这是最强大的通知手段,也是唯一需要用户明确授权的。Notification API允许你的应用在浏览器窗口最小化甚至不在前台时,弹出操作系统的原生通知。用错时机(如页面加载时自动弹窗)是导致用户永久拒绝通知的头号原因。
这里需要两个Hook配合:
usePermission("notifications"):用于查询当前的通知权限状态(granted、denied、prompt),从而决定渲染“开启通知”按钮还是“去设置开启”的链接。useWebNotification:提供ensurePermissions(安全地请求权限)和show(显示通知)方法。
import { usePermission, useWebNotification } from "@reactuses/core";
function NotificationButton() {
const { state } = usePermission("notifications");
const { ensurePermissions, show } = useWebNotification();
if (state === "granted") return <span>已开启</span>;
if (state === "denied") return <a href="#">去设置开启</a>;
return (
<button onClick={async () => {
const granted = await ensurePermissions();
if (granted) show("欢迎", { body: "你将收到新消息通知" });
}}>
开启桌面通知
</button>
);
}
关键原则是:永远在用户主动交互(如点击按钮)的回调中请求权限,而不是在组件挂载时自动弹出。
组装:一个具备“生命力”的聊天应用
现在,我们将上述所有原语整合到一个名为AttentionAwareChat的组件中。你会发现,原本需要大量事件监听器和生命周期管理的复杂逻辑,现在变得异常清晰和声明式。
import {
useTitle,
useFavicon,
useDocumentVisibility,
useWindowFocus,
usePageLeave,
usePermission,
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. 动态标题和Favicon
useTitle(unread > 0 ? `(${unread}) ${channel}` : channel);
useFavicon(`/favicon${unread ? `-${unread}` : ""}.png`);
// 2. 智能轮询:仅在可见时运行
const visibility = useDocumentVisibility("visible");
useEffect(() => {
if (visibility === "hidden") return;
const id = setInterval(fetchFeed, 5000);
return () => clearInterval(id);
}, [visibility]);
// 3. 焦点刷新:仅在真正回到前台时触发
const focused = useWindowFocus();
useEffect(() => {
if (focused) fetchFeed();
}, [focused]);
// 4. 离开提示
const isLeaving = usePageLeave();
// 5. 原生通知:后台收到新消息时弹出
const { show, isSupported } = useWebNotification();
const lastNotifiedId = useRef(null);
useEffect(() => {
if (!isSupported || !latest || visibility === "visible") return;
if (lastNotifiedId.current === latest.id) return;
lastNotifiedId.current = latest.id;
show(`${latest.author} 发来消息`, { body: latest.text });
}, [latest, visibility]);
return (
<>
<ChatPane />
{draftDirty && isLeaving && <Toast>草稿未保存!</Toast>}
</>
);
}
通过这六个Hooks,我们构建了一个在资源利用、用户体验和注意力管理上都达到原生应用水准的Web组件。它知道何时该休眠,何时该苏醒,何时该发出提醒,何时该保持安静。这,就是现代Web应用应有的样子。
随时随地看视频