[技术博客] 用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

语言服务效果

【软工】[技术博客] 用Monaco Editor打造接近vscode体验的浏览器IDE的更多相关文章

  1. [BUAA软工]第一次博客作业---阅读《构建之法》

    [BUAA软工]第一次博客作业 项目 内容 这个作业属于哪个课程 北航软工 这个作业的要求在哪里 第1次个人作业 我在这个课程的目标是 学习如何以团队的形式开发软件,提升个人软件开发能力 这个作业在哪 ...

  2. 2020BUAA软工个人博客作业

    2020BUAA软工个人博客作业 17373010 杜博玮 项目 内容 这个作业属于哪个课程 2020春季计算机学院软件工程(罗杰 任健) 这个作业的要求在哪里 个人博客作业 我在这个课程的目标是 学 ...

  3. 2020BUAA软工个人博客作业-软件案例分析

    2020BUAA软工个人博客作业-软件案例分析 17373010 杜博玮 项目 内容 这个作业属于哪个课程 2020春季计算机学院软件工程(罗杰 任健) 这个作业的要求在哪里 个人博客作业-软件案例分 ...

  4. [敏捷软工团队博客]Beta阶段项目展示

    团队成员简介和个人博客地址 头像 姓名 博客园名称 自我介绍 PM 测试 前端 后端 dzx 秃头院的大闸蟹 大闸蟹是1706菜市场里无菜可卖的底层水货.大闸蟹喜欢音乐(但可惜不会),喜欢lol(可惜 ...

  5. 自我介绍&软工实践博客点评

    想想既然写了点评博客,那就顺便向同学们介绍下自己吧. 我是16届计科实验班的,水了两件小黄衫,于是就来当助教了_(:_」∠)_ 实话说身为同届生来当助教,我心里还是有点虚的,而且我还是计科的..感觉软 ...

  6. [敏捷软工团队博客]Beta阶段事后分析

    设想和目标 我们的软件要解决什么问题?是否定义得很清楚?是否对典型用户和典型场景有清晰的描述? 我们的软件要解决的问题是:现在的软工课程的作业分布在博客园.GitHub上,没有一个集成多种功能的一体化 ...

  7. [敏捷软工团队博客]The Agiles 团队介绍&团队采访

    项目 内容 课程:北航-2020-春-敏捷软工 博客园班级博客 作业要求 团队作业-团队介绍和采访 团队名称来源 The Agile is The Agile. 敏捷就是敏捷.我们只是敏捷的践行者罢了 ...

  8. [2017BUAA软工助教]博客格式的详细说明

    一.为什么要强调博客格式 可以对比粗读一下这几篇博客然后自己感受一下博客格式对博客阅读体验的影响: MarkDown流:    [schaepher]2017春季 JMU 1414软工助教 链接汇总 ...

  9. [敏捷软工团队博客]Beta阶段发布声明

    项目 内容 2020春季计算机学院软件工程(罗杰 任健) 博客园班级博客 作业要求 Beta阶段发布声明 我们在这个课程的目标是 在团队合作中锻炼自己 这个作业在哪个具体方面帮助我们实现目标 对Bet ...

随机推荐

  1. Centos 6.5升级gcc : 源码安装 + rpm安装

    1. 前言 采用Centos 6.5默认的gcc版本为4.4.7,不支持c++ 11,需要升级: 首先想到用yum命令:执行yum update gcc-c++或yum update g++ 显示没有 ...

  2. 图论--网络流--最大流 洛谷P4722(hlpp)

    题目描述 给定 nn 个点,mm 条有向边,给定每条边的容量,求从点 ss 到点 tt 的最大流. 输入格式 第一行包含四个正整数nn.mm.ss.tt,用空格分隔,分别表示点的个数.有向边的个数.源 ...

  3. Codeforce 1155D Beautiful Array(DP)

    D. Beautiful Array You are given an array aa consisting of nn integers. Beauty of array is the maxim ...

  4. Redis 6.0 新特性-多线程连环13问!

    Redis 6.0 来了 在全国一片祥和IT民工欢度五一节假日的时候,Redis 6.0不声不响地于5 月 2 日正式发布了,吓得我赶紧从床上爬起来,学无止境!学无止境! 对于6.0版本,Redis之 ...

  5. Java——集合系列(1)框架概述

    该系列博文会告诉你如何从入门到进阶,一步步地学习Java基础知识,并上手进行实战,接着了解每个Java知识点背后的实现原理,更完整地了解整个Java技术体系,形成自己的知识框架. 集合框架概述 Jav ...

  6. 面试官:你说你懂i++跟++i的区别,那你会做下面这道题吗?

    面试官:你说你懂i++跟++i的区别,那你知道下面这段代码的运行结果吗? 面试官:"说一说i++跟++i的区别" 我:"i++是先把i的值拿出来使用,然后再对i+1,++ ...

  7. SpringCloudGateWay学习 之 从函数式编程到lambda

    文章目录 前言: 函数式编程: 什么是函数式编程: 函数式编程的特点 lambda表达式: 核心: 函数接口: 方法引用: 类型推断: 变量引用: 级联表达式跟柯里化: 前言: 这一系列的文章主要是为 ...

  8. 将csv文件导入sql数据库

    有一个csv文件需要导入到Sql数据库中,其格式为 “adb”,"dds","sdf" “adb”,"dds","sdf" ...

  9. Kubernetes中 Pod 是怎样被驱逐的?

    前言 在 Kubernetes 中,Pod 使用的资源最重要的是 CPU.内存和磁盘 IO,这些资源可以被分为可压缩资源(CPU)和不可压缩资源(内存,磁盘 IO).可压缩资源不可能导致 Pod 被驱 ...

  10. flush方法和close方法的区别

    package com.yhqtv.demo05.Writer; import java.io.FileWriter; /* * @author XMKJ yhqtv.com Email:yhqtv@ ...