如果你在程序员圈子里搜 "t3code",大概率会搜出一堆不相关的东西——这个名字是我自己起的内部代号,没打算做成什么知名产品。但它确实解决了我一个大痛点:在低配机器和远程服务器上,我再也忍不了"改个配置要开本地 IDE、再手动同步"的日子了。简单说,t3code 是一个轻量、本地优先、同时为远程场景优化的代码编辑器。它没有 Electron 那股重量级劲头,没有云端后台,数据默认只留在你自己机器上。它适合谁?适合长期和服务器打交道、电脑配置不高、又不想被各种现代化工具吃内存的程序员,也适合纯粹想搞清楚"一个编辑器到底怎么能跑得这么快"的人。
这篇文章我想把 t3code 从立项、选型、开发到踩坑的全过程,按真实顺序唠一遍。不是教程,不是广告,就是一个干了十多年活的老开发,在讲他怎么靠业余时间把一件"忍不了"的事变成了一套能用的工具。如果你正好也有类似的怨气,这里面的经验和坑大概率对你有用。
1. 先说说 t3code 这个项目是从哪来的
1.1 压垮我的最后一根稻草
大半年前,我一直在维护一台 2 核 2G 内存的云服务器,上面跑着好几个 Python 服务、Nginx、还有一堆定时任务。每次要改配置,流程都是标准三步:ssh 登上去,找个顺手的终端编辑器,改完再手动重启服务。听起来不算难,但问题出在次数上。一天要改五六次,每次都得重新记一堆 vim 快捷键,偶尔脑袋一热按错键,把一段本来就靠祈祷才能跑通的配置删了,那心情简直没法形容。
有一次凌晨两点改 Nginx 反向代理,一个误操作把 upstream 整段删了,服务直接挂了半小时。我一边恢复配置一边骂:本地开发有 VS Code、有 JetBrains,为什么到了服务器上,我就得像 1998 年一样在黑屏里靠记忆改文件?这台机器 2G 内存,跑不动 Docker,更跑不动 Electron 套壳的各种远程编辑器。我当天晚上就决定:要么自己写一个,要么继续被这种破体验折磨。
这就是 t3code 的起点。不是一时兴起,是积攒了很久的"不想再忍了"。
1.2 为什么不自研插件,非要做独立工具
一开始我也想过,直接在 VS Code 里装个 Remote-SSH 插件不就完事了?但我在那台 2G 内存的服务器上实测过,光是 VS Code Server 在远端跑起来,就要占接近 300M 内存,加上本地编辑器的开销,2G 机器一下就变得捉襟见肘。更别提那台服务器上还跑着业务服务。再后来我尝试过直接改 vimrc、装插件,效果确实好,但配置链条太长,换台机器就要重新折腾一遍,团队里的人也没法上手。
我最后定的方向是:一个没有"远端服务端"概念的远程编辑器。文件传输、保存、目录遍历都通过轻量的 HTTP 接口完成,不在服务器上驻留任何重量级进程。这样既保留了终端时代的轻量,又能拿到图形界面时代的编辑体验。t3code 这个名字,就是在那个凌晨敲定的——"t3"取"terminal、text、tool"的缩写,没什么玄学,就是好记。
1.3 目标:低配机器上的"快"和"稳"
立项时我给自己立了三条硬指标,后面所有技术选型都围着它们转:
- 冷启动速度:从点击图标到能编辑文件,必须小于 1 秒。VS Code 在我旧笔记本上要 3 到 5 秒,这个差距不能忍。
- 内存占用:常规编辑会话下,总内存占用控制在 150M 以内。对比一下,Electron 应用动辄 500M 起步。
- 远程能力:支持通过 HTTP 协议直接读写远程文件,远程机器的内存占用必须小于 20M。
这三条指标看起来很朴素,真正实现的时候才发现,每条都在逼着你做取舍。比如"启动快",意味着你不能在启动时加载一堆插件、解析一堆配置;"内存小",意味着你不能让整个文件都常驻 DOM 里,得想尽办法按需渲染。后面我会细说每一个取舍的代价。
2. 技术底座选型:为什么最终没有选 Electron 和 Tauri
2.1 先画一张候选清单
对一个编辑器来说,技术底座基本决定了它的天花板。我当时列了四个候选方向:
| 方案 | 优点 | 致命伤 |
|---|---|---|
| Electron + Monaco | 生态成熟,直接复用 VS Code 的编辑器内核 | 启动慢、内存大、和我的目标完全冲突 |
| Tauri + Web 前端 | 内存比 Electron 低很多,体积小 | 需要 Rust 环境,且 WebView 在某些 Linux 服务器上兼容性玄学 |
| Go/ Rust 后端 + 本地浏览器访问 | 内存极低,天然适合远程 | 界面能力受浏览器限制,文件系统能力要自己写 |
| 纯终端 TUI(类似 lazygit 风格) | 最轻,SSH 党狂喜 | 学习成本高,和"图形界面时代体验"的目标背离 |
我把每项都做了 2 天的小原型验证。Electron 那条路最先死:用 Electron 写了个空窗口,内存就吃到 90M,加载 Monaco 之后直接飙到 400M。这在 2G 服务器面前基本可以不谈。Tauri 那条路在本地 Mac 上表现很棒,内存 60M 左右,但我在服务器上跑了个 headless 的 WebView 测试,渲染兼容性坑太多,放弃。
最后我选了第四种方案里的"变体":桌面端用系统原生 WebView 封装一层薄壳,核心逻辑全部用 TypeScript 写在浏览器里,文件系统访问通过本地 HTTP 服务完成。这个"薄壳"是重点:它不是浏览器,也不装 Node,只是提供一个能跑 Web 前端的最小容器,类似 Electron 但去掉了所有重组件。实测下来,冷启动 0.8 秒,常规编辑内存 120M,远程服务器端内存只有 9M。等这个壳跑通了,本地 WebView 反而成了最顺手的调试环境。
2.2 "壳"的噩梦:跨平台兼容性的妥协
选型这里我必须多说一句:如果你要复刻,千万不要低估 WebView 这层壳的跨平台兼容性。Windows 上系统 WebView2 是 Chromium 内核,Linux 上可能是 WebKitGTK,macOS 上是 WKWebView。同样的 HTML/CSS/JS,在这三个环境里的表现会有细微差异,尤其是字体渲染、滚动条样式、textarea 聚焦行为。
我的解法简单粗暴:尽量少依赖 CSS 特性,能用 canvas 绘制的地方(比如行号、缩进线、搜索高亮)就自己画,不依赖 CSS 滚动条。这条路前期开发慢,后期兼容性问题几乎绝迹。你要是图快,用 Electron 确实能省很多事,但代价就是你得接受它那几百兆的内存开销。取舍没有对错,只看你的目标是不是像我一样变态。
3. 核心三件套:文件树、编辑器内核、命令面板
3.1 虚拟文件系统:不直接操作磁盘
t3code 的架构里有一个很关键的设计:前端不直接操作磁盘,所有文件读写都走一个"虚拟文件系统"(VFS)层。VFS 对外暴露的接口其实就是那几件事:listDir、readFile、writeFile、rename、delete。但它的内部实现要区分三种情况:
- 本地模式:直接映射到本地文件夹。
- 远程模式:通过 HTTP 代理发送到远程服务器。
- 剪贴板/临时模式:没有真实文件,只在浏览器内存里维护。
抛个具体的例子:远程模式下,前端看到一个目录树,其实是通过一次 POST/api/list拿到的 JSON 数组。这个数组不是全部文件,而是只有当前展开的节点;每次展开文件夹才请求一次。数据库一样的东西都不存在,我用的是最简单的"按需拉取"策略。这样远程服务器的压力极小,首屏速度也快。
VFS 还要解决一个痛点:文件监听(file watching)。本地模式可以用fs.watch实现,远程模式就得靠另一套机制——我做了个轻量轮询,默认每 2 秒拉一次目录mtime,发现变化才刷新列表。别小看这个设计,它直接决定你在远程改完代码后,本地编辑器能不能立刻看到变化。没有这个轮询,远程模式基本是残废。
3.2 编辑器内核:CodeMirror 6 而不是 Monaco
编辑器内核的选择,是我觉得整个项目里最值得讲的部分。当时 Monaco 似乎是最稳妥的选择,毕竟 VS Code 就用它。但我实际测试后发现,Monaco 在低配置机器上启动一次要 300ms 到 500ms,而且它默认内置太多语言服务,想精简反而费劲。CodeMirror 6 是我最终的选择。
为什么是 CodeMirror 6?三个原因:
- 模块化做得彻底:你只用引入需要的语言包、补全包、搜索包,其他一概不加载。
- 可扩展性好:通过 extension 机制几乎能改任何行为,没有锁死你。
- 纯 TS 无框架依赖:不绑定 React/Vue,我在 WebView 里用起来非常干净。
实现一个基本编辑器其实不用多少代码,核心就三步:创建 EditorState、配置扩展、挂载 EditorView。真正让我花时间的是那些"编辑器之外的东西"——多光标、选中高亮、行内错误提示、快速跳转。CodeMirror 6 的 state 管理是事务式的,每一次命令都是 dispatch 一个 transaction,这也让撤销/重做变得异常简单:我只用维护一条 transaction 历史栈,不用自己拼字符串快照。
有一些细节值得单独提醒:中文注释的多字节光标定位,还有 IME(输入法)组合态的问题。CodeMirror 6 对 IME 的处理比老版本好很多,但在 WebView 上你依然要处理 composition 事件,否则在拼音/五笔输入时,光标会忽然跳到行尾。这个坑在我后面踩坑章节会详细说。
3.3 命令面板:编辑器好不好的隐形标尺
一个编辑器好不好用,表面看是看高亮和补全,实际上我衡量编辑器的标准是命令面板。VS Code 的 Ctrl+Shift+P 之所以让人觉得"专业",不是因为面板本身炫,而是因为它把所有零散功能汇总在了一个可搜索的索引里。t3code 也做了一个类似的东西,背上一个"命令注册表"。
命令面板的实现思路很朴素:
- 所有功能(打开文件、切换文件、格式化、保存、搜索等)在启动时注册成 Command 对象,每个对象包含
id、title、keywords、execute函数。 - 面板打开时,把所有 Command 的
title和keywords拼成一份内存索引,用模糊匹配做过滤。 - 选中某个 Command 后执行
execute,如果涉及快捷键,也由这个注册表统一绑定。
别看这个东西逻辑不多,它直接决定了 t3code 的"肌肉记忆感"。我在命令注册表里维护了 60 多个命令,每一个都是我自己日常高频使用的。多人协作暂时没有考虑,因为定位就是个人工具,先把单人流程打磨顺。
4. 远程场景:把"两端"都做成第一等公民
4.1 为什么远程模式反而成了主战场
本来远程只是附加功能,做着做着,发现本地编辑反而是次要的了。因为在服务器上编辑文件这个高频场景,多年来一直没有好工具。我用 t3code 远程连那台 2G 服务器时,整个远程代理进程只占 9MB 内存,只开一个 80 端口的 HTTP 服务,不做任何后台数据库和文件索引。这个服务端组件我给它起了个名叫 "t3d",它本质上就是一个静态文件加三个 API 的集合。
远程通信协议我设计得很克制:所有东西都是 JSON over HTTP,没有 WebSocket、没有双向长连接。为什么不用 WebSocket?因为我要让 t3d 足够简单,能在一个标准库环境下跑起来,不需要额外依赖。目录列表、读文件、写文件都是普通的 POST 请求,唯一的例外是"文件变化轮询",它是用 setTimeout 发 GET 请求实现的。
协议字段也很简单,一个请求长这样:
{ "path": "/www/app/config.py", "action": "read", "encoding": "utf-8" }读文件时,t3d 返回{ "content": "...", "mtime": 1699999999 };写文件时,前端会把整个新内容 POST 上去,t3d 原子写入(先写临时文件再 rename)。这一步"临时文件+rename"很关键,能避免写一半时进程读到损坏文件。别看是细节,生产环境就靠这种细节保命。
4.2 安全和权限:能连上不等于能乱改
远程功能做出来以后,我最担心的是安全问题。我不能让 t3d 变成一个"谁连上就能删文件的裸奔服务"。所以 t3d 从一开始就带了两层防护:
- 访问令牌:启动时生成一个随机 token,前端发起请求必须在 Header 带上
Authorization: Bearer <token>,否则一律 403。 - 路径校验:所有请求里的 path 参数,先用
path.resolve标准化,再判断是否落在允许的根目录内。这样做是为了防路径穿越(../../etc/passwd)。
路径校验这块我踩过一个真实的坑,后面讲。先给个正确的判断逻辑样例:
const resolved = path.resolve(rootDir, req.body.path); if (resolved !== rootDir && !resolved.startsWith(rootDir + path.sep)) { throw new Error('forbidden'); }注意这里startsWith(rootDir + path.sep)的判断顺序:先处理掉等于根目录本身的情况,再加path.sep,不然你可能会把一个叫做/www/profile的目录当成/www/pro的子目录放行。这种小边界,测试时根本不容易想到,但真的会出问题。
4.3 大文件传输的分块处理
远程模式下,大文件是绕不开的场景。日志文件、数据库导出文件,动辄几十 MB。用最笨的办法一次性 readFile,前端内存会瞬间爆掉。我的方案是分块读取:前端发请求时带offset和length,t3d 只返回那一段的字节。
{ "path": "/www/logs/app.log", "action": "read", "offset": 0, "length": 262144 }配合虚拟滚动,编辑器实际上只渲染当前可视区域那几行,滚动时会继续向后拉数据。这种"按需加载"的思路,普通文件感受不到区别,但大文件打开速度和内存占用简直是两个世界。普通编辑器打开 100MB 日志可能要吃 1G 内存,t3code 把初始加载控制在了 8MB 以内,体验好得多。
5. 性能不是玄学:实测数据与优化手段
5.1 冷启动、内存、大文件三张成绩单
跑通第一个可用版本之后,我立刻做了一轮标准测试。测试环境是一台 2015 年的 MacBook Air(4G 内存,i5 双核),以及那台 2 核 2G 的云服务器。结果如下:
| 指标 | t3code | VS Code(对照组) |
|---|---|---|
| 冷启动到可编辑 | 0.8s | 4.2s |
| 常规项目内存占用 | 118MB | 512MB |
| 打开 50MB 日志文件时间 | 1.1s | 5.5s |
| 远程代理内存占用 | 9MB | 300MB(Remote-SSH) |
这个结果不是我自嗨,是有一次我去给朋友解决服务器问题,现场对比的。他在那台 2G 服务器上装了 VS Code Remote,还没开始写代码机器就快卡死了。换成 t3code 远程后,内存占用肉眼可见地降下来,他当场问我要了仓库地址。那一刻我觉得,这三个月的业余时间花得值了。
5.2 渲染层的三个大招
性能达标不是靠运气,我做了三件明确的事:
第一,列表虚拟化。文件树和搜索结果列都不会渲染所有 DOM 节点,只渲染可视区加少量缓冲区的节点。数据量从几万条缩小到几十条,DOM 节点数少了一个数量级,滚动自然就顺滑了。
第二,编辑器视图的块渲染。CodeMirror 6 本来就按 viewport 渲染内容,但我额外开启了lineWrapping的降级策略。代码超过 200 字符时,自动关闭自动换行,改用横向滚动,否则长行会触发文本布局风暴,卡到没法看。
第三,防抖保存。我把自动保存拆成两步:编辑时只更新内存状态和撤销栈,停止输入 300ms 后才触发写盘/写远程。同时用了一个"焦点失焦时强制刷新"的兜底策略,防止页面消失时数据没存上。这两步组合,既保证不丢数据,又避免每次击键都触发一次磁盘 IO。
这些优化说起来轻松,实际调试时每一步都伴随"为什么还有一点卡"的反复验证。给我最大教训的不是技术本身,而是"性能优化必须带着数据去做",任何一个优化,没有前后对比,就不要上,否则你改的代码究竟有没有效,完全靠猜。
6. 踩过的坑:从输入法到路径穿越,排查链路全记录
6.1 中文输入法导致光标乱跳
第一个让我抓狂的坑,是中文输入法下编辑代码时光标会乱跳。场景是这样:在 t3code 里输入中文注释,拼音打到一半,想移动光标再删个字,结果光标直接跑到了文件末尾,整个输入框的 composition 状态全乱了。
排查链路是这样的:先在本地浏览器复现,发现用 Chrome 自带输入法无法复现,只有系统输入法(Mac 的拼音和 Windows 的微软拼音)会出问题。然后把范围缩小到 WebView 和 Chrome 的差异;进一步检查 CodeMirror 6 的 input handler,发现它在处理compositionstart到compositionend期间,依然会执行大量基于位置的 DOM 操作,而我们自定义的 WebView 壳在合成事件上处理得不如 Chrome 完整。
解决方式不算复杂:在 EditorView 的 extension 里监听 composition 事件,进入组合态后暂停所有与光标位置有关的自动滚动和括号匹配,组合结束之后再重新校准。另外,我把 WebView 默认的input事件防抖时间从 16ms 调整到了 50ms,让浏览器有足够时间稳定 composition 树。这个坑前后花了我四个晚上,最终修复方案只有十几行代码,但排查过程教会我一个道理:遇到诡异 bug,先用最笨的办法复现,然后一步步缩小变量范围,比照着一堆 stackoverflow 瞎猜有效得多。
6.2 路径穿越漏洞:我从安全测试里学到的第一课
说一个更有价值的坑,路径穿越。起初我把路径校验做成这样:
if (!resolved.startsWith(rootDir)) { throw new Error('forbidden'); }看起来没毛病,直到我给 t3d 做了一次个人安全测试,请求路径是/www/pro%2e%2e/etc/passwd。我猜你会问:这不还是../吗?确实标准化之后路径会变成/etc/passwd,startsWith('/www/pro')依然通过,因为/etc/passwd确实以/www/pro开头——注意,/www和/etc共享了"www/pro"这层字符串前缀!这种鬼情况让我意识到,路径判断不能用"字符串前缀",必须用"目录边界"判断。后来我改成了resolved === root || resolved.startsWith(root + path.sep),并在所有远程 API 里都加了这个保护。
这个坑也提醒我,哪怕只是自己用的工具,只要暴露在网络上,就必须按野外生产环境的标准做安全防护。因为你在内网测试时觉得安全,不代表它不会意外暴露到公网。
6.3 远程写文件时的原子性
第三个坑,远程写文件丢一半。当时我所有远程写入都是直接fs.writeFile,有一次网络抖动,写文件写到一半,本地到服务器的连接断了,远端文件就停在半截,整个 Python 文件语法错误,服务也起不来。后来改成"临时文件+rename"方案:
const tmp = `${target}.${process.pid}.tmp`; await fs.writeFile(tmp, data); await fs.rename(tmp, target);这样写就算中途失败,受伤的只是临时文件,目标文件还是完整的旧内容。这个思路在本地文件编辑上同样适用,我把它推广到了所有文件写入路径。读者如果也在做任何涉及文件同步的工具,一定要记下这个"原子写入"模式,它能少挨很多骂。
7. 如果你也想做类似的东西,我的建议和路线图
7.1 别一上来就做全功能,先伺候自己
这是我最想给的一句话。t3code 能做到现在的完成度,最重要的原因是我一直拿自己的真实工作流来当验收标准。今天远程改 Nginx 配置不顺手,我就去改命令面板;明天大日志搜索太慢,我就去优化虚拟滚动。你要是想复刻一个类似工具,第一版功能哪怕只有"打开文件、编辑、保存"这三个,也一定要在真实战斗中使用至少一星期,把每一个不顺手的点都记录下来再做第二版,否则你会在无关紧要的功能上浪费大量时间。
7.2 技术栈建议:先保守,再冒险
想复刻的话,我的建议是直接采用我现在的组合:Web 前端 + CodeMirror 6 + 轻量本地 HTTP 服务。这个组合最大的好处是每个组件都是成熟的,你只需要把它们拼起来。等跑通 MVP 之后,再考虑要不要换更好的渲染层、加数据库索引、做多人协同。先把骨架立住,后面的冒险才有基础。一个常见的反面例子是我最开始想直接写一个 Rust 后端,结果一个月没弄出能打开文件的版本,白白消耗了热情。
7.3 我接下来打算做什么
t3code 现在还在继续迭代,下一步我想做两件事:一是给远程模式加一个"只读分享"链接,方便临时给别人看代码,但不给他们写权限;二是把本地 Git 操作集成到命令面板里,看 diff、暂存、提交都在编辑器里完成。这两件事做完,t3code 应该才算真正能顶替掉我日常工作流里的 VS Code。
7.4 最后分享一个我现在离不开的小技巧
写这个项目的过程中,我发现最提升幸福感的小功能不是代码高亮,而是"服务崩了之后,编辑器里的未保存内容还能找回来"。实现方式不复杂:每 30 秒把当前打开且未保存的编辑器内容快照存到 localStorage,下次启动时如果有残留快照,就弹一个"是否恢复未保存会话"的提示。这个功能挽救了我好几次凌晨三点的工作成果,建议所有做工具的人都加一个。工具好不好用,往往就藏在这种你以为很简单的兜底细节里。
说到底,t3code 对我而言不是一个产品,更像是我和"工具重量化"对抗的一个出口。代码编辑器的内核几十年来没有本质变化,但外围工程可以把体验搞得很重。如果你也在被某个工具折磨得难受,不妨像这样花点时间拆掉重来,哪怕第一版很粗糙,也比在别人的方案里将就着忍要强。