什么是Monaco Editor?
微軟之前有個項目叫做Monaco Workbench,后來這個項目變成了VSCode,而Monaco Editor(下文簡稱monaco)就是從這個項目中成長出來的一個web編輯器,他們很大一部分的代碼(monaco-editor-core)都是共用的,所以monaco和VSCode在編輯代碼,交互以及UI上幾乎是一摸一樣的,有點不同的是,兩者的平台不一樣,monaco基於瀏覽器,而VSCode基於electron,所以功能上VSCode更加健全,並且性能比較強大。
開始使用
本文采用的是webpack編譯,所以以下都是基於webpack來說明。
基本功能
首先,我們需要安裝monaco
npm install monaco-editor -S
然后在自己的文件中引入monaco,這里不需要全部引入,只需要引入自己需要使用的功能模塊即可。
HTML
<div id="monaco"> </div>
JS
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; const monacoInstance=monaco.editor.create(document.getElementById("monaco"),{ value:`console.log("hello,world")`, language:"javascript" }) monacoInstance.dispose();//使用完成銷毀實例

我們設置了語言為javascript,界面是出來了,但是卻發現沒有語法高亮,輸入命令發現其實根本沒有javascript語言,只有一個最基礎的plaintext。

所以我們還需要再定義一個javascript語言,但是定義一門語言並不是一件很容易的事情,幸好,monaco自身提供了許多種內置語言,我們只需要引入即可。
import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution';
引入完成,再次查看界面,發現已經有了語法高亮。

這時,我們可以嘗試像使用VSCode一樣使用monaco,按下ctrl+f來執行文本查找,我們會發現出來的不是monaco的查找控件,而是瀏覽器的,因此我們需要引入查找控件。
import 'monaco-editor/esm/vs/editor/contrib/find/findController.js';
再次嘗試查找,出來的已經是monaco的查找控件啦。

monaco還有許多這類控件,我們可以按需引入自己用到的。
不過有一個更加簡便的方法,那就是直接引入main文件來代替api文件。
import * as monaco from 'monaco-editor/esm/vs/editor/editor.main.js';
點開文件,我們可以看到
editor.main.js
import '../language/typescript/monaco.contribution'; import '../language/css/monaco.contribution'; import '../language/json/monaco.contribution'; import '../language/html/monaco.contribution'; import '../basic-languages/monaco.contribution'; export * from './edcore.main';
采用這種方式引入的話,會自動帶上所有的內置語言和控件,唯一的缺點就是包的體積過大。
目前為止,我們已經實現了一個可以輸入,高亮,查找的web編輯器,但是,和VSCode比較起來,還少了許多重要的功能,例如代碼補全,錯誤提示以及快捷命令功能等。
進階使用
首先,我們自己可以設想一下,假如要自己來實現代碼補全以及錯誤提示,我們會怎么做?
第一,我們要解析輸入的文本,這時,我們就需要寫一個Parser。
第二,根據Parser解析的結果來調用monaco的標注接口來標注錯誤的代碼從而實現錯誤提示功能
第三,根據Parser解析的結果信息,提供上下文相關的代碼候選項來實現代碼補全功能。
可以看出來,實現起來難度會很大,涉及到的點很多,不過,和語法高亮一樣,monaco也幫助我們實現了這些功能,目前支持html,css,ts/js,json四種語言,我們只需要引入即可。但是這邊的引入可沒有語法高亮那么簡單。
Monaco的實現采用worker的方式,因為語法解析需要耗費大量時間,所以用worker來異步處理返回結果比較高效。我們使用的話需要兩步。
- 提供一個定義worker路徑的全局變量
window.MonacoEnvironment = { getWorkerUrl: function (moduleId, label) { if (label === 'json') { return './json.worker.js'; } if (label === 'css') { return './css.worker.js'; } if (label === 'html') { return './html.worker.js'; } if (label === 'typescript' || label === 'javascript') { return './typescript.worker.js'; } if(label==="sql"){ return "./sql.worker.js"; } return './editor.worker.js'; } }
選擇對應的language,monaco會去調用getWorkerUrl去查worker的路徑,然后去加載。這邊默認會加載一個editor.worker.js,這是一個基礎功能文件,提供了所有語言通用的功能(例如已定義常量的代碼補全提示),無論使用什么語言,monaco都會去加載他。
2. 打包worker
在webpack中引入需要的worker
entry: { "main": path.resolve(process.cwd(), "src/main.js"), "editor.worker": 'monaco-editor/esm/vs/editor/editor.worker.js', "ts.worker": 'monaco-editor/esm/vs/language/typescript/ts.worker', },
好了,這邊開始就是monaco的地獄模式,我們會遇到非常多的問題。
問題一. 我們的輸出一般是加hash的,所以,輸出的worker文件也會有對應的hash值后綴,例如typescript.worker.a23sf4asfqw.js,那么,第一步中的getWorkerUrl中的配置(typescript.worker.js)就和他對不上了,導致查找worker路徑失敗。
問題二. worker是運行在單獨的線程中的,所以沒有window變量,我們需要修改webpack的全局變量為self才可以。
問題3. 假如使用html-webpack-plugin插件,我們就要防止worker被直接引入html文件(因為worker也是單獨的entry),因此還需要設置html-webpack-plugin的chunks。
....
不得不說monaco是一個很貼心的編輯器,他也幫我們解決了這一系列問題。解決我們問題的就是monaco-editor-webpack-plugin。
monaco-editor-webpack-plugin
使用起來也非常簡單
npm install monaco-editor-webpack-plugin -S
webpack配置
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin'); module.exports=function(){ return { ... plugins:[ new MonacoWebpackPlugin() ] ... } }
插件會幫我們做這么幾件事
- 自動注入getWorkerUrl全局變量
- 處理worker的編譯配置
- 自動引入控件和語言包。
具體要引入哪些控件和語言包,我們可以通過配置languages和features來控制
new MonacoWebpackPlugin({ languages:["javascript","css","html","json"], features:["coreCommands","find"] })
缺省情況下,插件的會引入默認的語言包和控件(瞄了一下,應該是所有的控件和語言包),具體可以查看這個地址Microsoft/monaco-editor-webpack-plugin。
很好,現在我們以及完成了一個擁有自動補全,錯誤提示,以及語法高亮的編輯器。

事件綁定
在完成了編輯器本身的配置之后,我們可以開始進行下一步,綁定編輯事件。
monacoInstance.onDidChangeModelContent((event) => { const newValue=monacoInstance.getValue(); console.log(newValue) })
monacoInstance是一個create方法返回的實例,他包含很多操作實例的方法。event是一個IModelContentChangedEvent對象,他包含了非常非常詳細的變更信息,包括操作的類型(撤銷、恢復,還是手動輸入引發的文本變更),變更的文本位置,變更的文本內容等。而我們要獲取最新的值,則需要調用
monacoInstance.getValue();
細心的朋友應該還會發現一個很奇怪的地方,那就是我們綁定的方法用的是onDidChangeModelContent,里面有一個Model,這命名可是很講究的,字面意思就是變更Model內容觸發事件,從頭到尾,我們都沒看到有Model的存在,那么為什么這邊是變更Model內容觸發事件呢,難道我們操作的是Model?
是的,其實我們在編輯的時候,就是在Model上編輯,默認情況下,monaco會幫我生成一個Model,我們可以調用getModel打印一下
monacoInstance.getModel()

看一看api,我們可以發現,Model其實是一個保存編輯狀態的對象,他里面含有語言信息,當前的編輯文本信息,標注信息等。所以我們可以緩存一下Model對象,在需要的時候直接調用setModel即可隨時切換到之前的狀態。或者也可以在初始化實例的時候設置一個Model。
const model=monaco.editor.createModel("hahahaha","javascript"); monacoInstance = monaco.editor.create(this.monacoDom.current, { model:model })
而且我們可以直接在model上來綁定我們的事件
model.onDidChangeContent((event)=>{ ... })
Model最后也需要我們銷毀,這里分兩種情況,假如是通過createModel創建的Model,那么我們需要手動銷毀,但是如果是monaco默認創建的,則不需要,在調用實例的銷毀方法時,會順帶銷毀默認創建的Model。
model.dispose();
除了編輯事件之外,Model還有許多其他事件
例如:
onDidChangeOptions 配置改變事件
onDidChangeLanguage 語言改變事件
...
在簡單的場景下,Model的存在可能使得我們使用起來比較繁瑣,但是,在復雜場景下,model可以極大的簡化代碼復雜性。
設想一下我們有5個tab,每個tab都是一個編輯器,每個編輯器都有各自的語言,內容和標注信息,如果沒有Model,我們需要保存每個tab的語言,內容等信息,在切換到對應tab時再將這些信息初始化到編輯器上,但是利用Model,我們不需要額外去保存每個tab的編輯信息,只需要保存每個tab的Model,然后將Model傳給編輯器進行初始化即可。
結尾
本文重點在於介紹monaco的基本設計和使用,代碼細節部分不是很詳細,如果有疑問,可以翻閱monaco的官方文檔。謝謝~