塔罗会塔罗会

新文章通知功能实现详解

|
3.0k
|
10 分钟
|
--
常熟市 · 西南路

博客文章更新通知功能实现文档

目录

  1. 功能原理概述
  2. 技术架构设计
  3. 核心实现步骤
  4. 关键代码解析
  5. 数据流程说明
  6. 异常处理机制
  7. 性能优化策略
  8. 使用示例

1. 功能原理概述

1.1 功能目标

博客文章更新通知功能旨在为用户提供实时的内容更新提醒,当博客有新文章发布或现有文章内容发生变更时,系统能够自动检测并通知用户,同时高亮显示具体的变更内容。

1.2 核心原理

该功能基于 RSS 订阅源差异检测客户端内容缓存比对 两大核心机制:

┌─────────────────────────────────────────────────────────────────┐
│                      功能原理架构图                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   ┌──────────────┐    ┌──────────────┐    ┌──────────────┐     │
│   │   RSS 源     │───>│  差异检测    │───>│  通知展示    │     │
│   │  (atom.xml)  │    │  (diff算法)  │    │  (UI组件)    │     │
│   └──────────────┘    └──────────────┘    └──────────────┘     │
│          │                   │                   │              │
│          v                   v                   v              │
│   ┌──────────────┐    ┌──────────────┐    ┌──────────────┐     │
│   │ IndexedDB    │    │ 内容高亮     │    │ 用户交互     │     │
│   │ (持久化存储) │    │ (页面内)     │    │ (跳转/忽略)  │     │
│   └──────────────┘    └──────────────┘    └──────────────┘     │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

1.3 工作流程

  1. 首次访问:获取 RSS 数据并存储到 IndexedDB,记录初始化时间
  2. 后续访问:获取最新 RSS 数据,与存储的数据进行差异比对
  3. 检测变更:识别新增文章和内容更新的文章
  4. 通知用户:通过 UI 组件展示更新通知,支持查看详细差异
  5. 内容高亮:在文章页面内高亮显示变更的具体内容

2. 技术架构设计

2.1 整体架构

系统由两个核心组件构成:

组件名称文件路径职责
NewPostNotificationcomponents/NewPostNotification.tsxRSS 订阅检测、新文章通知
PostContentHighlightercomponents/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 标签避免格式干扰
  • 返回的差异对象包含 addedremoved 标记

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
    }
  }
}, [])

性能优势

  • TreeWalkerquerySelectorAll 更高效
  • 只遍历文本节点,跳过元素节点
  • 找到目标后立即停止遍历

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. 浏览器兼容性

浏览器最低版本说明
Chrome60+完全支持
Firefox55+完全支持
Safari10.1+完全支持
Edge79+完全支持
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

评论

评论加载中...
内容已更新

检测到文章内容有变化,已为您高亮显示差异部分。