ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零手写编辑器:架构拆解与Python、Web实战

从零手写编辑器:架构拆解与Python、Web实战 你可能见过形形色色的编辑器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{i1: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, encodingutf-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, encodingutf-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去掉写入时再根据平台换回来。中文乱码几乎都是编码问题。文件读写必须显式指定encodingutf-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 charsetutf-8 title实时 Markdown 编辑器/title script srchttps://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 idwrap textarea idsource placeholder# 在这里输入 Markdown.../textarea div idoutput/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 图片不显示问题到底出在哪很多人在搜索“jshtml 编辑器添加图片不显示”最常见的原因只有四个。第一个是路径问题。如果你在 Markdown 里写的是![图](./img/a.png)浏览器会基于当前页面 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![${file.name}](${dataUrl})\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你会看到软件的本质而不是一堆华丽的按钮。
返回列表