
我说一个自己特别喜欢的极简小工具Caveman。名字很直白“穴居人”听起来像是石器时代的什么奇怪项目实际上它在开发者圈子里已经存在很久了核心功能就一句话——给 GitHub 的 commit 历史页加一个“已读/未读”标记。对你没听错就是这个看起来小到不能更小的功能却让每天高频刷 GitHub 动态的人舒服了不少。这篇文章我打算不只是介绍它而是把它的设计思路拆开然后带你手写一个同款“穴居人”工具讲清楚每一步为什么这么做顺带把我踩过的坑也一并说掉。适用的人很明确经常在 GitHub 上追仓库更新、看别人的提交记录、或者需要每天快速过一遍组织动态的人。你不用会写复杂的项目只要有一点 JavaScript 基础就行。看完这篇文章你可以得到一个属于自己的、不依赖任何服务的本地小脚本而且这个思路可以迁移到无数类似场景里。1. 标题拆解与需求本质为什么一个“穴居人”能活到今天1.1 Caveman 到底解决了什么问题先说一个场景。你跟进了几个活跃仓库每天打开 GitHub 的 commits 页面想看看朋友们又提了什么。麻烦马上来了GitHub 的动态页是瀑布式的你昨天看到第 3 页今天打开又是从最新一条开始你根本记不住上次看到哪了。来回翻页找“上次看到的位置”这种毫无技术含量的重复劳动每天都在消耗耐心。有人可能会说GitHub 不是有 notification 吗但我自己用下来通知里只有 issue、PR 这类互动消息commit 动态并不会帮你做成“已读/未读”状态。换句话说GitHub 官方并没有在这条浏览链路上给你提供“记忆”的能力。Caveman 就补上了这个缺口。它通过用户脚本的方式在每个 commit 行旁边注入一个“标记已读”的小按钮点击之后这条 commit 就被记录到本地存储里。刷新页面以后已读的 commit 会被折叠起来或者改变透明度你会非常直观地看到今天的更新只有那几条。整个工具的本质是给无序的动态信息流增加一个“位置指针”让你永远知道“我看到哪了”。1.2 为什么 GitHub 官方不做“已读标记”那为什么不直接等官方做这个功能呢现实点说这种需求对 GitHub 团队来说确实太小众了。做产品的人都知道任何功能一旦进入官方路线图就要考虑数据同步、跨设备一致性、多端 UI 适配、权限模型甚至隐私政策。一个“本地标记已读”的功能可能在内部评审时就会被质疑谁来存储存哪要不要同步到服务器这玩意儿值得吗这种产品逻辑我完全理解但个人开发者就没有这个负担。我可以把“已读状态”数据直接存在浏览器 localStorage 里不产生任何网络请求不依赖任何后端不用做登录授权。这种“自己用就行”的边界感正是个人小工具的优势所在。这一点也决定了工具形态的选择做成用户脚本而不是 Chrome 扩展也不是书签小脚本。用户脚本只要一个管理插件就能跑代码量小更新方便几乎不需要权限而且对 GitHub 这种特定网站的侵入性最小。Caveman 的整个存在就是“在别人的页面上给自己加一个折角书签”。1.3 “穴居人哲学”功能单一为什么反而好用“Caveman”这个名字起初让人觉得是粗糙、原始的意思但用久了之后我反倒觉得这是一种夸奖。它没有任何页面、没有配置面板、没有统计报表、没有云同步。你打开 GitHub看到一个多出来的小按钮点击就完了。这和现在动辄就要你登录、开工作区、自定义仪表盘的工具形成极大的反差。我自己的体会是个人效率工具最忌讳的就是“功能膨胀”。功能一多你以为你在提升效率其实你在消耗额外的认知带宽去维护工具本身。Caveman 的开发者在设计时显然刻意保持了克制我不管你的 commit 是哪个分支不管你有没有权限 review我也不猜你想不想看作者头像我就提供一个“已读”标记。这种极简哲学特别适合拿来复制到自己的工作流里。你现在如果想知道某个网站有没有更新、某个文档看没看完、某个列表有没有新增项先别急着找现成的看板应用也许一个几百行的用户脚本才是最优解。2. 核心运行原理它凭什么把 commit 变成“已读”2.1 用户脚本到底运行在什么位置要理解 Caveman先要理解它的运行环境。它并不是一个独立的浏览器扩展而是一个用户脚本通常配合 Tampermonkey 或 Greasemonkey 这类脚本管理器运行。你可以把它理解为浏览器在页面加载完之后额外帮你塞了一段 JavaScript 代码进去。这段代码可以操作页面上已经存在的 DOM。Caveman 的原理非常透明页面加载完成后它先检查当时的 URL 是不是 GitHub 的 commit 历史列表页如果是就在每条 commit 对应的 DOM 节点上做处理把符合“已读”条件的节点折叠并给未读节点添加一个操作按钮。这个运行机制决定了它的成本很低。你没有后台服务没有数据库你的“存储”就是浏览器的 localStorage页面一刷新JavaScript 再次运行再做一次同样的还原和渲染。整个过程可以说是“无状态”的页面是数据源localStorage 是记忆源脚本本身只是连接两者的胶水。2.2 localStorage 里到底存了什么localStorage 是一个键值对存储容量大约在 5MB 左右永久保存在浏览器里。Caveman 的核心数据模型其实非常简单用 commit 的 SHA 值作为唯一标识存储为一个列表。为什么用 SHA因为 GitHub 的每次提交都有一个全局唯一的 40 位十六进制哈希值。这个值可以是 commit 页面上链接目录的一部分可以是 API 返回的字段而且它天然稳定不会因为分支名变化、显示格式变化而改变。用 SHA 作为“已读”的凭证是最稳妥的选择。实际存储结构大致类似{ caveman_read_commits: { abc123def456abc123def456abc123def456abc1: 1699999999999, def456abc789def456abc789def456abc789def4: 1700000000000 } }每个 SHA 对应的值可以是时间戳用来记录“什么时候读的”。这样做有一个额外好处之后如果你想清理太久的已读记录可以按时间筛选不需要把整个存储一次删掉。有一点值得注意localStorage 存储的是字符串存对象时需要用 JSON.stringify 转换读取时再用 JSON.parse 还原。很多人第一次写用户脚本时都会忘记这一步控制台报错半天最后发现存进去的是 [object Object]。2.3 页面注入与状态还原是怎么配合的用户脚本的工作流基本是三步识别、注入、还原。识别阶段就是判断当前地址是不是https://github.com/*/commits/*或者/commit/*这种列表形态。这里不建议用绝对的、写死的方式判断因为 GitHub 的路径结构相对稳定但要考虑不同仓库名、不同分支名的情况用正则匹配比字符串 indexOf 要可靠得多。注入阶段是遍历页面上的 commit 容器元素。这一步在最开始写脚本的时候很简单因为页面刚加载DOM 还是静态的。但 GitHub 现在大量使用了异步加载和前端路由你可能要点开某个 commit 的详情不整页刷新数据就变了。这就需要用MutationObserver去监听列表容器的变化一旦有新节点插入就对新节点重新执行“渲染已读状态”的逻辑。还原阶段就是在已经读过的 commit 对应的节点上做视觉处理。最简单的方式是给节点加一个 CSS 类例如caveman-read然后在脚本里定义样式.caveman-read { opacity: 0.4; position: relative; } .caveman-read::after { content: 已读; font-size: 12px; color: #888; margin-left: 8px; }这里有一个细节GitHub 页面的样式是动态加载的用户脚本注入的style标签如果放在head里可能被后续加载的样式覆盖。解决办法是给选择器增加足够的特异性或者加上!important。想再稳妥一点可以直接用 JavaScript 设置element.style.setProperty(opacity, 0.4, important)来绕过样式优先级的问题。2.4 选择器别写死要找到“锚点”写用户脚本时最容易踩的坑就是选择器太脆弱。GitHub 改版频繁CSS 类名经常加前缀、换命名几个月前还好使的.commit-group-title可能一夜之间就变成了.react-app .commit-row-item之类的鬼样子。我的做法是优先找一些相对稳定的属性或者结构。commit 行里的a链接包含/commit/SHA这一小段路径结构是内容层面的而不是表现层面的改版时通常不会动它。也就是你需要遍历容器里的所有a[href*/commit/]从 href 里直接解析出 SHA再去 localStorage 里判断它是不是已读。这种“以内容为锚点”的方式比依赖 CSS 类名要稳得多。哪怕 GitHub 把整张页面换成了 React 渲染只要 commit 的链接结构不变脚本还能继续工作。我后来自己写的脚本基本都遵循这个原则能用 URL 里的信息就不用 DOM 里的样式类。3. 实操手写一个极简 GitHub 提交已读工具3.1 准备工作与安装环境这个项目不需要本地搭建 Node 环境也不需要安装任何依赖只需要一个浏览器和一个用户脚本管理器。我用的方案是 Chrome TampermonkeyFirefox 用户用 Violentmonkey 也没问题脚本语法本来就只是原生 JavaScript。安装流程三步先给浏览器装 Tampermonkey 扩展然后点击扩展图标选择“新建脚本”把下面的代码整体粘贴进去最后按CtrlS保存。Tampermonkey 会默认给脚本生成一个带元信息的模板你可以直接删掉从头写。脚本开头的元信息注释块非常重要它声明了脚本的名字、匹配的网址、运行时机等。比如没有match声明脚本可能根本不会在 GitHub 上运行你调试半天还以为是代码写错了。3.2 核心脚本代码与逐段讲解下面这个脚本是精简版本但已经完整实现了“标记已读”和“折叠已读”这两个核心功能。我把注释写详细一点你可以直接照着抄。// UserScript // name GitHub Commit Reader // namespace local.dev // version 1.0.0 // description 给 GitHub 提交历史加已读/未读标记 // author you // match https://github.com/*/commits/* // match https://github.com/*/commit/* // run-at document-end // grant none // /UserScript (function () { use strict; // 1. 存储键名 const STORAGE_KEY caveman_read_commits_v1; // 2. 读取已读集合 function loadReadSet() { try { const raw localStorage.getItem(STORAGE_KEY); const parsed raw ? JSON.parse(raw) : {}; return parsed; } catch (e) { console.warn([Caveman] 读取本地存储失败已重置, e); return {}; } } // 3. 保存已读集合防抖 let saveTimer null; function saveReadSet(set) { if (saveTimer) clearTimeout(saveTimer); saveTimer setTimeout(() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(set)); } catch (e) { console.error([Caveman] 写入本地存储失败, e); } }, 300); } // 4. 从链接中提取 commit SHA function extractShaFromLink(link) { const m link.match(/\/commit\/([0-9a-f]{7,40})/i); return m ? m[1] : null; } // 5. 给一条 commit 元素打“已读”标记 function markRowAsRead(row, sha) { if (row.dataset.cavemanMarked) return; // 防止重复处理 row.dataset.cavemanMarked 1; row.style.opacity 0.35; row.style.transition opacity 0.2s ease; const btn document.createElement(button); btn.textContent 已读; btn.style.cssText cursor:pointer;margin-left:8px;font-size:12px;border:1px solid #bbb;border-radius:4px;padding:2px 6px;background:transparent;color:inherit;; btn.disabled true; const actionSlot row.querySelector(.commit-title) || row; actionSlot.appendChild(btn); } // 6. 处理整个列表容器把已读的置灰给未读的加“标为已读”按钮 function processContainer(container, readSet) { const rows container.querySelectorAll(li, .commit, article, [class*commit]); rows.forEach((row) { const link row.querySelector(a[href*/commit/]); if (!link) return; const sha extractShaFromLink(link.getAttribute(href)); if (!sha) return; if (readSet[sha]) { markRowAsRead(row, sha); return; } if (row.dataset.cavemanInjected) return; row.dataset.cavemanInjected 1; const btn document.createElement(button); btn.textContent 标为已读; btn.style.cssText cursor:pointer;margin-left:8px;font-size:12px;border:1px solid #bbb;border-radius:4px;padding:2px 6px;background:transparent;color:inherit;; btn.addEventListener(click, () { readSet[sha] Date.now(); saveReadSet(readSet); markRowAsRead(row, sha); }); const actionSlot row.querySelector(.commit-title) || row; actionSlot.appendChild(btn); }); } // 7. 观察整个文档动态加载的新 commit 也能被处理 function startObserver(readSet) { const observer new MutationObserver((mutations) { for (const mutation of mutations) { const addedNodes mutation.addedNodes; for (const node of addedNodes) { if (node.nodeType 1 node.querySelector) { processContainer(node, readSet); } } } }); observer.observe(document.body, { childList: true, subtree: true, }); } // 8. 启动 function init() { const readSet loadReadSet(); processContainer(document, readSet); startObserver(readSet); } if (document.readyState loading) { document.addEventListener(DOMContentLoaded, init); } else { init(); } })();这段代码里面的关键点我分开说。首先看第 2 节和第 3 节这里没有直接用localStorage.setItem而是用了一个防抖函数原因是 GitHub 列表滚动时MutationObserver会高频触发如果你每来一个节点就写一次 localStorage页面会肉眼可见地卡顿。把写入操作聚合成 300 毫秒一次性能会好很多。再看第 5 节和第 6 节。标记已读之后我选择直接把按钮替换成“已读”字样并禁用而不是把按钮删掉。这样做的好处是视觉反馈清晰你知道操作成功了如果只是淡出但没有任何文字提示很容易怀疑脚本没生效。第 7 节的 MutationObserver 是整个脚本里最容易写错的部分。注意我递归监听了document.body并且是subtree: true。这样不管是点开分页加载、滚动懒加载还是 GitHub 前端路由切换页面只要列表区域有新节点插入都会被再次扫描处理。3.3 安装与功能验证保存脚本之后打开任意一个 GitHub 仓库的 commits 页面比如https://github.com/facebook/react/commits/main。你会看到每条 commit 标题后面多了一个“标为已读”按钮。点击其中一个按钮这条 commit 会立即变淡按钮变成“已读”文字。刷新页面你会发现刚才点过的那条 commit 直接变成了淡色状态而且按钮是“已读”。这就说明状态已经被正确持久化到 localStorage 了。再多测一步点开某条 commit 的具体详情页你会看到该 commit 的完整内容也被标记了“已读”。这里是因为我在元信息里也匹配了/commit/*路径详情页同样会被脚本扫描所以详情页内不会再出现“标为已读”按钮而是直接标记为已读。3.4 进阶只显示未读提交上面这个版本虽然能用但每次打开页面已读的 commit 列表还占着位置只是变灰了。如果你想更狠一点可以加一个“只看未读”的切换功能。实现思路很简单在页面顶部插入一个按钮和一个状态位。点击按钮后遍历所有已读节点给它们加上display: none再次点击则恢复。这个功能对每天要快速扫几十条更新的人来说非常实用本质上就是最简陋的过滤器。function toggleFilter() { const rows document.querySelectorAll([data-caveman-marked1]); const hidden document.body.classList.toggle(caveman-hide-read); rows.forEach((row) { row.style.display hidden ? none : ; }); }如果你经常看的是一个更新量特别大的仓库这个功能的价值甚至超过“标记已读”本身。因为你的视觉焦点不再需要在几十条灰色记录里找那几条新的了。3.5 进阶跨设备的本地同步方案localStorage 是浏览器本地的换一台电脑、换个浏览器已读状态就消失了。这其实不像大家想的那样是致命的缺陷因为对绝大多数人来说GitHub 浏览是个固定设备的习惯。但如果确实需要同步也有一个很轻的方案利用 GitHub 本身作为存储仓库。思路是每次写入已读集合后把这份 JSON push 到你自己建的一个私有仓库里的某个文件上启动脚本时先请求这个文件的 raw 内容拿不到就用上次的 localStorage 缓存最后以 API 为准。这里最麻烦的是要处理 GitHub API 的鉴权建议用个人访问令牌而且脚本必须明确提示用户令牌的权限范围和风险。这种做法显然超出了“穴居人”哲学的范畴我个人用得不多。但如果你真有跨设备需求我不建议你为了这个特意写一个后端服务直接用现成的云端存储文件就够了。4. 实际使用中的坑与排查实录4.1 GitHub 改版后脚本失效了怎么定位你要有一颗平常心GitHub 平均半年就会调整一次前端 DOM 结构。某一天你打开页面发现按钮不见了第一反应别是脚本被禁用了先去控制台看有没有报错。打开开发者工具在 Console 面板里输入localStorage.getItem(caveman_read_commits_v1)如果返回了 JSON 数据说明脚本运行过存储没丢问题一定出在 DOM 选择器上。接着在 Elements 面板里搜索commit看当前 commit 行的实际类名和结构。找到变化之后修改 script 里的row.querySelector和actionSlot选择器即可。个人经验是不要试图写一个“万能选择器”一劳永逸。你就让逻辑结构从“找列表项”变成“找带 commit 链接的统一锚点”即使页面结构改了链接结构大概率还是稳定的。4.2 localStorage 变大后性能下降你已经连续使用三个月之后本地存储里的 SHA 可能上万条。此时每次启动脚本时JSON.parse一个上万条属性的对象不一定会卡但如果遍历每个 commit 时都做一次属性查找再加上 MutationObserver 的频繁触发页面可能出现肉眼可见的迟钝。处理方式可以借鉴数据库的“冷热分离”不用把所有已读 SHA 都加载进内存。你可以把 SHA 存储改造为基础时间分桶例如按月份存储const readSet { 2025-10: { abc123: 1699999999999 }, 2025-11: {} };每次启动时只把最近三个月的桶加载到内存里三个月之前的记录只有在容器节点数量特别多的时候才临时查询。这样做以后即使你的使用跨度超过一年实际的内存占用也非常有限。如果你不想做分桶至少也要加一个清理机制超过 180 天的已读记录自动丢弃。大多数 repo 的 commit 你根本不会再回头看第二次。4.3 多标签页状态不同步开着两个 GitHub 标签页时左边这个点了一个 commit 已读右边那个页面可能还显示未读因为 localStorage 的变化只在当前标签页触发另一个标签页不会有任何提醒。解决方法是监听storage事件window.addEventListener(storage, (e) { if (e.key STORAGE_KEY e.newValue) { const newSet JSON.parse(e.newValue); // 重新渲染当前页面 document.querySelectorAll([data-caveman-marked1]).forEach((row) { row.style.opacity 1; const btn row.querySelector(button); if (btn) btn.remove(); delete row.dataset.cavemanMarked; }); processContainer(document, newSet); } });这个事件不是由当前页面自己触发的而是由其他标签页对 localStorage 的写入触发的。加入这段之后两个标签页之间基本上能保持实时同步不会有那种“我读了它却还亮着”的错觉。4.4 脚本和深色模式打架GitHub 的深色模式不是简单的背景变黑很多文字颜色、边框颜色都会动态切换。你给按钮设置的color: #333在浅色模式下没问题在深色模式下就变成看不见的深灰色了。最简单有效的办法是不要给按钮单独设置颜色让它继承父元素btn.style.cssText cursor:pointer;margin-left:8px;font-size:12px;border:1px solid currentColor;border-radius:4px;padding:2px 6px;background:transparent;color:inherit;;这里用currentColor代替固定色值按钮边框颜色会自动跟随文本颜色变化。深色模式下文本是浅色按钮边框自然就是浅色视觉上完全协调。这个小细节我第一次没注意到深色模式下一排按钮几乎隐形踩了相当尴尬的一坑。4.5 误标已读后怎么反悔因为没有做撤销所以误点后想恢复会比较麻烦。最简单的办法是打开控制台手动删除const set JSON.parse(localStorage.getItem(caveman_read_commits_v1)); delete set[这个SHA值]; localStorage.setItem(caveman_read_commits_v1, JSON.stringify(set)); location.reload();所以要提前做一个“一键重置已读”的入口不要等用出问题了再去找方法。在脚本里加一个右键菜单或者页面角落的小按钮每次点击前弹窗确认即可。一个顺手的小工具容错设计也得有。这算不上复杂但实际使用时会让你省心很多。5. 从 Caveman 延伸出去的思考个人效率小工具的边界5.1 需求很小的时候先动手而不是先找方案做 Caveman 这个级别的工具最难的不是写代码而是判断“这个需求值不值得做一个工具”。很多人会习惯性地打开应用商店搜索现成扩展发现没有就放弃然后继续忍受每天手工翻页。我越来越觉得这种小痛点的最优解就是自己动手半小时能写出来的东西不值得花两小时去找替代方案。你完全可以从一个最痛、最重复的操作开始。比如每天上班第一件事是打开某个后台页面看有没有待办那就可以写一个脚本把已处理列表折叠、高亮新增。这类需求有个共同点只影响你一个人不需要兼容别人所以实现方式完全自由甚至不需要考虑用户体验能帮你少点两次鼠标就已经值回票价。5.2 用户脚本、扩展、本地命令行工具怎么选如果你要改的是网页里的展示和操作体验用户脚本优先因为它轻、易改、和页面共享上下文。如果你希望多个网站复用逻辑或者需要在浏览器后台静默运行扩展更合适。如果你是处理本地文件、批量文本替换、监控目录这类非网页任务写一个 Node 脚本比折腾浏览器无限循环要快得多。我自己的分界线很简单是否依赖页面上明确可见的元素依赖页面元素用用户脚本不依赖页面元素用本地命令行或脚本。Caveman 就是第一种情况的完美样品——它只做 DOM 操作和本地存储完全不需要自己的界面。5.3 给个人工具留一条退路Caveman 这种脚本最容易被忽略的问题它和某个网站深度耦合网站一更新你就得跟着更新。所以个人效率工具一定要有“退路意识”。退路不是说出版本控制就行而是你的核心数据不要被工具绑架。已读数据存 localStorage 是一个很好的决定因为它是普通 JSON你可以导出。哪怕脚本彻底失效记忆还在。如果哪天我不想再用 GitHub 的网页版我完全可以把这个本地存储里的数据导出来写个小程序批量分析自己最近的阅读重心。这个思路推而广之任何个人工具都要确保数据是开放格式不绑定某个工具商、不绑定网络协议。你今天写的是一个点击按钮的小脚本但积累下来的数据可能成了你个人工作流里最有价值的资产。这恐怕是比我写的代码本身更值得记住的一点。