☰
Madeira:一款记录删除痕迹的浏览器本地笔记应用设计与实现
2026/10/1 1:09:12 网站建设 项目流程

如果你也是那种会在文档里把整段删掉、然后眼不见心不烦的人,那 Madeira 这个项目的出发点也许能引起你的注意。它不是大厂出品的时间管理工具,而是我花了一个月写出来的本地笔记应用:一个把编辑过的字符全部保留在屏幕上的浏览器记事本。没有文件夹、没有标签、没有云端同步,核心规则就一句话——你在文档里输入的每一个字符都被记录,删除操作不会被抹除,而是变成一条可见的删除痕迹留在原处,算是一款剥离掉所有干扰的记事本。

这篇文章适合两类人:一是想找一个反主流笔记产品的轻量写作环境,二是好奇浏览器端编辑器数据模型、中文输入法兼容、新标签页扩展这些事如何落地的人。我会把从取舍到踩坑的完整过程写出来,不是设计文档,而是实际动手做完之后那种“早知道这样会好很多”的复盘。

1. 为什么我把笔记软件做成了“不允许假装没写过”

1.1 大多数笔记软件都在悄悄纵容无效删除

先说说做 Madeira 之前我自己的状态。我日常会写技术复盘和项目记录,过去用的都是普通在线文档。写到一半觉得思路不对,随手选中文档内容,一个退格键删掉几千字。当时的感觉是“痛快”,尤其是删完之后屏幕重新变得干净,仿佛那段跑偏的思路从来不存在。

但次数一多,问题就暴露了:我发现自己越来越依赖“删除”这个动作来回避真正的判断。删的时候并没有在思考“这段为什么不好”,只是在消除视觉上的杂乱。过一两天想起某个被删掉的想法,想找回,又根本无从下手。在线文档虽然有历史记录,但我不会每次删东西都刻意去翻,而且多版本历史通常要等同步完成才能查看,打开路径太长,人早就失去了追索的耐心。

另一个让我不舒服的点是:编辑器默认的“干净”本身就是一种暗示。它暗示你应该直接写出成品,没写好的部分就应该彻底消失。可现实里,写作更像是在一堆半成品里挑挑拣拣,那些“写坏了”的句子恰恰是信息的入口,里面藏着最初的想法碎片。把这些东西一键抹掉,等于把自己的思考现场也一并抹掉了。

我试过一些替代方案:有人推荐用 Git 管理文档,每次提交就是一次快照,但写作不是一个稳定的 commit 过程,我写一段话四十五秒,不可能中途停下来 commit 一次。也有人推荐把删除的内容转移到文末“回收站”,但那样文档结构会碎掉,读起来很别扭。绕了一圈之后我意识到,真正要改的是产品的交互规则,不是给用户再加一个“备份”按钮。

1.2 产品规则:字符可以删,但删除的痕迹必须留下

所以 Madeira 的规则被我收敛成一句话:文档里只发生过字符的插入和删除,插入的内容正常显示,删除的内容不消失,而是以带删除线的弱化样式留在原位。你可以随时删掉一句话,但这句话会以一个“被删掉的状态”继续躺在屏幕上,变成文档的一部分。

这个规则的直接效果是:你没法再假装一段废稿没写过。每次退格都会在纸上留下一道划痕,划痕多了,你就不得不正面回答一个问题——为什么我在同一个句子上反反复复?这个回答,往往是写作时真正需要解决的问题。

我管这个叫“可见的编辑历史”。它与一般版本历史不一样:版本历史是静态快照,要你主动切出去对比;Madeira 的编辑历史是即时动态的,就在你眼睛正在看的位置。你对文本做的每个操作都会立刻反映在视觉上,不需要额外动作,也就没有“我待会儿再看”的心理缓冲。

至于产品边界,我在动手前就划好了线:不做账号体系、不做文件夹分类、不做多级目录,也不做富文本格式。所谓“剥离掉所有干扰”,不只是界面上的极简,连功能本身也要克制到只剩编辑。最开始的版本连导出按钮都没有,因为我觉得一旦有了导出,人又会回到“把内容整理干净再带走”的惯性里。后来越用越觉得不便,才补了一个最小化的“复制为纯文本”按钮,这个后面会细说。

1.3 技术选型:纯浏览器页面而不是 Desktop App

决定技术方案时,我一度考虑过 Electron,毕竟很多本地笔记工具都用它打包。但实际操作后发现,一个只做单文档编辑的应用用 Electron 太重了:启动要几百毫秒,内存占用动不动几百 M,和“打开就写”的产品属性冲突。

Madeira 最后用的是纯 HTML、CSS 和 TypeScript,跑在浏览器里,落地形态是一个可以覆盖浏览器新标签页的轻量页面。选择这个形态有两个原因。一是启动成本接近零,浏览器本身就是运行环境,新建一个标签页就能立刻进入写作状态。二是新标签页在用户心智里是一个“刚打开、还没进入任何一个网站”的空白区,把编辑器放在这里,天然会减少我顺手切去刷信息流的冲动。

本地存储用的是 localStorage,不引入 IndexedDB。它容量小,但胜在同步读写、API 简单、不需要处理复杂的事务。对一个单文档笔记工具来说,几百 K 的操作日志完全够用。我保留了一个后期把存储换到 IndexedDB 的接口,但在实际使用中一直没用到,说明第一版选型没有浪费。

2. 数据模型:用操作流而不是最终文本来记账

2.1 如果只存字符串,你的“历史”就丢了

这是整个项目里最应该想清楚的部分:内存里到底拿什么作为文档的“真相”。

初学做编辑器的人容易直接用字符串,输入就拼接、删除就 slice,操作起来很直接。但字符串只有最终状态,一旦删掉一段话,那段话就没有任何痕迹了。想实现“删除可见”,光有最终字符串远远不够,我们必须记录每个字符是怎么进来的、又是怎么没的。

所以 Madeira 把数据模型定义成一条只增不减的操作流(operation log)。每一次用户输入、每一次删除,都以一个独立事件追加到操作流末尾。文档的最终文本不是保存在某个变量里,而是通过回放整条操作流计算出来的。听起来有点绕,但它是整个“可见历史”的地基。

拿一个最简单的例子来说:

操作流: 1. insert: 在第0位插入 "今天写项目" 2. insert: 在第5位插入 "(有点难)" 3. delete: 从第2位删除3个字符 "写项目" 4. insert: 在第2位插入 "做复盘"

最终显示出来的文本是“今天做复盘(有点难)”,但操作流里仍然保存着“写项目”这三个字以及它被删除的秘密。渲染层可以从这条操作流里拿到两个东西:一个是“当前文档长什么样”,另一个是“哪些字符曾经被删除过、当时删在哪里”。

2.2 最小操作类型与一次输入的完整处理周期

我把操作类型收敛成四种:insert、delete、undo、redo。其中 undo 和 redo 并不是普通操作流的成员,属于“导航操作”,它们不追加历史记录,只负责把当前编辑位置和显示状态回退到某个操作之前。这个区分极其重要,不然你 undo 一次,历史里就会多一条“撤销了上次删除”的记录,画面语义会产生循环。

再看一次输入的完整周期。用户在输入框按下键盘,浏览器触发 beforeinput 或 input 事件,我读取当前光标位置和新增字符,生成一条 insert 操作。如果用户按退格,我读取被移除的字符和位置,生成一条 delete 操作。每次生成后,把操作追加到内存里的操作流,同时触发渲染函数,让删除线立刻出现在画面上。

这里有一个值得强调的小设计:操作流里的 position 是回溯全局的,不是相对某个版本。也就是说,第 10 条操作里的位置,被第 11 条操作插入之后,哪怕光标意义上的物理位置已经变了,日志里保存的仍然是最初记录时的绝对位置。这样回放时从头到尾应用每一条操作,才能得到一个唯一确定的文档状态。

删除操作还要额外满足一个约束:它必须记录被删除的具体字符,不能只记录“删除了 3 个字符”这种长度信息。因为在回放时,如果经过前面的操作,当前位置的字符已经被替换过,长度匹配但内容对不上,文档就会失真。所以 delete 操作会先根据当前快照取出真实文本,再把文本一起存进日志。

2.3 为什么不用 MongoDB 式的文档列表,而用事件流

如果不需要历史回放,最自然的数据结构肯定是一个数组或者字符串,改一下渲染一下,简单粗暴。但一旦要实现“删除可见”和“撤销重做”,事件流的优势就出来了。

首先是撤销的准确性。字符串模型下做撤销,要么拍快照,要么记录前后差异,但快照粒度很难把握;操作流模型下撤销很简单,找到最近一条 insert 或 delete,做一个反向操作即可。原因是事件流保留了用户意图的粒度,而不是丢失为字符状态。

其次是未来可扩展性。操作流天然支持“把今天所有删除的字符拿出来统计”——只需过滤 type 为 delete 的操作,汇总 text 字段的长度即可。如果想要按时间切换查看任意时刻的文档,回放操作流到目标时间戳就行。这些都建立在“发生了什么都被记下来”的前提上,而不是“现在变成什么样”。

当时我也担心过性能:如果写一个两万字的文档,操作流可能有上万条,每次输入都要全量回放一遍,会不会卡?实测下来,两万字文档全量回放只需要十几毫秒,浏览器完全扛得住。真正需要警惕的是渲染层不要每次都重新建 DOM,这个放到下一节说。

3. 渲染层:让每次删除都变成看得见的“横线”

3.1 三种编辑器方案的取舍

编辑器渲染层有一个经典选择题:textarea、contenteditable、自定义渲染。我把它们的核心差异列一下:

方案输入法兼容光标控制动态样式实现复杂度
textarea依赖系统控件,事件比较规整只能通过 selectionStart 读取几乎没法给局部字符加样式低
contenteditable各家浏览器处理差异大,但事件齐全有 Range 对象,可精确控制可以在节点里插入任意 DOM中高
自定义渲染需要自己接 IME 状态,工作量最大完全自己维护完全自由最高

选择 textarea 看似简单,但它只能显示纯文本,没法在一段文字中间给几个字加删除线,这直接否了核心需求。contenteditable 则允许我在文本流里插入 span 节点,给被删除的字符包一个含有删除线样式的标签。所以 Madeira 的主渲染层用的是 contenteditable,再用 TreeWalker 处理光标和节点的映射。

3.2 把输入事件吃进来,再把删除写成删除线

开启 contenteditable 之后,浏览器自带光标和选择,我只需要监听 input 事件,判断当前是插入还是删除。具体到 DOM 结构,我会把每个字符或者每段连续的“存活字符”包成这样的层级:

<contenteditable> <span>今天</span><span class="del">写项目</span><span>做复盘(</span><span>有点难</span><span>)</span> </contenteditable>

当用户删除“写项目”这三个字时,我不是直接从 DOM 里把这段文字拿掉,而是把它替换成带class="del"的 span,设置成text-decoration: line-through并降低不透明度。与此同时,操作流里追加一条 delete 操作。这样在视觉层面,删除变成了“文本仍然存在但明确地被标记为废弃”,而不是“文本干脆消失”。

这里有个决定要提前做:被删除的 span 得设置成contenteditable="false",否则光标移入之后用户又能往里输入,操作流和视觉状态就脱节了。处理方式是,在每次渲染后清理所有<span class="del">的编辑属性,并确保光标只落在非删除节点上。

3.3 渲染的脏检查与局部更新策略

刚开始实现时我偷懒,每次输入都重新渲染整个文档。几千字以内体验没问题,但到一万字以上,每次按键屏幕明显闪烁,光标还会跳到行首,非常难受。

于是改成增量渲染策略。思路是:维护一个渲染游标,每次只在操作流末尾有新增时,把新增的那一段文字追加到 DOM 末尾。删除操作则需要回溯到被删除字符的原始位置,修改对应节点。删除位置在中间时,大多数情况下也只需要把一整段跨度较小的文本替换成“存活字符 + 删除标记”的组合,不需要全量重建。

真正的全量重建只发生在两种场景:一是从 localStorage 恢复数据后的首次加载,二是用户手动点击历史回放按钮。日常输入时的性能问题,基本上靠“局部 diff + 200ms 节流”就能解决。你要是也做编辑器,建议一开始就把增量渲染设计进去,不要先全量再优化,后者改起来牵扯的边界情况特别多。

3.4 文本多起来之后的分片加载

写了半个月的笔记之后,我的最长文档到了两万字。此时即使增量渲染没问题,页面内一次性加载所有 DOM 节点也开始让我感到卡顿。Madeira 做了一件轻量的事:把文档按段落切成多个 container,初始渲染时只加载靠近光标位置的段落,其余段落用 IntersectionObserver 按需懒加载。因为笔记日常是线性书写,阅读时从上往下滚动,懒加载体验基本无感。

不过懒加载会带来一个注意力上的小骗局:屏幕上只渲染了你眼睛看到的这一段,没看到的地方暂时没有删除线。这不会影响操作流的完整性,因为操作流始终在内存里;只是渲染层给了自己一点喘息空间。我是接受这个折中的:数据完整性和视觉完整性是两件事,渲染层可以在视觉上偷工减料,但数据层必须绝对真实。

4. 长文测试中翻过车的三个技术坑

4.1 中文输入法的 composition 幽灵字符

第一个坑是中文输入法。用户用拼音输入“你好”时,记事本里会出现“nihao”的拼音候选过程。如果我在 compositionstart 到 compositionend 期间直接监听 input 事件,会把中间产生的拼音字母当成真实内容写入操作流,然后等候选词被确认时,又写入一遍“你好”。结局是操作流里多了一整串幽灵字符,看起来像“nihaonihao你好”。

解决办法是维护一个“正在输入法组合中”的状态位。在compositionstart时设为 true,这个阶段所有 input 事件全部忽略,不做日志记录,也不做渲染;在compositionend时,一次性读取最终文本,算清楚它和上一状态的差异,再生成一条完整的 insert 操作。这个不只对中文输入法必要,日文、韩文、以及带 preedit 的移动端输入法都会踩到类似问题。

还有另一个细节:有的浏览器在输入法组合结束后,会额外触发一次多余的重绘,如果处理不当,视觉上会闪一下。我的做法是在 compositionend 的下一帧统一做渲染,利用requestAnimationFrame把多次输入事件的渲染合并成一次。

场景现象处理方案
拼音输入过程出现拼音字母写入日志compositionstart 起忽略输入事件
输入法确认候选词最终文本与拼音重复compositionend 时统一计算差异
输入法结束后重绘视觉闪烁合并到下一帧统一渲染

4.2 Ctrl+Z 不再是浏览器的 Ctrl+Z

contenteditable 自带原生撤销,但它撤销的是浏览器内部的 DOM 变化,不理解我的操作流。按下 Ctrl+Z 时,浏览器可能把某个删除线的 span 活生生变回普通文本,但日志里根本不知道这条记录。这会导致日志和画面脱离感。

最干净的处理方式是最开始就拦截 Ctrl+Z 和 Ctrl+Y(或 Ctrl+Shift+Z),调用preventDefault()关掉原生撤销,然后用操作流实现自己的撤销栈。撤销的具体逻辑是:从操作流末尾向前找最近一条用户发起的 insert 或 delete,执行它的逆操作。插入的逆操作是删除,删除的逆操作是重新插入。逆操作的显示策略要额外注意——被重新插入的文本不是普通文本,而是显示为“曾被删除后又被恢复”的特殊样式,颜色比正常文本淡一些,但不带中间删除线。

这样即使执行了撤销,操作流里仍然留着最初的删除记录。它记录的永远是真实发生过的事件,而不是用户最终看到的结果。这个决定是我认为 Madeira 能成立的关键之一:如果撤销等于抹除,那么历史上发生过的事就永远消散了,用户迟早会开始钻空子,用撤销在心理上抹掉自己的草稿。

实现撤销栈时我还处理了一个老坑:连续输入应该合并成一条操作,而不是每个字符一条,否则按一次 Ctrl+Z 只回退一个字母,体验很差。合并的规则是:如果两次 input 事件时间间隔小于 500ms、且光标位置连续,就合并为同一批操作;反之就开一条新操作。

4.3 多标签页同时编辑:localStorage 的并发假象

我一开始默认用户只会开着 Madeira 的一个标签页。后来自己也犯了这个误操作:左边写着方案,右边重新打开新标签页写同一份笔记。两块数据同时往 localStorage 写,互相覆盖,最后打开文档时整个数据乱了。

localStorage 没有多标签页之间的实时通知机制,需要主动做同步。我用BroadcastChannel在多个标签页之间广播写操作,让其它标签页即时拉取最新操作流。写入端做了一个很简单的策略:最后写入者获胜,也就是每个标签页在 commit 前先读一次最新版本,把自己的操作追加在最后,再把结果写回去。

这个策略在单人高频编辑时够用,但会出现一种情况:标签页 A 在离线几分钟后切回来,它本地缓存了一大堆操作,一口气追加到最新版本后面,可能会让顺序错乱。我只能说,如果需要更强的实时协作能力,就应该换用后端服务加 CRDT 或者 OT,本地 localStorage 并发本质上是“伪并发”,能做到不崩已经不错了。作为本地工具,我更担心的是数据丢,所以每次 commit 都会先写操作流副本再更新索引,保证断电时最多丢最后一条操作而不是全盘皆输。

4.4 新标签页免打扰的实际边界

Madeira 之所以能成为“新标签页里的记事本”,是因为浏览器扩展支持chrome_url_overrides.newtab,把新建标签页指向本地笔记页面。这带来一个额外的产品收益:我每次想“开个新标签页随便看看”时,迎面先撞上自己的文档。这种先发制人的注意力提醒,比任何待办清单都有效。

不过“免打扰”没有想象中那么彻底。一开始我还想做全局通知静默,后来发现浏览器对扩展访问其它网站的推送通知非常严格,不能也不应该拦截不属于自己的通知。我退了一步,只在 Madeira 页面内部做免打扰:页面默认不弹任何通知,不闪烁标题栏,不产生声音;同时提供一个手动按钮,允许我把当前文档临时设为“可被打扰”,比如等一个外部反馈时。

这件事我的结论是:不要试图抢系统权限去管理其它应用的通知,那会让用户既反感又觉得复杂。免打扰的重点是把入口做在文档之前,让人第一眼看到的是自己在写什么,而不是让所有提醒一夜消失。

5. 成为新标签页之后,它改变了使用习惯里哪些环节

5.1 新标签页提示:可被忽视但每次都会出现

把编辑器放进新标签页的副作用是,它成为我打开浏览器时第一个接触到的界面。想打开 Gmail、想刷信息流,都得先看到这一页文档。这里没有任何强迫机制,单纯是位置优势。实际体验下来,我每天至少有五六次会因为这个“先看见”而顺手写几句话,要是在老版本里,那些零碎念头可能就随标签页一起关闭了。

后来我给新标签页加了一个很小的倒计时显示:从上次打开标签页到当前时间,写了多少字、删了多少字。本身没有提醒功能,就是一个数字。但人就是很吃这套——看到自己某天删掉的字符多于留下的字符,次数多了,会不自觉地调整思路节奏。

5.2 导出功能:必须存在的“逃生通道”

我很早就意识到,如果一个笔记工具永远不让你把内容带走,它和监牢没有区别。用了两个星期之后,我自己第一个受不了:想整理一篇稿子发出去,却没有干净文本可复制。于是补了一个“复制为纯文本”按钮:它会遍历操作流生成最终文档,忽略所有删除线标记,只输出真正存活的字符。

这个按钮本身又是一个产品判断:它让用户可以随时逃离,但又不主动提醒逃离。默认界面没有任何导出图标,用户需要按住 Ctrl+Shift+C 调出。这个设计是为了避免“为了导出而整理”的冲动。我想通过这个按钮表达:Madeira 不反对你离开,但它希望你离开时是带着完整内容走的,而不是带着删掉的草稿一起走。

5.3 删除率统计:一个让人脸红的数据

做统计是后面加的小功能,但我越看越觉得值得写。代码非常简单,从操作流里拉一遍就可以了:

const deletedCount = log .filter(op => op.type === "delete") .reduce((sum, op) => sum + op.text.length, 0);

这段代码的输出是我一个月里删掉的字符总数。我第一次看到这个数字时,比自己预期的多很多。它让我重新审视一个很基础的问题:写作时删除是效率的表现还是逃避的表现?不做统计时,我往往分不清这两者;做了统计后,我能看到哪天删得多、哪篇稿子删得少,并从中找到节奏规律。

我后来在这个基础上做了一点延展:把删除字符按时间段汇总成曲线,放在文档最下方。它不会弹出,不会抢注意力,但每次滚动到文末都能看到那条陡峭的鼓起。这种“知道自己删了多少”的感知,本身就是我最初做 Madeira 想要的自我觉察。

6. 如果你也要做一个“剥离干扰”的工具,这是我最想说的

6.1 产品上的经验:干扰不只是界面,而是删除这条默认路径

很多人在谈“极简工具”时,默认把干扰理解为界面上多余的按钮和配色。但我在做 Madeira 过程中最深的体会是:干扰可以藏在交互默认行为里。旧的笔记产品默认“删除即消失”,这个默认行为本身就在鼓励我假装某段思考没发生过,是一种隐性的干扰。

所以当你设计一个类似工具时,可以先问一个问题:这个工具默认允许我做什么?如果默认允许的操作里包含“无痕抹掉自己做过的事”,那么用户就始终有一个逃避反思的路径。把删除变成可见的操作,相当于把心理逃避的通道焊死了一个口子。这未必适用于所有工具,但至少对写作类工具,值得一试。

6.2 技术上的经验:数据层一定要先于界面层设计

我在整个项目中得的另一个教训是:很多编辑器项目先从 DOM 和 CSS 开始写,对着漂亮的文字输入框调样式,结果数据模型想不清楚,后面追加历史记录和撤销功能就举步维艰。Madeira 的顺序反过来,先定好操作流,所有界面只是操作流的投影。这个顺序让我在渲染层犯过的那些错都不会扩散到数据层,因为数据永远是对的。

如果你也想做编辑器,我强烈建议先把“一次输入会产生什么数据”想清楚,再碰 DOM。光标、样式、性能都可以后面补,数据模型错了,后面全盘返工。

最后再分享一个小技巧:本地笔记工具的导出格式,最好不要绑死成自己的私有 JSON。我在测试时保存过一份名为madeira-ops.json的备份,后来去读它时发现,操作流格式虽然结构清晰,但对不熟悉这套机制的人来说,和乱码没有太多区别。于是我给导出功能同时加了一个“导出为 Markdown”的选项,只把最终文档按段落拼成文本。如果你不想让数据被工具本身绑架,从一开始就保持“操作流 + 导出为通用格式”两条腿走路,会省去很多事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询