Monaco Editor 架构拆解:46k Stars 的浏览器编辑器,内核从 VS Code 剥离而来

发布于 2026年07月10日 18:18 #前端#Github 解读 原文链接

Monaco Editor 架构拆解:46k Stars 的浏览器编辑器,内核从 VS Code 剥离而来 封面图
  • 从 VS Code 剥离核心,通过 shim 适配浏览器,保留编辑器内核和语言服务
  • 三层架构:Model 数据层、Editor 视图层、Provider 智能层,实现数据视图分离和可插拔
  • Worker 架构:语言服务在独立线程运行,支持渐进式覆写 Worker 创建策略
  • 63 个 Feature 通过 import side-effect 注册,支持 tree-shaking 减少包体积
  • 语言服务分 Monarch 词法分析和完整 LSP 实现,TypeScript Worker 保持跨文件状态

46,314 stars,4,092 forks,持续更新 10 年。

这是 Monaco Editor 在 GitHub 上的成绩单。但比这些数字更有意思的是它和 VS Code 的关系——很多人的认知是「Monaco Editor 就是 VS Code 的浏览器版」,这话说对了一半,也错了一半。

大家好,我是若风。

说它对,是因为 Monaco Editor 确实「直接生成自 VS Code 的源码」。说它不对,是因为 FAQ 里白纸黑字写得很清楚:

VS Code 的扩展不能在 Monaco Editor 里运行。

换句话说,这俩共享同一套编辑器核心,但 VS Code 的整个插件生态、Electron 层、文件系统服务,全都被砍掉了。Monaco Editor 拿到的,只是 VS Code 编辑器内核的一个「浏览器剪影」。

那这个剪影是怎么剪出来的?它的架构长什么样?我们今天来拆一遍。

从 VS Code 到浏览器:一条剥离之路

Monaco Editor 的 src/index.ts 只有寥寥几行:

import * as lsp from '@vscode/monaco-lsp-client';
import { css, html, json, typescript } from './languages/register.all';
import './features/register.all';
import 'monaco-editor-core';
export * from './editor';
export { css, html, json, typescript, lsp };

它的核心是 monaco-editor-core——这是直接从 VS Code 的 src/vs/editor 目录提取出来的独立 npm 包。提取过程加了「一些 shims」,让原本依赖 VS Code 内部服务的代码能在浏览器环境下跑起来。

./editor 的导出更直白:

export * from 'monaco-editor-core/esm/vs/editor/editor.api';

整个 Monaco Editor 就是 editor.api 的再包装。上面那些 import 语句,就是包装的内容:4 个语言服务的 LSP 客户端 + 63 个 Feature 注册 + 核心编辑器。

坦白讲,这种「砍掉外壳只留核心」的架构策略非常务实。VS Code 体量太大,不可能完整搬进浏览器。Monaco 团队的选择是:只保留编辑器本身、语言服务、以及最基本的 UI 层,其余全交给集成者自己实现。

三层架构:Model、Editor、Provider

README 里定义了三层核心概念,但很多人读过去就忘了。这三层其实是理解整个编辑器的钥匙。

Model 层是数据层。它代表一个已打开的文件,包含文本内容、语言标识、编辑历史。每个 Model 通过 URI 唯一标识——这设计很像一个虚拟文件系统。如果你不指定 URI,默认是 inmemory://model/1inmemory://model/2 这样递增。

Editor 层是视图层。它是用户看到的 DOM 界面,附着在某个容器元素上。Editor 管理视图状态、光标位置、滚动位置、选中区域。一个 Model 可以被多个 Editor 共享,这在 split diff 场景下非常有用。

Provider 层是智能层。补全、悬停提示、定义跳转、诊断错误——这些「智慧功能」全部由 Provider 提供。Provider 的概念和 LSP(Language Server Protocol)一一对应:CompletionItemProvider 对应 textDocument/completionHoverProvider 对应 textDocument/hover

我一直在想这个设计为什么好。你看,三层架构其实解决了编辑器最核心的问题:数据与视图分离,智能功能可插拔。VS Code 能以极低的成本支持新语言,正是因为只需要为这门语言写一套 Provider,Model 和 Editor 完全不用动。

Monaco Editor 系统架构图
Monaco Editor 系统架构图

整个架构从下到上分五层:集成者通过 MonacoEnvironment 接入,入口模块加载 core 和 63 个 feature 插件,核心层管理 Model/Editor/Provider 三元组,Feature 插件层调用语言服务的 Worker,Worker 跑在独立的 Web Worker 线程里做语法分析和智能补全。

Worker 架构:语言服务跑在独立线程

这是 Monaco Editor 最硬核的设计之一。

打开 src/internal/common/workers.ts,核心函数 getWorker 的调用链路很有意思:

function getWorker(descriptor) {
  const monacoEnvironment = globalThis.MonacoEnvironment;
  if (monacoEnvironment) {
    if (typeof monacoEnvironment.getWorker === 'function') {
      return monacoEnvironment.getWorker('workerMain.js', label);
    }
    if (typeof monacoEnvironment.getWorkerUrl === 'function') {
      const workerUrl = monacoEnvironment.getWorkerUrl('workerMain.js', label);
      return new Worker(ttPolicy.createScriptURL(workerUrl), { name: label, type: 'module' });
    }
  }
  if (descriptor.createWorker) {
    return descriptor.createWorker();
  }
  throw new Error('You must define MonacoEnvironment.getWorkerUrl or MonacoEnvironment.getWorker');
}

设计逻辑很清晰:Monaco 给了集成者三个选择,优先级从高到低——

  1. 你自己提供 getWorker 函数,完全控制 Worker 创建
  2. 你只提供 getWorkerUrl,我来创建 Worker
  3. 你啥都不配,我用内置的 descriptor.createWorker

这种渐进式覆写策略,解决了 Monaco Editor 最大的部署痛点:跨域 Worker 脚本加载。很多场景下,Monaco 的 npm 包和你的应用部署在不同的 CDN 路径上,Worker 脚本的 URL 需要你手动指定。MonacoEnvironment.getWorkerUrl 就是为这个准备的。

更妙的是 Trusted Types 支持。同一个文件里有一段:

ttPolicy = createTrustedTypesPolicy('defaultWorkerFactory', {
  createScriptURL: (value) => value
});

当浏览器启用 Content Security Policy 的 Trusted Types 时,Monaco 会自动适配,避免 CSP 报错。这层防护在安全意识强的企业项目中特别重要。

两个 Worker 管理策略的对比

我读了两套 Worker 管理器的源码,发现它们的策略不同,这值得展开。

TypeScript Worker(src/languages/features/typescript/workerManager.ts

this._worker = createWebWorker({
  moduleId: 'vs/language/typescript/tsWorker',
  createWorker: () => new Worker(new URL('./ts.worker?esm', import.meta.url), { type: 'module' }),
  label: this._modeId,
  keepIdleModels: true,
  createData: {
    compilerOptions: this._defaults.getCompilerOptions(),
    extraLibs: this._defaults.getExtraLibs(),
    customWorkerPath: this._defaults.workerOptions.customWorkerPath,
    inlayHintsOptions: this._defaults.inlayHintsOptions
  }
});

TypeScript Worker 设置了 keepIdleModels: true,意味着即使用户切到别的文件,Worker 仍然保持同步。这是因为 TypeScript 的编译需要跨文件信息(import 解析、类型推断),Worker 必须持有所有相关文件的 AST。

它还传入了完整的 compilerOptionsextraLibs——没错,Monaco 是在 Worker 里运行了一个完整的 TypeScript 编译器。

JSON Worker(src/languages/features/json/workerManager.ts

private _idleCheckInterval = window.setInterval(() => this._checkIfIdle(), 30 * 1000);
private _lastUsedTime = 0;
const STOP_WHEN_IDLE_FOR = 2 * 60 * 1000;

private _checkIfIdle(): void {
  let timePassedSinceLastUsed = Date.now() - this._lastUsedTime;
  if (timePassedSinceLastUsed > STOP_WHEN_IDLE_FOR) {
    this._stopWorker();
  }
}

JSON Worker 的策略正好相反:30 秒检查一次,2 分钟不用就销毁 Worker。这是因为 JSON 验证是无状态的、文件独立的,不需要跨文件信息。Worker 空闲时占用内存没有意义,直接销毁,下次需要再重建。

你看,同一个 Worker 架构,针对不同语言特性做出了完全不同的资源管理策略。这种细粒度的取舍,恰恰是做工具类库最见功力的地方。

63 个 Feature 的插件注册体系

src/features/register.all.ts 是一个纯 import 文件,没有一行逻辑代码:

import './anchorSelect/register';
import './bracketMatching/register';
import './caretOperations/register';
// ... 一共 63 个
import './wordPartOperations/register';

每个 feature 通过 import side-effect 注册自己。比如 find/register.js 注册搜索功能,folding/register.js 注册折叠功能。

这套体系的精妙之处在于 tree-shaking。当集成者只用到编辑器的部分功能时,构建工具可以自动剔除未 import 的 feature,减少包体积。63 个 feature 不是每个项目都需要全部加载的。

feature 列表里有个有意思的细节:gpu/register。Monaco Editor 在尝试用 GPU 加速渲染?我没深入挖这个,但从命名看,编辑器团队确实在探索硬件加速的渲染路径。

语言服务:LSP 的浏览器移植

Monaco 的语言服务体系分两层:

基础层——Monarch tokenizer。每个语言定义(src/languages/definitions/)包含一个 Monarch 词法规则文件和一个语言配置。以 TypeScript 为例:

// src/languages/definitions/typescript/typescript.ts
export const conf: languages.LanguageConfiguration = {
  comments: { lineComment: '//', blockComment: ['/*', '*/'] },
  onEnterRules: [
    {
      beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/,
      action: { indentAction: languages.IndentAction.IndentOutdent, appendText: ' * ' }
    },
    // ...
  ]
};

Monarch 负责语法高亮和基础词法分析,不依赖 Worker,在主线程运行。

智能层——完整的 Language Server 实现。src/languages/features/ 下有 CSS、HTML、JSON、TypeScript 四套语言服务,每套都是一个完整的 Worker + Worker Manager + Mode 三层:

  • css.worker.ts / html.worker.ts / json.worker.ts / ts.worker.ts — Worker 入口
  • workerManager.ts — 管理 Worker 生命周期
  • cssMode.ts / htmlMode.ts / jsonMode.ts / tsMode.ts — 注册 Provider

每套语言服务的 Provider 注册逻辑在 lspLanguageFeatures.ts 中统一实现。这个文件实现了 DiagnosticsAdapter,统一管理实时诊断:

this._listener[model.uri.toString()] = model.onDidChangeContent(() => {
  window.clearTimeout(handle);
  handle = window.setTimeout(() => this._doValidate(model.uri, modeId), 500);
});

500 毫秒防抖,用户停止输入后自动触发诊断。这不算什么黑科技,但它放在基类里被所有语言服务复用,避免了每套语言各自写一套防抖逻辑。

工程细节:那些 README 不会告诉你的

编辑器主题不能独立切换

Issue #338 有 50 条评论,核心痛点是:editor.setTheme() 会全局生效,没法让两个编辑器实例用不同的主题。这在设计工具或对比编辑器场景下特别尴尬。这个限制来自 VS Code 的架构——VS Code 本身就是单主题的,Monaco 继承了这个设计。

不支持 Shadow DOM

Issue #3409 报告了 2 年前的问题:在 Web Component 的 ShadowRoot 里使用 HoverProvider 会报错。原因是 Monaco 的 DOM 事件处理直接绑在 document 上,没有穿透 Shadow DOM。这限制了它在现代 Web Component 架构中的应用。

不支持 JSX 语法高亮

Issue #264 是 Monaco 仓库里评论最多的问题(59 条),从 2017 年就有人提了。TypeScript 的 Monarch 词法定义不包含 JSX/TSX 的标签解析——<div> 会被当作小于号和变量名处理。虽然 TypeScript 编译器本身能解析 JSX,但语法高亮层只依赖 Monarch,而 Monarch 不支持 JSX。

没有移动端支持

FAQ 里直接写死了:No.。这其实不算槽点,是明确的取舍——桌面级代码编辑器需要的精确光标控制、快捷键体系、多光标编辑,在触摸屏上几乎没法复现。但如果你正在选型一个给移动端用的代码片段编辑器,Monaco 可以直接排除。

空闲 Worker 的自动回收

前面提到 JSON Worker 的 2 分钟空闲回收策略,这不是个例。实际上所有非 TypeScript 的 Worker 都有类似的闲置管理机制。对长期运行的 SPA 来说,这能省下可观的浏览器内存。TypeScript Worker 不回收是因为它必须持有跨文件状态。

不是 CodeMirror,也不是 VS Code

选型时,Monaco Editor 经常和 CodeMirror 放在一起比。我的判断是:它们解决的是不同规模的问题。

CodeMirror 轻量(gzip 约 40KB)、可扩展性强、原生支持移动端。Monaco 重(gzip 约 1MB+)、开箱即用、功能完整度远超 CodeMirror。

如果你只是在表单里需要一个代码输入框,CodeMirror 是更好的选择。如果你要做一个在线 IDE、代码 Playground、或需要完整语言服务的编辑器,Monaco 没有对手。

但 Monaco 也不是银弹。它的集成复杂度远高于 CodeMirror——Worker 跨域问题、包体积、CSP 配置、Shadow DOM 不兼容,这些坑你早晚会踩到。README 里「You will get ESM version」说得很轻松,实际配起来够喝一壶的。

模式提炼:浏览器版的「核心提取」架构

Monaco Editor 的架构模式可以总结为:从宿主应用中提取核心模块,通过 shim 层适配新环境

VS Code 是宿主,Monaco 是提取后的产物。提取过程不是复制粘贴,而是把对 VS Code 内部服务的依赖替换成可注入的接口——MonacoEnvironment 就是这些接口的集合。集成者通过这个全局对象告诉 Monaco:Worker 怎么创建、文件怎么读取、资源怎么加载。

这种模式不仅适用于编辑器。任何在特定环境中验证过的成熟工具,都可以考虑用同样的方式「移植」到新环境:核心逻辑保持不动,环境依赖抽象成接口。Monaco 证明了这条路走得通——从桌面 Electron 到浏览器,它只改了一层 shim,核心代码 99% 是共享的。

用同样的思路,你可以把 Node.js 的工具库通过 Worker 搬到浏览器,把桌面的渲染引擎通过 Canvas 搬到 Web。关键是找到那层「环境依赖的薄边界」,然后把它接口化。

如果你正在做工具类的 Web 移植,Monaco 的架构值得仔细读一遍。它的每行代码都是取舍的痕迹。

评论互动

© 2026 王若风的技术博客 · Powered by Astro