你可能见过形形色色的编辑器:Vim、GNU nano、Markdown 编辑器、富文本编辑器、PDF 编辑器,甚至游戏存档编辑器。它们有的藏在终端里,有的长在浏览器里,有的连窗口界面都没有。这些工具长得完全不一样,但底层的设计逻辑其实是一套东西。这篇文章不推荐任何现成工具,就带你从零手写一个编辑器。我会先从编辑器和编译器的区别讲起,再拆解编辑器架构,然后分别用 Python 和 Web 技术实现两个可用的编辑器,最后聊聊图像编辑器、游戏存档编辑器这些特殊形态。内容不深,但保证能直接照着做。
写编辑器这件事,最大的价值不在于“我又造了个轮子”,而在于你会被迫把数据模型、状态管理、渲染刷新、快捷键设计、文件读写这些平时被框架藏起来的细节全部想明白。哪怕你最终不用自己写的编辑器,这一趟下来你对现有工具的认知也会完全不一样。适合想深入理解编辑器原理、准备做工具类产品、或者单纯想找个小项目练手的人。
1. 编辑器和编译器,别再傻傻分不清
先解决一个最容易被问懵的问题:编译器(Compiler)和编辑器(Editor)到底什么关系?这俩词中文里都带“编”,但一个管“改”,一个管“算”。
1.1 两个“编”字,干的事完全不同
编辑器负责让人修改文本内容。它关心的是光标放哪、回车怎么换行、撤销能不能恢复、文件怎么保存。你可以把编辑器理解成 Word 的底层形态:你敲键盘,它记录,你保存,它写磁盘。
编译器负责把人类写的源代码转换成机器能执行的程序。它关心的是语法对不对、类型搭不搭、优化怎么做、生成什么样的指令。整个过程更像一条自动化流水线:输入一份代码文件,输出一个可执行文件。
真正让这两者产生交集的是“编辑-编译-调试”这套开发循环。你在编辑器里改代码,保存后调用编译器,随后根据报错信息回到编辑器修改,如此往复。Vim、Emacs 这类编辑器之所以被说“强大”,其中一个原因就是它们在内部集成了对编译流程的调用,让你不用离开编辑器就能完成整个循环。很多人刚接触 Linux 的时候,会在终端里先打开 GNU nano 编辑文件,改完以后再用命令行执行编译命令,这个过程就是典型的“编辑器 + 编译器”分工。
1.2 编辑器家族图谱
按使用场景,编辑器大致可以分成五类:
| 类型 | 代表工具 | 核心特征 |
|---|---|---|
| 纯文本编辑器 | Vim、GNU nano、Notepad | 面向代码和配置文件,以行为单位 |
| 结构化文本编辑器 | Markdown 编辑器、富文本编辑器 | 对内容做分段、加粗、标题等语义化处理 |
| 二进制/数据编辑器 | 暗黑2存档编辑器、无人深空存档编辑器 | 直接操作文件字节,校验和、偏移量是关键词 |
| 图形资源编辑器 | compositor 图像编辑器、Godot 地形编辑器 | 操作像素、网格、地形高度图 |
| 参数配置编辑器 | Zemax 多重结构编辑器、组策略编辑器 | 编辑特定数据表或系统配置项 |
看到这个分类你就明白了,编辑器不是一个“软件”,而是一类“交互范式”。PDF 编辑器要处理的是页面对象和注解,存档编辑器要处理的是游戏数据的二进制布局,Zemax 多重结构编辑器要处理的是光学系统的多重结构参数表。它们共享的骨架是:一个数据模型,一套交互层,一个渲染输出。这个骨架就是接下来要动手实现的东西。
1.3 写编辑器之前,先回答三个问题
任何一个编辑器,在动手前必须回答三个问题。
第一个问题:数据模型是什么?文本编辑器最朴素的数据模型是“字符串数组”,每一行是一个元素。富文本编辑器的数据模型是带格式标记的节点树,相当于把文档拆成段落、行内样式、超链接这些对象。存档编辑器的数据模型则是一个字节数组加上一整套字段解释规则。数据模型决定你后续所有代码怎么写。
第二个问题:渲染层在哪里?终端是一种渲染层,浏览器 DOM 是一种渲染层,原生 GUI 控件又是一种渲染层。终端渲染省事但简陋,适合展示核心逻辑;浏览器渲染丰富但要注意 DOM 性能;原生渲染最复杂,Windows/Linux/macOS 三套窗口系统都要处理。新手建议先从终端或浏览器入手。
第三个问题:交互入口是什么?Vim 是纯键盘驱动的模态交互,GNU nano 是底部快捷键区加文本主区,Markdown 编辑器是“左边写右边看”,富文本编辑器几乎全靠鼠标。交互形态会反过来影响数据模型的设计,比如 Vim 的“模式”概念就要求编辑器内部维护一个状态变量。
这三个问题想清楚了,编辑器怎么写就已经有了一张清晰的施工图。
2. 编辑器架构:先把地基聊透
写编辑器不是上来就敲代码,架构设计决定了你是花三天做完一个 Demo,还是花三个月维护一个工具。
2.1 数据模型:编辑器的一半灵魂
拿最常见的文本编辑器举例,最简单的数据模型是一个列表:
buffer = ["第一行", "第二行", "第三行"]每一行的字符串长度不等,增删行就是操作列表。这个模型理解起来容易,但真做起来有几个坑。比如光标移动到第 10 行的第 200 列时,你要先确认第 10 行真的存在,而且长度真的大于等于 200。再比如按退格键删到行首时,是直接删除这行,还是和上一行合并?这些细节都必须定义清楚。
进阶一点的数据模型是 Gap Buffer 或者 Piece Table。Gap Buffer 是在缓冲区中间预留一段空白,插入和删除都只操作这段空白,减少字符移动次数,很多桌面编辑器用的是它。Piece Table 是把原始文件和修改记录拆成片段,每次保存时才真正拼接内容,适合需要频繁撤销的场景。新手阶段没必要一上来就上这些高级结构,但你心里要知道:朴素数组能撑住一千行,撑不住一百万行。
2.2 状态机与命令模式:交互的核心
编辑器本质是一个状态机。同样按下一个字母键,在普通模式里是“触发命令”,在插入模式里是“输入字符”。Vim 正是靠这种模态切换,把几十个命令压缩到主键盘区,手不用离开中央区域就能完成所有操作。
实现模态切换很简单,一个变量的事:
mode = "normal" # 普通模式 mode = "insert" # 插入模式难的是命令系统。当你把“保存文件”“移动光标”“删除单词”“撤销上一步”这些操作全部抽象成命令对象时,编辑器就具备了可扩展性。每个命令都实现统一的 execute 和 undo 接口,撤销栈就能统一管理所有操作。这也是为什么好编辑器几乎都有插件系统——插件本质上就是把用户动作注册成命令。
2.3 渲染与合成:显示层的通用思想
编辑器写内容,最后总要让人看见。这个过程叫渲染,也叫合成。
终端渲染是逐行重绘,每次刷新都把当前屏幕覆盖掉再重新画一遍。Web 编辑器是把数据模型转换成 HTML 节点,由浏览器负责排版。图像编辑器里的“合成”更直接:compositor 图像编辑器这个名字里的 compositor,指的就是把图层按透明度和混合模式叠加成最终画面的模块。地图像素图层、文字图层、特效图层,最终合成到画布上,这和文本编辑器把多块内容渲染成一个页面,本质是一个思想:把分散的数据组织成有序的视觉输出。
渲染有一个通用性能原则:能少画就少画。终端里只重绘当前可见区域,浏览器里只更新变化的 DOM 节点。很多编辑器卡顿,不是算法不行,而是每次按键都把整个界面重新渲染了一遍。
3. 实操:从零写一个终端编辑器
现在进入正题。我们用 Python 标准库里的 curses 模块写一个极简终端编辑器。curses 负责处理终端输入输出,屏蔽掉各种终端型号差异,让我们能把注意力放在编辑器逻辑上。选择 Python,是因为它表达力强,逻辑清晰,适合教学;你写完以后完全可以移植到 Go、Rust 或者 C。
3.1 为什么要用 curses 而不是 input()
很多人会问:既然只是编辑文本,为什么不用 input() 逐行读取?因为 input() 依赖回车换行,它没法实现“按一下方向键光标就移动一格”这种实时交互。编辑器的核心是“渲染循环”:每按一个键,屏幕立刻反映变化。curses 提供的是原始终端控制,不经过行缓冲,每个按键都能立刻被程序拿到,同时能任意定位光标、刷新屏幕区域。
先做一个最基础的骨架:
import curses def main(stdscr): curses.curs_set(0) # 隐藏光标 stdscr.keypad(True) # 启用功能键/方向键 buffer = ["# 我的编辑器", "", "Hello, world!"] while True: stdscr.clear() for i, line in enumerate(buffer): stdscr.addstr(i, 0, f"{i+1:3d}| {line}") stdscr.refresh() key = stdscr.getch() if key == ord("q"): break curses.wrapper(main)这段代码已经能展示三行文本,按 q 退出。但它还不能移动光标,不能编辑内容。这就是最原始的编辑器形态:一个循环、一个缓冲区、一个刷新函数。
3.2 加光标、加插入模式,一个迷你 Vim
继续扩展:加入普通模式和插入模式,实现光标移动和字符输入。普通模式按 i 进入插入,按 ESC 返回普通,按 q 退出。插入模式下输入的任何字符都会插到当前光标位置。
import curses import sys def main(stdscr): curses.curs_set(0) stdscr.keypad(True) filepath = sys.argv[1] if len(sys.argv) > 1 else None buffer = [""] if filepath: try: with open(filepath, "r", encoding="utf-8") as f: buffer = f.read().split("\n") except FileNotFoundError: pass y, x, top = 0, 0, 0 mode = "normal" def refresh(): stdscr.clear() rows, _ = stdscr.getmaxyx() # 只渲染当前屏幕范围内的行 for i in range(rows - 1): index = top + i if index >= len(buffer): break try: stdscr.addstr(i, 0, f"{index + 1:4d}| {buffer[index][:60]}") except curses.error: pass # 底部状态栏 status = f" -- {mode} -- 行 {y + 1}/{len(buffer)} 列 {x + 1} 文件: {filepath}" try: stdscr.addstr(rows - 1, 0, status[:80]) except curses.error: pass stdscr.refresh() while True: refresh() key = stdscr.getch() if mode == "normal": if key == ord("q"): break elif key == ord("i"): mode = "insert" elif key == ord("j") and y < len(buffer) - 1: y += 1 elif key == ord("k") and y > 0: y -= 1 elif key == ord("h"): x = max(0, x - 1) elif key == ord("l"): x = min(len(buffer[y]), x + 1) elif key == ord("w"): if filepath: with open(filepath, "w", encoding="utf-8") as f: f.write("\n".join(buffer)) elif mode == "insert": if key == 27: # ESC mode = "normal" elif key == 10: # 回车 buffer.insert(y + 1, buffer[y][x:]) buffer[y] = buffer[y][:x] y += 1 x = 0 elif key in (263, 127): # 退格 if x > 0: buffer[y] = buffer[y][:x - 1] + buffer[y][x:] x -= 1 elif 32 <= key <= 126: buffer[y] = buffer[y][:x] + chr(key) + buffer[y][x:] x += 1 x = min(x, len(buffer[y])) curses.wrapper(main)跑起来以后,你已经拥有一个 100 行以内的 Vim 变体:有普通模式、插入模式、保存文件、光标移动、换行和退格。这个编辑器足以编辑一个小的 Python 文件,然后你在终端里执行python3 你的编辑器.py test.txt。
这里有三个容易踩的细节:
- 回车键在 curses 里是整数 10,不是
'\n'字符串,不少第一次写的人会在这里卡住。 - 退格键在不同终端上可能是 263 也可能是 127,最好两个都判断。我当时在 macOS 的 Terminal 里跑得好好的,换到 Windows Terminal 就发现退格变 127 了。
- 插入字符是“改一行”,不是“重写整行缓冲区”。字符串切片拼接虽然看上去笨,但实际效率足够撑住单行几百字符的编辑。
3.3 保存文件、换行符与中文乱码
保存的时候最怕遇到两件事:换行符不对、中文乱码。
Windows 的文本文件通常用\r\n作为换行,Linux/macOS 用\n。我们上面这个编辑器统一用\n读写。如果你的编辑器要跨平台打开 Windows 生成的文本文件,读取时最好把\r去掉,写入时再根据平台换回来。
中文乱码几乎都是编码问题。文件读写必须显式指定encoding="utf-8",否则 Python 在 Windows 上用默认的 GBK 编码读 UTF-8 文件,轻则乱码,重则直接抛UnicodeDecodeError。另外,有些 Windows 编辑器会在文件开头写入 BOM 头(\ufeff),读取后最好用strip()或者lstrip()把 BOM 去掉。
还有一个看似奇怪但很常见的现象:终端里显示中文时光标列位置会差一到两个字符。原因是终端渲染中文字符时占用两个单元格,而我们的代码里 x 是按“字符数”算的,不是按“单元格宽度”算的。真正要做好的编辑器,需要引入“显示宽度”概念,逐字符累加宽度。这个问题在 Web 编辑器里也存在,中文输入法组词拼音时的光标定位同样令人头疼。
3.4 往上加东西:撤销、高亮、搜索
基础版能跑以后,扩展方向很清晰。撤销功能要用命令栈,每次操作前把逆操作压栈;语法高亮要做分词器和颜色映射;搜索要用增量匹配加光标跳转。这些功能每个都值得单独写一篇,但对新手来说,最重要的不是一次性全做完,而是先把循环跑通:按下一个键,数据变,屏幕变,状态可回退。
4. 实操:写一个 Web 实时 Markdown 编辑器
终端编辑器写完了,我们再上一个台阶,做一个在浏览器里运行的 Markdown 编辑器。这类编辑器在网上的搜索量常年很高,很多人想要的就是左边写、右边实时预览的效果。
4.1 富文本编辑器的经典陷阱:contenteditable
先说说为什么我不推荐用 contenteditable 做富文本编辑器。contenteditable 就是让一个 HTML 元素内容可编辑,浏览器帮你处理输入,看起来省事,但一涉及自定义格式就非常痛苦:光标位置难控制,粘贴内容会带上一堆乱七八糟的样式,撤销历史完全是浏览器黑盒,跨浏览器行为差异大。
业界知名的富文本编辑器,比如 Quill、Slate、ProseMirror,核心思路都不约而同地绕开浏览器原生行为:自己管理数据模型,把 document 当成结构化的 JSON 树,再通过自定义渲染把树画到界面上。这样无论用户在界面上怎么折腾,内容状态始终是自己数据结构的一个快照。这种“数据模型即文档”的思想,和终端编辑器里 buffer 数组是一模一样的。
4.2 最小可用的实时预览实现
Markdown 编辑器比富文本编辑器简单得多,因为输入源就是纯文本,我们只需要做“渲染”,不需要做复杂选区操作。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>实时 Markdown 编辑器</title> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <style> html, body { height: 100%; margin: 0; } #wrap { display: flex; height: 100%; } #source, #output { width: 50%; height: 100%; box-sizing: border-box; padding: 16px; } #source { border: none; outline: none; resize: none; font-size: 16px; } #output { overflow-y: auto; border-left: 1px solid #ccc; } </style> </head> <body> <div id="wrap"> <textarea id="source" placeholder="# 在这里输入 Markdown..."></textarea> <div id="output"></div> </div> <script> const source = document.getElementById('source'); const output = document.getElementById('output'); let timer = null; function render() { const html = marked.parse(source.value); output.innerHTML = html; } source.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(render, 200); }); render(); </script> </body> </html>用 marked 这个库把 Markdown 解析成 HTML,然后塞进预览区域的 innerHTML 里。需要防抖,用 setTimeout 把连续输入包裹成 200 毫秒内的最后一次渲染,这样快速打字时不会每敲一个字符就全量解析一次。段落拆分、代码块、列表这些复杂语法 marked 已经处理好了,真正需要你动手写的业务逻辑不多,但核心架构思路很值得体会:源文本是唯一可信数据,预览只是它的一次带格式投影。
4.3 图片不显示,问题到底出在哪
很多人在搜索“js+html 编辑器添加图片不显示”,最常见的原因只有四个。
第一个是路径问题。如果你在 Markdown 里写的是,浏览器会基于当前页面 URL 解析这个相对路径。页面在http://localhost:8080/edit.html时它解析成http://localhost:8080/img/a.png,页面部署到子目录或者 CDN 后路径就变了,图片自然加载失败。
第二个是跨域问题。图床在其他域名时,浏览器默认允许<img>显示跨域图片,但如果你稍后要对图片做 Canvas 处理,比如裁剪、压缩,跨域图片会被画布污染。
第三个是大文件问题。用户本地拖拽一个 8MB 的截图进来,你用普通方式转成 URL 直接塞进 Markdown,页面会越来越卡。正确做法是用 FileReader 把文件转成 Data URL 或者上传到对象存储,再插入 CDN 地址。
第四个是反斜杠和空格问题。Windows 本地路径里带反斜杠和空格,直接写进 Markdown 容易被 Markdown 解析器理解成转义符或截断。建议在上传前把路径全部转成正斜杠,并对空格做 URL 编码。
一个可靠的本地图片插入方案是:
const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.onchange = e => { const file = e.target.files[0]; const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result; source.value += `\n\n`; render(); }; reader.readAsDataURL(file); }; fileInput.click();Data URL 把图片二进制直接内嵌进 Markdown 文本,预览一定不会出现路径问题。缺点是文件变大,所以别用它处理超大图片;更好的方式还是先压缩再上传。
4.4 公式、代码高亮与导出扩展
Markdown 编辑器做得再深入一点,就要支持公式。论文公式编辑器、试卷排版工具里,人们经常用 LaTeX 语法写公式,比如$E = mc^2$。渲染层用 MathJax 或 KaTeX 把公式从$...$中转成数学符号。代码高亮用 highlight.js,在渲染 HTML 后对code块做语法着色。导出 PDF 则可以在渲染完成后调用浏览器的打印功能,或者用 Puppeteer 在服务端生成。
这些扩展的共同逻辑是“在渲染管线里加钩子”。源文本不变,只是从“纯 HTML”变成“带公式、带高亮、带样式的 HTML”。
5. 从通用到专业:图像、PDF、存档编辑器是怎么做的
通用文本编辑器看多了,你会好奇那些特殊行业的编辑器为什么长得完全不一样。其实它们的共同结构仍然是“数据模型 - 交互 - 渲染”,只是数据模型换了。
5.1 图像编辑器:像素、图层的合成逻辑
compositor 图像编辑器听名字很陌生,本质就是一个图形编辑器,核心概念是“像素矩阵 + 图层合成”。一张图片在电脑里是一堆像素的 RGB 值,图像编辑器提供橡皮、画笔、滤镜这些操作来修改像素矩阵。图层则是把多个像素矩阵叠在一起,每个图层有透明度、混合模式,最终通过合成器(compositor)算出一个结果画面,这就是你屏幕上看到的效果。
如果让我用编辑器架构去理解图像编辑器,数据模型是像素和图层栈,交互是笔刷和选区,渲染是合成器输出。这和我们前面写的文本编辑器没有本质区别,只是“行文本”换成了“像素面”。
5.2 存档编辑器:二进制格式与校验和
游戏存档编辑器是另一个极端。暗黑2存档编辑器、无人深空存档编辑器,这类工具的输入是一份二进制文件,你要按照游戏的存档结构去解读每一个字节,再提供友好的表单让你修改装备属性、金币数量、技能点。
存档编辑器最难的部分不是界面,而是逆向文件格式。你需要弄清楚头部多少字节、角色名字段从偏移多少开始、装备前缀占几个字节、属性值是整数还是浮点数。游戏为了防止作弊,往往还会在存档里写校验和。你改了任何数据,校验和就对不上,游戏直接报“存档已损坏”。所以存档编辑器必须实现同样的校验和算法,改名存盘前重算校验和:
def fix_checksum(data): body = data[:10] # 头部 payload = data[10:-4] # 正文 new_checksum = calc_crc32(payload) # 按游戏算法重算 return body + payload + new_checksum.to_bytes(4, "little")很多人不理解为什么游戏存档修改器看起来都很简陋,因为精力全花在解析格式和校验算法上了,界面反而无所谓。
5.3 PDF 编辑器与专业的参数编辑器
PDF 编辑器处理的是页面树与注解对象,Zemax 多重结构编辑器处理的是光学系统在不同结构下的参数表,组策略编辑器处理的是 Windows 注册表之上的策略配置项。它们共通点很明显:编辑器面对的是特定领域的数据结构,界面的作用是“把二进制或结构化数据翻译成人能理解的表单”。
所以学写编辑器,最大的收获其实是:任何看似独立的软件形态,拆到底都是那几层——数据、交互、渲染、持久化。你掌握了这个拆解能力,换任何领域都能快速上手。
6. 常见问题与避坑实录
编辑器开发中踩到的坑,远比我预想的多。这里列几个最典型的问题,几乎每个人都会遇到。
6.1 中文输入法和编辑器的光标打架
Web 编辑器里有一个著名难题:输入法组词时,你还没选好候选词,input 事件就已经触发了。如果你在每次 input 时都重新调整光标位置,候选词就被打乱,用户根本没法正常打中文。解决思路是在 compositionstart 到 compositionend 期间冻结光标同步,只做数据更新,等组词完成后再统一渲染。这个细节不做,你的编辑器对中文用户就是半残废。
终端编辑器里也有类似问题。curses 原生对中文输入法的支持很差,很多终端模拟器在 raw 模式下根本收不到输入法组词事件,所以我的建议是:终端编辑器优先处理英文和代码场景,要真正做好中文支持,还是得到浏览器或原生 GUI 层面解决。
6.2 大文件卡顿:虚拟滚动和三段式渲染
打开一个 50 万行的日志文件,如果每次刷新都渲染全部行,程序必然卡死。解决方法是虚拟滚动:只渲染视口范围内的行,其他行不渲染。需要在内存里维护一个“可见行缓存”,滚动事件触发时计算当前 top 行索引,然后只拼接可见区域的那几十行。
数据模型层面也要优化。字符串数组在 50 万行时会变成 50 万个 Python 对象,内存占用惊人。可以考虑用行索引缓存:不保存每一行的完整字符串,只保存行起始偏移量,读取时从大文件里按偏移切片。这个思路对应了前面提过的 Piece Table 思想,真正的大规模文本编辑器都是这么做的。
6.3 保存文件别直接覆盖,先写临时文件
直接对目标文件执行open(path, "w")很危险:写入过程中程序崩溃,原文件就废了。正确做法是写到一个同目录的临时文件,写完以后用os.replace()原子替换。这样要么全部写成功,要么原文件保持不动。文本编辑器如此,PDF 编辑器、存档编辑器更是如此,尤其是暗黑2存档编辑器这种直接改玩家数月心血的工具,一个坏存档就能毁掉游戏体验。
保存时的另一个坑是不小心追加了多余的换行。用split("\n")读文件以后,写入时"\n".join(buffer),尾部是不是多一个换行,取决于你的拆分逻辑,一个符号之差就会让文件出现空行,甚至影响编译器的行号。
6.4 别再小看插件系统
当你把编辑器主程序做完,自然想加插件。插件系统设计的好坏,直接决定了这个编辑器有没有生命力。最基础的要求是:插件能注册命令、能监听事件、能访问编辑器对象模型。Vim 的插件体系就是这套逻辑,VS Code 也是。写插件系统的时候记住两条铁律:插件必须运行在受限环境里,不能因为一个插件崩溃拖垮主进程;插件 API 一旦发布就尽量稳定,否则生态做不起来。
7. 最后分享一点我的实际体会
编辑器这个东西,写起来容易,写“好”极难。我前前后后写过三个编辑器,第一个是模仿 Vim 的终端版本,第二个是给团队用的内部 Markdown 工具,第三个是给一个数据产品做的专业表单编辑器。每次重新开工,我都会把重心放在数据模型上,因为界面可以换、交互可以改,但数据模型一旦定错,后面全盘返工。
如果你看完这篇文章打算动手,我的建议很朴素:第一天用 Python 把终端编辑器跑通,第二天用浏览器把实时预览跑通,第三天试着给终端编辑器加一个撤销功能。就这三个任务,足够你把编辑器的核心问题全部体验一遍。遇到问题别急着去找现成库,先想想编辑器架构里它属于哪一层,是数据层、交互层还是渲染层。想清楚层,答案自然浮出来。
写编辑器不是终点,它是一把钥匙。等你亲手做出来一个哪怕极其简陋的编辑器,再回头看 Vim、VSCode、Notion,你会看到软件的本质,而不是一堆华丽的按钮。