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

前言作为专注 IT 技术分享的个人博客，一直有个隐性痛点：回访用户很难察觉 “上次访问后是否有新内容更新”，而新用户又怕被冗余信息骚扰。最初只想做个简单的新文章弹窗，没想到落地过程中踩了 3 个关键 Bug，最终通过层层拆解、逐步优化，形成了兼顾体验与稳定性的完整方案。现在把整个探索过程、Bug 修复细节和开源代码全部分享出来，供有需要的开发者参考。一、初始需求与第一版实现核心需求清单新用户首次访问不弹窗，仅静默记录时间，避免骚扰回访用户仅展示 “上次访问后新发的文章”，不重复显示已读内容文章较多时支持滚动，不撑大页面或溢出屏幕无新文章时给出明确提示，不静默消失让用户困惑支持手动关闭弹窗，关闭后不再打扰纯动态读取 RSS，不写死任何内容，适配后续文章更新第一版实现思路核心逻辑很直接：用localStorage记录用户访问时间，下次访问时拉取 RSS，筛选 “文章发布时间＞上次访问时间” 的内容，最后通过固定定位弹窗展示。代码结构如下： // 第一版核心逻辑（简化） 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:date或updated（而非标准pubDate），第一版仅读取pubDate，导致这些文章无论发布时间多新，都会被判定为 “旧文章”；更严重的是，若时间格式非法（如空值、非标准字符串），new Date(pubDate)会返回Invalid Date，直接导致筛选逻辑失效。排查与探索先打印 RSS 原始数据，发现不同文章的时间字段确实存在差异：有的用pubDate，有的用dc:date，少数甚至没有时间字段。测试new Date("")、new Date("invalid")等边界情况，确认这些场景会返回Invalid Date，其getTime()为NaN，无法参与时间对比。修复方案兼容多时间字段：优先读取pubDate，若不存在则依次读取dc:date、updated；增加时间有效性校验：只有!isNaN(date.getTime())时，才参与时间对比。 // 修复后的时间处理逻辑 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：新用户首次访问 “弹窗骚扰” 问题现象第一版逻辑中，新用户首次访问时，lastVisitStr为null，不会筛选新文章，但setShowPopup(true)仍会执行，导致弹窗显示空列表，严重影响新用户体验。排查与探索复盘逻辑：新用户首次访问时，仅需记录当前时间，无需显示弹窗；错误根源：弹窗显示逻辑与 “是否有新文章” 强绑定，却未考虑 “首次访问” 这一特殊场景。修复方案拆分弹窗显示条件：showPopup = 非首次访问 + (有新文章 || 无新文章但需提示)；首次访问时仅记录时间，不触发弹窗。 // 修复后的 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 && (
{newArticles.length > 0 ? ( newArticles.map(article => {article.title}) ) : (
暂无最新文章
)}
)} Bug 3：文章过多导致 “弹窗溢出 + 遮挡页面” 问题现象当同时发布多篇新文章时，弹窗会跟随文章数量无限拉长，不仅会溢出屏幕底部，还会遮挡页面底部的核心功能（如回到顶部按钮、版权信息）。排查与探索测试发现：弹窗未设置最大高度，overflow默认为visible，导致内容无限延伸；尝试设置固定高度，但固定高度在不同屏幕尺寸下适配性差（手机端可能过高，电脑端可能过低）。修复方案设置最大高度 + 滚动：给弹窗内容区设置maxHeight: 160px（约 6 行文字高度），超出部分显示垂直滚动条；优化布局：弹窗固定在右下角，距离底部 80px，避免遮挡页面其他元素。 // 修复后的弹窗样式

{/* 文章列表或暂无提示 */}

三、最终版完整逻辑与开源代码核心逻辑流程图 plaintext 用户访问 → 检测localStorage中是否有访问时间 ├─ 无 → 首次访问：记录当前时间 → 不显示弹窗 └─ 有 → 回访用户： ├─ 拉取RSS文章 → 兼容多时间字段+有效性校验 ├─ 筛选“文章时间＞上次访问时间”的新文章 ├─ 有新文章 → 显示带滚动的文章列表 └─ 无新文章 → 显示“暂无最新文章”提示 └─ 支持手动关闭弹窗，关闭后隐藏完整开源代码 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()({ 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([]); const [lastVisit, setLastVisit] = useState(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 ( {children} {/* 新文章提醒弹窗（最终优化版） */} {!isFirstVisit && showPopup && lastVisit && (
{/* 标题 + 关闭按钮 */}

自 {formatTime(lastVisit)} 后新增文章
setShowPopup(false)} style={{ background: "none", border: "none", color: "var(--muted-foreground)", fontSize: "16px", cursor: "pointer", padding: "0 4px" }} > ×
{/* 文章列表/暂无提示（支持滚动） */}
{newArticles.length > 0 ? ( newArticles.map((item, index) => ( e.currentTarget.style.color = "var(--primary)"} onMouseOut={(e) => e.currentTarget.style.color = "var(--foreground)"} > • {item.title} )) ) : (
暂无最新文章
)}

)} {/* 开发工具（保留原有功能） */} }, TanStackQueryDevtools, ]} /> ); } 四、使用说明与适配指南 1. 快速适配替换 RSS 地址：将代码中的https://taiyanglee.eu.org/rss.xml替换为自己博客的 RSS 地址；样式微调：根据博客主题修改弹窗的background、border、color等样式属性，保持视觉统一；无需额外依赖：代码基于 React 和 TanStack 生态，无第三方私有库，直接集成即可。 2. 功能验证步骤新用户体验：清除浏览器localStorage（F12→Application→Local Storage），访问博客，确认不显示弹窗；回访验证：刷新页面（此时已记录首次访问时间），确认弹窗显示 “暂无最新文章”；新文章测试：发布一篇新文章后刷新，确认弹窗显示该文章，且支持滚动；边界测试：故意发布无时间字段的文章，确认不会导致功能失效。五、总结与后续优化方向本次开发的核心收获边界场景不可忽视：最初只考虑了标准 RSS 格式，却忽略了时间字段差异、非法格式等边界情况，导致第一版存在明显 Bug；用户体验需精细化：新用户与回访用户的需求不同，首次访问不弹窗、无新文章给提示等细节，能显著提升体验；代码需兼顾稳定性与兼容性：通过多字段兼容、有效性校验等方式，让功能在不同 RSS 源、不同浏览器环境下都能稳定运行。很多人吹捧 RSS 能做这做那，实则纸上谈兵。RSS 本就存在 CDN 延迟、仅含摘要、非实时的特点，它的核心价值从不是万能工具，而是轻量、非实时、无压力的前端数据源—— 帮网站分担请求、减少数据库压力，这才是它的正确定位。我用 RSS 做新文章提醒，并非不会写接口，而是懂「什么工具该放在什么位置」，这就是架构思维。在多数人眼里，RSS 只是被废弃的订阅链接；但在懂架构的人手里，它是免费、高速、免维护、通用的轻量级 API。不是 RSS 没用，而是大多数人根本不会用。不少博主即便有动态后台和接口，仍不会用 RSS 实现新文章提醒，并非 RSS 不够优秀，而是缺乏前端性能与架构思维，习惯依赖数据库和接口开发，不懂利用静态资源轻量化实现功能。而 RSS 无需开发额外接口、不占用数据库、响应更快、兼容性更强，本就是真正懂技术的开发者，才会选择的优雅方案。后续优化方向增加弹窗自动隐藏：若用户 5 秒内未操作，自动隐藏弹窗，进一步减少干扰；支持用户手动开启 / 关闭功能：在博客设置中增加开关，允许用户自主选择是否接收新文章提醒；优化滚动体验：增加滚动条样式美化，提升视觉一致性。时区一致性：让全球的极客朋友们都能在不同时区下及时看到新文章提醒，将运用到时间戳+UTC全球时间。本功能已在我的博客（https://taiyanglee.eu.org）稳定运行，所有代码无加密、无冗余，可自由复制、修改、商用，无需注明出处。如果遇到问题或有优化建议，欢迎留言交流～最后，感谢每一位支持的朋友，技术分享的路上，一起进步！✨