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


[技術博客] 用Monaco Editor打造接近vscode體驗的瀏覽器IDE

官方文檔與重要參考資料

官方demo

官方API調用樣例 Playground

官方API Doc,但其搜索框不支持模糊匹配

官方GitHub Issues,可搜索相關問題

CSDN優秀博客

帶主題顏色選擇的demo

依賴與配置

在瀏覽器中搭建Monaco Editor,推薦使用ESModule版本+WebPack+npm插件的形式,比較簡單。鏈接中即為官方給出的部署樣例。

需要注意的是,經過筆者踩坑,推薦的node.js包版本為:

"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。在構造時可以指定一系列選項,如行號、minimap等。
其中,每個編輯器的代碼內容等信息存儲在ITextModel中。model保存了文檔內容、文檔語言、文檔路徑等一系列信息,當editor關閉后model仍保留在內存中

因此可以說,editor對應着用戶看到的編輯器界面,是短期的、暫時的;model對應着當前網頁歷史上打開/創建過的所有代碼文檔,是長期的、保持的。

創建model時往往給出一個URI,如inmemory://model1file://a.txt等。注意到,此處的URI只是一個對model的唯一標識符,不代表在編輯器中做的編輯將會實時自動保存在本地文件a.txt中!以下為樣例:

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

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的相關信息是簡單的,在ITextModelIStandaloneCodeEditor的API文檔中不難找到。

以下是一些常用信息,包括獲取model實例、獲取代碼內容(字符串)、獲取代碼長度、獲取光標位置、跳光標到給定位置、置焦點到某編輯器等。

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處預覽由brijeshb42/monaco-themes實現的部分主題,通過npm包的形式使用(見前鏈接中readme)或手動設置:

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);
		});
}

下面是切換顯示行號、切換顯示小地圖、設置字號字體等的實現:

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打印出所有action的ID。

Monaco支持多鍵快捷鍵和組合鍵。前者指形如F5Ctrl+SAlt+Ctrl+Shift+S,同時按下以觸發功能的鍵;后者指先按下Ctrl+K,再按下某(些)鍵以觸發功能的兩次按鍵。其中后者可以通過editor.addCommand(monaco.KeyMod.chord(chord1, chord2), callBackFunc)實現,因不太實用故不再贅述。

下面是為某些actions指定快捷鍵的實現方式:

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 modulemonaco-editor中,但我們仍然可以通過指定路徑獲取到。右鍵菜單分為若干個entries(可以理解為菜單組),每個組中包含一系列菜單項。每個菜單項中存儲了將執行的action、菜單項文本、菜單項ID等。因此以過濾右鍵菜單、只保留想留下的若干項、去除不需要的多余項為例,可以通過迭代和比較action進行修改:

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的二維數組:

[[${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中的實現方法:

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;
}

關鍵詞代碼補全

首先需要定義某語言的關鍵詞、內置函數等待補全詞的列表:

var python_keys = [
    // python keywords
    'and',
    'as',
    ...
    'yield',
    
    // python built-in functions
    'abs',
    'sum',
    ...
];

之后在上文的createDependencyProposals()中增加對關鍵詞的補全即可。其中monaco.languages.CompletionItemKind.Keyword可以換成對應的類型,如FunctionConstClass等,這里不再做區分:

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版詞匯補全將會失效,且現在沒有好的辦法能做到既保留原始word-based補全又使自定義規則生效。

Monaco Editor使用Monarch進行代碼parsing,但暫時沒有一個好的接口能直接獲取parse出的當前文檔的所有token。因此我們可以通過正則表達式自己進行簡單的parsing,將當前代碼的所有token取出,加入上述createDependencyProposals()中,從而間接達到基於token的word-based completion。

在Javascript中使用正則表達式進行全局多次模式匹配:

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補全:

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以構建一套通用的server/client語言服務系統。不同的IDE/編輯器作為client只要調用LSP的接口即可獲取代碼操作的結構,可共用相同的server。

筆者使用的Python Language Server Protocol實現是pyls,C/C++ Language Server Protocol實現是MaskRay/ccls

Monaco端client的接口是monaco-languageclient,遠程主機端server的接口是pyls_jsonrpc

它們之間通過基於WebSocket的json-rpc進行通信。

Client

Client端需要建立WebSocket連接,並監聽其信息傳輸。

注意python的語言服務由於多數場景是單文件補全,且在pyls中已經實現了用戶更改實時同步給server,因此不必要將所有用戶代碼文件同步到遠程server主機的BASE_DIR目錄下。但C++的語言服務是基於文件夾的,且在ccls中用戶的實時更改沒有通過WebSocket實時同步給server,因此需要額外將文件實時保存在遠程server中。筆者團隊使用http接口進行實時file update。

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

Server

Server端需要建立WebSocket連接,轉發命令給具體的LSP進程並轉發結果給client。

可以使用tornado實現,將web socket的read、write重定向到LSP進程的標准輸入輸出流中。

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

要想實現打開文件(或peek文件),需要在打開和peek動作前加載目標文件的內容。這需要在構造編輯器時重寫textModelService中的一系列方法。詳見vLab-Editor/master/src/Editor.js#L27

語言服務效果


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM