首页 / 资讯中心 / 文章详情

从零构建自托管稍后读工具:技术选型、架构设计与实践踩坑

从零构建自托管稍后读工具:技术选型、架构设计与实践踩坑 ★ FEATURED ARTICLE
1. 先拆需求这种工具到底该怎么设计1.1 为什么需要自己写一个收藏工具项目代号叫 rea是我随手起的名字。起完之后我一直没换因为它正好把三个动作拼在一起read、record、archive翻译过来就是“读取、记录、归档”。这个歧义其实挺有意思——它既像一个名词也像一个待办事项的缩写做阅读工具反而再合适不过。先说背景。我是一个收藏重度用户但收藏夹本身是有瓶颈的。浏览器书签栏里的链接收藏的时候很爽恢复的时候则完全是另一个心态——看到一堆标题根本想不起来当时为什么存它。手机上更是如此稍后读应用装了一堆能坚持用的没几个。有的要登录有的要订阅有的收藏列表短得不行有的把原文排版改得稀烂。我需要的其实很简单看到一个长文链接能快速存进去想读的时候能在一个干净的页面里直接读完读完以后能打个标签归档回头随时能翻出来。我不需要花里胡哨的算法推荐也不需要和几十个陌生人共同维护一个公开列表。这类东西自己写个工具反而更贴合需求rea 就是在这样的背景下动手的。如果你恰好也有类似的困扰这篇文章可以直接当一份参考方案来用。它讲的不只是一个项目的落地过程更是一套“如何为自己的信息消费场景搭一个顺手工具”的思考路径。1.2 MVP 边界怎么划所有个人工具最大的坑就是边做边加需求。我第一天原本想做的东西包括自动抓取正文、多平台分享、手机 App、浏览器插件、AI 摘要、OCR 识别……如果全做完这个项目可能到现在还在开发。后来我给自己划了一条非常严格的 MVP 边界只保留三件核心事保存把一条链接和它的标题、摘要、原文内容收进来阅读打开保存的文章像看一篇排版良好的本地文档一样直接读归档用文件夹和标签做简单分类能搜能筛能删其余功能一概不碰。理由很简单个人工具的最终目标是降低“收藏-阅读”这个链路的心智成本。每多加一个功能就多一层复杂度也就多一个劝退自己的理由。A同学后来问我为什么不做浏览器插件我当时的回答是插件的确方便可插件要处理浏览器版本兼容、权限更新、不同平台行为差异一个 MVP 阶段就引入这些纯属给自己找罪受。先用网页版把链路跑通如果真到了天天用、离不开的程度再回头做插件也不迟。1.3 目录与命名从第一天就定好另一件很容易被忽视的事是项目目录结构。个人项目刚开始的时候总觉得“就我自己看随便点没关系”等到想加功能的时候才发现代码堆在一起完全不想动。rea 从第一天就定了非常清晰的目录规则前端归前端、后端归后端、文档归文档。rea/ client/ # 前端项目 server/ # 后端服务 docs/ # 设计文档、使用说明这个划分看起来简单实际意义很大。它强制我每次写代码都先想“这行代码属于哪一层”不会出现把请求逻辑直接写在组件里的那种“一张大饼”式写法。后面扩展的时候哪个目录需要改一目了然连 git 提交信息都干净很多。2. 技术选型的取舍逻辑2.1 前端为什么用 React 而不是直接写 HTMLrea 的前端用的是 React具体脚手架是 Vite。说实话一个只用来“展示文章列表 阅读正文”的工具用原生 HTML 加少量 JavaScript 也能做出来但体验完全不是一个级别。列表页和详情页之间需要共享文章状态搜索时要实时过滤列表阅读时要做标签切换和归档操作这些交互如果都靠手动操作 DOM代码很快会变成一坨只有自己看得懂、过两周自己也看不懂的乱麻。React 的声明式写法在这里最大的价值不是“框架更流行”而是“状态管理起来更省心”。页面长什么样只由数据决定数据变了页面自动跟着变我不需要关心某个按钮应该改哪几个节点。Vite 的选择也经过了比较。最初我也考虑过 Create React App但那个方案的开发服务器启动速度实在堪忧尤其在机器上没装多少缓存依赖的时候冷启动能等到怀疑人生。Vite 基于原生 ES 模块开发时按需编译保存代码后的热更新基本是秒级体感上完全不一样。构建产物也比较干净不需要额外配一堆优化参数。2.2 后端为什么用 Node 而不是 Python项目标题是“rea”我做的事是“记录和读取”后端逻辑其实很薄基本就是一组 CRUD 接口再加一点正文抓取。按照国内常见的做法很多人会顺手选 Python 加 Flask 或 FastAPI。我最后用了 Node核心原因只有一个前后端语言统一心智负担小。Node 生态的 Express 框架我用得比较熟路由写法和中间件逻辑都很直接。而且前后端都是 JavaScript类型共享很方便不用维护两套数据定义。举个例子文章内容的数据结构我在前端定义一个字段集合后端接口返回的结构可以完全对齐遇到字段调整时前后端各改一次即可不会有跨语言序列化的坑。有人可能会说Python 的数据处理和抓取生态更丰富。这句话没错但 rea 的场景里我是读取别人页面里的正文不是做大规模清洗或分析Node 自带的 HTTP 请求能力配合正则表达式和简单的 HTML 解析就足够了。工具选型从来不是选“最强”的而是选“最顺”的。2.3 存储为什么选 SQLite数据库的选择是 rea 里我最想重点聊的部分。很多个人项目一上来就上 MySQL 或 PostgreSQL动辄配一个数据库服务还得惦记账号权限、端口暴露、备份策略。但 rea 的使用场景极其简单单用户、低并发、数据量大概在几千篇到几万篇文章之间。这种体量SQLite 单文件数据库完全能够胜任而且零配置库文件就在项目目录里备份时复制一份就行不怕丢。我在项目里也对比过更轻的 JSON 文件存储方案。方案的问题在于并发写入时容易互相覆盖而且数据多起来以后每次查询都要全量加载文件再内存里过滤性能完全不可控。SQLite 的好处恰恰是“看似朴素但该有的都有”支持索引、支持事务、支持 SQL 查询数据可靠性远高于手写 JSON 文件。对于几百篇文章这种规模SQLite 的查询速度几乎感受不到延迟。当然SQLite 也有它的边界它不适合多进程高并发写入也不适合需要复杂权限控制的多用户场景。但 rea 的目标场景完美绕开了这些雷区选它就顺理成章。2.4 API 设计和目录结构rea 的后端 API 设计遵循了非常经典的 RESTful 思路资源就是“文章”动作围绕文章的增删改查展开。方法路径作用POST/api/items新增一篇收藏GET/api/items获取文章列表支持分页和筛选GET/api/items/:id获取单篇文章详情PATCH/api/items/:id更新文章信息标题、标签、文件夹等DELETE/api/items/:id删除文章前端和后端之间通过 JSON 通信字段命名统一采用小驼峰比如folder、createdAt、readingTime避免出现前后端字段对不上的情况。服务端目录同样做了模块划分server/ src/ controllers/ # 路由分发层 services/ # 业务逻辑层 repositories/ # 数据访问层 routes/ # 路由定义这种分层是我个人项目中比较推荐的写法。控制器只负责拿参数、调服务、返回结果服务层处理业务规则比如“添加文章时自动去重”“更新标签时同步刷新计数”数据访问层只做 SQL 操作。缺点是文件数量会多一些但好处是后续加功能的时候我永远知道代码该写在哪里不存在“不重要的私有项目可以随便乱写”的问题。3. 核心功能从 0 到 1 的实操过程3.1 保存动作从一条链接到一篇可读文本rea 最核心的保存逻辑是用户把一条链接粘贴到输入框系统自动抓取这个链接对应的页面提取出标题、摘要和正文内容最后落库整个过程手动参与越少越好。这里最麻烦的部分是提取正文。不同网站的页面结构差异极大有些文章正文直接在一个article标签里有些嵌了好几层div有些页面打开后要等待几十毫秒脚本渲染才有内容。我最初尝试了纯正则匹配的方式踩了不少坑有的站点正文里有大量广告模块有的把评论区也算进了正文。后来我在解析时做了三个层面的处理优先寻找article或main语义化标签取其内部文本如果没有这些标签则统计页面里所有文本块的长度只保留最长的连续文本块去掉脚本标签、样式标签、隐藏元素display: none和导航区域常见关键词命中的块抓取时还要带上合适的请求头。有些站点对非浏览器的请求区分对待直接返回一个空壳页面。我解决的办法是在请求头里设置类似浏览器的 User-Agent并且遵循目标站点根目录下声明的抓取规则——如果对方明确表示不希望被采集就放弃自动抓取只保存链接和手工摘要。这个原则不只是为了合规更是为了少惹麻烦。我的工具是我的工具但尊重对方站点的意愿是一种基本的网络文明。保存完成后还会做一项“内容去重”如果数据库里已经存在同一条链接前端会给出友好提醒不再重复插入。这个功能我用 SQLite 的UNIQUE约束加应用层检查双重保障避免高并发下出现重复记录。下面是我在服务端实现新增文章的核心代码逻辑// 服务端保存文章逻辑经简化 async function saveItem(req, res) { const { url, folder, tags } req.body; // 基础校验 if (!url) return res.status(400).json({ error: url is required }); // 检查重复 const existing await db.get(SELECT id FROM items WHERE url ?, [url]); if (existing) return res.status(409).json({ error: duplicate url }); // 抓取并解析页面 const parsed await parsePage(url); // 写入数据库 const result await db.run( INSERT INTO items (url, title, excerpt, content, folder, tags) VALUES (?, ?, ?, ?, ?, ?), [url, parsed.title, parsed.excerpt, parsed.content, folder, JSON.stringify(tags)] ); res.json({ id: result.lastID }); }需要说明的是这里的parsePage内部逻辑远比示例复杂比如要对抓取到的 HTML 做编码判断、转换相对链接为绝对链接、计算预估阅读时间等。但这些都属于锦上添花的细节先把保存链路跑通才是最重要的。3.2 列表、阅读与归档的交互实现列表页是整个工具的主入口承载了“快速浏览”的任务。rea 的列表设计参考了主流稍后读产品的布局左侧是文件夹树中间是文章卡片列表右侧是预览面板。点击列表中的一篇文章右侧立刻展示正文不需要跳转到新页面。文章标题、摘要、文件夹、标签、保存时间和预估阅读时常都直接展示在卡片上一眼扫过去就能判断这篇要不要现在读。列表的排序规则很有讲究。短期阅读的体验和长期管理的体验其实是矛盾的所以我做了两层设计默认按保存时间倒序排最新收藏的优先展示同时支持按阅读状态筛选还做了一个“说人话”的排序选项——按预估阅读时长排序。这个选项用起来很舒服因为它解决了一个真实痛点晚上只有十五分钟空闲不知道读哪篇选“最短优先”就行挑一篇短文章轻松读完比每次都得一篇篇滚动找有成就感得多。阅读页面的排版是重头戏。正文宽度我固定控制在约 720px每一行的平均字数在 35 到 45 个汉字之间这是目前公认的连续阅读舒适区间字体用系统默认的 sans-serif 族正文的行高设置为 1.8段间距明显大于行间距不知道的人会觉得这是细节控项目其实只是按照大多数阅读类产品的参考规范来做的。原文章内如果有图片会把图片宽高限制在阅读容器内防止溢出。归档操作设计了两种方式一种是在卡片上点击文件夹下拉菜单直接移动另一种是在详情页里打标签。归档的本质其实只是给文章加一个元数据标记rea 把它做成了“拖到某个文件夹”这样符合直觉的动作。这个过程中也有一个让我意外的地方最初我以为阅读量是核心指标后来才发现“归档率”更重要。一个收藏工具如果只进不出收藏夹会越来越乱。所以 rea 在列表底部增加了一个“已完成”标签页鼓励读者把读完的文章主动归档减少未读列表的积压感。这个小小的交互改变让我使用工具的主动性提升了不少。3.3 搜索和标签这些“看起来简单”的功能搜索和标签看上去是最基础的功能但细节里有不少坑。rea 的搜索机制分两层第一层是标题匹配第二层是正文全文检索。标题匹配我用 SQLite 的LIKE查询实现速度很快正文搜索则只能保守一点因为在大段文本里做模糊匹配的开销确实不小哪怕数据量只是万级也会因为扫描全表而出现明显的卡顿感。我用了一种折中的办法为用户提供一个“只搜标题”的默认开关正文搜索作为可选项。这样既保证了绝大多数场景下搜索的即时响应又保留了大范围检索的能力。如果需要真正强大的全文检索SQLite 自带的全文索引FTS扩展是后续迭代中一个非常值得考虑的方向。标签的实现也踩了坑。一开始我用文本字段保存标签存a,b,c查询的时候再用LIKE匹配。表面上看没问题但一旦要统计某个标签下有几篇文章事情就变得非常别扭。后来我把标签拆成一个独立表用多对多关联来管理CREATE TABLE tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL ); CREATE TABLE item_tags ( item_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (item_id, tag_id) );这样统计、筛选、重命名标签都变成了常规 SQL 操作后续做标签自动补全和标签合并也方便得多。如果你也在做类似功能千万不要用逗号拼接这种省事方案后面治理起来真的会想哭。4. 部署与真实踩坑记录4.1 部署方案选择rea 部署起来并不复杂。前端构建产物是一堆静态文件后端是一个轻量的 Node 进程数据库就是一个.db文件。最终的部署方案是用系统服务托管 Node 进程由 Caddy 处理域名转发和 HTTPS 自动配置前端构建好的静态文件交给后端统一提供。这个思路放弃使用 Nginx理由不是 Nginx 不好而是个人项目的服务器资源本身就有限Caddy 配置更短一句话就能搞定域名和证书省去了手动放证书、续签证书的麻烦。部署过程中踩过的坑也值得一提。第一次上线时我忘了对 Node 进程做异常退出后的自动重启结果某天它因为一次数据库查询的锁竞争崩掉了导致第二天才发现服务挂了。后来我把托管方式换成了系统级服务并设置了自动重启策略才避免了“服务默默挂掉一整天”的尴尬。下面是一份可直接参考的系统服务配置文件[Unit] Descriptionrea server Afternetwork.target [Service] WorkingDirectory/srv/rea/server ExecStart/usr/bin/node src/index.js Restartalways RestartSec3 EnvironmentNODE_ENVproduction [Install] WantedBymulti-user.target通过systemctl enable rea设置开机自启之后理论上这个服务就算部署完成了。个人项目够用。4.2 查询变慢的排查rea 用了一段时间以后我某次打开列表体感上竟然出现了将近一秒的延迟。按理说几百篇文章不至于这么慢于是我做了一次简单的排查。打开 SQLite 控制台执行EXPLAIN QUERY PLAN一看问题一目了然列表查询里WHERE folder ? ORDER BY createdAt DESC没有走索引。表结构里唯一的主键是id而查询条件中的folder和排序字段createdAt都没有对应的索引。文件夹是低基数字段单独建索引效果一般但配合createdAt做联合索引查询效率能提升不少CREATE INDEX idx_items_folder_created ON items(folder, created_at DESC);建好索引以后列表查询的耗时立刻降到了几十毫秒体感从“卡顿”变成了“秒开”。这也提醒我SQLite 虽然简单但该建索引的地方不能省尤其当表里的记录数量开始增长查询条件里又带了排序字段时联合索引是不折不扣的刚需。4.3 常见问题速查表做 rea 的过程中我遇到过不少让人挠头的问题整理成表格方便你对照排查问题现象可能原因解决办法抓取的文章正文为空目标站点由前端动态渲染直接请求拿不到内容转为只保存链接和手工摘要不做高难度渲染抓取中文内容保存后是乱码页面编码声明不明确默认用了错误编码解析优先读取响应头 charset其次匹配 HTML 中的 meta 声明访问稍多时数据库报锁错误多个写入操作同时触发 SQLite 写锁写入采用串行队列避免并发事务同时写库列表加载变慢查询未走索引或全表扫描用EXPLAIN QUERY PLAN定位问题并补充联合索引某些站点图片无法显示原站点做了防盗链处理阅读时通过代理中转图片地址或直接保留原文链接供点击跳转其中乱码问题最耗费时间。最开始我拿到一篇中文文章保存到数据库里以后页面上看到的全是“锟斤拷”级别的乱码。排查后发现那个页面在响应头里没有声明编码HTML 元信息也只说自己是GB2312而解析器没有优先读取这些信息默认按UTF-8处理自然就乱了。修复方案也简单先读响应头再读 HTML 里的meta charset都找不到才用默认值。图片防盗链这个问题没有特别好的根治办法。rea 的兜底方案是如果在渲染正文时发现src指向外部域名就补一个简化处理——成功加载就展示失败就保留一个跳转链接点击后在浏览器新标签打开原文绕过限制。这个方案不完美但以个人工具的标准来衡量体验已经是可接受的了。5. 复用与扩展5.1 后续值得做的扩展方向rea 现在只是一个可以自部署的个人阅读工具但它背后的结构给了不少扩展空间。我个人比较看好的几个方向按优先级排列如下浏览器插件把“复制链接-打开 rea-粘贴保存”三步压缩成“点一下扩展图标”一步。这是最直接提升收藏效率的改造。全文检索升级引入 SQLite 自带的 FTS5 全文索引支持中文分词会更难一点但若能做通正文搜索的体验能提升一个台阶。统计面板记录每天收藏几篇、读了哪些、归档率多少。这类数据对个人阅读习惯的复盘非常有价值。导入导出支持通用网页书签格式和 Markdown 格式的批量导入导出避免被数据锁死。阅读进度同步一篇文章读到一半退出下次进来还能从原文位置继续。这个功能如果配合加密云存储可以做多端同步。扩展方向虽然多但我的建议是每一步都小步快跑做完了确实觉得提升明显再考虑下一步。5.2 最后一点个人建议如果你也想复刻一个类似 rea 的项目我最想说的不是技术选型也不是代码结构而是“先把这个工具填满真实数据”。很多个人项目死在空数据阶段因为界面上一片空白你看不出任何问题也觉得没有打磨的动力。我是在连续一周每天保存十几篇文章之后才开始真正体会 rea 哪里用得顺手、哪里别扭的。一个工具只有被你自己频繁使用才有资格被称作“趁手”。还有一个小技巧每次往 rea 里保存文章时顺手写一句话摘要。别小看这个习惯几个月后再回看时那张列表简直就像个人阅读史比任何算法推荐都懂你。
阅读完成 · 觉得有帮助?
咨询建站