1. 程式人生 > >【軟工】[技術部落格] 用Monaco Editor打造接近vscode體驗的瀏覽器IDE

【軟工】[技術部落格] 用Monaco Editor打造接近vscode體驗的瀏覽器IDE

# [技術部落格] 用Monaco Editor打造接近vscode體驗的瀏覽器IDE ## 官方文件與重要參考資料 [官方demo](https://github.com/Microsoft/monaco-editor-samples/) [官方API呼叫樣例 Playground](https://microsoft.github.io/monaco-editor/playground.html) [官方API Doc](https://microsoft.github.io/monaco-editor/api/index.html),但其搜尋框不支援模糊匹配 [官方GitHub Issues](https://github.com/Microsoft/monaco-editor/issues),可搜尋相關問題 [CSDN優秀部落格](https://blog.csdn.net/gao_grace/article/details/88890895) [帶主題顏色選擇的demo](https://editor.bitwiser.in/) ## 依賴與配置 在瀏覽器中搭建Monaco Editor,推薦使用[ESModule版本+WebPack+npm外掛](https://github.com/microsoft/monaco-editor-samples/tree/master/browser-esm-webpack-monaco-plugin)的形式,比較簡單。連結中即為官方給出的部署樣例。 需要注意的是,經過筆者踩坑,推薦的node.js包版本為: ```json "dependencies": { "monaco-editor": "=0.19.3", "monaco-editor-webpack-plugin": "=1.9.0", "webpack": "^3.6.0", "webpack-dev-server": "^2.9.1", } ``` 其中,`monaco-editor <= 0.19.1`時無換行自動縮排,`monaco-editor = 0.20.0`時編輯器有概率在網頁佈局中只佔高度5px。因此推薦使用版本0.19.2或0.19.3。對應的,`monaco-editor-webpack-plugin`使用版本1.8.2(對應editor的0.19.2)或1.9.0(對應editor的0.19.3+)。 在實現IntelliSense時推薦使用webpack v3.x。 ## 基礎介面 ### 建立model與editor 在Monaco Editor中,每個使用者可見的編輯器均對應一個[IStandaloneCodeEditor](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.istandalonecodeeditor.html)。在構造時可以指定一系列[選項](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.istandaloneeditorconstructionoptions.html),如行號、minimap等。 其中,每個編輯器的程式碼內容等資訊儲存在[ITextModel](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.itextmodel.html)中。model儲存了文件內容、文件語言、文件路徑等一系列資訊,當editor關閉後model仍保留在記憶體中 因此可以說,editor對應著使用者看到的編輯器介面,是短期的、暫時的;model對應著當前網頁歷史上開啟/建立過的所有程式碼文件,是長期的、保持的。 建立model時往往給出一個URI,如`inmemory://model1`、`file://a.txt`等。注意到,此處的URI只是一個對model的唯一識別符號,**不代表**在編輯器中做的編輯將會實時自動儲存在本地檔案`a.txt`中!以下為樣例: ```js let uri = monaco.Uri.parse("file://" + filePath); var model = monaco.editor.getModel(uri); // 如果該文件已經建立/開啟則直接取得已存在的model if (!model) // 否則建立新的model model = monaco.editor.createModel(code, language, uri); // 如 code="console.log('hello')", language="javascript" // 也可以不指定uri引數,直接使用model = monaco.editor.createModel(code, language),會自動分配一個uri let editor = monaco.editor.create(document.getElementById(container_id), { model: model, automaticLayout: true, // 構造選項,具體清單見上文連結 glyphMargin: true, lightbulb: { enabled: true } }); ``` 其中`container_id`為放置該編輯器介面的HTML div ID(為支援多編輯器)。一個合理的建立方式在一個共同的`editorRoot`下建立多個`container`: ```js let new_container = document.createElement("DIV"); new_container.id = "container-" + fileCounter.toString(10); new_container.className = "container"; document.getElementById("editorRoot").appendChild(new_container); let container_id = new_container.id; ``` 同時在css中設定`container`類的樣式等。 ### 獲取程式碼、程式碼長度、游標位置等資訊 獲取與editor或model的相關資訊是簡單的,在[ITextModel](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.itextmodel.html)和[IStandaloneCodeEditor](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.istandalonecodeeditor.html)的API文件中不難找到。 以下是一些常用資訊,包括獲取model例項、獲取程式碼內容(字串)、獲取程式碼長度、獲取游標位置、跳游標到給定位置、置焦點到某編輯器等。 ```js export function getModel(editor) { return editor.getModel(); } export function getCode(editor) { return editor.getModel().getValue(); } export function getCodeLength(editor) { // chars, including \n, \t !!! return editor.getModel().getValueLength(); } export function getCursorPosition(editor) { let line = editor.getPosition().lineNumber; let column = editor.getPosition().column; return { ln: line, col: column }; } export function setCursorPosition(editor, ln, col) { let pos = { lineNumber: ln, column: col }; editor.setPosition(pos); } export function setFocus(editor) { editor.focus(); } ``` ### 設定主題與外觀 可以在[這個demo](https://editor.bitwiser.in/)處預覽由[brijeshb42/monaco-themes](https://github.com/brijeshb42/monaco-themes)實現的部分主題,通過npm包的形式使用(見前連結中readme)或手動設定: ```js export function setTheme(themeName) { // 部分json檔案的名稱不能直接用於monaco.editor.defineTheme(如含有空格等) fetch('/themes/' + themes[themeName] + '.json') // 可以使用一個map進行轉換 .then(data => data.json()) .then(data => { monaco.editor.defineTheme(themeName, data); monaco.editor.setTheme(themeName); }); } ``` 下面是切換顯示行號、切換顯示小地圖、設定字號字型等的實現: ```js export function setLineNumberOnOff(editor, option) { // option === 'on' / 'off' if (option === 'on' || option === 'off') { editor.updateOptions({ lineNumbers: option }); } } export function setMinimapOnOff(editor, option) { // option === 'on' / 'off' if (option === 'on') { editor.updateOptions({ minimap: { enabled: true } }); } else if (option === 'off') { editor.updateOptions({ minimap: { enabled: false } }); } } export function setFontSize(editor, size) { editor.updateOptions({ fontSize: size }); } export function setFontFamily(editor, family) { editor.updateOptions({ fontFamily: family }); } ``` ## 定製快捷鍵、右鍵選單 ### 為操作指定快捷鍵 在Monaco中,大部分的編輯器行為(如複製、貼上、剪下、摺疊、跳轉等)都是一個`IEditorAction`。可以使用[getSupportedActions](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.istandalonecodeeditor.html#getsupportedactions)打印出所有action的ID。 Monaco支援多鍵快捷鍵和組合鍵。前者指形如`F5`、`Ctrl+S`、`Alt+Ctrl+Shift+S`,同時按下以觸發功能的鍵;後者指先按下`Ctrl+K`,再按下某(些)鍵以觸發功能的兩次按鍵。其中後者可以通過`editor.addCommand(monaco.KeyMod.chord(chord1, chord2), callBackFunc)`實現,因不太實用故不再贅述。 下面是為某些actions指定快捷鍵的實現方式: ```js function bindKeyWithAction(editor, key, actionID) { editor.addCommand(key, function () { editor.trigger('', actionID); }); } // 使用二進位制或符號表示同時按下多個鍵 // 使用monaco.KeyMod.CtrlCmd以確保跨平臺性:macOS下為command(⌘),win/linux下為Ctrl // Ctrl/⌘ [ jump to bracket bindKeyWithAction(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.US_OPEN_SQUARE_BRACKET, "editor.action.jumpToBracket"); // Ctrl/⌘ + expand bindKeyWithAction(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.US_EQUAL, "editor.unfold"); // Ctrl/⌘ - fold bindKeyWithAction(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.US_MINUS, "editor.fold"); // Alt Ctrl/⌘ + expand recursively bindKeyWithAction(editor, monaco.KeyMod.Alt | monaco.KeyMod.CtrlCmd | monaco.KeyCode.US_EQUAL, "editor.unfoldRecursively"); // Shift Ctrl/⌘ + expand all bindKeyWithAction(editor, monaco.KeyMod.Shift | monaco.KeyMod.CtrlCmd | monaco.KeyCode.US_EQUAL, "editor.unfoldAll"); ``` ### 定製右鍵選單 在Monaco中右鍵選單儲存在node module`monaco-editor`中,但我們仍然可以通過指定路徑獲取到。右鍵選單分為若干個`entries`(可以理解為選單組),每個組中包含一系列選單項。每個選單項中儲存了將執行的action、選單項文字、選單項ID等。因此以過濾右鍵選單、只保留想留下的若干項、去除不需要的多餘項為例,可以通過迭代和比較action進行修改: ```js var menus = require('monaco-editor/esm/vs/platform/actions/common/actions').MenuRegistry._menuItems; export function removeUnnecessaryMenu() { var stay = [ "editor.action.jumpToBracket", "editor.action.selectToBracket", // ... action IDs ... "editor.action.clipboardCopyAction", "editor.action.clipboardPasteAction", ] for (let [key, menu] of menus.entries()) { if (typeof menu == "undefined") { continue; } for (let index = 0; index < menu.length; index++) { if (typeof menu[index].command == "undefined") { continue; } if (!stay.includes(menu[index].command.id)) { // menu[index].command.id獲取action的ID字串 menu.splice(index, 1); } } } } ``` 然而由於右鍵選單是根據開啟的文件型別、語言動態決定的,因此建立editor後執行一次`removeUnnecessaryMenu()`不一定能全部過濾,推薦連續執行三次。 ## 新增程式碼片段、關鍵詞程式碼補全、Token程式碼補全 ### 快速程式碼片段 程式碼片段(snippets)是提高程式碼編寫效率的重要工具。其表現形式為,使用者輸入某些字元觸發自動補全提示,若選擇snippet型別的補全則會在游標後新增一段預先設計好的程式碼片段,且部分需要使用者設定的部分(如變數名、初始值等)為使用者留空,使用者按下tab鍵可以在各個留空位置直接快速切換。 如以下的snippets可以讓使用者在python程式碼中快速建立一個初值為-1的二維陣列: ```python [[${1:0}]*${3:cols} for _ in range(${2:rows})] ``` 其中`${1:0}、${2:rows}、${3:cols}`為使用者可能修改的位置,初始值為`0、rows、cols`。使用者鍵入-1即可將0更改為-1,按下tab再鍵入4即可將rows更改為4。 以下是在Monaco中的實現方法: ```js monaco.languages.registerCompletionItemProvider('python', { provideCompletionItems: function (model, position) { var word = model.getWordUntilPosition(position); var range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn }; return { suggestions: createDependencyProposals(range, languageService, editor, word) }; } }); function createDependencyProposals(range, languageService = false, editor, curWord) { let snippets = [ { label: 'list2d_basic', // 使用者鍵入list2d_basic的任意字首即可觸發自動補全,選擇該項即可觸發新增程式碼片段 kind: monaco.languages.CompletionItemKind.Snippet, documentation: "2D-list with built-in basic type elements", insertText: '[[${1:0}]*${3:cols} for _ in range(${2:rows})]', // ${i:j},其中i表示按tab切換的順序編號,j表示預設串 insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, range: range }, ]; return snippets; } ``` ### 關鍵詞程式碼補全 首先需要定義某語言的關鍵詞、內建函式等待補全詞的列表: ```js var python_keys = [ // python keywords 'and', 'as', ... 'yield', // python built-in functions 'abs', 'sum', ... ]; ``` 之後在上文的`createDependencyProposals()`中增加對關鍵詞的補全即可。其中`monaco.languages.CompletionItemKind.Keyword`可以換成對應的型別,如`Function`、`Const`、`Class`等,這裡不再做區分: ```js function createDependencyProposals(range, languageService = false, editor, curWord) { // snippets的定義同上 // keys(泛指一切待補全的預定義詞彙)的定義: let keys = []; for (const item of python_keys) { keys.push({ label: item, kind: monaco.languages.CompletionItemKind.Keyword, documentation: "", insertText: item, range: range }); } return snippets.concat(keys); } ``` ### 基於已輸入詞(Token)的動態補全 當上述snippets和keywords均沒有設定時,Monaco Editor會使用當前文件的所有詞彙進行“程式碼補全提示”。但增加任何自定義補全規則後,原來的naive版詞彙補全將會失效,且現在[沒有好的辦法](https://github.com/microsoft/monaco-editor/issues/1850)能做到既保留原始word-based補全又使自定義規則生效。 Monaco Editor使用Monarch進行程式碼parsing,但暫時[沒有一個好的介面](https://github.com/microsoft/monaco-editor/issues/75)能直接獲取parse出的當前文件的所有token。因此我們可以通過正則表示式自己進行簡單的parsing,將當前程式碼的所有token取出,加入上述`createDependencyProposals()`中,從而間接達到基於token的word-based completion。 在Javascript中使用正則表示式進行全域性多次模式匹配: ```js const identifierPattern = "([a-zA-Z_]\\w*)"; // 正則表示式定義 注意轉義\\w export function getTokens(code) { let identifier = new RegExp(identifierPattern, "g"); // 注意加入引數"g"表示多次查詢 let tokens = []; let array1; while ((array1 = identifier.exec(code)) !== null) { tokens.push(array1[0]); } return Array.from(new Set(tokens)); // 去重 } ``` 再新增到補全規則中即可實現實時更新的token補全: ```js function createDependencyProposals(range, languageService = false, editor, curWord) { // snippets和keys的定義同上 let words = []; let tokens = getTokens(editor.getModel().getValue()); for (const item of tokens) { if (item != curWord.word) { words.push({ label: item, kind: monaco.languages.CompletionItemKind.Text, // Text 沒有特殊意義 這裡表示基於文字&單詞的補全 documentation: "", insertText: item, range: range }); } } return snippets.concat(keys).concat(words); } ``` ## 語言服務 如何使各種型別的IDE/編輯器擁有程式碼補全、程式碼錯誤檢查、程式碼格式化等語言服務一直是一個難題。傳統的方法是為每個IDE/編輯器進行每種語言的適配,十分麻煩。於是微軟提出了[Language Server Protocol](https://microsoft.github.io/language-server-protocol/)以構建一套通用的server/client語言服務系統。不同的IDE/編輯器作為client只要呼叫LSP的介面即可獲取程式碼操作的結構,可共用相同的server。 筆者使用的Python Language Server Protocol實現是[pyls](https://github.com/palantir/python-language-server),C/C++ Language Server Protocol實現是[MaskRay/ccls](https://github.com/MaskRay/ccls)。 Monaco端client的介面是[monaco-languageclient](https://github.com/TypeFox/monaco-languageclient),遠端主機端server的介面是[pyls_jsonrpc](https://github.com/palantir/python-jsonrpc-server)。 它們之間通過基於WebSocket的json-rpc進行通訊。 ### Client Client端需要建立WebSocket連線,並監聽其資訊傳輸。 注意python的語言服務由於多數場景是單檔案補全,且在pyls中已經實現了使用者更改實時同步給server,因此不必要將所有使用者程式碼檔案同步到遠端server主機的BASE_DIR目錄下。但C++的語言服務是基於資料夾的,且在ccls中使用者的實時更改沒有通過WebSocket實時同步給server,因此需要額外將檔案實時儲存在遠端server中。筆者團隊使用http介面進行實時file update。 ```js import * as monaco from 'monaco-editor'; import { listen } from 'vscode-ws-jsonrpc'; import { MonacoLanguageClient, CloseAction, ErrorAction, MonacoServices, createConnection } from 'monaco-languageclient'; const ReconnectingWebSocket = require('reconnecting-websocket'); function getPythonReady(editor, BASE_DIR, url) { // 註冊語言 monaco.languages.register({ id: 'python', extensions: ['.py'], aliases: ['py', 'PY', 'python', 'PYTHON', 'py3', 'PY3', 'python3', 'PYTHON3'], }); // 設定檔案目錄。如果server為遠端主機則需要將檔案實時同步到遠端主機的BASE_DIR目錄下(C++需要 Python不需要) MonacoServices.install(editor, { rootUri: BASE_DIR }); // 建立連線 建立LSP client if (!connected) { const webSocket = createWebSocket(url); listen({ webSocket, onConnection: connection => { connected = true; // create and start the language client const languageClient = createLanguageClient(connection); const disposable = languageClient.start(); connection.onClose(() => disposable.dispose()); } }); } } ``` 其中`createWebSocket()`、`createLanguageClient()`等具體實現詳見[vLab-Editor/src/language/python.js](https://github.com/BUAASoftwareEngineering/vLab-Editor/blob/master/src/language/python.js)。 ### Server Server端需要建立WebSocket連線,轉發命令給具體的LSP程序並轉發結果給client。 可以使用tornado實現,將web socket的read、write重定向到LSP程序的標準輸入輸出流中。 ```python import subprocess import threading import argparse import json from tornado import ioloop, process, web, websocket from pyls_jsonrpc import streams class LanguageServerWebSocketHandler(websocket.WebSocketHandler): writer = None def open(self, *args, **kwargs): proc = process.Subprocess( ['pyls', '-v'], # 具體的LSP實現程序,如 'pyls -v'、'ccls --init={"index": {"onChange": true}}'等 stdin=subprocess.PIPE, stdout=subprocess.PIPE ) self.writer = streams.JsonRpcStreamWriter(proc.stdin) def consume(): ioloop.IOLoop() reader = streams.JsonRpcStreamReader(proc.stdout) reader.listen(lambda msg: self.write_message(json.dumps(msg))) thread = threading.Thread(target=consume) thread.daemon = True thread.start() def on_message(self, message): self.writer.write(json.loads(message)) def check_origin(self, origin): return True if __name__ == "__main__": app = web.Application([ (r"/python", LanguageServerWebSocketHandler), ]) app.listen(3000, address="127.0.0.1") # URL = "ws://127.0.0.1:3000/python" ioloop.IOLoop.current().start() ``` ### 實現peek/jump definition/references時自動載入和開啟檔案 上述的語言服務已經支援了對程式碼進行解析、處理和返回結果。然而要想獲得完整的、媲美VSCode的使用者互動體驗,還可以新增自動開啟查詢到的定義/引用指向的檔案。 要想實現Ctrl+單擊開啟識別符號的定義檔案和位置,需要重寫`StandaloneCodeEditorServiceImpl.prototype.doOpenEditor()`方法。詳見[vLab-Editor/master/src/app.js#L128](https://github.com/BUAASoftwareEngineering/vLab-Editor/blob/ceece3680549cdbcce62ec9d3dbea093e5261334/src/app.js#L128)。 要想實現開啟檔案(或peek檔案),需要在開啟和peek動作前載入目標檔案的內容。這需要在構造編輯器時重寫`textModelService`中的一系列方法。詳見[vLab-Editor/master/src/Editor.js#L27](https://github.com/BUAASoftwareEngineering/vLab-Editor/blob/ceece3680549cdbcce62ec9d3dbea093e5261334/src/Editor.js#L27)。 ### 語言服務效果 ![](https://img2020.cnblogs.com/blog/1615918/202005/1615918-20200527211542759-920805510.png) ![](https://img2020.cnblogs.com/blog/1615918/202005/1615918-20200527211547229-3849243