一、先说清楚要做什么
用 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 变体。本篇性能结论全部为定性,未做定量测试。安装命令不钉版本号,具体以官方文档为准。