我给自己写了一个本地 RSS 阅读器:安静、不焦虑、UI 全自定义实录
目录
- 缘起:为什么我不再用任何云端阅读器
- 需求拆解:从"能看文章"到"读得舒服"
- 技术选型:浏览器 + 一个 OPML 文件就够了
- 核心实现一:RSS 解析与去重
- 核心实现二:稍后读与已读状态
- UI 定制:阅读器也是"自己的书房"
- 三个让我卡了一阵的坑
- 结尾:信息消费的"主动权"才是奢侈
1. 缘起:为什么我不再用任何云端阅读器
我订阅了几十个 RSS 源——技术博客、独立思考者、几个新闻站。原本是为了"主动获取信息",但用着用着变味了:
- 云端阅读器要登录、要同步、哪天收费策略变了订阅就废了;
- UI 拥挤,未读数红点、推荐位、"今日热门"抢走注意力;
- 算法推荐开始介入,"你可能感兴趣"悄悄替换了我自己选的订阅源。
我忽然意识到:我订 RSS 就是为了"我自己决定读什么",不是为了"被推荐读什么"。既然现成阅读器都在背离这个初衷,那就自己写一个。
2. 需求拆解:从"能看文章"到"读得舒服"
把"做一个 RSS 阅读器"拆成可验收清单:
| 诉求(人话) | 拆解后(可验收) |
|---|---|
| 能订阅 | 导入 OPML 或手动添加源,本地存储订阅列表 |
| 能抓取 | 定时拉取各源最新文章,解析标题/链接/时间/摘要 |
| 不重复 | 文章按 GUID/链接去重,已读状态持久化 |
| 能读 | 三栏布局:源列表 / 文章列表 / 正文阅读 |
| 稍后读 | 标星文章单独列出,跨设备导出/导入 |
| UI 我说了算 | 字体、行宽、暗色模式、布局比例可自定义 |
这张表决定了阅读器的骨架。功能不多,但每一项都做到位——这是我对所有"自己用的工具"的一贯要求。
3. 技术选型:浏览器 + 一个 OPML 文件就够了
我犹豫过要不要上 Electron,但仔细想需求——我不需要系统级后台抓取、不需要托盘通知,浏览器 + setInterval 完全够用:
| 能力 | 方案 |
|---|---|
| 订阅导入 | 解析 OPML(XML 格式) |
| 抓取 RSS | fetch + CORS 代理兜底(部分源不支持跨域) |
| 解析 | 自写 RSS/Atom 解析器,约 50 行 |
| 存储 | IndexedDB 存文章 + 订阅列表 |
| 渲染正文 | 提取全文 RSS 或 iframe 嵌入原文 |
整个阅读器就是一个 HTML 文件,双击就能开。没有安装包、没有更新提示、没有后台进程——订阅源列表就是一个 OPML 文件,换机器复制走即可。
心得:选型时先问"我到底需要什么"。本地小工具的最高境界是双击即用、关掉即走。
4. 核心实现一:RSS 解析与去重
RSS 解析听起来复杂,其实格式很规整。一个最小的解析器:
function parseRSS(xml) {
const doc = new DOMParser().parseFromString(xml, 'text/xml');
return [...doc.querySelectorAll('item')].map(item => ({
title: item.querySelector('title')?.textContent,
link: item.querySelector('link')?.textContent,
pubDate: new Date(item.querySelector('pubDate')?.textContent),
summary: item.querySelector('description')?.textContent,
guid: item.querySelector('guid')?.textContent,
}));
}
去重是关键。每篇文章用 GUID 优先、链接兜底 作为唯一键:
const key = article.guid || article.link;
if (seen.has(key)) return; // 已抓过,跳过
seen.add(key); articles.push(article);
两个细节值得说:
- Atom 格式要单独处理。
<entry>而不是<item>,<updated>而不是<pubDate>——一个解析器同时兼容 RSS 2.0 和 Atom,能少踩一半坑; - 抓取失败要静默。某个源暂时挂了不应让整个阅读器崩,记下"上次抓取失败"展示给用户即可。
5. 核心实现二:稍后读与已读状态
阅读器的状态管理就两件事:已读和标星。都存在 IndexedDB 里:
// 文章 id → { read: bool, starred: bool, readAt: timestamp }
const state = await db.get('state', articleId) || {};
state.read = true;
state.readAt = Date.now();
await db.put('state', state);
几个让体验"舒服"的细节:
- 已读文章不消失,只是变灰。很多阅读器"已读即隐藏",让人焦虑——“我是不是漏了什么”。变灰保留可见,主动权在用户;
- 标星文章跨源聚合。"稍后读"视图不分来源,按标星时间倒序——这是 RSS 阅读最常用的入口;
- 未读数只显示在源名右侧,不弹通知。红点足够提示,通知是打扰。
心得:阅读器的核心指标不是"功能多",而是"用户能不能控制自己读还是不读"。任何"替用户决定"的设计,都是 RSS 初衷的背离。
6. UI 定制:阅读器也是"自己的书房"
本地工具最大的特权,是 UI 完全我说了算。我给自己定了几个商业软件不会这么做的设计选择:
- 三栏布局:源 / 列表 / 正文,比例可拖拽,默认 1:2:5,正文占大头;
- 正文区固定行宽(max-width 680px),长文阅读不费眼;
- 暗色为主,跟随系统。夜间阅读不刺眼;
- 无未读数徽章在应用图标上。不制造"必须打开处理"的压力。
这些设计在商业产品里几乎不可能出现——它们太"个人化"了。但本地工具的意义,恰恰就是让软件贴合你的阅读习惯,而不是让你迁就软件的功能预设。
7. 三个让我卡了一阵的坑
坑一:CORS 拦截抓取。 很多 RSS 源不支持跨域 fetch,浏览器直接报 CORS 错。解决办法是走一个公共 CORS 代理兜底,或者干脆做一个轻量后端转发——本地工具也要预先想好"浏览器限制兜底方案"。
坑二:RSS 时间格式不统一。 RSS 2.0 用 RFC 822(Wed, 02 Oct 2026 13:00:00 GMT),Atom 用 ISO 8601(2026-10-02T13:00:00Z)。统一用 new Date() 解析大部分能过,但个别源会写本地时区不带标识,排序错乱。教训:解析时间后统一存时间戳,展示时再按本机时区格式化。
坑三:订阅源"死链"。 几个月后总有几个源不再更新或域名失效。我加了一个"最近 30 天无新文章"的提示,让用户自己决定是否清理——工具不替你删除,但替你看见。
8. 结尾:信息消费的"主动权"才是奢侈
做完这个阅读器,我对"信息消费"有了一种更深的体会:
- 它不抢你注意力。没有推荐位、没有红点通知、没有"今日热门";
- 它尊重你的订阅。源列表是你的 OPML 文件,不是平台账号里的数据;
- 它尊重你的节奏。已读不隐藏、未读不催促、标星不丢失。
在这个所有软件都在用算法替你决定读什么的时代,一个"只读你自己选的源、按你自己节奏读"的本地工具,反而成了一种奢侈。
如果你也订阅了 RSS、也想要一个"安静的阅读器",我强烈建议自己写一个。技术门槛比想象中低,一个 HTML 文件就够;真正难的是克制住加功能的冲动——不做推荐、不做社交、不做"发现",就做一个老老实实的本地阅读器。
工具小而静,价值往往不在多,而在"它永远在那里,永远不替你做决定"。
- 点赞
- 收藏
- 关注作者
评论(0)