新文章通知功能实现详解
|
3.0k 字
|10 分钟
|--
常熟市 · 西南路
博客文章更新通知功能实现文档
目录
1. 功能原理概述
1.1 功能目标
博客文章更新通知功能旨在为用户提供实时的内容更新提醒,当博客有新文章发布或现有文章内容发生变更时,系统能够自动检测并通知用户,同时高亮显示具体的变更内容。
1.2 核心原理
该功能基于 RSS 订阅源差异检测 和 客户端内容缓存比对 两大核心机制:
┌─────────────────────────────────────────────────────────────────┐
│ 功能原理架构图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ RSS 源 │───>│ 差异检测 │───>│ 通知展示 │ │
│ │ (atom.xml) │ │ (diff算法) │ │ (UI组件) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ v v v │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ IndexedDB │ │ 内容高亮 │ │ 用户交互 │ │
│ │ (持久化存储) │ │ (页面内) │ │ (跳转/忽略) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
1.3 工作流程
- 首次访问:获取 RSS 数据并存储到 IndexedDB,记录初始化时间
- 后续访问:获取最新 RSS 数据,与存储的数据进行差异比对
- 检测变更:识别新增文章和内容更新的文章
- 通知用户:通过 UI 组件展示更新通知,支持查看详细差异
- 内容高亮:在文章页面内高亮显示变更的具体内容
2. 技术架构设计
2.1 整体架构
系统由两个核心组件构成:
| 组件名称 | 文件路径 | 职责 |
|---|---|---|
| NewPostNotification | components/NewPostNotification.tsx | RSS 订阅检测、新文章通知 |
| PostContentHighlighter | components/PostContentHighlighter.tsx | 文章内容变更检测、高亮显示 |
2.2 技术栈
前端框架: Next.js 14 (App Router)
UI 库: React 18
样式方案: Tailwind CSS + CSS-in-JS
差异算法: diff (npm package)
图标库: @iconify/react
数据存储: IndexedDB + localStorage
2.3 数据存储架构
┌─────────────────────────────────────────────────────────────┐
│ 数据存储层次结构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ IndexedDB (cofe-blog-rss-store) │
│ ├── 数据库名: cofe-blog-rss-store │
│ ├── 版本: 1 │
│ └── 对象存储: posts │
│ └── 键路径: id (hostname:guid) │
│ ├── title: 文章标题 │
│ ├── link: 文章链接 │
│ ├── guid: 文章唯一标识 │
│ ├── pubDate: 发布时间戳 │
│ └── content: 文章内容 │
│ │
│ localStorage │
│ ├── cofe-notification-init-time: 初始化时间戳 │
│ └── cofe-post-content-cache-{path}: 文章内容缓存 │
│ │
└─────────────────────────────────────────────────────────────┘
2.4 组件状态设计
// NewPostNotification 组件状态
interface NotificationState {
isOpen: boolean // 通知面板是否展开
isMinimized: boolean // 是否最小化为图标
hasNewPosts: boolean // 是否有新文章
newPosts: Post[] // 新文章列表
initTime: number // 初始化时间
lastCheckTime: number // 最后检查时间
expandedDiffs: Set<string> // 展开的差异项
}
// PostContentHighlighter 组件状态
interface HighlighterState {
showNotification: boolean // 是否显示通知
diffCount: number // 差异数量
}
3. 核心实现步骤
3.1 步骤一:IndexedDB 数据库初始化
const DB_NAME = 'cofe-blog-rss-store'
const DB_VERSION = 1
const STORE_NAME = 'posts'
// 打开/创建 IndexedDB 数据库
const openDB = useCallback((): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
// 数据库升级时创建对象存储
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
}
}
})
}, [])
关键点说明:
- 使用
keyPath: 'id'确保每条记录有唯一标识 onupgradeneeded事件处理数据库版本升级- Promise 封装使异步操作更易管理
3.2 步骤二:RSS 数据获取与解析
const fetchRSS = useCallback(async (): Promise<Post[]> => {
try {
// 获取 RSS 数据,禁用缓存确保获取最新内容
const response = await fetch('/atom.xml', { cache: 'no-store' })
const text = await response.text()
// 使用 DOMParser 解析 XML
const parser = new DOMParser()
const xml = parser.parseFromString(text, 'text/xml')
const entries = Array.from(xml.querySelectorAll('entry'))
// 提取文章信息
return entries.map((entry) => {
const title = entry.querySelector('title')?.textContent || ''
const link = entry.querySelector('link')?.getAttribute('href') || ''
const guid = entry.querySelector('id')?.textContent || link
const updated = entry.querySelector('updated')?.textContent || ''
const pubDate = new Date(updated).getTime()
const content = entry.querySelector('content')?.textContent || ''
return { title, link, guid, pubDate, content }
})
} catch (e) {
console.error('Failed to fetch RSS:', e)
return []
}
}, [])
关键点说明:
cache: 'no-store'确保每次都获取最新数据- 使用原生
DOMParser解析 XML,无需额外依赖 - Atom 格式使用
entry作为文章元素
3.3 步骤三:差异计算算法
import { diffLines } from 'diff'
const computeDiff = useCallback((oldText: string, newText: string) => {
if (!oldText || !newText) return null
// 去除 HTML 标签,只比较纯文本
const stripHtml = (html: string): string => {
const tmp = document.createElement('DIV')
tmp.innerHTML = html
return tmp.textContent || tmp.innerText || ''
}
const cleanOld = stripHtml(oldText)
const cleanNew = stripHtml(newText)
// 使用 diff 库进行行级差异比较
const diffs = diffLines(cleanOld, cleanNew)
const hasChanges = diffs.some((part) => part.added || part.removed)
if (!hasChanges) return null
return diffs.map((part) => ({
value: part.value,
added: part.added,
removed: part.removed,
}))
}, [])
关键点说明:
diffLines进行行级比较,适合文章内容- 先去除 HTML 标签避免格式干扰
- 返回的差异对象包含
added、removed标记
3.4 步骤四:新文章检测逻辑
const checkForNewPosts = useCallback(async () => {
try {
const db = await openDB()
const storedPosts = await getStoredPosts(db)
const fetchedPosts = await fetchRSS()
const newOrUpdatedPosts: Post[] = []
for (const post of fetchedPosts) {
const existingPost = storedPosts.find((p) => p.guid === post.guid)
if (!existingPost) {
// 新文章:存储中不存在
newOrUpdatedPosts.push({ ...post, isUpdated: false })
} else if (existingPost.content !== post.content) {
// 更新的文章:内容发生变化
const diff = computeDiff(existingPost.content, post.content)
if (diff) {
newOrUpdatedPosts.push({ ...post, isUpdated: true, diff })
}
}
}
// 保存最新数据
await savePosts(db, fetchedPosts)
// 更新 UI 状态
if (newOrUpdatedPosts.length > 0) {
setNewPosts(newOrUpdatedPosts)
setHasNewPosts(true)
}
} catch (error) {
console.error('Error checking for new posts:', error)
}
}, [openDB, getStoredPosts, fetchRSS, computeDiff, savePosts])
3.5 步骤五:定时检查机制
const CHECK_INTERVAL = 5 * 60 * 1000 // 5分钟
const intervalRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
// 立即执行一次检查
checkForNewPosts()
// 设置定时检查
intervalRef.current = setInterval(checkForNewPosts, CHECK_INTERVAL)
// 清理函数
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [checkForNewPosts])
4. 关键代码解析
4.1 作用域 ID 生成
const generateId = useCallback((guid: string): string => {
const scope = window.location.hostname
return `${scope}:${guid}`
}, [])
设计意图:
- 避免不同域名下的数据冲突
- 支持同一浏览器访问多个部署实例
- 格式:
example.com:article-slug
4.2 文章内容高亮检测
const checkContentDiff = useCallback(() => {
// 仅在文章页面运行
const isBlogPost = window.location.pathname.startsWith('/blog/')
if (!isBlogPost) return
const currentPath = window.location.pathname
const storageKey = STORAGE_PREFIX + currentPath
const currentText = getContentText()
if (!currentText) return
const cachedText = localStorage.getItem(storageKey)
if (!cachedText) {
// 首次访问:缓存当前内容
localStorage.setItem(storageKey, currentText)
} else if (cachedText !== currentText) {
// 内容变更:计算差异
const diffs = diffWords(cachedText, currentText)
// 过滤有意义的变更(长度 > 10 字符)
const addedParts = diffs.filter(
(part) => part.added && part.value.trim().length > 10
)
if (addedParts.length > 0) {
setDiffCount(addedParts.length)
setShowNotification(true)
highlightFirstDiff(addedParts[0].value)
}
// 更新缓存
localStorage.setItem(storageKey, currentText)
}
}, [getContentText, highlightFirstDiff])
关键点说明:
- 使用
diffWords进行词级比较,更精确 - 过滤短变更避免噪音(如标点符号变化)
- 自动高亮第一个变更位置
4.3 TreeWalker 文本节点查找
const highlightFirstDiff = useCallback((textToFind: string) => {
const container = document.querySelector('.prose') ||
document.querySelector('article')
if (!container) return
// 取前 50 字符作为搜索关键词
const searchStr = textToFind.trim().substring(0, 50)
// 使用 TreeWalker 高效遍历文本节点
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null
)
let node: Node | null
while ((node = walker.nextNode())) {
if (node.textContent?.includes(searchStr)) {
const parent = node.parentElement
if (parent) {
parent.classList.add('content-update-highlight')
parent.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 3秒后移除高亮
setTimeout(() => {
parent.classList.remove('content-update-highlight')
}, 3000)
}
break
}
}
}, [])
性能优势:
TreeWalker比querySelectorAll更高效- 只遍历文本节点,跳过元素节点
- 找到目标后立即停止遍历
4.4 UI 组件状态切换
// 最小化状态切换
<button
onClick={() => {
setIsMinimized(false)
setIsOpen(true)
}}
className={cn(
'pointer-events-auto p-3 rounded-full shadow-lg transition-all duration-500',
isMinimized ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none'
)}
>
{/* 红点通知指示器 */}
{showDot && (
<span className="absolute top-0 right-0 w-3 h-3 bg-red-500 rounded-full animate-pulse" />
)}
</button>
动画效果:
- 使用
transition-all duration-500实现平滑过渡 translate-y控制垂直位移animate-pulse提供呼吸动画效果
5. 数据流程说明
5.1 完整数据流程图
┌─────────────────────────────────────────────────────────────────────┐
│ 数据流程时序图 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 用户访问 ──> 组件挂载 ──> 检查初始化状态 │
│ │ │ │ │
│ │ │ ├── 首次访问? │
│ │ │ │ ├── 是: 记录初始化时间 │
│ │ │ │ └── 否: 读取上次检查时间 │
│ │ │ │ │
│ │ │ v │
│ │ │ 打开 IndexedDB │
│ │ │ │ │
│ │ │ v │
│ │ │ 获取存储的文章列表 │
│ │ │ │ │
│ │ │ v │
│ │ │ 获取最新 RSS 数据 │
│ │ │ │ │
│ │ │ v │
│ │ │ ┌─────────────┐ │
│ │ │ │ 差异比对 │ │
│ │ │ └─────────────┘ │
│ │ │ │ │
│ │ │ ├── 新文章? ──> 标记为新发布 │
│ │ │ │ │
│ │ │ ├── 内容变更? ──> 计算差异 │
│ │ │ │ │
│ │ │ └── 无变化? ──> 跳过 │
│ │ │ │
│ │ v │
│ │ 更新 IndexedDB │
│ │ │ │
│ │ v │
│ │ 有变更? │
│ │ │ │
│ │ ├── 是 ──> 显示通知 ──> 用户交互 │
│ │ │ ├── 查看差异 │
│ │ │ ├── 跳转文章 │
│ │ │ └── 清除通知 │
│ │ │ │
│ │ └── 否 ──> 等待下次检查 │
│ │ │
│ v │
│ 设置定时器 (5分钟) │
│ │
└─────────────────────────────────────────────────────────────────────┘
5.2 文章页面内容检测流程
┌─────────────────────────────────────────────────────────────────┐
│ 文章页面内容检测流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 进入文章页面 │
│ │ │
│ v │
│ 延迟 1 秒 (等待内容加载) │
│ │ │
│ v │
│ 获取当前文章内容文本 │
│ │ │
│ v │
│ 检查 localStorage 缓存 │
│ │ │
│ ├── 无缓存 ──> 存储当前内容 ──> 结束 │
│ │ │
│ └── 有缓存 │
│ │ │
│ v │
│ 比较内容是否相同 │
│ │ │
│ ├── 相同 ──> 结束 │
│ │ │
│ └── 不同 │
│ │ │
│ v │
│ 使用 diffWords 计算差异 │
│ │ │
│ v │
│ 过滤有意义的变更 │
│ │ │
│ v │
│ 显示通知 + 高亮变更位置 │
│ │ │
│ v │
│ 更新 localStorage 缓存 │
│ │
└─────────────────────────────────────────────────────────────────┘
6. 异常处理机制
6.1 IndexedDB 操作异常
const openDB = useCallback((): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
// 错误处理
request.onerror = () => {
console.error('IndexedDB open error:', request.error)
reject(request.error)
}
request.onsuccess = () => resolve(request.result)
})
}, [])
// 使用时的异常捕获
try {
const db = await openDB()
// ... 操作数据库
} catch (error) {
console.error('Database operation failed:', error)
// 降级处理:使用 localStorage 作为备选
}
6.2 RSS 获取异常
const fetchRSS = useCallback(async (): Promise<Post[]> => {
try {
const response = await fetch('/atom.xml', { cache: 'no-store' })
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const text = await response.text()
// ... 解析 XML
} catch (e) {
console.error('Failed to fetch RSS:', e)
return [] // 返回空数组,不影响页面正常显示
}
}, [])
6.3 内容解析异常
// XML 解析错误处理
const xml = parser.parseFromString(text, 'text/xml')
const parseError = xml.querySelector('parsererror')
if (parseError) {
console.error('XML parse error:', parseError.textContent)
return []
}
// DOM 操作安全检查
const title = entry.querySelector('title')?.textContent || ''
const link = entry.querySelector('link')?.getAttribute('href') || ''
6.4 浏览器兼容性处理
// 检查 IndexedDB 支持
if (!window.indexedDB) {
console.warn('IndexedDB not supported, falling back to localStorage')
// 使用 localStorage 作为降级方案
}
// 检查必要 API
if (typeof window !== 'undefined') {
// 确保在客户端环境运行
}
7. 性能优化策略
7.1 数据获取优化
// 1. 禁用缓存确保获取最新数据
const response = await fetch('/atom.xml', { cache: 'no-store' })
// 2. 合理的检查间隔
const CHECK_INTERVAL = 5 * 60 * 1000 // 5分钟,平衡实时性和性能
// 3. 延迟初始化
useEffect(() => {
const timer = setTimeout(() => {
checkContentDiff()
}, 1000) // 等待页面内容完全加载
return () => clearTimeout(timer)
}, [checkContentDiff])
7.2 差异计算优化
// 1. 先进行快速比较
if (existingPost.content !== post.content) {
// 内容不同才进行详细差异计算
const diff = computeDiff(existingPost.content, post.content)
}
// 2. 去除 HTML 标签减少计算量
const stripHtml = (html: string): string => {
const tmp = document.createElement('DIV')
tmp.innerHTML = html
return tmp.textContent || ''
}
// 3. 过滤短变更避免噪音
const addedParts = diffs.filter(
(part) => part.added && part.value.trim().length > 10
)
7.3 DOM 操作优化
// 使用 TreeWalker 替代 querySelectorAll
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT, // 只遍历文本节点
null
)
// 找到目标后立即停止
while ((node = walker.nextNode())) {
if (node.textContent?.includes(searchStr)) {
// 处理...
break // 立即退出循环
}
}
7.4 内存管理
// 使用 useRef 避免重复创建
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const isFirstRender = useRef(true)
// 组件卸载时清理
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [])
7.5 渲染优化
// 使用 useCallback 缓存函数
const checkForNewPosts = useCallback(async () => {
// ...
}, [openDB, getStoredPosts, fetchRSS, computeDiff, savePosts])
// 使用 Set 管理展开状态,O(1) 查找
const [expandedDiffs, setExpandedDiffs] = useState<Set<string>>(new Set())
8. 使用示例
8.1 组件集成
在 app/layout.tsx 中全局引入组件:
import NewPostNotification from '@/components/NewPostNotification'
import PostContentHighlighter from '@/components/PostContentHighlighter'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<main>{children}</main>
{/* 新文章通知组件 */}
<NewPostNotification />
{/* 文章内容高亮组件 */}
<PostContentHighlighter />
</body>
</html>
)
}
8.2 用户交互流程
场景一:首次访问博客
1. 用户打开博客首页
2. 组件自动获取 RSS 数据
3. 数据存储到 IndexedDB
4. 记录初始化时间
5. 通知图标显示(无红点)
场景二:有新文章发布
1. 用户再次访问博客
2. 组件获取最新 RSS 数据
3. 与存储数据比对,发现新文章
4. 通知图标显示红点
5. 用户点击图标,展开通知面板
6. 显示新文章列表,标记为"新文章"
场景三:文章内容更新
1. 用户访问已读过的文章页面
2. 组件获取当前文章内容
3. 与 localStorage 缓存比对
4. 发现内容变更,计算差异
5. 显示通知弹窗
6. 高亮页面内变更内容
7. 用户可点击"跳转到更新处"
8.3 API 参考
NewPostNotification Props
该组件无需传入 props,自动运行。
PostContentHighlighter Props
该组件无需传入 props,自动运行。
存储键名常量
// IndexedDB
const DB_NAME = 'cofe-blog-rss-store'
const DB_VERSION = 1
const STORE_NAME = 'posts'
// localStorage
const NOTIFICATION_STATE_KEY = 'cofe-notification-state'
const INIT_TIME_KEY = 'cofe-notification-init-time'
const STORAGE_PREFIX = 'cofe-post-content-cache-'
8.4 自定义配置
如需修改检查间隔,修改以下常量:
// NewPostNotification.tsx
const CHECK_INTERVAL = 5 * 60 * 1000 // 默认 5 分钟
// PostContentHighlighter.tsx
const MIN_DIFF_LENGTH = 10 // 最小差异长度阈值
const HIGHLIGHT_DURATION = 3000 // 高亮持续时间(毫秒)
附录
A. 依赖版本
{
"dependencies": {
"next": "^14.2.0",
"react": "^18.2.0",
"diff": "^5.1.0",
"@iconify/react": "^4.1.0"
}
}
B. 浏览器兼容性
| 浏览器 | 最低版本 | 说明 |
|---|---|---|
| Chrome | 60+ | 完全支持 |
| Firefox | 55+ | 完全支持 |
| Safari | 10.1+ | 完全支持 |
| Edge | 79+ | 完全支持 |
| IE | 不支持 | IndexedDB 部分支持,建议降级处理 |
C. 相关文件清单
components/
├── NewPostNotification.tsx # 新文章通知组件
└── PostContentHighlighter.tsx # 内容高亮组件
app/
├── layout.tsx # 布局文件(组件集成点)
└── atom.xml/
└── route.ts # RSS 订阅源
lib/
└── siteConfig.ts # 站点配置(动态 URL 获取)
文档版本: 1.0.0 最后更新: 2026-02-12
评论
评论加载中...