
第一次看到“rea”这个名字时我以为是哪个开源库的缩写查了一圈没找到后来才知道这就是个随手起名的个人项目。rea拆开来可以理解为 Resource Explorer App一个资源聚合展示的单页应用。它解决的问题很直接把散落在多个后台、多个表格里的资源数据统一塞进一个干净的前端页面里支持分类筛选、关键词检索、详情查看、收藏标记再按需要做一套明暗主题切换。如果你正在给团队做内部工具或者想给自己的内容整理一个统一入口这类应用几乎是最常见也最实用的起步形态。这篇文章我会把从命名思路、技术选型到关键代码落地、性能优化和踩坑记录全部拆开讲给你一条可以直接照着走的路线。1. rea 是什么——项目命名、定位与设计思路1.1 名字拆解与核心定位先解释一下项目名。rea 这三个字母刚好对应 Resource Explorer App也就是“资源浏览应用”。我当时起名就一个原则终端里好打、读起来顺口最好还能体现产品形态。rea 三个字母全是常用键位敲起来很快团队其他人记起来也毫无压力。这一点对工具类项目其实比想象中重要因为内部工具的入口往往是大家在地址栏里手敲的名字越短使用频率越高。从功能定位上讲rea 并不是一个追求大而全的业务系统它只专注一件事把资源列表以一种清爽、可检索、可分类的方式呈现给用户。这里的“资源”可以是文档、视频、工具链接、代码片段也可以是运营素材和内部知识条目。核心交付物就是三个页面资源列表页、收藏页、详情面板。数据来源可以是后端接口也可以是本地 mock前期完全可以脱离开后端独立推进。这类项目适合谁参考我认为特别适合三类人刚学完前端基础、需要一个完整实战项目来串联知识点的同学在小团队里负责内部工具建设、不想引入重型后台系统的开发者以及想快速验证内容聚合想法、先做一个可用原型的产品同学。rea 的代码量不大但覆盖了接口封装、状态管理、路由、懒加载、主题切换这些前端日常必备能力拿来练手或者做内部工具地基都很合适。1.2 功能边界做什么与不做什么做一个项目最怕的不是功能太少而是边界不清。rea 在设计阶段就明确了两张清单。第一版明确要做的是资源列表的展示与加载关键词搜索和分类筛选的组合过滤点击资源卡片查看详情收藏/取消收藏并在本地持久化明暗两套主题切换记住用户选择。明确不做的是用户登录与权限系统资源后台的新增、编辑、删除复杂的角色管理和操作审计记录移动端原生封装和推送能力。为什么这么切原因很朴素先跑通主流程再谈复杂化。登录权限这类需求一旦引入就需要后端配合、会话管理、权限模型原本两天能跑通的项目会被拉长到两周。而 rea 的核心价值在“展示、检索、阅读”这条主链路上把主链路做到流畅顺手远比堆砌一堆用不到的配置项更实际。等真实用户用起来告诉你“我需要审核角色”“我需要标记失效资源”那时候再迭代方向才真正清晰。1.3 技术选型逻辑解析选型这事我一直坚持一个原则小项目不要追求技术栈的“全和重”要追求“够用和稳”。rea 的技术组合并不新颖但每一环都是基于场景反复权衡后的结果。模块选型选择理由构建工具Vite冷启动快开发时热更新几乎无感配置比传统方案少很多前端框架React 18组件化模型适合这种卡片列表类界面生态成熟开发语言TypeScript资源数据结构一旦变动类型系统能在编译期暴露问题而不是等运行时报错全局状态zustandAPI 极简不需要写一堆样板代码支持中间件持久化路由react-router-dom稳定可靠社区使用面广遇到坑容易搜到答案样式方案CSS Modules避免全局类名冲突不引入 UI 库保持产物轻量请求方案原生 fetch 封装这种量级根本不需要引入完整请求库自己封装几十行就够这里重点说一下没有选的东西。没有上 Redux是因为 rea 的全局状态非常有限用 Redux 的 action、reducer、selector 一套组合拳打下来代码量翻倍心智负担也翻倍。没有上 UI 组件库是因为资源展示类页面的设计点不多自己写卡片和布局反而更容易贴近业务。组件库能省时间但也带来了主题覆盖、样式复写和体积增大的问题在这个项目里性价比不高。1.4 适合谁参考能扩展到哪些场景rea 这种“列表 筛选 详情”的形态几乎是内容型工具的通用骨架。最直接的扩展方向有几个把 ResourceItem 换成文章结构加一个富文本详情页就能变成企业知识库把筛选条件改成标签多选和日期范围就能变成运营素材库再往后接上评论和点赞能力就是一个轻量社区的内容浏览端。所以rea 表面上是一个具体项目实际上是一套可以反复套用的内容展示基础框架。理解了它的数据流和组件划分方式换业务场景只是换数据类型和页面细节。2. 核心模块拆解数据、状态与组件三层的实现思路2.1 数据层接口封装、类型约定与字段归一化数据层是 rea 的地基这一层设计不清楚后面所有组件都要跟着吃苦头。我习惯先把资源的数据结构用 TypeScript 定义清楚这相当于给整个项目立了一个契约interface ResourceItem { id: string; title: string; category: doc | video | tool | other; url?: string; description: string; tags: string[]; createdAt: number; }定义好类型之后再写请求封装。这里我选择了在原生 fetch 基础上做一层薄封装主要解决三件事统一的超时控制、统一的错误处理和统一的返回解析。代码大致是这个思路async function requestResourceList(signal?: AbortSignal): PromiseResourceItem[] { const res await fetch(/api/resources, { signal, headers: { Content-Type: application/json }, }); if (!res.ok) { throw new Error(HTTP ${res.status}); } const data await res.json(); return normalizeResourceList(data); }请求封装之外特别想强调“字段归一化”这步。后端返回的字段经常是另一个风格比如item_title、created_at这种下划线命名或者干脆混着大小写。如果不做归一化前端组件里就会到处出现类似于item.item_title和item.created_at这样的代码一旦后端改字段你就要全局搜索替换苦不堪言。我的做法是在normalizeResourceList里把后端数据转换成前端统一的 ResourceItem 结构之后所有组件都只认这一种结构。这样做的价值在联调阶段才会真正体现后端字段怎么变前端要改的地方永远只有归一化函数这一个文件。2.2 状态层轻量全局状态不做多余动作rea 的全局状态并不复杂我最终只放了三样东西资源列表、筛选信息关键词和分类、加载与错误状态。选择 zustand 的原因就是它能够用最少的代码把这个状态模型描述出来import { create } from zustand; interface ResourceState { list: ResourceItem[]; keyword: string; category: string; loading: boolean; error: string | null; fetchResources: () Promisevoid; setKeyword: (kw: string) void; setCategory: (cat: string) void; } export const useResourceStore createResourceState((set) ({ list: [], keyword: , category: , loading: false, error: null, fetchResources: async () { set({ loading: true, error: null }); try { const data await requestResourceList(); set({ list: data, loading: false }); } catch (err) { set({ error: (err as Error).message, loading: false }); } }, setKeyword: (keyword) set({ keyword }), setCategory: (category) set({ category }), }));有个容易犯的错误是把过滤后的结果也放进全局状态里。第一次我确实这么写了一版后来发现每次输入关键词整个页面所有订阅了 store 的组件都会重新计算和渲染性能浪费很大。正确的做法是store 里只保存原始列表和筛选条件过滤操作交回给组件用useMemo做派生计算const filteredList useMemo(() { const kw keyword.trim().toLowerCase(); return list.filter((item) { const matchKeyword !kw || item.title.toLowerCase().includes(kw) || item.tags.some((tag) tag.includes(kw)); const matchCategory !category || item.category category; return matchKeyword matchCategory; }); }, [list, keyword, category]);这个设计背后的核心思想是状态要“单源”但是派生数据不要“入库”。原始数据是唯一可信来源而筛选结果只是原始数据在不同条件下的投影。投影逻辑放在组件里反而能让 store 保持精简组件也更容易单独测试。2.3 组件层卡片、筛选器与详情抽屉的关键交互组件层是用户直接接触的部分rea 拆成了三个核心组件资源卡片、筛选栏、详情抽屉。资源卡片负责展示标题、描述、分类标签和收藏按钮筛选栏包含一个搜索输入框和一组分类 Tab详情抽屉从右侧滑出展示资源的完整信息、标签列表和跳转链接。组件拆分最重要的原则是“一个组件只做一件事”。把收藏按钮从卡片里单独抽出来哪怕只是一个小图标都是为了让它能够独立维护自己的状态和点击逻辑。这样当列表很大、需要做性能优化时React.memo 才能精准地只跳过不需要重渲染的部分而不是整张卡片跟着一起受影响。详情抽屉我特意设计成异步懒加载。因为抽屉的内容在用户点击卡片之前完全不需要下载懒加载可以让首屏的 JavaScript 体积再小一块。这个细节在开发环境看不出来但到了构建产物分析阶段你会发现首屏加载压力明显下降。3. 实操记录从空目录到可运行的完整落地过程3.1 初始化工程与目录约定我们用 Vite 官方脚手架来初始化这是当前创建 React TypeScript 项目最快的路径npm create vitelatest rea -- --template react-ts cd rea npm install npm install zustand react-router-dom目录结构我做了一次调整核心是按功能模块而不是按文件类型组织src/ api/ # 请求封装与接口定义 store/ # zustand 状态 components/ # 通用业务组件 pages/ # 路由页面 hooks/ # 自定义 Hook utils/ # 纯工具函数 styles/ # 全局样式为什么这么分我踩过按文件类型分类的坑把所有组件堆在components里所有函数堆在utils里项目大了之后找文件全靠记忆。按功能模块组织之后改一个功能需要打开的文件基本都在同一个目录下团队协作时文件归属也更清晰。3.2 数据流核心代码请求、状态与组件联动数据流落地的完整链路是页面挂载时触发 store 里的fetchResources请求完成后列表数据进入 store再经过useMemo的计算投影到列表组件上。核心代码已经很清晰这里讲几个关键细节。第一组件挂载触发请求时不要在组件主体里直接调用 action而是放在useEffect中useEffect(() { void useResourceStore.getState().fetchResources(); }, []);这么写是为了避免渲染期间的意外副作用。直接在组件主体里调用异步函数在 React 严格模式下会因为重复渲染导致重复请求放useEffect里能保证行为符合预期。第二详情抽屉的展示方式是路由驱动而非本地状态驱动。进入/resources/:id和打开抽屉实际上是一回事。这样设计的好处是详情页可以分享、可以刷新、可以直接通过 URL 定位而不是关掉抽屉就丢了当前状态。实现上点击卡片时用编程式导航跳到对应路由抽屉组件内部读取路由参数并渲染对应资源。第三收藏功能计算的是本地持久化。收藏集合用 Set 结构存储在 zustand 中并通过 persist 中间件同步到 localStorage。这样做用户刷新页面后收藏状态不会丢失而代码逻辑并不复杂persist( (set) ({ ... }), { name: rea-favorites } )3.3 主题切换与响应式样式实现rea 的主题切换没有引入任何运行时样式方案CSS 变量是最轻量的做法。全局样式里定义两套变量:root { --bg: #ffffff; --text: #1a1a1a; --card: #f6f6f6; --border: #e5e5e5; } [data-themedark] { --bg: #121212; --text: #e6e6e6; --card: #1e1e1e; --border: #333333; }主题切换的逻辑很简单切换时修改document.documentElement的>server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }不建议在前端代码里写死后端地址。写死看似简单但意味着每次换环境都要改代码或环境变量而且很容易把测试环境的地址误带到生产环境。代理配置让前端代码永远只认相对路径环境差异交给构建和部署配置来解决。发布前我习惯跑一遍完整检查链路npm run typecheck、npm run lint、npm run build。最后用npm run preview启动一个本地静态服务器打开页面实际点一遍搜索、分类、收藏、详情、切换主题确认产物没有问题。这一步虽然机械但确实帮我拦截过好几次“本地开发正常、产物异常”的问题。4. 性能与工程化处理列表优化、分包策略与开发体验4.1 长列表渲染的三个优化维度当资源数量达到几百条之后列表渲染的卡顿感会逐渐明显。rea 在这个问题上从三个维度做了处理。第一个维度是组件记忆化。给资源卡片包一层React.memo同时确保传入的每个 prop 都是稳定引用。收藏按钮这个场景需要特别小心如果你在列表组件里给每个卡片传递一个内联箭头函数() toggleFavorite(item.id)那么memo完全失效因为每次渲染都会生成新的函数引用。解决方法是把toggleFavorite定义在组件外部并让卡片自己传入 idconst ResourceCard memo(function ResourceCard({ item }: { item: ResourceItem }) { const toggle useResourceStore((state) state.toggleFavorite); // 组件内部使用 toggle(item.id) });第二个维度是筛选计算的 memo 化。前面已经实现了useMemo过滤这里再强调一下过滤条件和列表本身发生变化时才需要重新计算否则直接用上一次的结果。资源数量越多这个优化节省的计算越可观。第三个维度是虚拟滚动。我是在资源量超过 500 条之后才考虑引入的。虚拟滚动的原理很朴素固定行高的情况下用容器高度除以行高估算当前可视区起始索引只渲染可视区域附近十倍的条目其余条目用空白占位代替。如果数据量没有大到极致用 React 自带的useDeferredValue降低列表更新优先级也是一种更轻量的折中方案。评估性能优化时先量化问题再动代码不要一上来就上重型方案。4.2 路由懒加载与依赖分包虚拟滚动解决的是渲染卡顿懒加载解决的是首屏过重。rea 有三个路由页面收藏页和详情页在用户首次访问前完全不需要加载。React.lazy 配合 react-router 的写法非常直接const FavoritePage lazy(() import(./pages/FavoritePage)); const DetailPage lazy(() import(./pages/DetailPage));构建阶段Vite 会根据动态 import 自动把这些页面拆成独立的 chunk。另外我还在构建配置里手动分了 vendor 包把框架代码单独抽出来build: { rollupOptions: { output: { manualChunks: { vendor: [react, react-dom, react-router-dom], }, }, }, },这么做的价值在于业务代码每次更新后浏览器只需要重新下载业务部分vendor 部分因为文件名 hash 没变会命中缓存。首次访问的加载体积在我的实测里能减少 40% 左右体感提升很明显。4.3 提升日常开发体验的几个小配置开发体验这件事本质上是用一次性的配置时间换每天多次重复劳动的节省。rea 里我有三个小配置想推荐给大家。第一路径别名。在vite.config.ts里加一个指向src目录从此../api/request这种烦人的相对路径就彻底消失了resolve: { alias: { : path.resolve(__dirname, src), }, }第二提交前的类型检查和 lint 检查。我把tsc --noEmit和eslint挂到了 git 的 pre-commit 钩子上让错误在提交前就被拦截而不是等 CI 跑完再回来修。第三及时清理无用的依赖。很多项目的依赖体积膨胀不是因为一开始选错了库而是因为功能迭代中不再使用的代码没有同步从 package.json 里移除。每次重构完我习惯用npm ls和构建产物分析检查一遍哪些包实际上没有被引用能删就删。5. 踩坑记录常见问题与排查思路5.1 数据渲染不同步store 实例与异步时序rea 开发到一半时我遇到过一个很诡异的现象请求明明返回了数据接口里也能打印出新数组但页面上的列表纹丝不动。排查到最后发现store 不是在模块顶层创建的而是放在了一个被多个模块调用的工厂函数内部导致不同组件拿到的是不同的 store 实例一个组件更新了状态另一个组件订阅的根本不是同一个 store。这类问题还挺隐蔽因为代码在单一入口时表现正常一旦加了路由懒加载某些组件加载顺序变化问题就会浮出来。排查思路其实就两步先确认 store 是否全局唯一把create调用移到模块顶层导出再确认组件更新时序把请求触发收敛到useEffect里不要在事件回调中反复触发。5.2 路由切换后状态丢失详情页刷新空白另一个典型问题是详情页分享给别人时直接打开一片空白。原因是详情页的数据完全依赖列表页传入的 props 或历史状态一旦跳过列表页直接访问详情 URL数据就不存在了。正确的做法是详情页自行保证数据可得性。路由参数中的 id 是唯一的凭据详情页内部通过 id 去 store 里查找资源如果 store 里没有就主动触发一次列表请求等列表返回后再自动定位到对应资源。这种设计也许会让个别情况下的页面加载慢一拍但换来了链接的可靠性和可分享性。5.3 打包体积异常膨胀一层依赖带来的教训有段时间 rea 的构建产物突然翻倍查了半天发现只是因为我在一个组件里引入了某个工具库的完整包而实际只用到其中一个极小的函数。这类问题几乎每个前端项目都会遇到。教训有两条。一是引入依赖之前先看一眼它的大小和导出结构优先用子路径导出。二是养成看构建分析报告的习惯Vite 项目可以配置可视化的 bundle 分析插件每个依赖在产物里占多大体积一目了然。如果发现某个大包只为一个功能服务手写三十行代码往往比引入整个依赖更划算。5.4 主题切换闪白与样式错乱初始化顺序问题rea 的主题初始化一开始是在 React 渲染之后才执行的结果就是深色模式下刷新页面会出现一个短暂的白屏在弱光环境里非常刺眼。修复办法是在入口文件中、ReactDOM 渲染之前就同步读取 localStorage 并设置>