兰 亭 墨 苑
期货 · 量化 · AI · 终身学习
首页
归档
编辑文章
标题 *
URL 别名 *
内容 *
(支持 Markdown 格式)
# 开发手记:两天,我给自己造了一间数字王国 > 这不是一篇技术教程,而是一个普通开发者在某个周末,决定不再忍受、自己动手的故事。 --- ## 一、起因:那个让我忍了太久的"不爽" 故事的开端很简单——我在写一篇技术文章,用 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 点:本地文件的打开和保存。** 我加上了 `showOpenFilePicker` 和 `showSaveFilePicker`。这是 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。 实现上最关键的一点是:**键盘事件要在捕获阶段拦截**,而不是冒泡阶段。 ```javascript 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 行。支持 `hjkl`、`0$`、`dd/yy/p`、`gg/G`、`i/a/A/I/o/O`、`u`(撤销),外加最近新加的 `Ctrl+D/U` 翻页和理想列记忆。 功能不多,但对我来说已经够了。我真正高频使用的就是移动、删除行、复制行、粘贴、插入这几个操作。那些更高级的 Vim 功能(宏录制、可视化模式、命令模式),我一年也用不了几次,不做。 **这是我对"主权归我"的第一个实践:不是做最多功能,而是做我最需要的功能。** **悬浮格式刷:把格式操作从菜单里"捞"出来** 我平时写文章,最频繁的操作就是加粗、斜体、标题、代码块。这些操作在传统的编辑器里都在顶部工具栏,每次都要把鼠标移到最上面去点。移动距离长,而且打断了写作流。 我想做一个工具条,在选中文字的时候自动浮在选区上方,点一下就能应用格式。 实现思路和 Vim 的方块光标有点像:计算选区的像素位置,然后把工具条定位在选区正上方。 ```javascript 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.js` 和 `index.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` 到今天,两周多的时间。这个项目已经从一个粗糙的"能用的东西"长成了一个让我每天打开都感到舒适的数字空间。 而这个空间,还在继续长。 因为我还在用,还在写,还在看哪里不爽。 **毕竟,主权归我,哪里不爽,一言不合就手搓功能。** 🚀
配图 (可多选)
选择新图片文件或拖拽到此处
标签
更新文章
删除文章