开发手记:两天,我给自己造了一间数字王国

开发手记:两天,我给自己造了一间数字王国

这不是一篇技术教程,而是一个普通开发者在某个周末,决定不再忍受、自己动手的故事。


一、起因:那个让我忍了太久的"不爽"

故事的开端很简单——我在写一篇技术文章,用 Obsidian。

这不是我第一次用 Obsidian,也不会是最后一次。但那天下午,我第无数次被同一个问题卡住:我想把文章分享给微信上的朋友看看,让他帮忙提点意见。

Obsidian 的分享方案,我试过几个:

  • 用 Obsidian Publish?要付费,而且内容要过他们的服务器。
  • 用 GitHub 仓库公开?我不想让所有人都看到草稿。
  • 复制粘贴到微信?格式全乱,代码块直接报废。

我又试了试 Notion。分享倒是方便,但那个编辑器在写技术文档时总让我觉得隔了一层。Markdown 支持不纯粹,快捷键也不顺手。而且,我下意识地不想把还没写完的文章放在别人的服务器上。

那天下午,我对着屏幕发了一会儿呆。

然后我做了一个决定,这个决定在当时看起来有点冲动,但现在回想起来,可能是这两年我做过的最正确的技术决策之一——

"我给自己写一个。"

这句话说起来轻巧,但我知道它在暗示什么:这意味着接下来的几十个小时,我要告别所有现成的解决方案,从零开始搭建一个只服务于我自己的工具。没有产品经理,没有设计师,没有测试工程师——只有我和我的代码。

但我当时想的是:反正我每天都要用,花两天时间打磨一个"天天握在手里的东西",这笔账怎么算都不亏。


二、定位:不是"又一个编辑器",而是"我的编辑器"

在动手之前,我先问了自己一个问题:我到底想要什么?

如果只是要一个能在浏览器里写 Markdown 的工具,GitHub 上有一大把。但那都不是"我的"。

我列了一个清单,写下了那些让我无法忍受的痛点,以及我理想中的解决方案应该长什么样:

我的核心需求清单:

  1. 数据必须在我手里。 这一点没得商量。所有文档存在本地浏览器里,但必须能自动备份到我能控制的地方(我有一台 NAS)。我不接受任何"先上传到我们的服务器再同步"的方案。

  2. 打开就能写。 不要登录,不要注册,不要等待加载。打开浏览器,开始敲字。保存和关闭是同一个动作。

  3. 手感要顺。 我是 Vim 用户。任何编辑器如果没有 Vim 模式,对我来说就像用一只手打字。这听起来可能有点偏执,但我相信每个长期使用 Vim 的人都能理解那种"离开 hjkl 就像断了手指"的感觉。

  4. 分享要简单。 我写完一篇文章,想给朋友看,不应该经历"导出-上传-生成链接-复制粘贴"这套流程。最好点一下按钮,链接就有了。

  5. 功能要有,但不能让我看见。 我不需要所有按钮都摆在眼前。高频操作(加粗、斜体、标题)应该在手边;低频功能(导出 PDF、查看历史版本)藏进菜单里。我不需要工具教我怎么做,我需要工具在我需要的时候出现。

这份清单写完后,我发现自己要的其实不是"功能堆砌",而是一个**"顺手"**的东西。就像一把刀,不用镶金戴玉,但握在手里要舒服,切东西要利落。

定位清楚了。剩下的就是把它做出来。


三、第一天上午:从零到一,先把地基打稳

我不打算用任何框架。

这个决定可能让很多人觉得奇怪。在 2026 年,用 React 或者 Vue 是常态,甚至有人说"不用框架写应用是自虐"。但我有自己的考量:

  • 我只想维护一个文件。 app.js 里面装下所有逻辑,出了问题我知道去哪找,改功能我知道去哪加。
  • 我不想等构建。 每次修改完,刷新浏览器就看到效果。没有 Webpack 编译的那几秒等待,这听起来是小事,但在频繁迭代时,那几秒的累积会让你烦躁。
  • 我没有团队。 只有我一个人,我不需要模块拆分、路由管理、状态管理这些工程化设施。它们只会增加我的认知负担。

所以我选择了最原始的方式:一个 index.html、一个 app.js、一个 styles.css。所有的代码都在这三个文件里。

上午 9 点:开始敲第一行代码。

我写的第一件事是编辑器本体——一个 <textarea>。然后绑上 input 事件,内容变化时触发渲染。Markdown 解析我用了 marked.js,代码高亮用了 highlight.js。这两个库我是从 CDN 引的,但很快就决定下载到本地 vendor/ 目录——因为我要确保离线也能用。

渲染这一块其实没什么好说的,几乎所有的 Markdown 编辑器都是这套流程。但我在这一步做了一个选择:预览区用 DOMPurify 做净化。因为我知道后面会支持直接粘贴图片、导入外部文档,我不能让 XSS 攻击有机会通过 Markdown 注入执行。这个决定后来被证明非常明智——安全这件事,一开始就做比后面补要容易得多。

上午 11 点:数据持久化的设计。

本地存储怎么搞?我一开始想用 localStorage,因为简单。但很快我就意识到不行:一个文档的正文可能就几万字,localStorage 的容量限制只有 5MB,而且它没法存图片二进制数据。

于是我上了 IndexedDB。这玩意儿 API 难用得出了名,但它是浏览器里真正能用的本地数据库。我设计了两张表:

  • docs:存文档的 id、名称、内容、更新时间、历史版本
  • images:存图片的 Blob 数据

这个分离很有意思——后来有人在评价我的代码时提到,我用 libimg:// 这个伪协议来引用图片,而不是把 Base64 塞进正文里。这个设计让文档变得非常轻量,即使插了 20 张图片,.md 内容本身也只有几 KB。图片 Blob 单独存,渲染时再生成 URL.createObjectURL

下午 2 点:第一个让我觉得"对了"的时刻。

我把编辑器、预览、数据存储这三块拼到一起后,试着写了一篇 500 字的草稿。关闭浏览器,重新打开,草稿还在。刷新页面,内容恢复。

这个时刻,我心里第一次有了底——这东西是能用的。

虽然它还很粗糙:没有行号,没有统计,没有快捷键,界面丑得跟 2005 年的论坛发帖框一样。但"能保存内容"这个核心闭环已经通了。剩下的,都是锦上添花。

下午 4 点:本地文件的打开和保存。

我加上了 showOpenFilePickershowSaveFilePicker。这是 File System Access API,让网页能直接读写磁盘上的文件。以前 Web 应用只能通过 <input type="file"> 读取文件,修改后只能下载新文件。现在我可以直接打开电脑上的 .md 文件,修改后按 Ctrl+S,它就保存在原地了——和桌面软件没有任何区别。

这个功能对我来说很重要。因为我经常在桌面端写长篇内容,我需要能和本地文件系统无缝对接。我把这个功能加进去之后,在状态栏加了一个小小的提示:Ctrl+S 保存。每次按下快捷键,看到状态栏闪一下"已保存",那种安全感是实在的。

第一天收工时: 编辑器能用了。能写、能存、能打开本地文件。粗糙,但五脏俱全。我躺在床上想,明天把"顺手"的部分做完,这个工具就算成了。


四、第二天上午:让工具"长出手感"

如果说第一天是在盖房子的骨架,那第二天就是在给房子做精装——让每一个细节都"长在手边"。

Vim 模式:200 行代码实现的灵魂注入

这是我最期待的功能,也是最担心的。

期待是因为我太想要一个带 Vim 模式的 Web 编辑器了。担心是因为我知道在 <textarea> 上实现 Vim 行为有多麻烦——光标位置、选区、键盘事件的拦截,全是坑。

我花了一个小时想怎么设计这个状态机,然后开始动手。

核心思路很简单:

  • Normal 模式:键盘事件被拦截,hjkl 移动光标,dd 删行,yy 复制行。
  • Insert 模式:键盘事件透传,正常输入文字。
  • Esc 或者 Ctrl+[ 切换回 Normal。

实现上最关键的一点是:键盘事件要在捕获阶段拦截,而不是冒泡阶段。

editor.addEventListener('keydown', vimKeydown, true);  

这个 true 是点睛之笔。它确保在浏览器默认行为(比如 Tab 切换焦点)和编辑器自身的 Tab 缩进逻辑之前,Vim 引擎先拿到按键控制权。这样 Normal 模式下按 j,就不会在文本里插入一个字符。

另一个让我花了心思的是方块光标。Vim 用户都知道 Normal 模式下的光标是方块状,覆盖在当前字符上。但在 <textarea> 里,你没法直接改光标的样式。我的做法是:

  1. 用一个隐藏的 <div> 镜像 <textarea> 的字体和内容
  2. 计算当前光标位置所在的像素坐标
  3. 在那个位置叠加一个半透明的彩色方块
  4. 把原生光标用 caret-color: transparent 隐藏掉

这个方案不是我想出来的,算是借鉴了社区的做法。但把它移植到我的代码里,让它和编辑器现有的滚动、选区逻辑配合好,确实花了一些调试时间。

最后写出来的 Vim 引擎大约 200 行。支持 hjkl0$dd/yy/pgg/Gi/a/A/I/o/Ou(撤销),外加最近新加的 Ctrl+D/U 翻页和理想列记忆。

功能不多,但对我来说已经够了。我真正高频使用的就是移动、删除行、复制行、粘贴、插入这几个操作。那些更高级的 Vim 功能(宏录制、可视化模式、命令模式),我一年也用不了几次,不做。

这是我对"主权归我"的第一个实践:不是做最多功能,而是做我最需要的功能。

悬浮格式刷:把格式操作从菜单里"捞"出来

我平时写文章,最频繁的操作就是加粗、斜体、标题、代码块。这些操作在传统的编辑器里都在顶部工具栏,每次都要把鼠标移到最上面去点。移动距离长,而且打断了写作流。

我想做一个工具条,在选中文字的时候自动浮在选区上方,点一下就能应用格式。

实现思路和 Vim 的方块光标有点像:计算选区的像素位置,然后把工具条定位在选区正上方。

function getTextareaCaretPos(el, position) {  
  // 用镜像 div 计算坐标  
  // ...  
}  

这个函数返回光标在 <textarea> 内容坐标系里的像素位置,然后结合 getBoundingClientRect 转成视口坐标,再把工具条定位过去。

当用户选中文字时,工具条浮出来;点完按钮,工具条消失,但选区保留,方便继续操作。我还加了一个"AI"按钮在工具条最右边,点一下就把格式刷隐藏,换成 AI 工具条——这个切换是无缝的,选区不会丢失,上下文完全保留。

这个功能做好后,我试了半小时。 选中一段文字 → 点 B 加粗 → 选中另一段 → 点 I 斜体 → 整个过程鼠标几乎没有离开编辑区。手感很顺。


五、第二天下午:打通数据闭环

到了下午,我开始琢磨数据流。这是我整个项目里最看重的一块——数据主权不是喊口号,它必须体现在每一个数据流动的细节里。

本地文库:IndexedDB 的"自动回写"机制

文库是本地文档库,所有文档存在 IndexedDB 里。当我打开一篇文库文档开始编辑,每次输入,input 事件触发去抖 800ms,然后把内容写回 IndexedDB。同时保留历史版本(最多 20 条),支持随时恢复到之前的某个快照。

这个机制运行在后台,完全不打扰我写作。状态栏会显示"✓ 已存文库",让我知道数据已经写好了。

但这里有个问题:IndexedDB 是浏览器里的,万一浏览器清缓存怎么办?万一电脑坏了怎么办?

我需要在 IndexedDB 之外,有一个我能完全控制的备份位置。

NAS 自动同步:从"数据安全"到"数据自由"

我有一台 NAS,跑着自己搭的 knowly 服务。它有一个上传接口,接收文件并归档。

于是我在编辑器里加了这样一个逻辑:每当文库中的文档被修改并写回 IndexedDB,就触发一个去抖的同步任务,把改动推送到 NAS。

具体实现是这样的:

  • 每篇文库文档都有一个 updatedAt 时间戳
  • 每次编辑都会更新这个时间戳
  • 同步任务会对比本地 updatedAt 和 NAS 记录的上次同步时间,只推送更新的文档
  • 推送成功后,更新本地同步状态

这个过程是全自动的。我写文章时完全感知不到数据在上传,只有在状态栏看到一个小圆点从蓝色变成绿色,才知道又一批文档已经安全抵达 NAS。

"单向同步"的设计哲学

这里有一个重要的设计选择:同步是单向的——本地修改会推送到 NAS,但 NAS 不会主动推回本地。

为什么?

因为我的核心使用场景是"在手机或电脑上写作,所有内容在 NAS 备一份"。NAS 是保险箱,不是工作台。如果 NAS 主动推回,就会出现"我在手机上删了一篇草稿,NAS 又给恢复了"这种矛盾。

"用的时候再拉一份"——我在 NAS 上存了几百篇文章,但本地文库只保留最近打开的二三十篇。需要旧稿子的时候,在搜索框里找到它,点一下就从 NAS 拉回来。

这套设计让我的手机端永远保持精简,但所有的数据都安然无恙地躺在我的 NAS 里。这种感觉很踏实。

微信分享:被评价为"教科书级"的实现

最后一块拼图是分享。我当时遇到的问题是:很多时候我只是想给朋友看一篇文章的草稿,不想发到 GitHub 公开,也不想用微信直接贴纯文本(格式全丢了)。

我用 Cloudflare Workers + R2 对象存储做了一个简单的分享机制:

  1. 点击"生成协作链接",编辑器把当前内容 POST 到 Worker
  2. Worker 生成一个随机 ID,存到 R2,返回 { id: "xxx" }
  3. 前端构造链接:md.want.biz?share_r2=xxx
  4. 其他人打开这个链接,页面自动从 R2 拉取内容并载入编辑器

最让我在意的是微信环境下的兼容性。iOS 微信的 navigator.clipboard 经常失效,所以我做了降级方案:弹出一个模态框显示明文链接,用户可以直接看到内容并手动复制。同时,我用 document.execCommand('copy') 配合隐藏 textarea 作为兜底,尽可能让复制成功。

后来有人评价这个方案是"教科书级"的微信分享实现,物理隔离、降级兜底、URL 净化全做到了。我看了挺高兴,不是因为被夸,而是因为这些细节确实解决了我自己的痛点。


六、第二天深夜:那些看不见的"工程感"

功能做完了,但我还在继续。因为一个工具好不好用,往往不在核心功能,而在那些边边角角的细节——它们决定了你愿不愿意长期使用。

自愈守卫(Self-Heal Guard)

我在项目里做了一个巧妙的防御机制。因为 Service Worker 会缓存旧版本的 app.jsindex.html,有时候页面刷新后,HTML 是新的但 JS 是旧的,导致某些 DOM 元素找不到。这是一个非常隐蔽的 bug——它只在 PWA 更新后出现,而且不是每次都能复现。

我的解决方案是:在 JS 开头检查关键按钮(#btnMore)是否存在,如果不存在,说明版本错配了。此时记录一个时间戳到 localStorage,60 秒内只触发一次刷新,防止死循环。

这个设计后来被评价为"很多高级前端团队都未必能想到的防御性编程"。说实话,我当初只是觉得"不能让用户看到白屏或报错",没想那么远。

Service Worker 更新提示

PWA 的另一个痛点是:你发布了新版本,但用户的浏览器还在用缓存的旧版本。除非用户手动硬刷新,否则永远看不到新内容。

我加了一个更新提示条。当 Service Worker 检测到新版本可用时,在页面底部弹出一个不显眼的条:"🔄 发现新版本,刷新以应用更新"。用户点一下,页面重新加载,新版本生效。

这个小功能加上后,每次我部署新代码,打开自己的编辑器,都能看到那个提示条出现,然后点一下刷新。有一种"我在给自己推送更新"的掌控感。

全文检索(你提醒我后才意识到它有多重要)

说实话,这个功能一开始只是顺手加的。文库列表顶部有个搜索框,输入关键词,列表会过滤出匹配的文档——不仅匹配文件名,还匹配正文内容。

我本来没觉得这是个多大的事,直到你提醒我"搜索已经有了"。我才意识到,对于一个人工具来说,当你写了几百篇文章后,能不能快速找到想要的那篇,直接决定了整个系统的可用性。

这个搜索功能很朴素,没有 Elasticsearch,没有倒排索引,就是最简单的 Array.filter()String.includes()。但因为我的文章数量是个人级别(几百篇,不是几百万篇),内存过滤的速度完全是毫秒级的。

布局拖拽和左右交换

这个功能纯粹是"顺手加的"。我在桌面端用编辑器时,有时候想把预览区放大看效果,有时候想把编辑区拉宽一点方便打字。所以我手搓了一个分隔线,拖拽可以调整编辑区/预览区的宽度比例。

后来又加了"交换左右布局"——有人喜欢编辑区在左预览区在右,有人喜欢反过来。我让这两种布局都支持,并且在切换时保留当前的宽度比例。

这个功能是纯 CSS + 原生事件实现的,没有依赖任何第三方库。拖拽时页面不会卡顿,因为我没有用 mousemove 做大量 DOM 操作,而是只在 mouseup 时更新最终值。


七、反思:为什么这个项目让我"极度舒适"

写到这里,我想停下来问自己一个问题:为什么这个工具让我这么爽?

答案不是因为它功能多,也不是因为它代码写得好。而是因为它完完全全属于我

这种感觉,在当下的软件生态里越来越稀缺了。

我们每天用的软件,大部分是租来的。Notion 的数据在 Notion 的服务器上,飞书的文档在飞书的数据库里。你打开一个 App,看到的是产品经理为你设计的界面,你只能在它给定的"泳道"里游泳。

而我的工具不一样。我看到一个不爽的地方,半小时后它就不存在了。我想加一个功能,晚上睡觉前它就有了。这种"全栈掌控"的感觉,不是效率问题,是心理问题——它让我在使用这个工具的时候,没有任何"憋屈"感。

"哪里不爽就手搓功能"——这句话听起来像玩笑,但它背后是一种生活态度:我不愿意在不舒服的环境里凑合。 如果有一件事我每天都在做(比如写文档),那我愿意花点时间让这件事变得顺手。这笔账怎么算都值。


八、数据主权:比想象中更重要

这个项目还有一个维度,是做完之后才慢慢体会到的:数据主权不是技术问题,是尊严问题。

我们这代人,几乎所有的数字生活都建立在别人的平台上。微信聊天记录在腾讯,笔记在 Notion,照片在 iCloud。这些平台都很可靠,但它们终究不是"你的"。平台可以改条款,可以关服务,可以封账号。你积累的数字资产,其实是平台的。

而我的这套系统不一样:

  • 文档存在我的浏览器 IndexedDB 里
  • 自动同步到我的 NAS
  • 分享通过 Cloudflare R2,但 Worker 代码是我写的,R2 存储桶是我的
  • 所有数据我都能导出、迁移、删除,没有任何人拦着我

这种"完全掌控"的感觉,用一句话说就是:我没有任何担心。

我不怕 Notion 涨价,不怕 Obsidian 停止维护,不怕任何一个平台把我锁在里面。因为我的核心数据流不依赖任何一个第三方服务。就算 Cloudflare 今天倒闭了,我的 NAS 上还有所有文档,换一个分享方式就行。

这种安全感,是任何商业软件都提供不了的。


九、未来:我还会继续手搓

这个项目还会继续迭代。

我脑子里还有很多想法没有实现,比如:

  • 全局全文搜索:目前在文库列表里可以搜索,但如果能在任何界面按 Cmd+Shift+F 呼出一个搜索弹窗,体验会更好。
  • 标签系统:让文库里的文档能打标签,按标签聚合。
  • 导出为公众号格式:既然打通了微信,那下一步可以考虑一键生成公众号文章排版。

但这些功能不会一次性做完。我会在某个周末,突然觉得"现在需要这个了",然后花几个小时加上去。这就是"个人专属工具"的魅力——它永远在进化,而且永远以我的节奏进化。


十、最后:写给看到这里的你

如果你读到了这里,说明你对"自己动手造工具"这件事感兴趣。我想给你几个建议:

第一,别觉得"造轮子"是浪费时间。 如果你每天都要用某个工具,花两天时间造一个完全合手的,这笔账怎么算都不亏。轮子还是那个轮子,但它是按你的尺寸做的。

第二,不要追求"什么都做"。 只做你最需要的功能。一个好的个人工具,不是功能最多的,而是功能最精准的。那些你一年只用两次的功能,不做也罢。

第三,数据主权比功能重要。 在任何一个设计决策上,如果"方便"和"数据可控"冲突,选后者。短期内可能麻烦一点,但长期来看,这是最保值的设计决策。

第四,享受这个过程。 写代码最大的快乐,不是代码本身,而是你用代码改变了自己生活的某个角落。当你的工具让你比昨天更舒服一点,那种成就感,比任何 KPI 都实在。


我的编辑器版本号现在是 v2.1.6。从 v1.11.24 到今天,两周多的时间。这个项目已经从一个粗糙的"能用的东西"长成了一个让我每天打开都感到舒适的数字空间。

而这个空间,还在继续长。

因为我还在用,还在写,还在看哪里不爽。

毕竟,主权归我,哪里不爽,一言不合就手搓功能。 🚀