ARTICLE · 1266987
ll-editor源码剖析,一个MIT开源的编辑器,可以管理知识库、配置数据源、编写SQL直接生成API接口;支持Desktop客户端扩展
源码解剖 · 开源项目研读
ll-editor 源码解剖:26k 行代码里,1.6k 行的「内核」值钱在哪
一个基于 Monaco 的插件化代码编辑器,如何用分层 + 白名单把内核和业务彻底切开
日期:2026-10-28ll-editorMonaco编辑器内核架构分层插件化前端工程“把 Monaco 关进 editor/,把业务挡在 core/ 之外——这 1,600 行内核,是整份代码里最值钱的部分。”
约 3519 字 · 阅读约 9 分钟
研究对象:linlannet/vue-base 的 ll-editor 子包(@linlannet/ll-editor v1.1.1,2026-10-25,MIT)。本文基于 master 分支全部源码、文档站与测试逐层阅读,不含外部推测。
一句话结论
这是一个「壳子很大、内核很小」的项目,而且这个比例是它的设计成功之处。约 26,500 行代码里,真正可复用的编辑器内核只有约 1,600 行(6%),其余 94% 是与 magic-api 工作台绑定的 UI 与业务适配层——但正是这条切分线,让内核可以被任何宿主(Vue / React / 原生)直接搬走。
192
仓库内文件数
ll-editor 子包(不含 node_modules)
26,471
SRC 代码行数
TypeScript + Vue,含注释
60
VUE 组件
全部集中在 components/
1,600
内核行数
core / languages / editor / runtime / storage / plugin-api
~11KB
库产物体积
monaco 与 vue 均 external 后的 dist
1.1.1
当前版本
2026-10-25,CHANGELOG 记录三个版本
01
01 项目速览:它到底解决什么问题
vue-base 是一个「企业级前端公共组件」仓库,ll-editor 是其中第二个子包。它的定位不是又一个富文本编辑器,而是可被任意后端平台复用的代码编辑器内核。
仓库用子包方式维护两个包:lib-vue-base(请求封装、AES/RSA 加密、工具函数、Pinia store)与 ll-editor。ll-editor 的自述非常直接:
- 内核
借鉴 linlan-editor 的 Monaco 多标签编辑器(剥离业务) - 架构
采用分层(core / languages / editor / runtime / storage / plugin-api)+ 白名单导出 + 注册表 - 生态
借鉴插件市场的 Manifest 分发模式(文档中标注为 P3,尚未落地)
从 CHANGELOG 能看出三个版本的演进节奏,也说明了这个项目的真实用途:
一个容易被忽略的定位细节
它同时产出两种形态:build:lib 产出可被宿主集成的库(dist),build:app 产出可独立部署的完整编辑器页面(dist-app)。前者是「组件」,后者是「产品」——同一份代码、两个构建目标,这是它能在企业内同时服务「嵌入」与「独立部署」两种诉求的关键。
02
02 分层:一条被单测强制的依赖线
架构图在 README 里只是示意图;真正约束代码的不是文档,而是 tests/architecture/DirectoryBoundaries.test.ts。
先看各层的实际体量(src/ 下按目录统计):
core/ | |||
languages/ | |||
editor/ | |||
runtime/ | |||
storage/ | |||
plugin-api/ | |||
components/ | |||
scripts/ | |||
api/ | |||
i18n/demo/ |
内核 6 个目录合计 1,600 行 / 26 个文件;UI 与业务合计约 24,800 行。 比例悬殊,但方向正确:内核轻到可以整体复制到另一个项目并在一小时内跑起来,业务重到足以支撑一个真实的 magic-api 控制台。
依赖方向被写成了测试断言,而不只是注释:
plugin-api只允许 import core/与types,不得碰ui/plugins/plugin-marketui不得反向依赖 plugin-market内部实现违规判定用的是「相对路径 + 禁止层目录名」的正则匹配,扫全部 .ts文件
这套守卫目前只「半个生效」
一是 src/ 下并不存在 ui/、plugins/、plugin-market/ 目录(实际 UI 在 components/、scripts/),所以后两条断言在空目录上直接通过;二是测试只扫 .ts 不扫 .vue,而 60 个组件才是最容易越界的地方。真正在起作用的只有第一条——plugin-api 的白名单约束。这一点如果补上,架构守护才算完整。
03
03 内核解剖:一个 316 行的编排器
core/LLEditor.ts 是整个项目的心脏,也是唯一「不依赖 Vue、不依赖 DOM 框架」的类。它的写法很像后端的分层服务:只做编排,不做实现。
构造函数一口气完成六件事:注册内置语言 → 按配置创建存储 → 用主题名创建 EditorHost(Monaco)→ 建 TabManager 与 BreakpointManager → 组装插件 API → 挂上内容变更、断点点击两个回调。此后所有操作都是对这些部件的转发:
openload / save / saveAs / list / close | doc:opened,保存即发 doc:saved | |
switch | ||
setTheme | lightvs,dark → vs-dark,其它名字直接透传给 Monaco 自定义皮肤 | |
registerLanguage | LanguageRegistry,发 language:registered | |
runcontinueDebug / stepDebug / toggleBreakpoint / setBreakpoints | ||
getConsolegetVariables / getDebugState / getDiagnostics | ||
getPluginApi | ||
dispose |
事件总线是自己写的 59 行泛型实现(Map<keyof Events, Set<Handler>>),on 返回取消函数。事件表覆盖 14 个事件,从 doc:*、tab:switched、theme:changed,到 run:started / run:output / run:error / run:ended、debug:breakpoint / debug:resumed、problems:changed。
值得学的一点:类型定义被放在 core/types.ts 并被当作「稳定白名单」对待
EditorDoc、LanguageInfo、Breakpoint、Diagnostic、DebugAdapter、LLEditorOptions 全部集中于此,注释明确写着「禁止依赖 UI / 插件内部实现」。插件与宿主共享类型,但共享不到实现——这是插件化能长期不腐化的前提。
04
04 运行时:把「断点」做进 Web Worker
这是整个项目技术上最有意思的部分。没有后端、没有 DAP 协议,它用一个字符串形式的 Worker 源码,实现了「运行 → 断点暂停 → 继续 → 变量快照」的最小闭环。
实现路径拆开看是五步:
1沙箱源码以字符串常量写死,运行时 new Blob(...) + URL.createObjectURL 建 Worker,绕开打包器对 worker 文件的依赖。
2Worker 内把 log/info/warn/error/debug 全部替换为 postMessage,逐条流式回传输出,结束后恢复原函数。
3把源码按行切开,命中断点的行前插 __bp__(N);,整体包进 async function 再用 new Function 编译执行。
4__bp__ 返回一个 Promise,把 resolve 存进闭包并 postMessage 通知宿主;宿主点「继续」时向 Worker 发 {kind:'continue'},Promise resolve,执行恢复。
5运行前记录 globalThis 的键集合,断点与结束时对比新增键,得到「这轮执行产生了哪些全局变量」。
调试适配器分两类,按语言自动路由:
WorkerDebugAdapter | ||
MockDebugAdapter |
三个必须知道的边界
① TypeScript 其实跑不了:transpileToJs 是恒等函数(为省掉约 3.4MB 的 TS 编译器),TS 源码会原样丢进沙箱,语法合法才可能执行;文档说「宿主可注入转译器」但接口未留钩子。
② 单例 Worker:activeWorker 是模块级全局变量,两个编辑器实例或并发运行会互相抢消息通道。
③ 单步退化为继续:stepDebug() 直接调 continueDebug(),源码注释也坦白了这点(P3 换真实后端通道才能区分)。
另外两个务实到有点「丑」但很有效的决定:语法检查只用 new Function 做一次解析(只为抓语法错误,TS 不检查,避免打进编译器);以及全局关掉 Monaco 的 JS/TS 诊断——因为接口脚本是 magic-script(含 db、http 等注入全局与顶层 return),标准 ts worker 会反复抛异常重启、把页面拖死。项目选择自己维护 Diagnostic 管线,用 setModelMarkers 上波浪线。
05
05 插件化:白名单 API 与「预留签名」
插件体系的成熟度,看的不是有没有市场,而是暴露面有多窄。
plugin-api/api.ts 只有 64 行,通过 createPluginApi(editor) 选择性暴露,插件拿到的是一个普通对象,而不是 editor 实例:
注册类: registerLanguage、registerDebugAdapter读取类: getActiveDoc、listDocs、openDoc事件类: on/off/emit控制类: run、continueDebug、stepDebug、toggleBreakpoint、getConsole预留(可选签名,P3 用): registerCommand、registerView、setBreakpoints
插件化最聪明的一步:把「预留」写成可选方法
registerCommand? 与 registerView? 用可选方法声明,意味着 P1 不实现也不会让类型检查失败,而 P3 落地时插件代码不需要改签名。这是低成本占位、零成本演进的做法。
值得注意的是,工程里已经有大量「事实上的插件形状」:scripts/monaco/* 里的补全、悬浮、签名帮助、折叠、主题、mybatis 语言注册,全都是以独立函数挂在 Monaco 上的贡献点,只是还没被卷进统一的 Manifest 体系。P3 要做的事,其实是给这些散点补一层注册表。
06
06 存储与构建:11KB 是怎么来的
库产物只有约 11KB,靠的不是压缩技巧,而是把最重的两个依赖交给宿主。
style.css | vue-tsc --noEmit 兜 | |
vuemonaco-editor 设 external | ||
--mode appdist-app,base: './',vue / monaco 单独分包 | ||
StorageProvidercreateStorage(options) 工厂 | restRestStorageProvider |
存储层的抽象很干净:LocalStorageProvider 用 ll-editor:doc:{namespace}:{id} 存文档、单独一个 __index__ 键维护 id 列表,list() 只回传 DocSummary(id/name/language/updatedAt),避免把全文拉到列表里。「列表不带正文」这个细节,比很多同类项目做得好。
07
07 工程质量:诚实但不完整的护栏
这个项目的文档密度远高于一般开源库(VitePress 中英双语、各 7 篇),但代码里同时存在三类「说得到、没做到」。读源码的价值,有一半在于看清这些缝。
做得好的:
每个 core/runtime文件都有解释「为什么这样做」的中文注释,而不是复述「做了什么」strict: true+ noUnusedLocals+noUnusedParameters,全量vue-tsc类型检查vitest 覆盖运行时(转译、语法检查、控制台、沙箱运行/断点/恢复)与目录边界 中英双语文档站(VitePress)+ 三份 CHANGELOG 颗粒度到功能条目,可追溯性强
需要补的:
tsconfig.jsonexclude 排除了 components/editor、layout、resources、common | ||
ui/、plugins/、plugin-market/,这些目录不存在;且只扫 .ts 不扫 .vue | ||
storage: {type:'rest'},代码里该分支直接抛异常;事件文档把 run:* 标为「P2 预留」,实际已实现 | ||
npm install ll-editor,实际包名 @linlannet/ll-editor | ||
KnowledgeBaseKBFile、magicApiVersionText、baseURL 等业务概念进了 core/types.ts |
最值得警惕的一条是第一与最后一条的组合
业务概念进入内核类型,同时内核宣称「框架无关、可嵌入任意宿主」。这意味着真正的可复用边界,并不在 core/ 的目录名上,而在 core/types.ts 的字段里——后者才是难改的那部分。
08
08 可以抄走的五个设计取舍
抛开 magic-api 的业务外衣,这五条对任何「要做编辑器 / 工作台」的项目都成立。
1LLEditor 不碰 DOM、不碰框架,只转发给 Host / Tabs / Storage / Adapters。换 UI 框架时内核零改动。
2src/index.ts 是唯一公共出口,plugin-api 是唯一插件入口,两层白名单把「可依赖」与「不可依赖」写死在代码里。
3Monaco 与 Vue 全部 external,库产物 11KB——升级 Monaco 的权力交给宿主,也把版本地狱交了出去。
4没有等真后端,先用 Worker + 断点注入 + 变量快照跑通「运行/暂停/继续/输出」,DebugAdapter 接口留好,P3 可整体替换为真实通道。
5中英双语文档站、逐条 CHANGELOG、design.md 引用——这套习惯让 26k 行项目在三个月内从 1.0 走到 1.1.1 而不失控。
结语
ll-editor 的价值不在「又一个在线编辑器」,而在于它示范了一件事:把 94% 的业务代码和 6% 的内核代码放在同一个仓库里,却能用目录、白名单、类型文件三道墙把它们分开。这套墙目前有两道是漏的(UI 类型检查、架构测试覆盖),但墙的位置本身是对的。
如果要给作者一条建议,优先级我会这样排:先把 tsconfig 的 exclude 收回来,再让架构测试真正扫到 components/,最后才是拆 core/types.ts 里的业务概念。前两件是几小时的事,第三件要等 P3 插件体系落地才有意义。
项目信息:@linlannet/ll-editor v1.1.1 · MIT · TypeScript 5.6 + Vue 3.5 + Monaco 0.52 + Vite 5 · 文档 VitePress 中英双语 · 代码截至 master 分支 2026-10-25 提交。
本文由 MD工具助手 排版生成 · 内容版权归原作者所有















































































