IDE нормального человека или почему мы выбрали Monaco
Памятка от редактора
В прошлой статье мы рассказали про релиз панели управления Voximplant, не забыв упомянуть обновленную IDE. Сегодня мы посвящаем этому инструменту отдельный лонгрид — наша коллега Geloosa заботливо описала как процесс выбора технологии, так и имплементацию с вкладками, автокомплитом и кастомными стилями. Садитесь удобнее, отложите остальные дела и заходите в подкат, где любопытных ждут кишки Monaco — не поскользнитесь, их там много :) Приятного чтения.
Какую библиотеку выбрать для редактора кода?
Npm выдает 400+ результатов по запросу «code editor». По большей части это UI-обертки нескольких самых популярных либ, сделанные для определенного фреймворка или проекта, плагины для тех же либ или их форки с доработками под себя, а также либы не для редактирования кода в браузере, просто попавшие в выдачу по ключевым словам. Так, к счастью, выбор значительно сужается. Еще несколько либ — а-ля CodeFlask, легковесные, но малофункциональные, предназначенные для небольших сниппетов и интерактивных примеров, но не для полноценной веб-IDE с функциональностью, к которой мы привыкли в десктопных редакторах.
В конечном итоге у нас осталось 3 библиотеки на выбор: Ace, CodeMirror и Monaco Editor. Самая ранняя из них — CodeMirror — была частной инициативой берлинца Марина Хавербеке (Marijn Haverbeke), которому понадобился редактор кода для упражнений в его онлайн-учебнике Eloquent JavaScript. Первая версия редактора выпущена в 2007 году. В 2010-м на JSConf.eu в том же Берлине была представлена первая версия Ace, который тогда разрабатывала Ajax.org для своей облачной IDE Cloud9 (собственно, Ace и расшифровывается как Ajax.org Cloud9 Editor). В 2016-м Cloud9 был куплен Амазоном и сейчас является частью AWS. Самый поздний, Monaco Editor, является компонентом VS Code и опубликован Microsoft в конце 2015-го.
У каждого редактора есть свои сильные и слабые стороны, каждый используется не в одном крупном проекте. К примеру, CodeMirror используется в инструментах разработчика Chrome и Firefox, IDE в Bitbucket, в RunKit у npm; Ace — в Codecademy, Khan Academy, MODX; Monaco — в IDE GitLab и CodeSandbox. Ниже приведена сравнительная таблица, которая, возможно, поможет вам выбрать библиотеку, наиболее подходящую для вашего проекта.
Библиотеки | |||
Ace | CodeMirror | Monaco | |
Разработчик | Cloud9 IDE (Ajax.org), ныне — часть AmazonMozilla |
Marijn Haverbeke | Microsoft |
Поддержка браузеров | Firefox ^3.5 Chrome Safari ^4.0 IE ^8.0 Opera ^11.5 |
Firefox ^3.0 Chrome Safari ^5.2 IE ^8.0 Opera ^9.2 |
Firefox ^4.0 Chrome Safari (v — ?) IE ^11.0 Opera ^15.0 |
Поддержка языков (подсветка синтаксиса) |
>120 | >100 | >20 |
Кол-во символов в последних версиях на cndjs.com |
366 608 (v1.4.3) | 394 269 (v5.44.0) | 2 064 949 (v0.16.2) |
Вес последних версий, gzip |
2.147 KB | 1.411 KB | 10.898 KB |
Рендеринг | DOM | DOM | DOM и частично |
Документация | 7 из 10: нет поиска, не всегда понятно, что возвращают методы, есть сомнения в полноте и актуальности (в доке работают не все ссылки) |
6 из 10: слита с юзергайдом, поиск по Ctrl+F, есть сомнения в полноте |
9 из 10: красивая, с поиском и перекрестными ссылками -1 балл за отсутствие пояснений к некоторым флагам, применение которых не вполне очевидно из названия |
Quickstart, демки | How-to — текстовые документы с примерами кода, отдельно есть демки с примерами кода (правда, они разбросаны по разным страницам, не все работают и ищутся они проще всего через гугл), есть демка, где можно пощупать разные фичи, но управлять ими предлагается через UI-контролы, то есть потом надо еще отдельно искать методы для их подключения |
How-to прямо-таки бедные, в основном все разбросано по github и stackoverflow, зато есть демки фич с примерами кода для их реализации |
Объединены в формате плейграунда: код с комментами и рядом демо, можно сразу попробовать и оценить многие возможности |
Активность сообщества | Средняя | Высокая | Средняя |
Активность разработчиков | Средняя | Средняя | Высокая |
Бессмысленно сравнивать библиотеки по размеру, потому что все зависит от того, что и как подключать для конкретного проекта: грузить готовый файл с одним из билдов (которые тоже разнятся) или прогонять npm-пакет через какой-то сборщик? А самое важное — в каком объеме используется редактор: подгружаются ли все стили и темы, сколько и каких аддонов и плагинов использовано. Например, в CodeMirror большая часть функциональности, которая работает в Monaco и Ace из коробки, доступна только с аддонами. В таблице приведено количество символов в последних версиях на CDN и вес их сжатых файлов для общего представления, о каких порядках идет речь.
Во всех библиотеках примерно одинаковый набор базовых фич: автоформатирование кода, сворачивание строк, cut/copy/paste, горячие клавиши, возможность добавления новых синтаксисов для подсветки и тем, проверка синтаксиса (в CodeMirror — только через аддоны, в Ace — пока только для JavaScript/CoffeeScript/CSS/XQuery), подсказки и автокомплит (в CodeMirror — через аддоны), продвинутый поиск по коду (в CodeMirror — через аддоны), методы для реализации табов и сплит-режима, дифф-режим и инструмент для мержа (в CodeMirror — либо с плюсам и минусами в одном окне, либо двухпанельный через аддон, в Ace — отдельная либа). Для CodeMirror в силу его возраста написано много аддонов, но их количество будет влиять и на вес, и на скорость редактора. Monaco многое умеет из коробки, причем, на мой взгляд, лучше и в большем объеме, чем Ace и CodeMirror.
Мы остановились на Monaco по нескольким причинам:
- Наиболее развиты инструменты, которые мы сочли критично важными для нашего проекта:
- IntelliSense — подсказки и автокомплит;
- умная навигация по коду в контекстном меню и через minimap;
- двухпанельный дифф-режим из коробки.
- Написан на TypeScript. Наша панель управления написана на Vue+Typescript, поэтому поддержка TS была важна. К слову, Ace с недавнего времени тоже поддерживает TS, но изначально он был написан на JS. Для CodeMirror есть типы в DefinitelyTyped.
- В нем наиболее активно идет разработка (возможно, потому что он вышел не так давно), быстрее правятся баги и мержатся пул-реквесты. Для сравнения, с CodeMirror у нас был печальный опыт, когда баги не правились годами и мы ставили костыль на костыле и костылем погоняли.
- Удобная автосгенеренная (что дает надежду на ее полноту) документация с перекрестными ссылками между интерфейсами и методами.
- На наш вкус, наиболее красивый UI (наверное, тоже связано с временем создания) и лаконичный API.
- Поспрашивав знакомых разработчиков, какой из редакторов вызывал больше головной боли, в лидерах оказались Ace и CodeMirror.
Отдельно стоит сказать про скорость работы. Затратный синтаксический анализ происходит в параллельном потоке воркера. Плюс все вычисления ограничиваются размером вьюпорта (все типы, цвета, отрисовка рассчитываются только для тех строк, которые видны). Тормозить начинает, только если коде под 100 000 строк — подсказки могут вычисляться по несколько секунд. Ace, который тоже использует воркеры для тяжелых вычислений, оказался быстрее: в коде такой же длины подсказки появляются практически моментально, да и с 200 000 строками он быстро справляется (на официальном сайте заявлено, что даже 4 млн строк не должны оказаться проблемой, хотя у меня разогнались винты, стал тормозить ввод и исчезли подсказки после 1-го миллиона). CodeMirror, где параллельных вычислений нет, совсем с трудом тянет такие объемы: может мелькать и текст, и подсветка синтаксиса. Поскольку в реальном мире 100 000 строк в файле — редкость, мы закрыли на это глаза. Даже с 40–50 тысячами строк Monaco справляется прекрасно.
Подключение Monaco и использование основных фич (на примере интеграции с Vue)
Подключение
Здесь я буду давать примеры кода из vue-компонентов и использовать соответствующую терминологию. Но все это легко переносится в любой другой фреймворк или чистый JS.
Исходник Monaco можно скачать на официальном сайте и положить себе в проект, можно забрать с CDN, можно подключить к проекту через npm. Я расскажу про третий вариант и сборку с помощью webpack.
Ставим monaco-editor и плагин для сборки:
npm i -S monaco-editor
npm i -D monaco-editor-webpack-plugin
В конфиг вебпака добавляем:
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');
module.exports = {
// ...
plugins: [
// ...
new MonacoWebpackPlugin()
]
};
Если вы используете Vue и vue-cli-service для сборки, добавляем во vue.config.js:
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');
module.exports = {
// ...
configureWebpack: (config) => {
// ...
config.plugins.push(new MonacoWebpackPlugin());
}
};
Если вам не нужны все языки и фичи Monaco, для уменьшения размера бандла можно передать в MonacoWebpackPlugin
объект с настройками:
new MonacoWebpackPlugin({
output: '', // папка, куда собирать скрипты воркеров
languages: ['markdown'], // массив строк с названиями языков, для которых нужна подсветка
features: ['format', 'contextmenu'] // массив строк с нужными фичами
})
Полный список фич и языков для плагина здесь.
Создаем и настраиваем редактор
Импортируем editor
и вызываем editor.create(el: HTMLElement, config?: IEditorConstructionOptions)
, передавая в качестве первого аргумента элемент DOM, в котором хотим создать редактор.
В компоненте редактора:
Контейнеру для редактора нужно обязательно задавать высоту, чтобы она не оказалась нулевой. Если вы создадите редактор в пустом div-е (с нулевой высотой — ваш К.О.), Monaco пропишет такую же высоту инлайн-стилем у окна редактора.
Второй необязательный аргумент editor.create
— конфиг редактора. В нем более сотни опций, полное описание интерфейса IEditorConstructionOptions есть в документации.
Для примера зададим язык, тему и изначальный текст и включим перенос строк (по дефолту они не переносятся):
const config = {
value: `function hello() {
alert('Hello world!');
}`,
language: 'javascript',
theme: 'vs-dark',
wordWrap: 'on'
};
this.editor = editor.create(this.$refs.editor, config);
Функция editor.create
возвращает объект с интерфейсом IStandaloneCodeEditor. Через него теперь можно управлять всем происходящим в редакторе, в том числе изменять первоначальные настройки:
// выключаем перенос строк и переключаем редактор в read-only режим
this.editor.updateOptions({wordWrap: 'off', readOnly: true});
Теперь о боли: updateOptions
принимает объект с интерфейсом IEditorOptions, а не IEditorConstructionOptions. Они немного отличаются: IEditorConstructionOptions шире, в него входят свойства данного инстанса редактора и некоторые глобальные. Свойства инстанса меняются через updateOptions
, глобальные — через методы глобального editor
. И соответственно, те, что меняются глобально, меняются для всех инстансов. Среди таких параметров — theme
. Создадим 2 инстанса с разными темами; y обоих будет та, которая задана в последнем (здесь — темная). Глобальный метод editor.setTheme('vs')
также сменит тему у обоих. Это скажется даже на тех окнах, что находятся на другой странице вашего SPA. Таких мест немного, но за ними надо следить.
Удаление редактора
При уничтожении окна Monaco надо вызвать метод dispose
, иначе не очистятся все листенеры и созданные после этого окна могут работать некорректно, реагируя на некоторые события по несколько раз:
beforeDestroy() {
this.editor && this.editor.dispose();
}
Вкладки
Вкладки открытых в редакторе файлов используют одно и то же окно Monaco. Для переключения между ними используются методы IStandaloneCodeEditor: getModel
для сохранения и setModel
для обновления модели редактора. Модель хранит текст, позицию курсора, историю действий для undo-redo. Для создания модели нового файла используется глобальный метод editor.createModel(text: string, language: string)
. Если файл пустой, можно не создавать модель и передать null
в setModel
:
switchTab(tab.id)'>
{{tab.name}}
Дифф-режим
Для дифф-режима нужно использовать другой метод editor
при создании окна редактора — createDiffEditor
:
// ...
mounted() {
this.diffEditor = editor.createDiffEditor(this.$refs.diffEditor, config);
}
// ...
Он принимает те же параметры, что editor.create
, но конфиг должен иметь интерфейс IDiffEditorConstructionOptions, который несколько отличается от конфига обычного редактора, в частности, в нем нет value
. Тексты для сравнения задаются после создания через setModel
возвращенного IStandaloneDiffEditor:
this.diffEditor.setModel({
original: editor.createModel('const a = 1;', 'javascript'),
modified: editor.createModel('const a = 2;', 'javascript')
});
Контекстное меню, палитра команд и горячие клавиши
Monaco использует свое, не браузерное, контекстное меню, где есть умная навигация, мультикурсор для изменения всех вхождений и командная палитра как в VS Code (Command palette) с кучей полезных команд и горячих клавиш, ускоряющих написание кода:
Monaco context menu
Monaco command palette
Контекстное меню расширяется через метод addAction
(он есть и в IStandaloneCodeEditor
, и в IStandaloneDiffEditor
), принимающий объект IActionDescriptor:
// ...
// ...
// импортируем KeyCode и KeyMod для привязки горячих клавиш
import {editor, KeyCode, KeyMod} from "monaco-editor";
// ...
private editor = null;
private diffEditor = null;
private isDiffOpened = false;
private get activeTab() {
return this.tabs.find(tab => tab.active);
}
mounted() {
this.diffEditor = editor.createDiffEditor(this.$refs.diffEditor);
this.editor = editor.create(this.$refs.editor);
this.editor.addAction({
// идент группы, в которой появится новый пункт.
contextMenuGroupId: '1_modification',
// всего их три: 1 - 'navigation', 2 - '1_modification', 3 - '9_cutcopypaste';
// можно создать свои
contextMenuOrder: 3, // очередность пункта меню в рамках группы
label: 'Show diff',
id: 'showDiff',
keybindings: [KeyMod.CtrlCmd + KeyMod.Shift + KeyCode.KEY_D], // горячие клавиши
// функция, вызываемая при клике или
// нажатии указанных клавиш
run: this.showDiffEditor
});
}
// показываем дифф для активной вкладки
private showDiffEditor() {
this.diffEditor.setModel({
original: this.activeTab.initialText,
modified: this.activeTab.editedText
});
this.isDiffOpened = true;
}
Чтобы только привязать сочетание клавиш к действию, не показывая его в контекстном меню, используется тот же метод, только не указывается contextMenuGroupId
у действия:
// ...
// кастомные действия
private myActions = [
{
contextMenuGroupId: '1_modification',
contextMenuOrder: 3,
label: this.$t('scenarios.showDiff'),
id: 'showDiff',
keybindings: [KeyMod.CtrlCmd + KeyMod.Shift + KeyCode.KEY_D],
run: this.showDiffEditor
},
// действие, запускаемое по Ctrl + C + L и невидимое в контекстном меню
{
label: 'Get content length',
id: 'getContentLength',
keybindings: [KeyMod.CtrlCmd + KeyCode.Key_C + KeyCode.Key_L],
run: () => this.editor && alert(this.editor.getValue().length)
}
];
mounted() {
this.editor = editor.create(this.$refs.editor);
this.myActions.forEach(this.editor.addAction); // добавляем все кастомные действия
}
В палитру команд попадут все добавленные действия.
Подсказки и автокомплит
Для этих целей в Monaco использован IntelliSense, что круто. По ссылке можно почитать и посмотреть на скринах, сколько полезной инфы он умеет показывать. Если для вашего языка еще нет автокомплита, его можно добавить через registerCompletionItemProvider. А для JS и TS уже есть метод addExtraLib
, позволяющей загрузить определения на TypeScript для подсказок и автокомплита:
// ...
import {languages} from "monaco-editor";
// ...
// объект, в который будет записан интерфейс для последующего удаления либы
private myAddedLib = null;
mounted() {
// languages используется глобально всеми инстансами Monaco
this.myAddedLib = languages.typescript.javascriptDefaults.addExtraLib('interface MyType {prop: string}', 'myLib');
}
beforeDestroy() {
// удаляем определения, если нужно
this.myAddedLib && this.myAddedLib.dispose();
}
В первом параметре строкой передаются определения, во втором, необязательном, — название либы.
Кастомные языки и темы
В Monaco есть модуль Monarch для определения синтаксиса своих языков. Синтаксис описывается вполне стандартно: задается соответствие между регулярками и токенами, характерными для данного языка.
// ...
// описываем язык, синтаксис которого состоит из:
private myLanguage = {
defaultToken: 'text',
// круглых скобок,
brackets: [{
open: '(',
close: ')',
token: 'bracket.parenthesis'
}],
// слов, обозначающих времена года,
keywords: [
'autumn',
'winter',
'spring',
'summer'
],
// дат и имен людей
tokenizer: {
root: [{
regex: /\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}/,
action: {
token: 'date'
}
},
{
regex: /(boy|girl|man|woman|person)(\s[A-Za-z]+)/,
action: ['text', 'variable']
}
]
}
};
mounted() {
// теперь регистрируем новый язык
languages.register({
id: 'myLanguage'
});
// и устанавливаем определения для него
languages.setMonarchTokensProvider('myLanguage', this.myLanguage);
// ...
}
Также для своих токенов можно создать тему — объект с интерфейсом IStandaloneThemeData — и установить ее в глобальный editor
:
// ...
private myTheme = {
base: 'vs', // тема, от которой наследуется подсветка токенов
inherit: true,
// переопределения старых и определения новых токенов
rules: [
{token: 'date', foreground: '22aacc'},
{token: 'variable', foreground: 'ff6600'},
{token: 'text', foreground: 'd4d4d4'},
{token: 'bracket', foreground: 'd4d4d4'}
]
};
mounted() {
editor.defineTheme('myTheme', this.myTheme);
// ...
}
Теперь текст на описанном языке будет выглядеть так:
Применять эту фичу можно, насколько хватит фантазии. Например, мы сделали в своей панели для разработчиков просмотрщик логов звонков. Логи зачастую длинные и непонятные, но когда они показываются с подсветкой синтаксиса, умным поиском, сворачиванием/разворачиванием строк, нужными командами (вроде приттифаера), выделением всех строк звонка по его id или переводом времени в логе в другой часовой пояс, то копаться в них становится намного проще (скриншот кликабелен):
Заключение
Резюмируя, скажу, что Monaco — огонь. После нескольких месяцев работы с ним у меня исключительно приятные воспоминания. Если вы выбираете редактор для кода, обязательно зайдите на его Playground и поиграйтесь с кодом, посмотрите, что еще он умеет. Возможно, это именно то, что вы ищете.