zoukankan      html  css  js  c++  java
  • 【软工】[技术博客] 用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 
    , 	 !!!
    	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

    语言服务效果

  • 相关阅读:
    【整理】close 和 shutdown 的原理
    【理解】 Error 10053和 Error 10054
    【转载】 socket recv 和 read
    【转载】socket 的 connect、listen、accept 和全连接队列、半连接队列的原理
    【原创】MySQL 生产环境备份还原
    【原创】【问题记录】系统管理员设置了系统策略,禁止此安装的最终解决办法
    【原创】rabbitmq 学习
    mvc, web mvc, spring web mvc 区别
    Spring 读取配置文件的俩种方式
    移动端web开发技巧和常见问题
  • 原文地址:https://www.cnblogs.com/FuturexGO/p/12976656.html
Copyright © 2011-2022 走看看