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

React 18如何在浏览器标签页标题动态添加动画红圈图标

React 18实现浏览器标签页带动画红圈效果

标签页红圈效果示例

核心前提说明:document.title 仅支持纯文本内容,无法直接插入HTML元素、编写CSS实现红圈样式和动画效果。所有标签页上的自定义视觉提示,都是通过动态修改页面favicon(标签页左侧的站点小图标)实现的,动画效果通过定时切换favicon的绘制帧模拟。

实现步骤

1. 封装自定义Hook(React 18推荐方式)

把favicon修改、动画控制的逻辑封装成可复用的Hook,自动处理原始favicon保存、动画帧切换、组件卸载恢复的逻辑,不需要额外引入第三方依赖,通过Canvas动态绘制徽标即可。

import { useEffect, useRef } from 'react';

// 初始化favicon节点,提前保存原始favicon地址用于后续恢复
const initFavicon = () => {
  let link = document.querySelector('link[rel="icon"]');
  if (!link) {
    link = document.createElement('link');
    link.rel = 'icon';
    document.head.appendChild(link);
  }
  return {
    node: link,
    originalHref: link.href
  }
}
const { node: faviconNode, originalHref: originalFavicon } = initFavicon();

/**
 * favicon带动画红圈徽标Hook
 * @param {boolean} visible - 是否显示红圈
 * @param {number} count - 红圈内显示的未读数,传0则显示纯红圈
 */
export function useFaviconRedDot(visible = false, count = 0) {
  const animationTimer = useRef(null);
  const frameRef = useRef(0);

  // 绘制单帧favicon
  const drawFrame = (scale = 1, opacity = 1) => {
    const canvas = document.createElement('canvas');
    canvas.width = 32;
    canvas.height = 32;
    const ctx = canvas.getContext('2d');
    const baseIcon = new Image();
    baseIcon.crossOrigin = 'anonymous';

    baseIcon.onload = () => {
      // 先绘制原始站点favicon作为底图
      ctx.drawImage(baseIcon, 0, 0, 32, 32);
      if (visible) {
        ctx.save();
        ctx.globalAlpha = opacity;
        // 绘制红色圆形徽标
        ctx.fillStyle = '#f43f32';
        const radius = 7 * scale;
        ctx.beginPath();
        ctx.arc(32 - radius, radius, radius, 0, Math.PI * 2);
        ctx.fill();

        // 绘制徽标内的数字
        if (count > 0) {
          ctx.fillStyle = '#fff';
          ctx.font = 'bold 11px system-ui, sans-serif';
          ctx.textAlign = 'center';
          ctx.textBaseline = 'middle';
          const showText = count > 99 ? '99+' : String(count);
          ctx.fillText(showText, 32 - radius, radius);
        }
        ctx.restore();
      }
      faviconNode.href = canvas.toDataURL('image/png');
    }
    baseIcon.src = originalFavicon;
  };

  useEffect(() => {
    // 隐藏红圈时清除动画,恢复原始favicon
    if (!visible) {
      if (animationTimer.current) clearInterval(animationTimer.current);
      animationTimer.current = null;
      faviconNode.href = originalFavicon;
      return;
    }

    // 开启动画:每120ms切换一帧,实现平滑呼吸效果
    animationTimer.current = setInterval(() => {
      frameRef.current = (frameRef.current + 1) % 30;
      const progress = frameRef.current / 30;
      // 用正弦函数计算缩放、透明度参数,实现自然的呼吸动画
      const currentScale = 0.85 + 0.2 * Math.sin(progress * Math.PI * 2);
      const currentOpacity = 0.7 + 0.3 * Math.sin(progress * Math.PI * 2);
      drawFrame(currentScale, currentOpacity);
    }, 120);

    // 组件卸载时清理定时器、恢复原始favicon
    return () => {
      if (animationTimer.current) clearInterval(animationTimer.current);
      faviconNode.href = originalFavicon;
    }
  }, [visible, count]);
}

2. 在业务组件中调用

直接在全局组件或者需要触发提示的组件中引入Hook,传入显示状态和未读数即可:

import { useState } from 'react';
import { useFaviconRedDot } from './useFaviconRedDot';

function GlobalLayout() {
  const [unreadMessage, setUnreadMessage] = useState(0);
  // 未读数大于0时自动显示带动画的红圈
  useFaviconRedDot(unreadMessage > 0, unreadMessage);

  return (
    <div className="app">
      <button onClick={() => setUnreadMessage(prev => prev + 1)}>
        模拟收到新消息
      </button>
      <button onClick={() => setUnreadMessage(0)}>
        标记所有消息已读
      </button>
    </div>
  );
}

export default GlobalLayout;

注意事项

  • 如果你的站点favicon是跨域资源(比如存放在CDN),需要给CDN配置跨域访问头,否则Canvas绘制时会触发跨域污染报错,无法导出图片。
  • 动画帧间隔不建议低于100ms,过高的刷新频率会不必要地消耗浏览器性能,100-200ms的间隔已经能实现流畅的提示效果。
  • 如果不需要显示数字,只需要纯红圈效果,调用Hook时把count参数固定传0即可。

内容的提问来源于stack exchange,提问作者Guillaume SOTTON

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.29 03:33:21