Хабрахабр

IDE нормального человека или почему мы выбрали Monaco

Памятка от редактора

В прошлой статье мы рассказали про релиз панели управления Voximplant, не забыв упомянуть обновленную IDE. Сегодня мы посвящаем этому инструменту отдельный лонгрид – наша коллега Geloosa заботливо описала как процесс выбора технологии, так и имплементацию с вкладками, автокомплитом и кастомными стилями. Садитесь удобнее, отложите остальные дела и заходите в подкат, где любопытных ждут кишки Monaco – не поскользнитесь, их там много 🙂 Приятного чтения.

Какую библиотеку выбрать для редактора кода?

Npm выдает 400+ результатов по запросу «code editor». По большей части это UI-обертки нескольких самых популярных либ, сделанные для определенного фреймворка или проекта, плагины для тех же либ или их форки с доработками под себя, а также либы не для редактирования кода в браузере, просто попавшие в выдачу по ключевым словам. Так, к счастью, выбор значительно сужается. Еще несколько либ – а-ля CodeFlask, легковесные, но малофункциональные, предназначенные для небольших сниппетов и интерактивных примеров, но не для полноценной веб-IDE с функциональностью, к которой мы привыкли в десктопных редакторах.

Самая ранняя из них – CodeMirror – была частной инициативой берлинца Марина Хавербеке (Marijn Haverbeke), которому понадобился редактор кода для упражнений в его онлайн-учебнике Eloquent JavaScript. В конечном итоге у нас осталось 3 библиотеки на выбор: Ace, CodeMirror и Monaco Editor. В 2010-м на JSConf.eu в том же Берлине была представлена первая версия Ace, который тогда разрабатывала Ajax.org для своей облачной IDE Cloud9 (собственно, Ace и расшифровывается как Ajax.org Cloud9 Editor). Первая версия редактора выпущена в 2007 году. Самый поздний, Monaco Editor, является компонентом VS Code и опубликован Microsoft в конце 2015-го. В 2016-м Cloud9 был куплен Амазоном и сейчас является частью AWS.

К примеру, 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 и частично <canvas>
(для скролла и minimap)

Документация

7 из 10: нет поиска, не всегда понятно,
что возвращают методы, есть сомнения
в полноте и актуальности
(в доке работают не все ссылки)

6 из 10: слита с юзергайдом,
поиск по Ctrl+F,
есть сомнения в полноте

9 из 10: красивая, с поиском и
перекрестными ссылками
-1 балл за отсутствие пояснений
к некоторым флагам, применение которых
не вполне очевидно из названия

Quickstart, демки

How-to – текстовые документы с примерами кода,
отдельно есть демки с примерами кода
(правда, они разбросаны по разным страницам,
не все работают и ищутся они проще всего через гугл),
есть демка, где можно пощупать разные фичи,
но управлять ими предлагается через UI-контролы,
то есть потом надо еще отдельно искать методы
для их подключения

How-to прямо-таки бедные,
в основном все разбросано по github
и stackoverflow, зато есть демки фич с примерами
кода для их реализации

Объединены в формате плейграунда:
код с комментами и рядом демо, можно
сразу попробовать и оценить
многие возможности

Активность сообщества

Средняя

Высокая

Средняя

Активность разработчиков

Средняя

Средняя

Высокая

Бессмысленно сравнивать библиотеки по размеру, потому что все зависит от того, что и как подключать для конкретного проекта: грузить готовый файл с одним из билдов (которые тоже разнятся) или прогонять npm-пакет через какой-то сборщик? А самое важное – в каком объеме используется редактор: подгружаются ли все стили и темы, сколько и каких аддонов и плагинов использовано. Например, в CodeMirror большая часть функциональности, которая работает в Monaco и Ace из коробки, доступна только с аддонами. В таблице приведено количество символов в последних версиях на CDN и вес их сжатых файлов для общего представления, о каких порядках идет речь.

Для CodeMirror в силу его возраста написано много аддонов, но их количество будет влиять и на вес, и на скорость редактора. Во всех библиотеках примерно одинаковый набор базовых фич: автоформатирование кода, сворачивание строк, cut/copy/paste, горячие клавиши, возможность добавления новых синтаксисов для подсветки и тем, проверка синтаксиса (в CodeMirror – только через аддоны, в Ace – пока только для JavaScript/CoffeeScript/CSS/XQuery), подсказки и автокомплит (в CodeMirror – через аддоны), продвинутый поиск по коду (в CodeMirror – через аддоны), методы для реализации табов и сплит-режима, дифф-режим и инструмент для мержа (в CodeMirror – либо с плюсам и минусами в одном окне, либо двухпанельный через аддон, в Ace – отдельная либа). Monaco многое умеет из коробки, причем, на мой взгляд, лучше и в большем объеме, чем Ace и CodeMirror.

Мы остановились на Monaco по нескольким причинам:

  1. Наиболее развиты инструменты, которые мы сочли критично важными для нашего проекта:
    • IntelliSense — подсказки и автокомплит;
    • умная навигация по коду в контекстном меню и через minimap;
    • двухпанельный дифф-режим из коробки.

  2. Написан на TypeScript. Наша панель управления написана на Vue+Typescript, поэтому поддержка TS была важна. К слову, Ace с недавнего времени тоже поддерживает TS, но изначально он был написан на JS. Для CodeMirror есть типы в DefinitelyTyped.
  3. В нем наиболее активно идет разработка (возможно, потому что он вышел не так давно), быстрее правятся баги и мержатся пул-реквесты. Для сравнения, с CodeMirror у нас был печальный опыт, когда баги не правились годами и мы ставили костыль на костыле и костылем погоняли.
  4. Удобная автосгенеренная (что дает надежду на ее полноту) документация с перекрестными ссылками между интерфейсами и методами.
  5. На наш вкус, наиболее красивый UI (наверное, тоже связано с временем создания) и лаконичный API.
  6. Поспрашивав знакомых разработчиков, какой из редакторов вызывал больше головной боли, в лидерах оказались Ace и CodeMirror.

Отдельно стоит сказать про скорость работы. Затратный синтаксический анализ происходит в параллельном потоке воркера. Плюс все вычисления ограничиваются размером вьюпорта (все типы, цвета, отрисовка рассчитываются только для тех строк, которые видны). Тормозить начинает, только если коде под 100 000 строк — подсказки могут вычисляться по несколько секунд. Ace, который тоже использует воркеры для тяжелых вычислений, оказался быстрее: в коде такой же длины подсказки появляются практически моментально, да и с 200 000 строками он быстро справляется (на официальном сайте заявлено, что даже 4 млн строк не должны оказаться проблемой, хотя у меня разогнались винты, стал тормозить ввод и исчезли подсказки после 1-го миллиона). CodeMirror, где параллельных вычислений нет, совсем с трудом тянет такие объемы: может мелькать и текст, и подсветка синтаксиса. Поскольку в реальном мире 100 000 строк в файле — редкость, мы закрыли на это глаза. Даже с 40-50 тысячами строк Monaco справляется прекрасно.

Подключение Monaco и использование основных фич (на примере интеграции с Vue)

Подключение

Здесь я буду давать примеры кода из vue-компонентов и использовать соответствующую терминологию. Но все это легко переносится в любой другой фреймворк или чистый JS.

Я расскажу про третий вариант и сборку с помощью webpack. Исходник Monaco можно скачать на официальном сайте и положить себе в проект, можно забрать с CDN, можно подключить к проекту через npm.

Ставим 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 =
};

Если вам не нужны все языки и фичи Monaco, для уменьшения размера бандла можно передать в MonacoWebpackPlugin объект с настройками:

new MonacoWebpackPlugin({ output: '', // папка, куда собирать скрипты воркеров languages: ['markdown'], // массив строк с названиями языков, для которых нужна подсветка features: ['format', 'contextmenu'] // массив строк с нужными фичами
})

Полный список фич и языков для плагина здесь.

Создаем и настраиваем редактор

Импортируем editor и вызываем editor.create(el: HTMLElement, config?: IEditorConstructionOptions), передавая в качестве первого аргумента элемент DOM, в котором хотим создать редактор.

В компоненте редактора:

<template> <div ref='editor' class='editor'></div>
</template> <script> import {editor} from 'monaco-editor'; import {Component, Prop, Vue} from 'vue-property-decorator'; @Component() export default class Monaco extends Vue { private editor = null; mounted() { this.editor = editor.create(this.$refs.editor); } }
</script> <style> .editor { margin: auto; width: 60vw; height: 200px; }
</style>

Контейнеру для редактора нужно обязательно задавать высоту, чтобы она не оказалась нулевой. Если вы создадите редактор в пустом div-е (с нулевой высотой – ваш К.О.), Monaco пропишет такую же высоту инлайн-стилем у окна редактора.

В нем более сотни опций, полное описание интерфейса IEditorConstructionOptions есть в документации.
Для примера зададим язык, тему и изначальный текст и включим перенос строк (по дефолту они не переносятся):
Второй необязательный аргумент editor.create – конфиг редактора.

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. Таких мест немного, но за ними надо следить.

<template> <div ref='editor1' class='editor'></div> <div ref='editor2' class='editor'></div>
</template> <script> // ... this.editor1 = editor.create(this.$refs.editor1, {theme: 'vs'}); this.editor2 = editor.create(this.$refs.editor2, {theme: 'vs-dark'}); // ...
</script>

Удаление редактора

При уничтожении окна Monaco надо вызвать метод dispose, иначе не очистятся все листенеры и созданные после этого окна могут работать некорректно, реагируя на некоторые события по несколько раз:

beforeDestroy() { this.editor && this.editor.dispose();
}

Вкладки

Вкладки открытых в редакторе файлов используют одно и то же окно Monaco. Для переключения между ними используются методы IStandaloneCodeEditor: getModel для сохранения и setModel для обновления модели редактора. Модель хранит текст, позицию курсора, историю действий для undo-redo. Для создания модели нового файла используется глобальный метод editor.createModel(text: string, language: string). Если файл пустой, можно не создавать модель и передать null в setModel:

Посмотреть код

<template> <div class='tabs'> <div class='tab' v-for="tab in tabs" :key'tab.id' @click='() => switchTab(tab.id)'> {{tab.name}} </div> </div> <div ref='editor' class='editor'></div>
</template> <script> import {editor} from 'monaco-editor'; import {Component, Prop, Vue} from 'vue-property-decorator'; @Component() export default class Monaco extends Vue { private editor = null; private tabs: [ {id: 1, name: 'tab 1', text: 'const tab = 1;', model: null, active: true}, {id: 2, name: 'tab 2', text: 'const tab = 2;', model: null, active: false} ]; mounted() { this.editor = editor.create(this.$refs.editor); } private switchTab(id) { const activeTab = this.tabs.find(tab => tab.id === id); if (!activeTab.active) { // создаем модель редактора (если ее нет и есть текст) или берем текущую const model = !activeTab.model && activeTab.text ? editor.createModel(activeTab.text, 'javascript') : activeTab.model; // активируем новую вкладку и сохраняем модель предыдущей активной вкладки this.tabs = this.tabs.map(tab => ({ ...tab, model: tab.active ? this.editor.getModel() : tab.model, active: tab.id === id })); // обновляем модель редактора this.editor.setModel(model); } }
</script>

Дифф-режим

Для дифф-режима нужно использовать другой метод editor при создании окна редактора — createDiffEditor:

<template> <div ref='diffEditor' class='editor'></div>
</template>
// ...
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:

Посмотреть код

// ... <div ref='diffEditor' :style='{display: isDiffOpened ? "block" : "none"}'></div>
// ... // импортируем 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: <string>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); // ...
}

Теперь текст на описанном языке будет выглядеть так:

Применять эту фичу можно, насколько хватит фантазии. Например, мы сделали в своей панели для разработчиков просмотрщик логов звонков. Логи зачастую длинные и непонятные, но когда они показываются с подсветкой синтаксиса, умным поиском, сворачиванием/разворачиванием строк, нужными командами (например, Prettify params), выделением всех строк звонка по его id или переводом времени в логе в другой часовой пояс, то копаться в них становится намного проще (скриншот кликабелен):

Заключение

Резюмируя, скажу, что Monaco — огонь. После нескольких месяцев работы с ним у меня исключительно приятные воспоминания. Если вы выбираете редактор для кода, обязательно зайдите на его Playground и поиграйтесь с кодом, посмотрите, что еще он умеет. Возможно, это именно то, что вы ищете.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»