
DESIGN.md 这个东西在工程里的地位有点特殊它不是给机器读的是给人读的。它记录一个模块为什么这么设计、关键取舍是什么、后续扩展要注意什么。可越是这样“给人看”的文档越容易在协作里变成摆设——大家不是不想维护而是维护成本实在有点高。拉代码、切分支、改文件、提交、推送一套流程走下来十分钟过去了就为了改两行字谁都不乐意。我这次要解决的就是把这个流程缩短成“打开浏览器改完直接保存”。项目核心很简单一个 Web 应用能在网页上读取和编辑项目里的 DESIGN.md顺手还能做 Markdown 预览、Git 提交和回滚。它不需要像完整 IDE 那样复杂但得轻、快、适合团队内多人直接使用。这篇博文我把整个思路、技术选型、实现细节和排坑过程都梳理出来给同样想给团队搭一套“文档轻编辑器”的同学做个参考。1. 先拆需求这个编辑器到底要解决什么问题1.1 DESIGN.md 在工程里的真实处境先说个现象。很多项目仓库里都有 DESIGN.md、ADRs架构决策记录或者类似的设计文档通常放在根目录或者 docs 目录下。这类文档的读者不只是开发者可能还有测试、运维、甚至产品同学。但对非开发岗的同事来说让 TA 用 IDE 去改文档是一件很劝退的事情——Git 是什么、分支怎么切、Pull Request 怎么提每一个环节都是学习成本。就算是对开发者来说频繁为了改文档来回切工具体验也很割裂。我见过不少团队DESIGN.md 最后一次更新停留在大半年前不是没有设计变更而是变更记录都散落在 PR 描述和群聊记录里文档本身反而彻底落伍了。这其实就是“写文档”和“做事情”之间隔了一层工具摩擦本地 IDE 编辑流程的仪式感太强不适合高频、轻量的文档维护。1.2 期望的交互形态是什么我在做之前把目标场景写得很具体。团队内部有一个项目所有人通过浏览器访问一个内网地址打开之后左侧是仓库里的文件树中间是 Markdown 源码编辑区右侧是实时渲染的预览面板。编辑完按一下保存或者快捷键内容直接写回服务器上的 DESIGN.md。如果有人想留个版本记录可以顺手填个提交信息把这次改动作为一个 Git 提交固化下来。听起来和很多在线编辑器差不多但这里有一个关键差别它是直接读写项目仓库里的真实文件而不是把内容存进数据库或者什么笔记系统。也就是说你在 Web 界面存的每一个字最终是进了 Git 仓库的。这样 DESIGN.md 的历史记录、差异对比、代码审查能力都延续了下来并不会因为换了编辑方式就丢失。1.3 为什么不自建一个更完善的 CMS也有人问过我直接用现成的 CMS、知识库、甚至内网 Wiki 不行吗数据和文章都存服务端数据库不是一样可以协作编辑我评估过答案是不行。核心问题是割裂DESIGN.md 是和代码仓库紧密耦合的很多内容里会引用代码路径、资源目录、配置项名称甚至要贴一小段示例代码。如果文档在 Wiki 里代码在仓库里两边就是两套体系改完代码忘了同步 Wiki 是常态。反过来如果文档就在仓库里代码改动和文档改动可以出现在同一个 Commit、同一个 PR 里约定的“文档和代码一起改”才能真正落地。所以我的结论很明确这个工具必须是一个面向仓库文件的轻量编辑入口而不是一个独立的知识库。2. 方案选型为什么没直接用现成方案也没上重型框架2.1 候选方案对比与取舍确定要做“Web 编辑仓库文件”这件事之后我先后看了一眼市面上已有的方案。列个表看得更清楚。方案部署成本适配度说明code-serverVS Code Web 版高中功能非常强但整套环境很重启动慢多人同时编辑体验也比较吃资源Gitea/GitLab 内置 Web IDE高中依赖完整代码托管平台普通后端项目为了个文档编辑器起一套 GitLab 不现实自研轻量编辑器低高只实现需要的功能可控性最强后续扩展方便我最后选了自研原因其实很朴实需求就巴掌大一点非得上个重型武器运维成本和资源开销直接抵消掉“轻量编辑”的所有优势。一个 Node.js 服务加一个静态页面加起来不到一千行代码部署就一条命令的事跑在最小规格的服务器上都毫无压力。2.2 技术栈为什么这么定技术栈选型的时候我考虑过 Python FastAPI、Node.js Express、还有纯 Go最后用的是 Node.js Express前端没有上框架用原生 HTML/CSS/JavaScriptMarkdown 解析用 marked.js代码高亮用 highlight.js。理由有这么几层。首先Node.js 和前端是同一门语言维护起来心智负担最小。团队里如果有人要改前端逻辑不需要额外掌握一套后端语言。其次Express 生态成熟处理静态文件、JSON 接口、文件上传下载都很顺手。关键是我需要在服务端调用 Git 命令来做提交和查看历史Node.js 的 child_process 直接就可以执行系统命令实现非常直接。前端不上框架也经过考虑。编辑器的核心交互只有三个文件树、编辑区、预览区。用 React/Vue 当然是常规选择但原生 JS 配合少量 DOM 操作完全可以覆盖还能省掉构建步骤。整个 public 目录直接由 Express 托管刷新页面就是最新代码联调起来非常舒服。等以后功能真的复杂到需要组件化再迁移不迟。2.3 目录结构与服务端基本骨架项目结构长这样design-editor/ ├── package.json ├── server.js # Express 服务端入口 ├── repo/ # 要编辑的目标仓库可以 git clone 或者软链 │ └── DESIGN.md └── public/ # 前端静态资源 ├── index.html ├── style.css └── app.js服务端骨架代码const express require(express); const path require(path); const fs require(fs); const { execSync } require(child_process); const app express(); const PORT process.env.PORT || 3210; const REPO_ROOT path.resolve(__dirname, repo); app.use(express.json({ limit: 10mb })); app.use(express.static(public)); app.listen(PORT, () { console.log(DESIGN.md editor running at http://0.0.0.0:${PORT}); });几点说明。JSON body 的 limit 我设为 10mb因为 Markdown 文档里可能嵌入 base64 图片体积会变得很大默认 100kb 限制肯定不够。REPO_ROOT 指向实际要编辑的仓库目录可以是一个完整的 Git 仓库也可以只是一个普通目录代码上两者都兼容。3. 核心实现文件读取、保存、预览一条龙3.1 文件树接口把仓库目录结构吐给前端前端要展示左侧文件树我先在后端写一个递归遍历目录的接口。app.get(/api/files, (req, res) { function walk(dir, relativePath) { const entries fs.readdirSync(dir, { withFileTypes: true }); return entries .filter(entry entry.name ! .git entry.name ! node_modules) .map(entry { const fullPath path.join(dir, entry.name); const relPath path.join(relativePath, entry.name); if (entry.isDirectory()) { return { name: entry.name, type: dir, path: relPath, children: walk(fullPath, relPath) }; } return { name: entry.name, type: file, path: relPath, ext: path.extname(entry.name).toLowerCase() }; }); } const tree walk(REPO_ROOT, ); res.json(tree); });这里我过滤了.git和node_modules避免把版本库内部文件和依赖目录暴露出来。实际使用时还可以按需过滤掉dist、build、target之类的产物目录让文件树更干净。前端的文件树渲染用递归函数点击文件事件通过事件委托处理不用每个节点单独绑定监听器。这样做的好处是性能好即使文件多也不卡。3.2 文件读取与保存注意路径穿越这个坑文件读取接口接受一个 path 参数直接拼接在 REPO_ROOT 后面。这里有一个必须处理的坑路径穿越攻击。如果用户传入../../etc/passwd这种路径服务端就可能在仓库目录之外读文件了。我处理这个问题的方式是先用path.resolve把目标路径转成绝对路径再用path.relative判断它是否真的在 REPO_ROOT 之内。function safeResolve(relativePath) { const targetPath path.resolve(REPO_ROOT, relativePath); const relative path.relative(REPO_ROOT, targetPath); if (relative.startsWith(..) || path.isAbsolute(relative)) { const err new Error(Invalid path); err.status 400; throw err; } return targetPath; } app.get(/api/file, (req, res) { try { const filePath safeResolve(req.query.path || ); const content fs.readFileSync(filePath, utf-8); const stat fs.statSync(filePath); res.json({ content, mtime: stat.mtimeMs, path: req.query.path }); } catch (err) { res.status(err.status || 500).json({ error: err.message }); } });保存接口类似但还有一层逻辑我把文件上次修改时间mtime一起返回给前端前端保存时把 mtime 传回来。服务端对比当前文件的 mtime如果发现不一致说明这个文件在你编辑期间被别人改过了直接拒绝覆盖返回一个冲突错误。这能避免两个人同时对同一个文档编辑时后保存的人把先保存的人的内容静默覆盖掉。app.post(/api/file, (req, res) { try { const { path: filePath, content, mtime } req.body; const targetPath safeResolve(filePath); const currentStat fs.statSync(targetPath); if (mtime currentStat.mtimeMs ! mtime) { return res.status(409).json({ error: 文件已被其他人修改请刷新后再编辑 }); } fs.writeFileSync(targetPath, content, utf-8); const newStat fs.statSync(targetPath); res.json({ ok: true, mtime: newStat.mtimeMs }); } catch (err) { res.status(err.status || 500).json({ error: err.message }); } });前端拿到 409 状态码后弹一个明显的提示并且不关闭编辑框让用户决定是强制覆盖还是先复制走内容。实测下来这个设计非常必要团队协作时最大的矛盾就是“你改的时候我也在改”没有这层保护文档被覆盖一次大家的信任感就崩了。3.3 前端编辑区与预览区分屏联动前端页面布局很简单点开 index.html一个三栏结构左侧文件树固定宽度右侧整体是上下布局还是左右分屏取决于屏幕宽度。编辑器我用的是一个textarea改造成本最低所有浏览器原生支持也不会有光标位置丢失之类的问题。实时预览用 marked.js 解析 Markdown再用 highlight.js 给代码块做高亮。核心代码段大概是这样的const editor document.getElementById(editor); const preview document.getElementById(preview); editor.addEventListener(input, () { const html marked.parse(editor.value); preview.innerHTML DOMPurify.sanitize(html); // 高亮代码块 preview.querySelectorAll(pre code).forEach(block { hljs.highlightElement(block); }); });注意我加了 DOMPurify。这个很重要——Markdown 里是可以写原始 HTML 的如果直接把 marked 的渲染结果注入页面等于允许任何能编辑文档的人在预览面板里执行任意脚本。虽然这是内部工具但安全习惯要养成DOMPurify 就一行代码的事必须带上。预览区的滚动位置也做了联动。如果 Markdown 文档很长用户在编辑区滚动时预览区跟随滚动方向相反。这个细节体验提升很大我刚开始没做的时候自己用一会儿总觉得别扭。快捷键方面我做了 CtrlS / CommandS 保存。浏览器里这个快捷键默认是“保存网页”弹窗必须先拦截默认行为再调用保存接口否则会弹一个没用的下载对话框。4. 更进一步把 Git 能力融进编辑器4.1 Git 提交编辑完不留“脏文件”编辑器最终要落回 Git 仓库所以保存文件之外还需要一个提交入口。我的设计是界面上放一个“提交当前修改”按钮点击后弹一个小窗让用户填提交说明服务端执行一条 git 命令。app.post(/api/git/commit, (req, res) { try { const { message } req.body; if (!message || !message.trim()) { return res.status(400).json({ error: 请填写提交说明 }); } // 防止特殊字符破坏命令行 const safeMessage message.replace(/[$;|]/g, ).trim(); execSync(git add -A, { cwd: REPO_ROOT }); execSync(git commit -m ${JSON.stringify(safeMessage)}, { cwd: REPO_ROOT }); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.stderr ? err.stderr.toString() : err.message }); } });填提交说明这个“强制动作”是刻意的。Git 之所以比直接覆盖文件好就在于每次修改都有痕迹、有理由。如果用户不用填说明就能一键提交提交记录就会变成一堆没有意义的文字历史价值大打折扣。命令行注入的问题也要认真对待。提交说明是用户输入直接丢进 shell 里执行会有风险我用了三层防护去掉$\;|这类特殊字符、用 JSON.stringify 做双引号包装、设置cwd 限定在 REPO_ROOT。足够应对内部工具的场景。4.2 查看历史与差异对比除了提交我还加了历史记录接口用git log查看最近提交再配合git show拿到某次提交的完整 diff。app.get(/api/git/log, (req, res) { try { const output execSync(git log --prettyformat:%h|%an|%ad|%s --dateshort -n 20, { cwd: REPO_ROOT, encoding: utf-8 }); const commits output.split(\n).filter(Boolean).map(line { const [hash, author, date, ...subjectParts] line.split(|); return { hash, author, date, subject: subjectParts.join(|) }; }); res.json(commits); } catch (err) { res.status(500).json({ error: 仓库可能还没有提交记录 }); } });前端拿到提交列表后点某一条提交右侧预览区显示这次变更的 diff用醒目的红绿背景区分删除行和新增行。这样用户每次在 Web 界面上编辑保存之后都能立刻看到自己这轮操作到底改了哪些内容要不要单独提交、提交说明怎么写更清楚一目了然。4.3 多用户协同不做实时只做安全有人会问要不要做成像 Google Docs 那样的多人实时协同我的回答是不要至少第一版绝对不要。DESIGN.md 的场景是低频编辑、协议讨论不是多人在同一段文字上实时码字。实时协同需要 OT 算法或者 CRDT复杂度比整个编辑器还高而且运维成本、心智负担都是指数级别上升。我选择了“乐观锁 手动刷新”的折中方案两个人同时打开同一个文件任何一个人先保存后另一个人再保存时会收到 409 冲突提示必须刷新页面拿到最新内容再改。这个体验虽然比实时协同“笨”一点但在绝大多数文档协作场景里完全够用。真正会同时改 DESIGN.md 并且想立刻看到对方改字的场景说实话少之又少为一个低频需求背上巨大的复杂度不是聪明的做法。5. 排坑实录这些坑我替你们踩过了5.1 中文内容乱码与换行符问题第一个坑是编码。我最初用fs.readFileSync(filePath)不指定编码结果前端拿到的中文字符全是乱码。原因很简单readFileSync 不传 encoding 参数时返回的是 BufferJSON 序列化会自动按 utf-8 处理但你拼接字符串时如果混用了编码就很容易出问题。解决方案是一律显式传utf-8无论是读还是写。Windows 环境下还有一个隐藏的换行符问题。在 Windows 上编辑过的文件行尾是\r\n在 Linux 上会显示成^MGit diff 也会把整行标红。我的处理是保存时统一把内容里的\r\n转成\n服务端在写入前做一次清理content content.replace(/\r\n/g, \n);这样不管用户从什么系统上传仓库里始终是统一的 LF 换行Git 历史也干净。5.2 Service Worker 导致“保存了但刷新变了”这个坑特别隐蔽。团队里之前有其他业务往同域下注册了一个 Service Worker 来做静态资源缓存结果用户访问我的编辑器时浏览器也把 GET 请求的响应缓存了。现象很诡异编辑保存后刷新界面又显示旧内容抓包看后端返回的的确是新内容但页面渲染的是 Service Worker 缓存里的旧数据。排查方法是打开 DevTools 的 Application 面板看 Service Workers 一栏然后逐个禁用测试。解决方案也很简单编辑器的静态资源和 API 接口都不注册任何 Service Worker如果同域已有注册就在页面启动时调用navigator.serviceWorker.getRegistrations().then(regs regs.forEach(reg reg.unregister()))主动清掉。另外后端对页面请求设置Cache-Control: no-store也能减少一层缓存干扰。5.3 Git 提交时提示无法自动检测用户身份首次在新环境跑git commit时会遇到unable to auto-detect email address的报错。这是因为服务器上的 Git 没有配置 user.name 和 user.email。理想做法是在服务端配置全局身份但如果团队里多人共用一个服务每个人的提交都会显示成同一个账号。更合理的做法是在编辑器里增加一个“提交人”字段用户提交时填自己的昵称服务端执行git commit --author昵称 昵称local来指定作者。这样既不出错提交历史里也能分清是谁改的。倘若填了昵称历史记录页直接显示出来一目了然。5.4 大文件渲染卡顿一个 DESIGN.md 动辄几百行预览时 marked 解析一次需要几十毫秒打字过程中每个 input 事件都全量解析能感觉到明显卡顿。为了优化我加了一个简单的防抖let timer null; editor.addEventListener(input, () { if (timer) clearTimeout(timer); timer setTimeout(renderPreview, 200); });打字停顿 200 毫秒之后才重新渲染预览区输入过程完全不再卡顿。如果文档进一步增大还可以考虑用 web worker 来跑 marked但以 DESIGN.md 的正常体量防抖已经足够。5.5 常见问题速查表问题原因解决方案中文乱码文件读取未指定 utf-8 编码读写都显式传utf-8保存提示 409文件在编辑期间被其他人修改刷新页面拿到最新内容再改提交时 Git 报错缺身份服务器 Git 未配置 user.name/email前端增加提交人字段提交时指定 author修改后刷新变旧内容Service Worker 缓存主动注销 SW响应头设置 no-store保存后文件换行被标红Windows 换行符 CRLF 进入仓库服务端保存前统一替换为 LF输入时预览卡顿每次 input 全量解析给渲染函数加防抖延迟 200ms超长路径文件打不开文件名含空格或中文未编码前端请求统一用 encodeURIComponent这些坑里最有价值的还是 409 冲突保护和 Service Worker 缓存两个。前者标志着这个编辑器从“个人玩具”变成了“团队工具”后者则是真实环境里才会撞见的诡异场景文档和正常网上的教程基本不会写。我做完这个小工具的实际体会是很多看起来“太简单不值得做”的东西一旦放到真实协作环境里细节会以意想不到的方式冒出来。路径穿越、编码、缓存、并发冲突、命令行注入任何一项没想清楚上线就是一个事故。不过换个角度看正是因为这些问题被逐个解决一个小工具才能真正在团队里活下来成为日常工作的固定入口。如果你也在维护 DESIGN.md或者搭过类似的轻量工具欢迎对照方案试试至少冲突保护和提交人记录这两点我强烈建议无论如何都要加上。