前言
前面几篇我们已经看了进程模型和命令系统:VS Code 如何把系统能力拆到不同进程里,又如何用 command id 把各种入口统一起来。到了真正写代码的地方,问题就变成了:一个文件被打开、修改、保存、切换 tab 时,编辑器内部到底在维护什么状态?
这一篇就从 Monaco Editor 开始,看清楚 editor、model、URI、dirty 状态和 tabs 之间的关系。理解这条线之后,再看语法高亮、跳转定义、诊断提示、插件同步这些能力,就不会觉得它们是凭空接上去的了。
mini-vscode: github.com/zenoskongfu...
为什么代码编辑器不能当成一个普通 textarea?
一个普通的 textarea 只能输入内容,可以实现的代码代码编辑器最基础的需求,但是代码编辑器所需要的功能,比 textarea 能给的要多得多。
- 代码高亮
- 光变
- 代码选区
- 代码跳转定义
- 代码诊断提示
- 滚动位置
- 多文件切换,也就是说一个文件被切走之后,下一次再切回来,用户希望它还保留之前的编辑状态
所以代码编辑器不是一个简单的输入框,而是一个有自己状态系统的工作台对象。vscode 中用的代码编辑器就是 Monaco
Monaco editor 和 Monaco model 到底分别负责什么?
在 monaco 中,editor 是编辑器视口,model 是文本模型。
编辑器视口负责显示代码,渲染高亮、选区、光标等等功能;而 model 就负责存储文件内容、语言、文件的 URI,以及编辑的状态等等;
一个 editor 可以对应多个 model;在 editor 切换的 model 的时候,保留而不销毁被切掉的 model,下次冲洗打开该文件,就会复用这个 model。为什么要这么做?因为文件重新打开,需要保留文件的编辑历史,光标位置等等内容
为什么文件内容不能只放在 React state 里?
React state 是用来描述 UI 的,依赖 UI 而存在的,而文件的内容状态等信息,并不应该是依赖组件存在的,一个 editor 才是一个组件,而一个 editor 有多个 model。
编辑器可以关闭,可以重载,但是每次打开文件,文件的编辑状态都不能丢,React State 并不能满足这样的需求。vscode 的做法是让 Monaco model 自己维护编辑器内部状态,而 Monaco editor 只是将状态投影了出来
在 mini-vscode 中,也是如此,只是传入了文件的内容,指定了语言、主题,以及其他格式上的要求,并没有存储文件编辑的状态等信息
javascript
<div className="monaco-editor-wrapper">
<Editor
path={path}
language={getLanguageForPath(path)}
value={value}
theme={monacoTheme}
onMount={handleMount}
onChange={handleChange}
keepCurrentModel
options={{
fontFamily: 'var(--font-family-mono)',
fontSize,
minimap: { enabled: minimap },
scrollBeyondLastLine: false,
smoothScrolling: true,
cursorBlinking: 'smooth',
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },
'semanticHighlighting.enabled': true,
glyphMargin: true,
automaticLayout: true,
tabSize: 2,
lineNumbers: 'on',
renderLineHighlight: 'all',
fixedOverflowWidgets: true
}}
loading={<div className="monaco-editor-wrapper__loading">Loading editor...</div>}
/>
</div>
为什么一个文件必须有稳定的 path / URI 作为身份?
在 VSCode 中,我们用文件路径,也就是 path/URI 作为文件的唯一标识。不仅仅在 Monaco 中是这么做的,在文件的读写,语言功能中需要跳转定义、诊断信息等,都采用 URI 作为文件的唯一标识,这是绝对的唯一的。所以 URI 就是 Monaco 的身份证
dirty 状态到底是谁和谁在比较?
在 VSCode 有个很棒的设计,就是允许文件有未保存的状态,未保存的意思就是代码编辑的内容不会存到磁盘中,但是代码的诊断,代码的跳转又是基于未保存的代码来分析的。我们一起看看它是怎么实现的
从这几个步骤来看:
打开文件 -> 更新
tab或者复用原来的tab->monaco显示文件内容 -> 同步至extensionHost-> 修改文件内容 -> 触发monaco change事件 ->tab.content更新 ,tab.dirty设为true-> 同步至extensionHost
保存文件 -> 写入磁盘 ->
tab.savedContent更新,tab.dirty设为false
第一部分
打开文件。点击文件,就会调用onOpenFile
jsx
<Sidebar
className="workbench__sidebar"
activeView={activeView}
onOpenFile={path => editorService.openEditor(path)}
/>
更新 tab。先判断 tab 是否已经存在,如果不存在就读磁盘,拿到文件内容,并且拼装新 tab,包含文件路径,文件名,文件内容,脏状态等等。更新 activePath,显示当前文件。
javascript
async openEditor(path: string): Promise<void> {
const existing = this._tabs.find((t) => t.path === path);
if (existing) {
this._setActive(path);
return;
}
let content = "";
try {
content = await window.electronAPI.fs.readFile(path);
} catch {
content = "";
}
const tab: EditorTab = {
path,
name: path.split("/").pop() ?? path,
savedContent: content,
content,
dirty: false,
};
this._tabs = [...this._tabs, tab];
this._onDidChangeTabs.fire();
this._setActive(path);
}
monaco 显示文件内容。使用 activeTab 的 path 以及 content(读取的是未保存的内容):
javascript
<MonacoEditor
path={activeTab.path}
value={activeTab.content}
onChange={handleChange}
onSave={handleSave}
onCursorChange={onCursorChange}
/>
同步至 extensionHost,监听 chang 事件,在代码变更的时候,也会同步至 extensionHost:
javascript
const track = (model: monaco.editor.ITextModel): void => {
this._extHostDocs.$acceptModelOpened(this._uri(model), model.getValue(), model.getLanguageId());
// 绑定monaco editor的变化,任何代码的编辑,都会实时同步到插件的textDocument
model.onDidChangeContent(() => this._extHostDocs.$acceptModelChanged(this._uri(model), model.getValue()));
// 触发 onLanguage: 激活事件(让按语言激活的扩展------如 LSP 客户端------在打开对应文件时启动)
this._extHostExtensions.$activateByEvent(`onLanguage:${model.getLanguageId()}`);
// 坑 #2:文件此刻才打开,把之前缓存的诊断补设为波浪线
this._applyCachedMarkers(model);
};
monaco.editor.onDidCreateModel(track);
代码变更,触发 change:
javascript
const handleChange = useCallback(
(value: string) => {
if (activePath) editorService.updateContent(activePath, value);
},
[editorService, activePath]
);
// <MonacoEditor
// path={activeTab.path}
// value={activeTab.content}
// onChange={handleChange}
// onSave={handleSave}
// onCursorChange={onCursorChange}
// />
变更 tab.content,同时会将 tab.dirty置为 true
javascript
updateContent(path: string, currentContent: string): void {
const tab = this._tabs.find((t) => t.path === path);
if (!tab || tab.content === currentContent) return;
const wasDirty = tab.dirty;
tab.content = currentContent;
tab.dirty = currentContent !== tab.savedContent;
if (tab.dirty !== wasDirty) {
// 换新数组引用:updateContent 原地改了 tab.dirty,若数组引用不变,
// useEvent(onDidChangeTabs, ()=>tabs) 检测不到变化 → dirty 圆点不刷新。
this._tabs = [...this._tabs];
this._onDidChangeTabs.fire();
}
}
上面就是打开文件以及修改代码这条线了,保存文件我就不带大家看了,代码很简单:)
model、EditorService ,谁才是"文件内容真相"?
model 和 EditorService 都是文件内容的真相,只是描述维度不同
对于工作台的状态来说,content、savedContent、dirty 判断、tab 的展示等等,都是在 EditorService 中;对于编辑器的状态来说,用户的光标、undo/redo、语言服务、marker 等等都是保存在对应的 model 中,所以部分都有文件状态的存储,只是维度不同,并且要保持高度的同步
在和插件通信时,为什么不能直接跨进程传 Monaco 对象来传递编辑器状态?
跨进程传递的数据有个要求,必须是可序列化为普通JSON 的对象。但是 Monaco 对象中有方法,监听器、内部状态等等,并不能序列化为普通的 JSON 对象。
而对于一个语言服务来说,不需要拿到整个 Monaco 对象,只需要拿到文件的 url、content、language 就可以了
结尾
这一篇的重点不是 Monaco 有多少 API,而是先把编辑器里的几层状态分清楚:Monaco editor 负责显示和交互,Monaco model 负责文本模型和编辑器内部状态,EditorService 负责 tabs、content、savedContent 和 dirty 这些工作台状态,而 URI/path 则把这些状态统一指向同一个文件身份。
有了这套模型,很多能力就顺了:切换 tab 时可以复用 model,未保存内容可以留在内存里,保存时再写回磁盘,语言服务和 extensionHost 也能通过可序列化的 URI、content、language 信息拿到最新文本。也就是说,代码编辑器真正复杂的地方,不只是"把文本画出来",而是让文件身份、编辑状态、工作台状态和语言能力始终对得上。