cutting-board 实跑:一个剪切板悬浮窗技能包与它的配额降级问题
cutting-board 实跑:一个剪切板悬浮窗技能包与它的配额降级问题
一、这个技能包做什么
给一个 Web 前端增加「剪切板历史」能力:在页面输入框里输入 显示剪切板 / 打开剪贴板 之类触发词回车,弹出一个可拖动的常驻悬浮窗,自动记录用户复制剪切的内容,全局去重、上限 200 条、localStorage 持久化、刷新页面不丢失。
整个能力是一个"技能包"——不改动宿主代码,只往宿主前端目录注入 4 个源文件 + 一个 5 行的钩子(改一处 hub.ts),然后触发宿主自己的 TypeScript 构建流程。技能包本体一共 5 个文件,加起来不到 50 KB。
我把它 clone 下来,用一个自己写的 Node.js 测试脚手架真跑了一遍。测试没跑在浏览器里(没有真实 DOM),但核心算法层的 12 条断言全部通过,还在存储层的降级路径里发现一个值得单独讨论的问题。
二、五个文件怎么协作
assets/
├── history.ts # 状态存储 + localStorage + 全局去重
├── recorder.ts # 页面 copy/cut 监听 + 系统剪切板轮询
├── panel.tsx # 可拖动悬浮窗 UI(React)
├── panel.module.css # 悬浮窗样式
└── hub-sink.patch.md # hub.ts 手动补丁说明
scripts/
└── install.mjs # 一键安装脚本
数据流:recorder.ts 从两处采集剪切板事件(页面 copy/cut 事件 + 系统剪切板 800ms 轮询)→ 调用 history.ts 的 recordClipboardEntry → 通过外部订阅机制(subscribeClipboardHistory)广播给 panel.tsx → React 用 useSyncExternalStore 消费快照 → 悬浮窗重渲染。
整个流程没有任何跨模块的全局变量,只有三个纯函数式入口:subscribeClipboardHistory(listener) / getClipboardHistory() / recordClipboardEntry(kind, text)。这是标准 external-store 协议。
三、真跑测试:12 条断言
我搭了个 Node.js 24 测试脚手架(tsx 加载 TypeScript、mock localStorage),把 history.ts 的核心行为逐条测过。下面各段是断言输出摘要,$ 开头的行为调用示意,不是逐字 shell 会话。
Test 1:新条目置顶
$ recordClipboardEntry('copy', 'hello world')
$ recordClipboardEntry('copy', 'second entry')
$ recordClipboardEntry('cut', 'third')
history: [
{ text: 'third', kind: 'cut', ... }, // 最新在 [0]
{ text: 'second entry', kind: 'copy', ... },
{ text: 'hello world', kind: 'copy', ... } // 最旧在 [2]
]
ok: 3 entries after 3 records
ok: newest is at [0]
ok: oldest kept its original kind
Test 2:全局去重——同一文本再记录时,不新增行,把旧条目移到顶部并刷新 kind 和 time:
$ recordClipboardEntry('cut', 'hello world') // 已有条目
history: [
{ text: 'hello world', kind: 'cut', time: <new> }, // 移到顶,kind 变了
{ text: 'third', kind: 'cut', ... },
{ text: 'second entry', kind: 'copy', ... }
]
ok: no duplicate row
ok: moved to top
ok: kind refreshed to the new kind
ok: time refreshed
Test 3:字符上限截断——超 20,000 字符的内容被截断,并置 truncated: true:
$ recordClipboardEntry('copy', 'x'.repeat(20001))
entry[0].text.length = 20000
entry[0].truncated = true
ok: text truncated to 20_000 chars
ok: truncated flag set
Test 4:条目数上限——250 条记录后只剩 200 条:
$ for i in 0..249: recordClipboardEntry('copy', `entry-${i}`)
history.length = 200
history[0].text = 'entry-249'
history[199].text = 'entry-50'
ok: capped at 200
ok: newest kept
ok: oldest survivors are entry-50..249
Test 5:localStorage 持久化——200 条记录后 localStorage 里是 15,306 字节合法 JSON,反序列化回 200 条。
Test 6:空内容过滤——'\r\n\r\n' 这种 trim 后为空的内容被静默丢弃。
Test 7:触发词匹配——CLIPBOARD_TRIGGERS 是 8 个精确短语的 Set,isClipboardTrigger 只匹配 trim 后完全相等的短语:
isClipboardTrigger('显示剪切板') → true
isClipboardTrigger(' 打开剪贴板 ') → true // 前后空白被 trim
isClipboardTrigger('剪切板 历史') → false // 中间空白不 trim
isClipboardTrigger('hello') → false
Test 8-10:按 id 删除、id 格式(c<seq>-<base36 timestamp>)、订阅/退订全部通过。
12 条断言全绿,核心算法层没问题。
四、一个真实问题:超配额时内存被砍半
跑通主流程以后,我又往深处看了一个非典型场景:localStorage 超配额时,存储层的降级策略会怎样。
配额降级先砍内存
history.ts 的 persist() 有段降级逻辑:写入失败时把内存条目数砍半、重试一次:
function persist(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
} catch {
// Storage quota: keep roughly half the history and retry once
entries = entries.slice(0, Math.max(1, Math.floor(entries.length / 2)))
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
} catch { /* memory-only from here */ }
}
}
注释写的是 "keep roughly half the history and retry once",看起来只是在磁盘上少存一半;但代码实际上同步修改了内存——entries.slice(0, ...) 直接把数组截断了。这意味着一旦任何一次持久化抛 QuotaExceededError(比如浏览器配额被其他站点占满了),内存里的历史就当场减半。
我在测试里模拟了这个场景:
$ // 先塞满 200 条
$ for i in 0..199: recordClipboardEntry('copy', `e${i}`)
$ history.length = 200
$ // 现在把 localStorage 切成超配额模式
storeMode = 'quota'
$ // 加第 201 条
recordClipboardEntry('copy', 'new-entry-under-quota')
$ history.length = 100 // 从 201 直接掉到 100
$ history[0].text = 'new-entry-under-quota'
$ history[1].text = 'e199' // 第 100-199 位全部丢失
新条目还在(因为 slice(0,100) 保留了前 100 位,恰好包含新条目),但第 100 到第 199 位的 100 条历史在当前会话里已经没了。这里要分两种情况:如果第二次写入成功,localStorage 会被减半后的数组覆盖,丢掉的条目刷新也回不来;如果第二次写入仍然失败,localStorage 里还留着原来的 200 条,刷新页面能恢复,但当前会话里已经少了 100 条。
再试一次会再砍半吗?看代码:第二层 try 里如果又失败,直接进 catch { /* memory-only from here */ } 什么都不做。所以每次新增条目都会重新触发 persist(),第一次超配额减到 100,第二次超配额减到 50,直到减到 1 条为止。用户在当前会话里看到的现象是"剪切板历史莫名其妙越来越短",而且整个过程不报错。
正确做法是先尝试写入失败再截断,或者把内存视图和磁盘视图分离——内存保持完整,磁盘可以少存一半:
function persist(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
return
} catch {
// 只在磁盘视图里砍半,内存保持完整
const trimmed = entries.slice(0, Math.max(1, Math.floor(entries.length / 2)))
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
} catch { /* retry on next persist */ }
}
}
或者干脆把 slice 挪到 recordClipboardEntry 里,在添加新条目之前就检查配额:把 200 条降到 150 条再添加,避免撑爆。
五、一个微基准
顺手跑了个微基准,看每次 recordClipboardEntry 到底要花多久(Node.js 24、tsx 加载、localStorage 用内存 stub):
N = 1000 records (with JSON.stringify + setItem on every call)
elapsed = 76.35 ms
per-record avg = 0.076 ms
localStorage backing size after = 17,403 bytes
history length (capped) = 200
76 毫秒跑完 1000 条,每条平均 0.076 毫秒——这个数字非常低,因为 Node 内存 stub 的 setItem 是纯内存操作,没有真实的序列化到磁盘成本。真实浏览器里 JSON.stringify 200 条 × 20KB 上限的最坏情况大概要 1-2 ms,但仍然远低于 copy 事件的触发频率。
不过 recorder.ts 里的 800 ms 轮询开销值得单独看一眼:
poller extrapolation (24h @ 800ms):
polls/day = 108,000
polls/year = 39,420,000
一天 10.8 万次,一年近 4 千万次。每次 poll 都要跑 document.hasFocus()、navigator.clipboard?.readText、以及可能的 recordClipboardEntry 调用(内部又做一次 localStorage.setItem)。虽然单次开销微乎其微,但轮询是浏览器里最"永远不为 0"的持续开销——关掉所有标签页再打开都会重新开。对移动端的电量友好度不算好,一个合理的改进是用 navigator.permissions.query({name:'clipboard-read'}) 的 change 事件替代轮询。
六、总结
这个 40 KB 的技能包把「剪切板历史」这件事从 0 做到能用,架构上没什么花活:external-store 协议 + React useSyncExternalStore + localStorage 持久化 + 全局去重。真跑 12 条断言全过,说明核心算法层是靠谱的。
一个真实问题出在降级路径:主路径写得完整,降级路径却直接改了内存里的权威数据。history.ts 在 localStorage 写入失败时同步砍掉一半内存条目,用户看到的是"历史凭空少了";第二次写入成功时这份损失会落盘,刷新也回不来,第二次写入失败时才有机会从旧数据恢复。
这类问题不会抛错,也不会出现在正常测试里,只有真的把存储写满才会暴露。值得维护者看一眼。
(注:cutting-board 由第三方社区贡献者发布,我本文只讨论代码层面观察,不做归属主张。)
- 点赞
- 收藏
- 关注作者
评论(0)