这套前端的技术组合是 React 18 + TypeScript + Vite 作为骨架,Zustand 管全局状态,TanStack Query 管服务端数据的请求与缓存,ECharts 承担图表,Tailwind CSS 处理样式,实时通道用封装后的原生 WebSocket 单例。整体实现拆成四层:类型契约层与后端标准模型对齐,数据层同时消费 REST 首屏与 WebSocket 增量,状态层做唯一数据源,视图层只订阅状态渲染。AI 热点监控工具的前端按这个分层组织,图表与列表组件互不感知数据来源。下面逐层说明选型依据与实现方式。
一、技术选型与理由
| 技术 | 承担职责 | 选择依据 |
|---|---|---|
| React 18 | 组件化与并发渲染 | 生态组件丰富,批量更新机制适配高频推送 |
| TypeScript | 类型契约 | 热点数据字段多且嵌套,编译期拦下字段错拼 |
| Vite | 开发服务与打包 | 冷启动快,热更新即时,构建产物体积可控 |
| Zustand | 全局状态 | API 面小,无需模板代码,可细粒度订阅 |
| TanStack Query | 服务端数据缓存 | 内建重试、失效、分页,减少手写请求状态 |
| ECharts | 图表渲染 | 趋势、占比、词云等图型齐全,配置式声明 |
| Tailwind CSS | 样式体系 | 原子类保证视觉一致,改版无需重排样式文件 |
| 原生 WebSocket 封装 | 实时通道 | 依赖轻,重连与心跳策略完全自控 |
选型的取舍点有两个。状态管理放弃了更重的方案,因为热点列表的更新是高频小增量,需要组件按字段粒度订阅,避免一条推送触发整棵树重渲染。实时通道没有引入带回退能力的通信库,原因是后端只提供 WebSocket 端点,不需要长轮询回退,自己封装反而能精确控制退避节奏。
二、四层结构与数据流向
2.1 类型契约层
后端标准数据模型的字段被翻译成一份 TypeScript 声明,前端所有模块引用它,不各自定义结构。字段一旦调整,编译期就能暴露全部受影响的位置。
export type ContentType = 'text' | 'video' | 'image';
export type HeatLevel = '高热' | '升温' | '常规';
export interface HotItem {
uid: string;
source: string;
title: string;
summary: string;
contentType: ContentType;
heatScore: number;
heatLevel: HeatLevel;
category: string;
publishedAt: string; // UTC ISO 字符串
url?: string;
}
export type WsFrame =
| { type: 'hot.created'; payload: HotItem }
| { type: 'hot.updated'; payload: Pick<HotItem, 'uid' | 'heatScore' | 'heatLevel'> }
| { type: 'stats.tick'; payload: { source: string; count: number }[] }
| { type: 'pong' };
判别联合类型让消息分发具备穷尽性检查:后端新增一种帧类型时,忘记处理的分支会在编译阶段报错。
2.2 数据层
首屏与增量走两条通道。TanStack Query 负责首屏与筛选切换时的 REST 请求,缓存键由筛选条件构成,切回旧条件直接命中缓存。WebSocket 只投递增量帧,进入状态层与已有列表合并。
2.3 状态层
Zustand store 是唯一数据源。列表以 uid 为键存成映射结构,插入与更新都是常数级操作,避免每次推送扫描整个数组。
import { create } from 'zustand';
import type { HotItem } from './types';
interface HotState {
items: Record<string, HotItem>;
order: string[];
filters: { source?: string; level?: string };
applyBatch: (frames: HotItem[]) => void;
patchHeat: (uid: string, heatScore: number, heatLevel: HotItem['heatLevel']) => void;
setFilters: (f: HotState['filters']) => void;
}
export const useHotStore = create<HotState>((set) => ({
items: {},
order: [],
filters: {},
applyBatch: (frames) =>
set((s) => {
const items = { ...s.items };
const fresh: string[] = [];
for (const it of frames) {
if (!items[it.uid]) fresh.push(it.uid);
items[it.uid] = it;
}
// 新增项前插,并对列表长度设上限,防止内存持续增长
const order = [...fresh, ...s.order].slice(0, 500);
return { items, order };
}),
patchHeat: (uid, heatScore, heatLevel) =>
set((s) =>
s.items[uid]
? { items: { ...s.items, [uid]: { ...s.items[uid], heatScore, heatLevel } } }
: s,
),
setFilters: (filters) => set({ filters }),
}));
列表长度设上限是必要的。前端长时间挂着接收推送,不裁剪就会让内存单向增长直至页面卡死。
2.4 视图层
组件只从 store 取自己需要的切片。卡片组件订阅单条记录,图表组件订阅聚合数据,两者的更新互不牵连。
// 细粒度订阅:只有这一条数据变化时该卡片才重渲染
function HotCard({ uid }: { uid: string }) {
const item = useHotStore((s) => s.items[uid]);
if (!item) return null;
return (
<article className="rounded-lg border p-3">
<h3 className="truncate text-sm font-medium">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{item.summary}</p>
<footer className="mt-2 flex gap-2 text-xs">
<span>{item.source}</span>
<span>{item.heatLevel}</span>
<span>{item.heatScore}</span>
</footer>
</article>
);
}
三、从零搭建的执行顺序
- 用 Vite 初始化 React 与 TypeScript 模板,开启严格类型检查;
- 接入 Tailwind,定义颜色、间距与字号的设计变量;
- 按后端标准模型编写类型声明,作为跨模块的唯一契约;
- 配置 TanStack Query 客户端,约定缓存时长与失败重试次数;
- 建立 Zustand store,以映射加顺序数组的形式承载列表;
- 封装 WebSocket 单例,把增量帧按类型分发到 store 的对应动作;
- 拆分组件:筛选栏、热点列表、明细抽屉、趋势图表、连接状态条;
- 图表模块改为按需加载,把首屏包体与图表库解耦;
- 构建后交由 Nginx 托管静态资源,并为长连接端点单独配置反向代理。
四、组件拆分的边界划分
界面被拆成五个互不重叠的组件族,每一族只对一类状态负责。
筛选栏持有筛选条件,改动后写回 store 并触发一次 REST 查询,同时上行通知服务端调整推送范围。它不持有列表数据,避免筛选变化引起列表组件的连带重渲染。
热点列表只负责顺序与虚拟化,逐行渲染卡片组件,自身不读取单条记录的字段。卡片组件按 uid 订阅,字段变化的影响被限制在单行内。
明细抽屉按需拉取正文、评论样本与分析结果。这部分数据体积大且访问频次低,不进入常驻的列表状态,改由 TanStack Query 按 uid 独立缓存,关闭抽屉后随缓存策略自然回收。
趋势图表消费的是聚合切片,与明细数据分开存放。聚合值由服务端定时推送,前端不在浏览器里对数百条记录做实时统计,省下的计算量直接反映在滚动流畅度上。
连接状态条订阅长连接的状态机,在断线与重连期间给出明确提示。实时界面缺了这个组件,用户无法区分”当前没有新热点”与”连接已经断了”,这类误解会直接削弱数据可信度。
五、渲染性能的三个着力点
虚拟滚动解决节点数量。热点列表常驻数百条,只挂载视口内的行,滚动时复用节点,DOM 规模与列表长度脱钩。
订阅粒度解决渲染范围。store 的选择器返回单条记录而非整个集合,配合组件记忆化,一条推送只重绘一张卡片。
按需加载解决首屏体积。图表库体积较大,用动态导入切成独立分块,进入图表面板时才拉取,列表页首屏不受影响。
六、部署与联调约定
开发阶段用 Vite 的代理把接口与长连接路径转发到本地后端,避免跨域配置侵入代码。生产构建输出静态文件由 Nginx 托管,接口路径与长连接路径分别配置转发规则,长连接所在的 location 需要放行协议升级请求头并关闭响应缓冲,否则消息会被代理层攒住不发。
环境差异通过构建期环境变量注入,接口地址不写死在源码里。
常见问题(FAQ)
Q1:为什么状态管理不用更重的方案?
热点更新是高频小增量,需要按字段粒度订阅,轻量 store 的选择器更容易控制重渲染范围。
Q2:列表数据一直累积会不会内存溢出?
store 对列表长度设上限并裁剪尾部,历史数据改由接口按需分页查询。
Q3:TypeScript 类型和后端字段怎么保持一致?
前端维护单份类型声明对齐后端标准模型,字段变更后编译期报错即定位受影响模块。