Архитектура GriffinJS

Как устроен слой: ядро с реестрами, примитив motion и прогресс 0…1 как общая валюта, дорожка как состояние и движок за интерфейсом из трёх частей, контракт с CSS, модульная сборка и её цена.

GriffinJS packages/ui/src/griffinjs/

Ядро и реестры

Ядро не знает ни одного виджета. Оно держит два реестра — GriffinJS.engines и GriffinJS.widgets, — куда модули регистрируются, а не патчат его: defineEngine(name, factory) и defineWidget(name, factory). Ядро монтирует виджеты на элементы с data-gr-<виджет> и ведёт жизненный цикл: start() поднимает всё, destroy() снимает всё до последнего атрибута.

Участник жизненного цикла (G.use) кроме start и destroy может объявить init(root): ядро зовёт его после подъёма виджетов в корне — на старте и на каждой порции сканера. На этом стоит догрузка бандла полейgriffinjs-loader.js, общая часть вне ядра: единственное, что она знает поимённо, — не виджет, а файл поставки, девять имён второго бандла griffinjs-fields.js (список тот же, что в сборке, и тест сверяет их). Встретив data-gr-<поле> без зарегистрированного виджета, она вставляет <script> из GriffinJS.config.fields = { src, countries } — или из data-fields / data-countries на теге — один раз, с nonce с тега, и поднимает поля после загрузки; без config предупреждает раз на имя. Ядро по-прежнему не знает, что делает маска, — и даже откуда её взять: оно даёт хук и объект config. Подробно — «Догрузка по потребности».

GriffinJS.defineWidget('counter', function (el, opts, G) {
  var attrs = G.recorder();        // всё, что ставится на элементы, снимется restore()
  var events = G.listeners();      // все подписки снимутся removeAll()

  attrs.set(el, 'data-gr-state', 'ready');
  events.add(el, 'click', function () { /* … */ });

  return { destroy: function () { events.removeAll(); attrs.restore(); } };
});

GriffinJS.mount(el, 'counter', { step: 2 });   // или <div data-gr-counter="step: 2">
GriffinJS.instance(el, 'counter');             // живой экземпляр
GriffinJS.unmount(el, 'counter');
Часть ядраФайлЗа что отвечает
Реестры и жизненный циклcore/griffinjs-core.jsdefine*, mount/unmount/instance, init(root) с хуком init для участников use, start/destroy, автостарт, объект config, помощники recorder, listeners, merge
Параметрыcore/options.jsdata-gr-x="a: 1; b; c: text"{a: 1, b: true, c: 'text'}; ключи в camelCase; значение с { — JSON целиком
Делегированиеcore/registry.jsG.on(type, selector, handler) — один слушатель на документ на тип события; G.emit(name, node, detail)
Сканерcore/scanner.jsMutationObserver: появившиеся узлы получают виджеты, удалённые — теряют; одна порция на тик
Догрузка полейcore/loader.jsВне ядра: data-gr-<поле> без виджета → <script> из config.fields один раз, с nonce; griffinjs:fields-loaded
Брейкпоинтыcore/media.jsЕдинственный источник — токены --gr-bp-* подключённого CSS; media.on('md', fn)
Движениеcore/motion.jsИсточники прогресса → --gr-progress
Жестcore/gesture.jsСвайп и тяга по pointer-событиям, порог, скорость, ось; поведение по pointerType каждого события
Дорожкаcore/track.jsСостояние и интерфейс движка

Прогресс как валюта: motion

Всё движение в слое сводится к одному числу — прогрессу 0…1 между двумя состояниями, который публикуется на элементе как --gr-progress. Источники разные, потребитель их не знает:

ИсточникОткуда числоКто пользуется
ДорожкаscrollLeft / размер — считает движок scrollСлайд (масштаб соседей, проявление), индикатор
Окно просмотраIntersectionObserver + один пересчёт на кадрПараллакс: 0 — вошёл снизу, 1 — ушёл вверх
ВремяТикер на rAF с паузойАвтоплей слайдера и его индикатор на кнопке паузы
ЖестСмещение пальца / размерДвижок fade: слайд следует за пальцем

Отсюда главный запрет слоя: JS никогда не пишет transform и opacity. Иначе fade и параллакс стали бы нестилизуемыми — эффект был бы зашит в скрипт, а не открыт в CSS. Проверка стоит в check-dist по исходникам.

Дорожка: состояние и движок

track разделён на состояние — индекс, число слайдов, цикл, страницы, синхронизация с другой дорожкой, aria, клавиатура, inert неактивных — и движок за интерфейсом из трёх частей:

engine = {
  goTo(index, { instant }),   // доехать до слайда
  onChange(index, progress),  // сообщить дорожке, где мы
  layout()                    // пересчитать геометрию (брейкпоинт, мутация)
};

Индекс дорожки виртуальный: он никогда не равен позиции в DOM, между ними всегда physical(index). Поэтому бесконечная прокрутка — режим дорожки, а не движок: у scroll это клоны краёв и мгновенный перескок, у fade — индекс по модулю. Движок объявляет engine.loop; без него loop честно вырождается в rewind.

ДвижокКак двигаетЧто даёт браузер
scroll (v1)scroll-snap: scrollTo({behavior}) из JS, в CSS дорожки scroll-behavior: autoСвайп, инерция, колесо, RTL, «тянуть мышью»
fade (модуль)Стопка grid-area: 1/1, движок публикует позицию, переход — CSS-transition; жест через gestureНичего — отсюда и жест в ядре

Шесть требований к дорожке для iOS Safari — в спецификации, не «по жалобам»: scrollend только за 'onscrollend' in window, плавность только из JS, очередь команд на время инерционного жеста, overscroll-behavior-x: contain, перепривязка после мутации, inert на неактивных слайдах.

Второй вектор: контроллеры поверх платформы

«Показать, спрятать, позиционировать» для окон и выпадающего делает платформа — <dialog> и popover. Различия между дропдауном, комбобоксом и мегаменю лежат в ролях и клавиатуре, не в показе, поэтому общего «класса окна» в слое нет: каждый контроллер — тонкая обёртка над примитивом, добавляющая то, чего у него нет (наведение с задержкой, роли, стек, hash, ajax).

Положение панели в верхнем слое считается в JScore/anchor.js, getBoundingClientRecttop/left, переворот при нехватке места. CSS anchor positioning зависимостью не является, развилки @supports в слое нет: в Firefox и Chromium панель стоит одинаково. Эта часть лежит в core/, но в griffinjs-core.js не входит: нужна она только дропдауну и подсказке — см. «Модульную сборку» ниже.

Контракт с CSS

JS пишетJS читаетJS не трогает
aria-*, role, id, inert, data-gr-state, --gr-progress; координаты top/left панелей в верхнем слоеТолько data-gr-*Классы ui — ни добавляет, ни требует; transform, opacity

Оформление привязывается к состояниям: .gr-slide[aria-current], [data-gr-state="open"], var(--gr-progress). Стили контроллеров в griffinjs.css стоят под [data-gr-state] — без скрипта состояния нет, и правила компонентов ui не тронуты.

Инварианты

  1. Состояние дорожки не выводится из scrollLeft нигде, кроме движка scroll.
  2. JS не пишет transform и opacity.
  3. Модуль регистрируется в реестре, а не патчит ядро.
  4. Ни одной развилки по User-Agent; по возможностям — только 'onscrollend' in window; указатель — по pointerType события.
  5. Разметка без скрипта остаётся рабочей: у каждого виджета есть «база без JS».
  6. Всё, что рантайм ставит на элемент, снимается destroy().
  7. JS читает только data-gr-* и пишет только состояния. Расширение, объявленное явно: поля второго бандла — маска, телефон, дата, сумма, одноразовый код, файл, мультивыбор комбобокса — пишут value контрола (иначе форматировать набранное нельзя). Договор такого поля: после каждой своей записи оно шлёт input, а по уходу фокуса change; не пишет в контрол, на котором не смонтировано; destroy() возвращает нативный тип, имя и значение. Второе расширение: поле, пишущее value, пишет и валидность контрола — маска ставит setCustomValidity на недоборе и aria-invalid по уходу фокуса, дата так же помечает невозможное значение и выход за границы, сумма — выход за границы через спутник type="number", — и destroy() возвращает валидность вместе со значением. Третье: виджет пишет min/max чужого поля только через собственный API соседа (limit у пары дат «с — по»), и авторская граница при этом остаётся, если она уже. Кто форматирует набранное, тот отвечает и за то, отправится ли оно. Ввод оценки расширением не является — он пишет --gr-rating, число для CSS, которое разрешает второй инвариант.

Модульная сборка и размеры

Исходники — ES2020 без модулей, самодостаточные IIFE; склейка в порядке списка без бандлера, затем terser. На выходе griffinjs.js целиком, griffinjs-core.js и по файлу на движок, общую часть и виджет — подключаются после ядра в любом наборе.

В ядре — только то, чем пользуются все. Дорожка нужна трём виджетам из двенадцати, анимация — двум, жест — одному движку, медиазапросы — одному виджету: держать их в ядре значило бы брать плату со всех за то, чем пользуется меньшинство. Каждая такая часть уезжает своим файлом, а что модуль из них берёт, он объявляет сам — вторым аргументом defineWidget ({ needs: ['track', 'motion'] }) или G.needs(), если модуль ничего не регистрирует. На старте start() сверяет объявленное со сборкой и предупреждает в консоли: отказ из-за забытого файла обязан быть слышен, а не проявиться у читателя страницы. Новая общая возможность кладётся в SHARED и переезжает в ядро, только когда ею пользуются все. Таблица «виджет → что подключить» на странице «Слой GriffinJS» собрана из этих объявлений при сборке.

ФайлgzipСостав
griffinjs-core.js2,8 КБядро, options, registry, scanner — нужны каждому виджету
griffinjs-track.js / -motion.js / -gesture.js / -media.js / -anchor.js / -loader.js2,5 / 1,0 / 1,0 / 0,5 / 0,8 / 0,9общие части вне ядра: дорожка, анимация, жест, медиазапросы, позиция у якоря, догрузка полей
griffinjs-scroll.js / -fade.js1,5 / 0,5движки дорожки
griffinjs-slider.js / -gallery.js / -lightbox.js / -parallax.js1,6 / 0,8 / 1,7 / 0,3семейство прокрутки и параллакс
griffinjs-megamenu.js / -dropdown.js / -tooltip.js2,5 / 2,1 / 0,9семейство выпадающего
griffinjs-dialog.js / -combobox.js / -range.js / -sortable.js2,1 / 3,0 / 1,1 / 1,2окна, комбобокс, ползунок и сортируемая таблица
griffinjs.js19,2 КБвсё вместе (бюджет 19,3); ядро — 2,8 при бюджете 2,8, набор слайдера — 7,7 при бюджете 7,7
griffinjs-mask.js / -phone.js / -datetime.js / -file.js / -number.js1,7 / 1,9 / 4,6 / 1,5 / 2,0поля второго бандла: маска, телефон, дата и время, файл, сумма с разрядами
griffinjs-rating.js / -otp.js / -counter.js / -validate.js0,8 / 1,1 / 0,8 / 1,4ввод оценки, одноразовый код, счётчик символов, сводка ошибок
griffinjs-fields.js11,2 КБвсе поля без ядра (бюджет 11,2); набор «ядро + anchor + поля» — 13,9 при бюджете 13,9; таблица стран griffinjs-countries.js0,9 при бюджете 0,9. В griffinjs.js поля не входят ни одним байтом

Ориентиры измерены 2026-08-30 (gzip уровня 9): uikit.min.js — 49,4 КБ, uikit-core.min.js — 33,1 КБ, swiper-bundle.min.js — 41,0 КБ плюс 4,7 КБ своего CSS. Слой легче полного UIkit в 2,6 раза на потолке, набор слайдера легче связки Swiper в 5,9 раза. Потолок полного файла рос только под названные покупки: сортируемая таблица (724 Б), мультивыбор в комбобоксе (+495 Б — единственная правка старого бандла ради полей: сами поля лежат вторым файлом со своими потолками), затем 19,3 КБ под догрузку бандла полей (общей частью, не ядром), закрытие <details> с анимацией темы у мегаменю и дропдауна и плавный рост окна. У ядра и набора слайдера запаса нет: рост в них не планируется, новая общая возможность кладётся в общие модули, а не в ядро — так и легла догрузка полей, ядру достались 37 Б хука. Бюджеты проверяет check-dist; слой в griffincss-all.js не входит и в бюджет рантаймов не считается.

Проверка

Состояние, разбор параметров и синхронизация проверяются на node:test с мок-DOM без зависимостей; клавиатура, фокус в верхнем слое и snap — Playwright на трёх движках по странице-полигону «Полигон», включая прогон с заблокированным griffinjs.js: каждый пример без бейджа обязан работать и так.