用 React + Vite 做一个热榜阅读客户端

一、先说清楚要做什么

用 React + Vite 做一个热榜阅读客户端:左边一列榜单(微博/知乎/百度……),右边列出选中榜单的条目,点开过的条目变灰(已读),能收藏任意条目,刷新后已读与收藏都还在。数据来自一个热榜聚合 API。三句话说完。“数据在别人手里、每次切换都是一次网络往返”这个约束会让数据层的每个决策都变重——本篇七成的篇幅就在这。

指标 目标值 怎么测
切换榜单不闪空屏 切回来时先见旧数据,再静默更新 快速连点两个榜单来回切换,肉眼观察
无竞态错乱 快速切换后列表内容与选中榜单一致 连点 A、B、C 三个榜单停住,核对标题与内容对应
已读与收藏持久 刷新、重开浏览器数据完整 标记几条后刷新;再手动写坏 localStorage 验证不崩
上游容错 某个榜单挂了只影响该榜单 断网或停掉 API 后切换榜单,界面出错误提示不白屏
性能 百条条目渲染无感卡顿 定性体感;不测具体毫秒,也不编数字

二、整体架构与工程形态

# App.jsx(布局与榜单切换)
1.  ├── BoardNav        左栏:榜单列表 + 收藏入口
1.  └── ItemList ── ItemRow   右栏:条目、已读样式、收藏按钮
1.         │
1.         ▼
1. hooks/useBoard.js     数据层:请求、缓存、竞态防御(唯一发请求的地方)
1. hooks/useLocalStorage.js   持久化(唯一碰 localStorage 的地方)
1.         │
1.         ▼
1. dailyhot-api(自部署的热榜聚合服务,60+ 平台统一 JSON 接口)

工程形态选 Vite 最小工程而不是 Next.js:这是纯浏览器里的阅读器,没有 SEO、没有服务端取数的需求,框架的约定是给”服务端在场”的场景付的费。热榜数据的服务端聚合怎么做(RSSHub、redis 缓存、内存限额那一整套),站内《叮叮当当 #1:热榜聚合站,选型为什么这么选》讲的是同一份数据的另一半故事——本篇客户端可以直接对着那套栈的 API 写。

数据源用 imsyy 的 dailyhot-api:面向浏览器前端设计,一个 GET /{平台} 返回统一结构(name、title、data 数组,每条含 id/title/hot/url)。自部署一条 Docker 命令的事,见其仓库 README;浏览器直连遇到跨域限制时,用 Vite 开发代理转发:

// vite.config.js —— 开发期把 /hotapi 转发到自部署实例,绕开 CORS
server: {
  proxy: {
    '/hotapi': {
      target: 'http://localhost:6688',
      rewrite: p => p.replace(/^/hotapi/, ''),
    },
  },
}

三、数据层决策:手写 fetch、SWR 还是 TanStack Query

这是本篇最重要的决策链。三个候选摊开:

方案 优点 缺点 适用边界
手写 fetch + useEffect 零依赖;缓存、竞态、去重全部亲手写,写一遍就知道库在替你做什么 缓存失效、重试、焦点重验证都要自己补;补齐后约等于自研一个库 单一读接口、无分页无变更的小应用;学习期
SWR 2 stale-while-revalidate 哲学一把梭:先给旧数据再后台刷新;体积小、心智简单 变更(mutation)与分页能力弱于 Query;社区生态小 读多写少的中型应用,想要”切回来永远新鲜”的默认行为
TanStack Query v5 缓存、去重、竞态、分页、乐观更新、失效编排、DevTools 全内置 概念量(queryKey/失效模型)需要专门学;引一个全家桶级依赖 多数据域、有变更、有分页的真实产品

本篇选手写。理由摆齐:这个应用只有一个 GET 接口,没有分页没有变更,SWR 与 Query 的主力能力都用不上;更重要的是教学目的——缓存命中、请求取消、竞态防御这三件事亲手写一遍约 40 行,之后无论用哪个库,你都知道它在替你做什么、什么时候失效。什么时候该换库也提前说死:出现”翻页加载更多””收藏要乐观更新并同步到服务端””多接口需要编排失效”任何一条,就上 TanStack Query,那是《TanStack Query 基础》的主场。

四、useBoard:把缓存、竞态、新鲜度一次写对

// src/hooks/useBoard.js —— 全应用唯一发请求的地方
import { useEffect, useState } from 'react'

const API_BASE = '/hotapi'      // 走 Vite 代理;部署时换成真实地址
const FRESH_MS = 30_000         // 新鲜度窗口:30 秒内切回来不发请求
const cache = new Map()         // 模块级缓存:组件卸载也活着——放 useState 里切换即丢

export const BOARDS = [
  { id: 'weibo', label: '微博' },
  { id: 'zhihu', label: '知乎' },
  { id: 'baidu', label: '百度' },
  { id: 'douyin', label: '抖音' },
]

export function useBoard(platform) {
  const hit = cache.get(platform)
  const [state, setState] = useState(() =>
    hit ? { status: 'ready', items: hit.items } : { status: 'loading', items: [] }
  )
  // 初始值用惰性初始化:缓存命中时首帧就有数据,不闪 loading

  useEffect(() => {
    const hit = cache.get(platform)
    if (hit && Date.now() - hit.at < FRESH_MS) {
      setState({ status: 'ready', items: hit.items })   // 新鲜:直接用,不请求
      return
    }
    const ac = new AbortController()    // 竞态防御:切换/卸载时作废这次请求
    setState({ status: 'loading', items: hit?.items ?? [] })   // 旧数据兜底,不闪空
    fetch(`${API_BASE}/${platform}`, { signal: ac.signal })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() })
      .then(json => {
        const items = (json.data ?? []).map(x => ({
          id: String(x.id),
          title: x.title,
          hot: x.hot,
          url: x.url,
        }))                             // 只留用到的字段:缓存小,渲染也快
        cache.set(platform, { at: Date.now(), items })
        setState({ status: 'ready', items })
      })
      .catch(err => {
        if (err.name === 'AbortError') return   // 主动取消不算错误
        setState({ status: 'error', items: hit?.items ?? [], error: String(err) })
      })
    return () => ac.abort()            // 清理函数:下次执行前与卸载时都调用
  }, [platform])

  return state
}

三个反模式在此点名:

反模式一:Effect 里裸 fetch,不取消。快速从榜单 A 切到 B,A 的响应后到,把 B 的数据覆盖掉——界面显示 B 的标题、A 的内容。AbortController 加清理函数是唯一正解,这也是本字典篇”常见报错”里竞态条的预防针。

反模式二:缓存放组件 state。useState 随组件生死,切一次榜单卸载重挂,缓存即丢、请求重发。缓存要跨切换存活就放模块作用域——这也是所有请求库的第一块地基。

反模式三:localStorage 无容错。直接 JSON.parse(localStorage.getItem(...)),坏数据一次就崩整个应用。持久化统一收口在 useLocalStorage——语法篇自定义 Hook 一节写过的那条,直接搬来——坏数据回默认值。

五、分栏、已读与收藏

// src/App.jsx
import { useMemo, useState } from 'react'
import { BOARDS, useBoard } from './hooks/useBoard'
import { useLocalStorage } from './hooks/useLocalStorage'
import BoardNav from './BoardNav'
import ItemRow from './ItemRow'

export default function App() {
  const [board, setBoard] = useState(BOARDS[0].id)
  const { status, items, error } = useBoard(board)

  // 已读:board → id 数组存储(localStorage 放不了 Set)
  const [readMap, setReadMap] = useLocalStorage('hotreader:read.v1', {})
  const readSet = useMemo(
    () => new Set(readMap[board] ?? []),      // 数组换 Set:row 渲染时 O(1) 查询
    [readMap, board]
  )
  function markRead(id) {
    setReadMap(m => ({
      ...m,
      [board]: [...new Set([...(m[board] ?? []), id])].slice(-500),   // 封顶 500 条防无限膨胀
    }))
  }

  // 收藏:独立于榜单的全局列表
  const [stars, setStars] = useLocalStorage('hotreader:stars.v1', [])
  const starSet = useMemo(
    () => new Set(stars.map(s => `${s.board}/${s.id}`)),
    [stars]
  )
  function toggleStar(item) {
    setStars(list =>
      list.some(s => s.id === item.id && s.board === board)
        ? list.filter(s => !(s.id === item.id && s.board === board))
        : [...list, { ...item, board, savedAt: Date.now() }]
    )
  }

  return (
    <div className="layout">
      <BoardNav boards={BOARDS} active={board} onSelect={setBoard} starCount={stars.length} />
      <main>
        {status === 'error' && <p className="error">这个榜单暂时拉不到:{error}</p>}
        {items.length === 0 && status === 'loading' && <p>加载中…</p>}
        {items.map(item => (
          <ItemRow key={item.id} item={item}
            read={readSet.has(item.id)}
            starred={starSet.has(`${board}/${item.id}`)}
            onOpen={markRead} onStar={toggleStar} />
        ))}
      </main>
    </div>
  )
}
// src/ItemRow.jsx —— 行组件:点击打开原文并标记已读
export default function ItemRow({ item, read, starred, onOpen, onStar }) {
  return (
    <div className={'row' + (read ? ' row-read' : '')}>
      <a href={item.url} target="_blank" rel="noopener noreferrer"
         onClick={() => onOpen(item.id)}>
        {item.title}
      </a>
      {item.hot != null && <span className="hot">{item.hot}</span>}
      <button onClick={() => onStar(item)}>{starred ? '★' : '☆'}</button>
    </div>
  )
}

布局就是两栏 flex(左栏固定宽、右栏自适应),样式几十行不占正文。两个设计取舍说明:已读与收藏分两个 key 存——已读是按榜单隔离的(换个榜单 id 语义不同),收藏是全局的,混在一个结构里迟早要迁数据;target="_blank" 必须配 rel="noopener noreferrer",新页面拿不到 window.opener 是安全底线。

六、性能:定性结论与分界线

这个应用的渲染规模(单榜单几十到百条)下,任何手动优化都是过度设计:不加 React.memo、不加 useCallback,直接写。判断依据:重渲染是每次切换榜单必然发生的,真正贵的 DOM 更新只发生在条目变化的那几行——React 的 diff 机制天然兜住。什么时候需要上手段?条目过千、或交互频率上来之后,先用 DevTools Profiler 录一段看谁在反复渲染,再决定 memo 还是收敛状态粒度,方法论整篇在《React 渲染与重渲染》。新项目还可以把 Vite 模板换成 React Compiler 变体(编译器当前为 RC 阶段),让编译器自动做 memoization——但同样建议先用 Profiler 确认有需求再开。

七、验证

场景 操作 预期
竞态 快速连点微博、知乎、百度后停住 右栏内容与左栏选中项一致,无错乱
缓存 微博 → 知乎 → 30 秒内切回微博 立即显示旧数据,Network 面板无新请求
持久化 标记已读与收藏各几条,刷新 状态完整;URL 还能点开
脏数据 DevTools 把 hotreader:stars.v1 改成 {bad 再刷新 应用正常启动,收藏回到空列表
上游故障 停掉 dailyhot-api 容器再切榜单 对应榜单显示错误提示,其余已缓存榜单不受影响

八、总结

  • 数据层的价值排序:竞态防御 > 缓存 > 重试验证——前两个没做对,后面全是白搭。
  • 手写 40 行不是为了省一个依赖,是为了换库那天你知道自己在买什么;出现分页、乐观更新、失效编排任何一条,直接上 TanStack Query。
  • localStorage 三原则:收口一个文件、坏数据回默认值、key 带版本号(read.v1)给未来迁移留路。
  • 性能手段在 Profiler 录出证据之前一律不加——百条列表的 memo 是负优化。

下一篇《React 学习路线》把本系列五篇串成线并给出进阶分岔;热榜数据的服务端故事(RSSHub、redis 缓存、单机内存约束)回到《叮叮当当 #1》继续。

版本核实(撰写时检索,2025-08):React 当前稳定版 19.1.x;SWR 当前主线 2.x;TanStack Query 当前主线 v5;dailyhot-api(imsyy)为开源项目、接口形态以其仓库 README 为准;React Compiler 处于 RC 阶段、create-vite 提供 Compiler 变体。本篇性能结论全部为定性,未做定量测试。安装命令不钉版本号,具体以官方文档为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇