cutting-board 实跑:一个剪切板悬浮窗技能包与它的配额降级问题

举报
yd_232353574 发表于 2026/10/20 14:52:48 2026/10/20
【摘要】 React + localStorage 实现的剪切板悬浮窗技能包,去重持久化上限 200 条。

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 由第三方社区贡献者发布,我本文只讨论代码层面观察,不做归属主张。)

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。