Механизмы расширяемых расширений в JavaScript

Автор оригинала: Marijn Haverbeke
  • Перевод
Здравствуйте, коллеги!

Напоминаем, что не так давно у нас вышло 3-е издание легендарной книги «Выразительный JavaScript» (Eloquent JavaScript) — на русском языке напечатано впервые, хотя качественные переводы предыдущих изданий встречались в Интернете.



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


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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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

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

Формирование групп и дедупликация

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

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

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

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

Проект

Здесь я опишу в общих чертах, что мы делаем в CodeMirror 6. Это просто набросок, а не Состоявшееся Решение. Вполне возможно, что эта система будет развиваться и далее, когда библиотека стабилизируется.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Здесь есть несколько новых концепций, которые пользователь должен усвоить, чтобы пользоваться данной системой. Кроме того, работать с такой системой и правда немного сложнее, чем с традиционными императивными системами, принятыми в сообществе JavaScript (вызываем метод для добавления/удаления эффекта). Однако, если правильно скомпоновать расширения, то, по-видимому, польза от этого перевешивает сопутствующие издержки.
Издательский дом «Питер»
143,05
Компания
Поделиться публикацией

Комментарии 2

    0
    сервис междустрочного интервала

    Какой-то звездолёт получился в итоге.

      0
      «Ядерные модули» — мне одному режет глаз?

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое