三个月前我给自己定了个小目标:做一个真正属于自己的阅读工具。项目代号在键盘上随手敲了两下,就成了rea——它听着像read的前半截,也挺好记。rea不是一个框架,也不是某个开源库,它是我最近一直在打磨的一个纯前端项目的代号,核心功能是订阅RSS和Atom源,把分散在互联网各处的文章更新聚合到一个页面里,提供干净的阅读体验、离线缓存和简单的内容过滤。
做这个项目直接解决两个问题:一是现在很多信息流平台越用越吵,个性化推荐把时间稀释得很碎,我越来越想回到主动选择信息源的阅读方式;二是市面上现成的阅读器要么绑定了特定生态,要么在性能和自定义上不够顺手。rea全部代码都跑在浏览器里,逻辑简单直接,非常适合前端开发的同学用来练手,尤其能覆盖React工程化、状态管理、浏览器存储和性能优化这些方向。这篇文章不打算写成文档式教程,我想像聊天一样,把rea从0到1的过程、踩过的坑和背后取舍都摊开聊一遍。
1. 从"rea"这个代号说起:项目定位与核心思路
1.1 为什么是"rea"
给项目起代号的时候,我脑子里的第一反应是reader。rea是read/reader的一个极简投射,敲起来顺手,听起来顺耳,就这么定了下来。它的定位是:一个跑在浏览器里的私人阅读器,把分散在十几个RSS源里的文章拉到同一个界面,按时间排序,干干净净读完。这个想法谈不上新,但真正实现下来我发现,RSS生态的一个隐性价值正在于"标准统一"。只要源站还输出XML,你就能拿到同样的字段:标题、链接、时间、正文。相比各类App暴露的接口,RSS反而是最稳定的数据源。
于是rea的定位越来越清楚:不追求大而全,不做账号和云端,只把"订阅-拉取-存储-阅读"这条链路做到顺手。目标读者也很明确:一是本身就在用RSS的信息收集型用户,二是想通过一个完整前端项目来巩固React工程化能力的同学。对我来说,这个项目也是一个很好的训练场,因为阅读器虽然看起来简单,但它的数据流、存储设计和异常处理密度一点也不低,几乎每天都在跟真实世界的脏数据打交道。
1.2 核心需求拆解
要想把一个阅读器做好,其实绕不开三件事:信息获取、信息组织、信息阅读。信息获取对应订阅源的解析和抓取;信息组织对应数据库里的去重、排序与分类;信息阅读对应详情页的排版、字体和离线能力。另外一个特别容易被忽视的点是:当网络不可用或者源站挂掉的时候,用户能不能依然看到之前缓存过的内容,这是rea必须承诺的离线可读能力。
基于这几点,我重新拆出了完整功能清单:订阅源管理(手动添加加OPML导入导出)、文章聚合与去重、关键词过滤、收藏和已读标记、详情页阅读优化、离线缓存。这六个功能之间尽量解耦,实现时可以独立推进,调试起来也省心。我特别想把订阅源管理做得轻巧,因为很多阅读器把导入OPML这个功能藏得很深,第一次使用的人根本找不到。rea把导入入口直接放在侧边栏底部,一个按钮进文件选择器,解析完成后立刻刷新列表,整个过程不超过三秒。
1.3 整体架构与数据流
整体架构采用前后端分离,只不过后端被换成了"远程RSS源加浏览器存储"。整个应用分四层:视图层是React组件,状态层由Zustand管理全局store,数据层是Dexie封装的IndexedDB,网络层是封装好的fetch请求函数。数据流是单向循环:拉取函数请求远端XML,解析成结构化文章列表,批量写入IndexedDB,同时更新store里的"最近获取时间",视图订阅store变化后重新渲染。每一条数据都只有一条官方来源,不存在状态不同步的问题。
这个架构最大的好处是每一步都可以单独测试。比如解析器拿到一段XML样本,可以不依赖页面直接跑单测;数据库层可以在控制台里手动插入一条文章记录,然后观察列表是否更新。对于阅读器这种数据驱动型项目,单向数据流比双向绑定更容易排查问题:哪里显示不对,顺着数据链路往上查,很快就能定位到是解析问题、存储问题还是渲染问题。
2. 技术方案选型:每一步背后的取舍
2.1 框架、语言与构建工具
前端生态选择React,主要看中它的组件模型简单,以及资料覆盖足够广。在rea这种项目里,我并不需要服务端渲染,也不需要复杂的路由嵌套,React的纯客户端渲染完全够用。配合TypeScript,整个数据模型在编码阶段就能被约束住,比如Feed和Article的基础字段、可选字段、联合类型都能清晰表达,这对后面写解析器帮助很大,很多undefined的问题在编译期就被拦下来了。
构建工具用Vite。最早我也纠结过是webpack还是Vite,实际跑起来后发现,Vite基于原生ES模块的冷启动确实快,HMR的响应速度在阅读器这种偏交互式的页面里非常跟手。日常开发中,改一个store里的字段,页面几百毫秒内就能反映出来,这种体验会极大改善开发节奏。另外Vite的配置文件很直观,后面配置接口代理只需要几行,不像webpack那样要记一堆loader规则。
2.2 状态管理为什么选Zustand
rea里的跨组件状态不算复杂,大致有:当前订阅源、当前文章索引、是否处于阅读模式、侧边栏折叠状态。这类状态用Redux去维护会显得重,写一堆action和reducer反而拖慢实现。Context本身也可以,但Context更新时会触发所有消费该Context的组件重新渲染,在列表频繁滚动切换的场景下很容易造成卡顿。Zustand采用选择器订阅的机制,组件可以只订阅自己关心的字段,其他字段变化不会造成重渲染,这在文章列表和详情页这种高频交互场景里尤其重要。
Zustand的写法也很直观,比如reader store里无非就是几个字段和对应方法,一个create调用就完成了。组件里用useReaderStore带上选择器函数,就能精准触发局部更新。实际用下来,这个选择器机制配合React的并发特性非常舒服,既没有Redux的样板代码,也没有Context的全局穿透问题,属于"用最少的心智负担搞定状态管理"的典型方案。
2.3 数据库为什么选择IndexedDB
文章正文经常是几十KB甚至几十KB到上百KB,localStorage的5MB配额很快见底。rea的数据量上限可以预料:100个订阅源乘以每个源1000篇文章,就是10万条索引记录,这个量级只有IndexedDB能从容应对。直接用IndexedDB很痛苦,因为它的API是事件驱动,没有Promise封装,写起来啰嗦。我用Dexie这个库做了封装,可以像操作普通表格一样写代码。
rea里定义了三张表:feeds、articles、meta。articles表的索引有feedId、publishedAt、read等字段。尤其publishedAt做索引之后,按时间倒序翻页可以直接走数据库游标,不需要把所有文档一次性load出来。这里有个很关键的实践心得:存文章时不要一股脑把整个HTML全塞进一条记录,更合理的做法是核心字段展开存储(标题、链接、摘要、时间、作者、源ID),正文作为一种大字段放进去,但建立查询时不要经常读写它,否则大字段会拖慢整体性能。
2.4 样式与组件组织
样式上没有引入任何UI组件库。阅读器的核心是内容可读性,我不需要按钮和卡片的一致性靠组件库来保证。主要的CSS变量包括字号、行高、正文宽度、背景色、前景色,主题切换时直接切换这一组变量即可。我甚至给正文区域单独设了一个最大宽度,控制在68字符左右,这个宽度是阅读长文最舒服的体验区间,比满屏拉伸的阅读体验好很多。
组件组织按功能区域拆分:Sidebar负责订阅源列表,ArticleList负责中间的文章列表,Reader负责详情阅读。每个组件下面按局部功能再拆小组件,比如ArticleList里的ArticleItem单独抽出来,配合React.memo避免整列表全部重渲染。组件之间的数据传递全部走store或props,不搞跨组件直接操作DOM,保持了数据流的一致性。
3. 核心模块设计与关键实现
3.1 项目初始化和依赖安装
创建一个Vite加React加TS项目很简单,用官方脚手架就可以快速搭好:
npm create vite@latest rea -- --template react-ts cd rea npm install npm install zustand dexie @tanstack/react-virtual dompurify原有的模板会生成一个示例页面,我直接把它清空,只保留入口文件。依赖里的zustand用于状态管理,dexie用于IndexedDB封装,@tanstack/react-virtual用于虚拟滚动,dompurify用于正文HTML清洗。这些依赖都很轻,项目整体体积依然可控。安装之后,我建议先做一件事:把tsconfig的strict模式打开。RSS解析和数据库操作涉及大量未知字段,开启strict能提前暴露很多潜在的undefined情况,还能让编辑器给出更准确的类型提示。
3.2 数据模型的约定
rea的数据模型经过几次迭代,最终稳定下来的结构不算复杂。Feed记录订阅源基本信息,包括标题、URL、站点地址、分类、最后抓取时间;Article记录一篇文章的完整信息,包括所属feedId、标题、链接、摘要、正文、作者、发布时间、已读和收藏状态。
read和favorited两个字段我故意用数字0和1而不是boolean,这是出于IndexedDB索引的考虑,数字类型在复杂查询里更稳定,也方便做统计。初始化数据库时用Dexie的version和stores方法建表,其中&link唯一索引是为了从源头上防止同一条文章被重复写入。URL里偶尔会有utm参数之类的东西,存储前统一清洗掉,避免同一个文章因为链接参数不同而成为两条记录。
3.3 订阅源的XML解析与内容标准化
这一步是rea的核心,也是最需要耐心的地方。RSS 2.0和Atom是两种主流格式,结构差异不小。我在parseFeed里先判断根元素是feed还是rss,然后分别解析entry和item节点。RSS的正文可能出现在description或content:encoded里,Atom则用content或summary,解析时要优先取长度更长的那个字段,否则列表页只能看到一段摘要,点进详情也没有全文。
这段代码能跑通大部分订阅源,但真实线上还会遇到两个问题:一是CDATA包裹的内容取出后可能带一层包装,需要再解析一次;二是有些站点把正文放在description里,有些放在content:encoded里,必须优先取长内容。另一个细节是相对路径。文章正文里的图片和链接经常写成/uploads/xxx.jpg这样的相对路径,必须在展示之前拼上源站域名,否则图片一读一个404。实现起来也不复杂:把内容扔进一个临时的DOM容器,遍历img和a标签,对src和href做URL拼接。
3.4 抓取调度、去重与OPML导入导出
订阅源的抓取我做成一个fetchAllFeeds方法,利用Promise.allSettled并发请求,并发数控制在6个,防止被源站拒绝。每次请求前先查meta表里的lastFetchedAt,如果距上次不足30分钟,就直接跳过。这个节流机制非常重要,否则手动操作里多点了两次刷新,很容易触发某些源站的限流。抓取完成后,解析出的新文章批量写入articles表,写入前对link做清洗,去掉不必要的追踪参数。
去重逻辑分两层:第一层是数据库唯一索引对link精确去重;第二层是对标题做归一化,去掉空格和特殊符号后比较,一定程度上拦截同文不同链接的重复内容。第二层不会去得太狠,因为有不少网站确实会重发旧文,你无法判断究竟是重复还是更新,所以只做保守的模糊匹配,避免误杀。OPML导入导出则完全是XML解析的另一处复用:OPML本质上是XML的outline节点嵌套结构,直接递归遍历即可,导出时把feed表转换成outline树,导入时把outline树摊平写成Feed记录。
3.5 详情阅读与正文清洗
阅读器页面的正文,本质上就是innerHTML渲染。但直接渲染很危险,会有XSS风险,也难免带进样式污染。我在react里用一个MemoizedRichContent组件,内部先对原始HTML做DOMPurify清洗,再通过sanitize配置移除script、iframe、style标签,保留标题和列表标签。纯文本信息源一般不会用到脚本,但很多站点会在正文里塞视频iframe,所以iframe默认禁用。如果以后想支持视频内容,可以只对少数视频平台域名开放白名单,但默认安全优先。
阅读器右侧还提供字号调节和主题切换。字号用CSS变量挂在根节点上,主题则分为日间、夜间、羊皮纸三种。夜间模式我不推荐用filter反转来做,因为滤镜会让图片颜色变得很奇怪,更合理的方案是直接切换背景色和文字色的CSS变量,让正文的图片保持正常色彩。实际体验下来,这个方案的实现成本不高,但阅读舒适度提升非常明显。
4. 性能优化与体验打磨
4.1 虚拟滚动:把千条列表变成几十个节点
rea里最容易被卡住的地方就是文章列表。当某一个源积攒了上千条文章后,一次性渲染所有DOM会导致滚动明显掉帧。我的方案是用@tanstack/react-virtual来做虚拟列表。虚拟列表的核心思想是:固定一个可视容器的高度,根据滚动位置计算出应该显示的条目范围,只渲染那些条目,其余区域用空白占位。react-virtual提供了useVirtualizer这个hook,业务代码并不复杂,只需要传入总数、滚动容器引用、预估高度和上下额外渲染的数量。
使用虚拟列表的首要前提是列表项高度要相对稳定。文章列表的卡片高度不固定时,我会在卡片内部用overflow省略,并把高度固定成96px,这样虚拟滚动才能稳定工作。实测下来,1500条数据渲染节点从1500个降为40个左右,滚动流畅度有明显提升。这个翻倍体验的优化方案在阅读器里几乎是必做的,因为用户天天都要刷列表,一个卡顿的列表比加载慢更劝退。
4.2 图片与正文的懒加载
文章列表里的缩略图、详情页里的大图,都直接用原生loading="lazy"属性,浏览器会延迟加载未进入视口的图片。这个属性已经得到现代浏览器广泛支持,不需要额外引入库。真正容易忽略的是图片高度未占位导致的布局抖动。我给列表里的每个图片容器固定了一个aspect-ratio,让浏览器在图片下载前就知道占位尺寸,这样滚动时不会出现内容和图片交替跳动的情况。正文里的图片还额外处理了referrerPolicy,部分图床在跨域请求时会带referer被拦截,我在图片标签里统一加上referrerPolicy="no-referrer"来缓解。
4.3 状态更新与渲染粒度优化
Zustand虽然能避免整个应用的无关重渲染,但组件内部的拆分也要跟上。ArticleList里每条ArticleItem被包裹在React.memo里,只有当它自己的article对象引用变化时才重新渲染。因为每次抓取后写进数据库的都是新数组,memo在这里非常有效。详情页同样用memo包住RichContent组件,因为正文HTML清洗很消耗CPU,如果每次切换文章都必须重新清洗一遍,用户会明显感觉到卡顿。
我的做法是把清洗后的结果缓存在内存Map里,key为文章ID加更新时间,这样同一篇文章第二次打开时直接命中缓存。这个缓存内存占用并不大,因为用户实际会查看的文章数量有限。除了渲染层面的优化,我还把"已读状态更新"做成异步的,点击列表项后UI立即跳转,数据库写入放到后台队列里,避免点击后出现约一两百毫秒的阻塞感。阅读器这类工具型页面,交互反馈的即时性比华丽动效更重要。
4.4 加载态、错误态与空态设计
rea里的每个异步区域都必须有三种状态:加载中转圈、失败后显示原因和重试按钮、成功但无数据显示空态。举一个订阅源列表的例子:页面首次启动,数据库里还没有任何数据,侧边栏不应该是一片空白,而应该显示一个引导提示,附上添加订阅源的按钮。抓取失败则更常见。某一个订阅源可能因为跨域问题、SSL证书过期、域名迁移而失效,我设计成单条源级别的错误标识,在列表里显示一个小红点,鼠标悬浮能看到失败原因,一条源挂了不影响其余源继续抓取。
这种局部容错对用户体验影响很大。最开始我把所有源的抓取结果汇总成一个Promise,任何一个源失败就整个报错,结果常常是十个源里有一个临时超时,所有源都刷不出来,用户会觉得整个应用坏了。改成allSettled逐条处理之后,每次刷新都能最大化地拿到可用内容,失败源单独提示,整体体验立刻稳定了很多。这个小改动值得每个人在做聚合类工具时参考。
5. 实际开发中遇到的坑与排查清单
5.1 跨域限制:绕不开也要绕得优雅
开发模式下的跨域,Vite的proxy解决得最省心。在vite.config里加一段接口代理配置,把特定路径代理到目标RSS域名,这样开发时请求/rss/feed.xml就等于请求远端地址,浏览器不感知跨域。但发布之后代理就不存在了,这种方案只适用开发期。正式环境我建议走两条路:一条是接公开的RSS转JSON服务,把解析放在服务端完成,由它返回JSON给前端,另一条是用轻量云函数做转发和解析。
关键点是不要期望用户配置浏览器来绕过跨域,普通用户不会也没必要这样做。所以你必须在自己的应用层把这个问题消化掉。自建转发服务时需要注意缓存,很多RSS源的内容更新频率并不高,服务端做一层十分钟级缓存能明显减少源站压力和你的流量消耗。跨域问题在这个项目里虽然不是核心功能,但它能逼迫你思考"前端在没有后端的情况下到底能走多远"。
5.2 日期解析与内容清洗的细坑
RSS里的pubDate格式五花八门,常见的有RFC 822、ISO 8601、甚至一些中文博客用的是北京时间但缺时区后缀。Date.parse大部分能兜住,但遇到解析不出来的时候不要直接给0,而应该回退到当前时间,同时在日志里记录异常,否则会出现一篇2025年的文章被排到1970年的诡异情况。内容清洗里还有一个高频问题:实体编码。有些源站把正文里的&、<、>转义成了Unicode字符或者HTML实体,直接用textContent取出来会看到一串乱码。
我写了一个unescapeEntities方法,先用DOM解析一次,把实体转成正常字符,再进入清洗管线。还有一个容易被忽略的点是正文里的代码块,很多技术博客会在pre里放置高亮代码,清洗时需要注意不要误删class属性,否则代码高亮样式就丢了。DOMPurify在保留class方面是可控的,可以通过配置允许特定class前缀,这样既安全又能保留视觉效果。
5.3 IndexedDB版本升级与数据迁移
开发期最烦的就是数据结构迭代。第一次我存文章只存了title和link,后来加了content字段,数据库版本从1升到2,如果没有迁移函数,旧数据就永远没有content。所以我从第一版就养成了习惯:每次升级版本号时,都在upgrade里写旧版本到新版本的迁移逻辑。Dexie的写法可以直接在version后面接upgrade回调,遍历旧记录并补充新字段默认值,这样老用户升级后数据依然可以平滑读取。
IndexedDB在隐私模式或存储空间不足时还会抛QuotaExceededError,我的兜底策略是捕获异常后提示用户清理数据,并把新抓取内容直接放在内存态,保证当前会话依然能阅读。这里有个值得注意的细节:千万不要在用户不知情的情况下清空数据库,那是一种很恶劣的数据丢失体验。我会弹一个清晰的提示,说明当前存储空间不足,并提供"清理缓存图片"和"保留文章文字"两个选项,让用户自己决定取舍。
5.4 常见问题速查表
把开发过程中碰过的典型问题整理成一张速查表,遇到类似情况可以直接对照排查。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 新增订阅源后抓不到文章 | CORS跨域、XML解析失败、网址是HTML不是XML | 打开开发者工具看请求响应,确认返回内容格式,用解析器解析responseText,换正确的订阅源URL |
| 日期显示错乱 | 时区未按本地转换、pubDate为null | 存储统一时间戳,展示时调用toLocaleString,缺失时间回退为抓取时间 |
| 文章列表滚动掉帧 | DOM节点过多、图片未懒加载 | 使用虚拟滚动,列表项高度固定,图片加loading="lazy" |
| 刷新后重复文章 | 去重逻辑只按title | 用清洗后的link做精确去重,再用归一化标题做模糊匹配 |
| 页面刷新后状态丢失 | store未持久化 | zustand persist插件持久化轻量状态,文章数据从IndexedDB重新读取 |
| 正文图片全部404 | 图片是相对路径未拼接域名 | 渲染前遍历img标签,用new URL拼接源站域名 |
| 正文乱码或样式被去掉 | 实体编码未解析、清洗白名单过度 | 先用DOM解析实体,再通过DOMPurify配置保留必要标签和class |
这张表的价值在于它把"异常"和"排查方向"直接对应起来,不用每次从头翻代码。实际开发中你会发现,阅读器项目90%的bug都集中在解析和存储这两层,UI层相对稳定得多。所以调试时优先看解析器输出的数据结构,再往上查渲染层,能省不少时间。
另外还有一个容易被忽视的小地方:日志规范。我在reqres里给每次抓取、解析、写入都加了带时间戳的日志输出,失败时还会打印源站返回的前200个字符。这些日志平时不怎么看,但一旦排查问题,它就是最重要的线索来源。
rea项目开发到现在,带给我的收获远超出预期。我个人最深的体会是:阅读器这种看似简单的工具,恰恰是把前端各类知识串起来最好的练习场。你既要懂浏览器存储,又要懂网络请求,还得处理各种不规范的第三方数据源,每当一个奇怪的订阅源导致解析失败,你总得想办法让程序变得更强壮。如果你也打算动手做类似的东西,我的建议是先从一条订阅源开始,让它真正跑通从抓取到展示的完整链路,再逐步扩展。不要一开始就追求对接几十个源,那样会把大量精力耗在边缘情况上。最后分享一个rea后续想加入的小功能:给已读和未读的文章做一个时间瀑布流统计,看看自己每天到底在哪些信息上花了多少时间。这个统计本身,也会成为我下一次打磨rea的起点。