Привет, Хабр!

Обращаем ваше внимание на долгожданную допечатку книги "Выразительный JavaScript", которая только-только пришла из типографии.


Тем, кто еще не знаком с творчеством автора книги (при всей энциклопедичности она понравится и начинающим разработчикам) — предлагаем познакомиться со статьей из его блога; в статье изложены мысли об организации расширений в языке JavaScript.

В наше время стало модно структурировать большие системы в виде множества отдельных пакетов. Движущая идея, лежащая в основе данного подхода, такова: лучше не ограничивать людей конкретной (предлагаемой вами) реализацией фичи, а предоставить эту фичу в качестве отдельного пакета, который человек может загрузить наряду с базовой комплектацией системы.
Для этого, в общих чертах, вам потребуется…

  • Возможность не загружать те фичи, которые вам не требуются – это особенно полезно в клиентских системах.
  • Возможность заменять другой реализацией часть функционала, которая не отвечает вашим целям. Таким образом, снижается и нагрузка на модули ядра – не требуется с их помощью охватывать все возможные практические случаи.
  • Проверка интерфейсов ядра в реальных условиях – реализуя базовые фичи поверх интерфейса, обращенного к клиентской части, вы вынуждены сделать этот интерфейс, как минимум, настолько мощным, чтобы он мог поддерживать эти возможности. Таким образом, вы можете быть уверены, над ним удастся надстроить аналогичные вещи, написанные сторонними разработчиками.
  • Изоляция между частями системы. Участники проекта могут просто поискать интересующий их пакет. Пакеты можно версионировать, помечать как нежелательные или заменять, не затрагивая ядро кода.

Такой подход обычно связан с повышенной сложностью. Чтобы пользователям было проще начать, можете предоставить им пакет-обертку, в котором «все включено», но рано или поздно им, пожалуй, придется избавиться от этой оболочки и заняться установкой и конфигурацией конкретных вспомогательных пакетов, что порой сложнее, чем просто переключиться на другую фичу в монолитной библиотеке.

В этой статье мы попробуем обсудить варианты механизмов расширения, поддерживающих «масштабную расширяемость» и позволяющих создавать новые точки расширения там, где это не было предусмотрено.

Расширяемость


Чего мы хотим от расширяемой системы? В первую очередь, конечно же, она должна располагать к расширению собственных возможностей при помощи внешнего кода.

Но едва ли этого достаточно. Позвольте лирическое отступление по поводу одной глупой проблемы, с которой мне однажды довелось столкнуться. Я занимаюсь разработкой редактора кода. В одной из ранних версий этого редактора в клиентском коде можно было задавать стиль для конкретной строки. Это было отлично – избирательное оформление строк.

За исключением того случая, когда попытки изменить оформление строки осуществляются сразу из двух участков кода – и эти попытки начинают бодаться. Второе расширение, применяемое к строке, затирает стиль первого расширения. Либо, когда первый код пытается удалить внесенное им оформление где-нибудь в дальнейшей части кода, он затирает стиль, привнесенный вторым фрагментом кода.

Решение удалось найти, обеспечив возможность добавлять (и удалять) расширение, а не задавать его, так, чтобы два расширения могли взаимодействовать с одной и той же строкой, не саботируя работу друг друга.

В более общем смысле, требуется добиться, чтобы расширения можно было комбинировать, даже если им совершенно не известно о существовании друг друга – не вызывая при этом конфликтов между ними.

Для этого нужно сделать так, чтобы на каждую точку расширения могло воздействовать любое количество акторов. Как именно будут обрабатываться множественные эффекты – зависит от ситуации. Вот некоторые подходы, которые могут оказаться целесообразными:

  • Все они вступают в силу. Например, при добавлении CSS-класса к элементу или при показе виджета обе эти возможности добавить одновременно. Зачастую их все равно придется каким-то образом упорядочивать: виджеты должны выводиться в предсказуемой, четко определенной последовательности.
  • Они выстраиваются в виде конвейера. В качестве примера приведу обработчик, который может фильтровать изменения, добавляемые в документ, прежде, чем они будут внесены. Каждое изменение сначала скармливается обработчику, который, в свою очередь, может дополнительно его изменить. Упорядочивание в данном случае некритично, но может иметь значение.
  • К обработчикам событий можно применять подход «первым пришел – первым обслужен». Каждый обработчик имеет шанс обслужить событие, пока один из них не заявит, что уже справился с ним, после чего обработчики, стоящие в очереди за ним, уже не опрашиваются.
  • Бывает и так, что действительно требуется выбрать конкретное значение – например, определить значение конкретного конфигурационного параметра. Здесь может быть целесообразно использовать некий оператор (скажем, логическое и, логическое или, минимум или максимум), чтобы ограничить количество входных значений для одной позиции. Например, редактор может перейти в режим «только для чтения», если любое расширение ему это прикажет. Либо можно задать максимальную величину документа, либо минимальное количество значений, сообщаемых данной опции.

Во многих таких случаях порядок важен. Это означает, что приоритет применяемых эффектов должен быть контролируемым и предсказуемым.

Именно на этом фронте обычно не справляются с работой императивные системы расширений, основанные на применении побочных эффектов. Например, операция addEventListener, выполняемая DOM-моделью браузера, обусловливает вызов обработчиков событий именно в том порядке, в котором они были зарегистрированы. Это нормально, если все вызовы контролирует единственная система, или если порядок операций действительно не важен, однако, когда приходится иметь дело со множеством программных фрагментов, независимо добавляющих обработчики, порой бывает очень сложно спрогнозировать, какие из них будут вызваны в первую очередь.

Простой подход


Приведу простой пример: впервые я применил модульную стратегию в ProseMirror, системе для редактирования насыщенного текста. Ядро этой системы само по себе, в принципе, бесполезно – оно всецело полагается на дополнительные пакеты, описывающие структуру документов, привязывающие клавиши, ведущие историю отмен. Хотя, пользоваться этой системой действительно немного сложно, она взята на вооружение в таких продуктах, где требуется пользовательское оформление текста, недоступное в классических редакторах.

Механизм расширений, применяемых в ProseMirror, относительно прямолинеен. При создании редактора клиентский код указывает единый массив подключаемых объектов. Каждый из таких плагинов может так или иначе влиять на работу редактора, например, добавлять фрагменты данных о состоянии или обрабатывать события интерфейса.

Все эти аспекты спроектированы с расчетом на работу с массивом конфигурационных значений с использованием стратегий, очерченных в предыдущем разделе. Например, при указании множественных назначений клавиш порядок указания экземпляров плагина keymap определяет их приоритет. Конкретную клавишу получает в обработку первый keymap, умеющий с ней обращаться.

Обычно этот механизм достаточно мощный, и им активно пользуются. Однако, на определенном этапе он переусложняется, и работать с ним становится неудобно.

  • Если плагин оказывает множество эффектов, то вам остается либо надеяться, что именно в таком порядке они будут применяться к другим плагинам, либо придется разбить их на более мелкие плагины, чтобы можно было правильно их упорядочить.
  • В целом упорядочивание плагинов становится очень щепетильным делом, так как конечному пользователю не всегда понятно, какие плагины могут влиять на работу других плагинов, если получат более высокий приоритет. Все ошибки обычно проявляются только во время исполнения, при использовании конкретного функционала – поэтому их легко пропустить.
  • Плагины, опирающиеся на другие плагины, должны документировать этот факт – и остается надеяться, что пользователи не забудут включить их зависимости (в правильном порядке).

CodeMirror в версии 6 – это переписанный одноименный редактор. В шестой версии я пытаюсь развивать модульный подход. Для этого требуется более выразительная система расширений. Давайте рассмотрим некоторые вызовы, связанные с проектированием такой системы.

Упорядочивание


Несложно спроектировать систему, обеспечивающую полный контроль над порядком расширений. Но весьма сложно спроектировать такую систему, которая при этом будет приятна в использовании и позволит вам комбинировать код независимых расширений без обширного и тщательного ручного вмешательства.

Когда дело доходит до упорядочивания – тянет применить значения приоритета. Подобный пример – свойство z-index в CSS, позволяющее задавать число, которое указывает, на какой глубине элемент будет располагаться в стеке.

Поскольку в таблицах стилей порой встречаются смехотворно большие значения z-index, очевидно, что такой способ указания приоритета проблематичен. Конкретный модуль в отдельности «не знает», какие значения приоритета указывают другие модули. Опции – это просто точки в неопределенном числовом диапазоне. Можно указывать запредельно высокие (или глубоко отрицательные значения), надеясь достать до кончиков этой шкалы, но все прочее – это игра в угадайку.

Данную ситуацию можно несколько улучшить, определив ограниченный набор четко обозначенных категорий приоритета, чтобы расширения можно было классифицировать по примерному «уровню» их приоритета. Но все равно придется каким-то образом разрывать связи внутри этих категорий.

Создание групп и дедупликация


Как я упоминал выше, как только вы начинаете всерьез полагаться на расширения, могут возникать ситуации, в которых одни расширения будут использовать другие. Управление взаимными зависимостями плохо масштабируется, поэтому было бы неплохо, если бы вы могли вытянуть группу расширений разом.

Однако, мало того, что в таком случае проблема упорядочивания обострится еще сильнее; возникнет и другая проблема. От конкретного расширения может зависеть сразу множество других расширений и, если представлять их как значения, то вполне может сложиться ситуация с многократной загрузкой одного и того же расширения. В некоторых случаях, например, при назначении клавиш или обращении с обработчиками событий, это нормально. В других, например, при отслеживании истории отмен или работе с библиотекой всплывающих подсказок, такой подход будет пустой тратой ресурсов с риском что-нибудь сломать.

Следовательно, допуская композицию расширений, мы вынуждены переложить на систему расширений часть сложности, связанной с управлением зависимости. Нужно уметь распознавать те расширения, которые не должны дублироваться, и загружать всего по одному экземпляру каждого из них.

Однако, поскольку в большинстве случаев расширения можно конфигурировать, и все экземпляры конкретного расширения будут чем-то отличаться друг от друга, мы не можем просто взять один экземпляр расширения и использовать его – придется сочленять их каким-либо осмысленным способом (или сообщать об ошибке, когда это невозможно).

Проект


Здесь я опишу, что сделано в CodeMirror 6. Я предлагаю этот пример как вариант решения, а не как единственно верное решение. Вполне возможно, что эта система будет развиваться дальше по мере того, как библиотека стабилизируется.

Основной примитив в рамках данного подхода называется поведение. Поведения – это как раз те вещи, которые и можно расширять, указывая значения. В качестве примера рассмотрим поведение поля состояния, где при помощи расширений можно добавлять новые поля, сообщая описание каждого поля. Либо поведение браузерного обработчика событий, где при помощи расширений можно добавлять собственные обработчики.

С точки зрения потребителя поведений, те поведения, что конфигурируются в конкретном экземпляре редактора, дают упорядоченную последовательность значений, где значения с более высоким приоритетом идут первыми. У каждого поведения есть тип, и значения для него должны соответствовать этому типу.

Поведение представляется как значение, используемое и для объявления экземпляра поведения, и для обращения к значениям, которые могут быть у поведения. Например, расширение, определяющее фон номера строки, может определять поведение, позволяющее другому коду добавлять новые маркеры к этому фону.

Расширение – это значение, которое может использоваться при конфигурировании редактора. Массив расширений сообщается при инициализации. Каждое расширение разрешается в ноль или более поведений.

Простейший тип расширения – это экземпляр поведения. Задавая значение для данного поведения, мы получаем в ответ значение расширения, реализующего такое поведение.
Последовательность расширений также можно сгруппировать в единое расширение. Например, в конфигурации редактора для заданного языка программирования может подтягиваться ряд других расширений, в частности, грамматика для разбора и подсветки языка, информация о том, как расставлять отступы, а также источник информации об автозавершении, который интеллектуально дополняет код на этом языке. Так у вас получится языковое расширение, в котором попросту собраны все нужные расширения, дающие совокупное значение.

Описывая простую версию такой системы, на этом можно было бы остановиться и просто вписать вложенные расширения в единый массив расширений для поведений. Затем их можно было бы группировать по типу поведения и получать упорядоченные последовательности значений поведений.

Однако, мы до сих пор не разобрались с дедупликацией, и нам нужен более полный контроль над упорядочиванием.

К значениям третьего типа относятся уникальные расширения, это и есть механизм для обеспечения дедупликации. Расширения, которые не хочется инстанцировать дважды в одном редакторе – именно такие. Чтобы определить такое расширение, указывается spec-тип, то есть, тип конфигурационного значения, ожидаемого конструктором расширений, и инстанцирующая функция, принимающая массив таких spec-значений и возвращающая расширение.
Уникальные расширения несколько осложняют процесс разрешения коллекции расширений в набор поведений. Пока в выровненном наборе расширений есть уникальные, механизм разрешения должен выбрать тип уникального расширения, собрать все его экземпляры, вызвать инстанцирующую функцию с их spec-значениями и заменить их результатом (в одном экземпляре).

(Есть еще одна закавыка – они должны разрешаться в определенном порядке. Если сначала вы разрешите уникальное расширение X, но затем расширение Y разрешится в другой X, это будет ошибкой, поскольку все экземпляры X должны комбинироваться вместе. Поскольку инстанцирование расширений – это чистая операция, система, столкнувшись с нею, выполняет ее методом проб и ошибок, перезапуская процесс – и записывая выясненную информацию.)
Наконец, поговорим о приоритете. Базовый подход в данном случае – сохранять тот порядок, в котором сообщались расширения. Составные расширения выравниваются и встраиваются в этот порядок именно на той позиции, где впервые встречаются. Результат разрешения уникального расширения также вставляется в том месте, где впервые встречается.

Но расширения могут присваивать некоторые из своих подрасширений к категории с другим приоритетом. В системе определяются виды таких категорий: откат (вступает в силу после того, как произойдут другие вещи), по умолчанию, расширить (более высокий приоритет, чем у основной массы) и переопределить (пожалуй, должно располагаться на самом верху). Фактическое упорядочивание осуществляется сначала по категории, а затем по исходной позиции.

Итак, расширение, обладающее низкоприоритетным назначением клавиш и обработчиком событий с обычным приоритетом может дать нам составное расширение, построенное на основе расширения с назначением клавиш (при этом, не требуется знать, какие поведения входят в его состав, с приоритетом «откат» плюс экземпляр поведения обработчика событий.

Основным достижением кажется, что мы приобрели возможность комбинировать расширения вне зависимости от того, что делается внутри каждого из них. В расширениях, смоделированных нами до сих пор, среди которых: две системы разбора, характеризующиеся одинаковым синтаксическим поведением, подсветка синтаксиса, сервис умной расстановки отступов, история отмен, фон номера строки, автозакрытие скобок, назначение клавиш и множественный выбор — все работает хорошо.

Для использования такой системы действительно придется освоить несколько новых концепций, и она определенно сложнее традиционных императивных систем, принятых в сообществе JavaScript (вызвать метод для добавления/удаления эффекта). Однако, возможность корректной компоновки расширений, по-видимому, оправдывает эти издержки.