Архитектура GriffinJS
Как устроен слой: ядро с реестрами, примитив motion и прогресс 0…1 как общая валюта, дорожка как состояние и движок за интерфейсом из трёх частей, контракт с CSS, модульная сборка и её цена.
Ядро и реестры
Ядро не знает ни одного виджета. Оно держит два реестра —
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.js | define*, mount/unmount/instance, init(root) с хуком init для участников use, start/destroy, автостарт, объект config, помощники recorder, listeners, merge |
| Параметры | core/options.js | data-gr-x="a: 1; b; c: text" → {a: 1, b: true, c: 'text'}; ключи в camelCase; значение с { — JSON целиком |
| Делегирование | core/registry.js | G.on(type, selector, handler) — один слушатель на документ на тип события; G.emit(name, node, detail) |
| Сканер | core/scanner.js | MutationObserver: появившиеся узлы получают виджеты, удалённые — теряют; одна порция на тик |
| Догрузка полей | 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).
Положение панели в верхнем слое считается в JS —
core/anchor.js, getBoundingClientRect →
top/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 не тронуты.
Инварианты
- Состояние дорожки не выводится из
scrollLeftнигде, кроме движкаscroll. - JS не пишет
transformиopacity. - Модуль регистрируется в реестре, а не патчит ядро.
- Ни одной развилки по User-Agent; по возможностям — только
'onscrollend' in window; указатель — поpointerTypeсобытия. - Разметка без скрипта остаётся рабочей: у каждого виджета есть «база без JS».
- Всё, что рантайм ставит на элемент, снимается
destroy(). - 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.js | 2,8 КБ | ядро, options, registry, scanner — нужны каждому виджету |
griffinjs-track.js / -motion.js / -gesture.js / -media.js / -anchor.js / -loader.js | 2,5 / 1,0 / 1,0 / 0,5 / 0,8 / 0,9 | общие части вне ядра: дорожка, анимация, жест, медиазапросы, позиция у якоря, догрузка полей |
griffinjs-scroll.js / -fade.js | 1,5 / 0,5 | движки дорожки |
griffinjs-slider.js / -gallery.js / -lightbox.js / -parallax.js | 1,6 / 0,8 / 1,7 / 0,3 | семейство прокрутки и параллакс |
griffinjs-megamenu.js / -dropdown.js / -tooltip.js | 2,5 / 2,1 / 0,9 | семейство выпадающего |
griffinjs-dialog.js / -combobox.js / -range.js / -sortable.js | 2,1 / 3,0 / 1,1 / 1,2 | окна, комбобокс, ползунок и сортируемая таблица |
griffinjs.js | 19,2 КБ | всё вместе (бюджет 19,3); ядро — 2,8 при бюджете 2,8, набор слайдера — 7,7 при бюджете 7,7 |
griffinjs-mask.js / -phone.js / -datetime.js / -file.js / -number.js | 1,7 / 1,9 / 4,6 / 1,5 / 2,0 | поля второго бандла: маска, телефон, дата и время, файл, сумма с разрядами |
griffinjs-rating.js / -otp.js / -counter.js / -validate.js | 0,8 / 1,1 / 0,8 / 1,4 | ввод оценки, одноразовый код, счётчик символов, сводка ошибок |
griffinjs-fields.js | 11,2 КБ | все поля без ядра (бюджет 11,2); набор «ядро + anchor + поля» — 13,9 при бюджете 13,9; таблица стран griffinjs-countries.js — 0,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: каждый пример без бейджа обязан работать и так.