博客新文章提醒功能:从需求到落地,3 个核心 Bug 的修复与开源

摘要

本文分享了一个 IT 博客新文章提醒功能的实现过程。作者在开发中解决了三个关键 Bug:一是 RSS 时间字段不标准导致漏判,通过增加多字段兼容与有效性校验修复;二是新用户误触弹窗,通过区分首次与回访逻辑解决;三是弹窗溢出遮挡页面,通过设置最大高度与滚动解决。最终实现了基于 React 和 RSS 动态读取的稳定方案,现已开源。

前言

作为专注 IT 技术分享的个人博客,一直有个隐性痛点:回访用户很难察觉 “上次访问后是否有新内容更新”,而新用户又怕被冗余信息骚扰。最初只想做个简单的新文章弹窗,没想到落地过程中踩了 3 个关键 Bug,最终通过层层拆解、逐步优化,形成了兼顾体验与稳定性的完整方案。现在把整个探索过程、Bug 修复细节和开源代码全部分享出来,供有需要的开发者参考。

一、初始需求与第一版实现

核心需求清单

  1. 新用户首次访问不弹窗,仅静默记录时间,避免骚扰

  2. 回访用户仅展示 “上次访问后新发的文章”,不重复显示已读内容

  3. 文章较多时支持滚动,不撑大页面或溢出屏幕

  4. 无新文章时给出明确提示,不静默消失让用户困惑

  5. 支持手动关闭弹窗,关闭后不再打扰

  6. 纯动态读取 RSS,不写死任何内容,适配后续文章更新

第一版实现思路

核心逻辑很直接:用localStorage记录用户访问时间,下次访问时拉取 RSS,筛选 “文章发布时间>上次访问时间” 的内容,最后通过固定定位弹窗展示。代码结构如下:

TSX
// 第一版核心逻辑(简化)
useEffect(() => {
  const lastVisitStr = localStorage.getItem("last_visit_time");
  if (lastVisitStr) {
    const lastTime = new Date(lastVisitStr);
    fetchRSSAndFilter(lastTime); // 拉取RSS并筛选新文章
  }
  localStorage.setItem("last_visit_time", new Date().toISOString());
}, []);

const fetchRSSAndFilter = async (lastTime: Date) => {
  const res = await fetch("https://taiyanglee.eu.org/rss.xml");
  const xml = new DOMParser().parseFromString(await res.text(), "application/xml");
  const items = xml.querySelectorAll("item");
  const newArticles = Array.from(items).filter(item => {
    const pubDate = item.querySelector("pubDate")?.textContent;
    return new Date(pubDate) > lastTime; // 直接对比时间
  });
  setNewArticles(newArticles);
  setShowPopup(true);
};

本以为这样就能满足需求,没想到测试时立刻暴露了问题。

二、落地过程中的 3 个核心 Bug 与修复探索

Bug 1:时间解析失败导致 “新文章漏判”

问题现象

部分 RSS 源中的文章时间格式为dc:dateupdated(而非标准pubDate),第一版仅读取pubDate,导致这些文章无论发布时间多新,都会被判定为 “旧文章”;更严重的是,若时间格式非法(如空值、非标准字符串),new Date(pubDate)会返回Invalid Date,直接导致筛选逻辑失效。

排查与探索

  1. 先打印 RSS 原始数据,发现不同文章的时间字段确实存在差异:有的用pubDate,有的用dc:date,少数甚至没有时间字段。

  2. 测试new Date("")new Date("invalid")等边界情况,确认这些场景会返回Invalid Date,其getTime()NaN,无法参与时间对比。

修复方案

  • 兼容多时间字段:优先读取pubDate,若不存在则依次读取dc:dateupdated

  • 增加时间有效性校验:只有!isNaN(date.getTime())时,才参与时间对比。

TSX
// 修复后的时间处理逻辑
const fetchRSSAndFilter = async (lastTime: Date) => {
  // ... 省略RSS请求逻辑
  items.forEach(item => {
    // 兼容多时间字段
    const pubDate = 
      item.querySelector("pubDate")?.textContent ||
      item.querySelector("dc:date")?.textContent ||
      item.querySelector("updated")?.textContent || "";
    const date = new Date(pubDate);
    // 增加有效性校验,避免Invalid Date干扰
    if (!isNaN(date.getTime()) && date > lastTime) {
      newArticles.push({
        title: item.querySelector("title")?.textContent || "",
        link: item.querySelector("link")?.textContent || "",
        date
      });
    }
  });
};

Bug 2:新用户首次访问 “弹窗骚扰”

问题现象

第一版逻辑中,新用户首次访问时,lastVisitStrnull,不会筛选新文章,但setShowPopup(true)仍会执行,导致弹窗显示空列表,严重影响新用户体验。

排查与探索

  1. 复盘逻辑:新用户首次访问时,仅需记录当前时间,无需显示弹窗;

  2. 错误根源:弹窗显示逻辑与 “是否有新文章” 强绑定,却未考虑 “首次访问” 这一特殊场景。

修复方案

  • 拆分弹窗显示条件:showPopup = 非首次访问 + (有新文章 || 无新文章但需提示)

  • 首次访问时仅记录时间,不触发弹窗。

TSX
// 修复后的 useEffect 逻辑
useEffect(() => {
  const now = new Date();
  const lastVisitStr = localStorage.getItem("last_visit_time");
  
  if (!lastVisitStr) {
    // 新用户:仅记录时间,不显示弹窗
    localStorage.setItem("last_visit_time", now.toISOString());
    return;
  }
  
  // 回访用户:执行筛选逻辑
  const lastTime = new Date(lastVisitStr);
  fetchRSSAndFilter(lastTime);
  localStorage.setItem("last_visit_time", now.toISOString()); // 更新访问时间
}, []);

// 修复后的弹窗显示逻辑
{!isFirstVisit && (
  <div className="popup">
    {newArticles.length > 0 ? (
      newArticles.map(article => <a key={article.link} href={article.link}>{article.title}</a>)
    ) : (
      <div className="no-new-articles">暂无最新文章</div>
    )}
  </div>
)}

Bug 3:文章过多导致 “弹窗溢出 + 遮挡页面”

问题现象

当同时发布多篇新文章时,弹窗会跟随文章数量无限拉长,不仅会溢出屏幕底部,还会遮挡页面底部的核心功能(如回到顶部按钮、版权信息)。

排查与探索

  1. 测试发现:弹窗未设置最大高度,overflow默认为visible,导致内容无限延伸;

  2. 尝试设置固定高度,但固定高度在不同屏幕尺寸下适配性差(手机端可能过高,电脑端可能过低)。

修复方案

  • 设置最大高度 + 滚动:给弹窗内容区设置maxHeight: 160px(约 6 行文字高度),超出部分显示垂直滚动条;

  • 优化布局:弹窗固定在右下角,距离底部 80px,避免遮挡页面其他元素。

TSX
// 修复后的弹窗样式
<div style={{
  position: "fixed",
  bottom: "80px", // 避开页面底部元素
  right: "20px",
  width: "280px",
  maxHeight: "200px", // 弹窗整体最大高度
  background: "var(--background)",
  border: "1px solid var(--border)",
  borderRadius: "14px",
  padding: "14px 16px",
  boxShadow: "0 10px 30px -8px rgba(0,0,0,0.08)",
  zIndex: 999 // 合理层级,不遮挡核心功能
}}>
  <div style={{
    maxHeight: "160px", // 内容区最大高度
    overflowY: "auto", // 超出滚动
    paddingRight: "4px" // 给滚动条预留空间
  }}>
    {/* 文章列表或暂无提示 */}
  </div>
</div>

三、最终版完整逻辑与开源代码

核心逻辑流程图

plaintext

纯文本
用户访问 → 检测localStorage中是否有访问时间
├─ 无 → 首次访问:记录当前时间 → 不显示弹窗
└─ 有 → 回访用户:
   ├─ 拉取RSS文章 → 兼容多时间字段+有效性校验
   ├─ 筛选“文章时间>上次访问时间”的新文章
   ├─ 有新文章 → 显示带滚动的文章列表
   └─ 无新文章 → 显示“暂无最新文章”提示
   └─ 支持手动关闭弹窗,关闭后隐藏

完整开源代码

TSX
import { TanStackDevtools } from "@tanstack/react-devtools";
import type { QueryClient } from "@tanstack/react-query";
import {
  createRootRouteWithContext,
  HeadContent,
  Scripts,
  useRouteContext,
} from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import theme from "@theme";
import { ThemeProvider } from "@/components/common/theme-provider";
import { siteConfigQuery } from "@/features/config/queries";
import TanStackQueryDevtools from "@/integrations/tanstack-query/devtools";
import { clientEnv } from "@/lib/env/client.env";
import { getLocale } from "@/paraglide/runtime";
import appCss from "@/styles.css?url";
import { useEffect, useState } from "react";

// 路由上下文类型定义
interface MyRouterContext {
  queryClient: QueryClient;
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
  beforeLoad: async ({ context }) => {
    const siteConfig = await context.queryClient.ensureQueryData(siteConfigQuery);
    return { siteConfig };
  },
  loader: async ({ context }) => {
    return { siteConfig: context.siteConfig };
  },
  head: ({ loaderData }) => {
    const env = clientEnv();
    return {
      meta: [
        { charSet: "utf-8" },
        { name: "viewport", content: "width=device-width, initial-scale=1" },
        { title: loaderData?.siteConfig?.title },
        { name: "description", content: loaderData?.siteConfig?.description },
      ],
      links: [
        { rel: "icon", type: "image/svg+xml", href: loaderData?.siteConfig?.icons.faviconSvg },
        { rel: "icon", type: "image/png", href: loaderData?.siteConfig?.icons.favicon96, sizes: "96x96" },
        { rel: "shortcut icon", href: loaderData?.siteConfig?.icons.faviconIco },
        { rel: "apple-touch-icon", type: "image/png", href: loaderData?.siteConfig?.icons.appleTouchIcon, sizes: "180x180" },
        { rel: "manifest", href: "/site.webmanifest" },
        { rel: "stylesheet", href: appCss },
        { rel: "alternate", type: "application/rss+xml", title: "RSS Feed", href: "/rss.xml" },
        { rel: "alternate", type: "application/atom+xml", title: "Atom Feed", href: "/atom.xml" },
        { rel: "alternate", type: "application/feed+json", title: "JSON Feed", href: "/feed.json" },
      ],
      scripts: [],
    };
  },
  shellComponent: RootDocument,
});

// RSS文章类型定义
interface RssItem {
  title: string;
  link: string;
  date: Date;
}

function RootDocument({ children }: { children: React.ReactNode }) {
  const { siteConfig } = useRouteContext({ from: "__root__" });
  const umamiWebsiteId = clientEnv().VITE_UMAMI_WEBSITE_ID;
  const [newArticles, setNewArticles] = useState<RssItem[]>([]);
  const [lastVisit, setLastVisit] = useState<Date | null>(null);
  const [showPopup, setShowPopup] = useState(false);
  const [isFirstVisit, setIsFirstVisit] = useState(true);

  // 格式化时间显示
  const formatTime = (d: Date) =>
    d.toLocaleString("zh-CN", {
      year: "numeric", month: "2-digit", day: "2-digit",
      hour: "2-digit", minute: "2-digit"
    });

  // 拉取RSS并筛选新文章(修复后版本)
  const fetchAndFilterNewArticles = async (lastTime: Date) => {
    try {
      const res = await fetch("https://taiyanglee.eu.org/rss.xml");
      if (!res.ok) throw new Error("RSS请求失败");
      
      const text = await res.text();
      const xml = new DOMParser().parseFromString(text, "application/xml");
      const items = xml.querySelectorAll("item");
      const filteredArticles: RssItem[] = [];

      items.forEach((item) => {
        // 兼容pubDate/dc:date/updated三种时间字段
        const pubDateText =
          item.querySelector("pubDate")?.textContent ||
          item.querySelector("dc:date")?.textContent ||
          item.querySelector("updated")?.textContent || "";
        
        const articleDate = new Date(pubDateText);
        // 校验时间有效性 + 筛选新文章
        if (!isNaN(articleDate.getTime()) && articleDate > lastTime) {
          filteredArticles.push({
            title: item.querySelector("title")?.textContent || "无标题文章",
            link: item.querySelector("link")?.textContent || "#",
            date: articleDate
          });
        }
      });

      setNewArticles(filteredArticles);
      setShowPopup(true); // 回访用户显示弹窗
    } catch (err) {
      console.log("新文章检测失败(不影响页面正常运行):", err);
    }
  };

  // 页面加载时执行核心逻辑
  useEffect(() => {
    const now = new Date();
    const lastVisitStr = localStorage.getItem("last_visit_time");

    if (!lastVisitStr) {
      // 新用户:仅记录时间,不显示弹窗
      localStorage.setItem("last_visit_time", now.toISOString());
      setIsFirstVisit(true);
      return;
    }

    // 回访用户:读取历史时间并筛选新文章
    const lastTime = new Date(lastVisitStr);
    setLastVisit(lastTime);
    setIsFirstVisit(false);
    fetchAndFilterNewArticles(lastTime);

    // 更新本次访问时间
    localStorage.setItem("last_visit_time", now.toISOString());
  }, []);

  return (
    <html lang={getLocale()} suppressHydrationWarning style={theme.getDocumentStyle?.(siteConfig)}>
      <head><HeadContent /></head>
      <body>

        <ThemeProvider>{children}</ThemeProvider>

        {/* 新文章提醒弹窗(最终优化版) */}
        {!isFirstVisit && showPopup && lastVisit && (
          <div
            style={{
              position: "fixed",
              bottom: "80px", // 避开页面底部元素
              right: "20px",
              width: "280px",
              background: "var(--background)",
              border: "1px solid var(--border)",
              borderRadius: "14px",
              padding: "14px 16px",
              fontSize: "13px",
              boxShadow: "0 10px 30px -8px rgba(0,0,0,0.08)",
              zIndex: 999,
              backdropFilter: "blur(8px)",
              transition: "all 0.2s ease",
            }}
          >
            {/* 标题 + 关闭按钮 */}
            <div style={{
              display: "flex",
              justifyContent: "space-between",
              alignItems: "center",
              marginBottom: "10px"
            }}>
              <div style={{
                fontWeight: 600,
                color: "var(--primary)",
                fontSize: "14px"
              }}>
{formatTime(lastVisit)} 后新增文章
              </div>
              <button
                onClick={() => setShowPopup(false)}
                style={{
                  background: "none",
                  border: "none",
                  color: "var(--muted-foreground)",
                  fontSize: "16px",
                  cursor: "pointer",
                  padding: "0 4px"
                }}
              >
                ×
              </button>
            </div>

            {/* 文章列表/暂无提示(支持滚动) */}
            <div style={{
              display: "flex",
              flexDirection: "column",
              gap: "8px",
              maxHeight: "160px", // 内容区最大高度
              overflowY: "auto", // 超出自动滚动
              paddingRight: "4px"
            }}>
              {newArticles.length > 0 ? (
                newArticles.map((item, index) => (
                  <a
                    key={index}
                    href={item.link}
                    target="_blank"
                    rel="noreferrer"
                    style={{
                      color: "var(--foreground)",
                      textDecoration: "none",
                      whiteSpace: "nowrap",
                      overflow: "hidden",
                      textOverflow: "ellipsis",
                      padding: "4px 2px",
                      transition: "color 0.2s"
                    }}
                    onMouseOver={(e) => e.currentTarget.style.color = "var(--primary)"}
                    onMouseOut={(e) => e.currentTarget.style.color = "var(--foreground)"}
                  >
{item.title}
                  </a>
                ))
              ) : (
                <div style={{ color: "var(--muted-foreground)" }}>暂无最新文章</div>
              )}
            </div>
          </div>
        )}

        {/* 开发工具(保留原有功能) */}
        <TanStackDevtools
          config={{ position: "bottom-right" }}
          plugins={[
            { name: "Tanstack Router", render: <TanStackRouterDevtoolsPanel /> },
            TanStackQueryDevtools,
          ]}
        />
        <Scripts />
      </body>
    </html>
  );
}

四、使用说明与适配指南

1. 快速适配

  • 替换 RSS 地址:将代码中的https://taiyanglee.eu.org/rss.xml替换为自己博客的 RSS 地址;

  • 样式微调:根据博客主题修改弹窗的backgroundbordercolor等样式属性,保持视觉统一;

  • 无需额外依赖:代码基于 React 和 TanStack 生态,无第三方私有库,直接集成即可。

2. 功能验证步骤

  1. 新用户体验:清除浏览器localStorage(F12→Application→Local Storage),访问博客,确认不显示弹窗;

  2. 回访验证:刷新页面(此时已记录首次访问时间),确认弹窗显示 “暂无最新文章”;

  3. 新文章测试:发布一篇新文章后刷新,确认弹窗显示该文章,且支持滚动;

  4. 边界测试:故意发布无时间字段的文章,确认不会导致功能失效。

五、总结与后续优化方向

本次开发的核心收获

  1. 边界场景不可忽视:最初只考虑了标准 RSS 格式,却忽略了时间字段差异、非法格式等边界情况,导致第一版存在明显 Bug;

  2. 用户体验需精细化:新用户与回访用户的需求不同,首次访问不弹窗、无新文章给提示等细节,能显著提升体验;

  3. 代码需兼顾稳定性与兼容性:通过多字段兼容、有效性校验等方式,让功能在不同 RSS 源、不同浏览器环境下都能稳定运行。

  4. 很多人吹捧 RSS 能做这做那,实则纸上谈兵。RSS 本就存在 CDN 延迟、仅含摘要、非实时的特点,它的核心价值从不是万能工具,而是轻量、非实时、无压力的前端数据源—— 帮网站分担请求、减少数据库压力,这才是它的正确定位。我用 RSS 做新文章提醒,并非不会写接口,而是懂「什么工具该放在什么位置」,这就是架构思维。

  5. 在多数人眼里,RSS 只是被废弃的订阅链接;但在懂架构的人手里,它是免费、高速、免维护、通用的轻量级 API。不是 RSS 没用,而是大多数人根本不会用。不少博主即便有动态后台和接口,仍不会用 RSS 实现新文章提醒,并非 RSS 不够优秀,而是缺乏前端性能与架构思维,习惯依赖数据库和接口开发,不懂利用静态资源轻量化实现功能。而 RSS 无需开发额外接口、不占用数据库、响应更快、兼容性更强,本就是真正懂技术的开发者,才会选择的优雅方案。

后续优化方向

  1. 增加弹窗自动隐藏:若用户 5 秒内未操作,自动隐藏弹窗,进一步减少干扰;

  2. 支持用户手动开启 / 关闭功能:在博客设置中增加开关,允许用户自主选择是否接收新文章提醒;

  3. 优化滚动体验:增加滚动条样式美化,提升视觉一致性。

  4. 时区一致性:让全球的极客朋友们都能在不同时区下及时看到新文章提醒,将运用到时间戳+UTC全球时间

本功能已在我的博客(https://taiyanglee.eu.org)稳定运行,所有代码无加密、无冗余,可自由复制、修改、商用,无需注明出处。如果遇到问题或有优化建议,欢迎留言交流~

最后,感谢每一位支持的朋友,技术分享的路上,一起进步!✨

正文结束
更新Cookie偏好设置