Griffincss в React и Vue

Пакетов-обёрток нет и не планируется. Не потому, что до них не дошли руки, а потому, что работу обёртки уже делает сканер: узел, вставленный кем угодно, получает свои раскладки и виджеты, удалённый — теряет их вместе с destroy.

Ни одного нового пакета packages/ui/src/griffinjs/core/scanner.js packages/core/src/griffincss.js

Обёрток нет, и это не заготовка под будущий пакет. Обёртка над библиотекой без состояния делает ровно одно: ловит момент, когда фреймворк вставил узел, и говорит библиотеке «подними тут своё», а на размонтировании — «сними». Оба рантайма Griffincss делают это сами, через MutationObserver, и им всё равно, кто вставил узел — React, Vue, шаблонизатор на сервере или innerHTML. Пакет griffincss-react состоял бы из вызовов, которые уже происходят без него.

Что именно делает сканер

Наблюдателей два, и они независимы. Рантайм ядра (griffincss.js) следит за data-gr-layout: появившийся контейнер получает разобранную раскладку, и правило CSS генерируется однократно на строку, сколько бы контейнеров её ни просили. Слой GriffinJS (griffinjs.js) следит за data-gr-виджет: появившийся узел получает экземпляр виджета, исчезнувший — вызов destroy(), снимающий слушатели и возвращающий атрибуты в исходное состояние.

Оба наблюдают childList и subtree: во время разбора документа — на document.documentElement, после загрузки — на body; рантайм ядра добавляет к ним атрибут class, и только ради шестой оговорки ниже. Наблюдение за живым деревом у обоих включено по умолчанию (у ядра — с 0.26.0; выключатель — data-observe="false" на теге скрипта). Мутации копятся и разбираются одной порцией: сначала снимаются виджеты с узлов, которых в документе больше нет, потом поднимаются виджеты на добавленных. Устройство слоя разобрано на странице «Архитектура», устройство рантайма ядра — на странице «JS-рантайм».

Событие фреймворкаЧто делает библиотека
Узел вставлен в документinit(узел) по каждому зарегистрированному имени виджета; раскладка разбирается и применяется
Узел удалён из документаdestroy() у экземпляра, запись снимается с учёта
Узел перемещённичего: к разбору порции узел снова подключён, экземпляр тот же
Изменился атрибут data-gr-*ничего: из атрибутов наблюдается один class
Перезаписан className контейнера раскладкирантайм возвращает свои .gr-ready и .gr-l-*; с data-observe="false" — ничего

Виджет поднимается не в тот же миг, а следующим тактом. Порция мутаций разбирается через setTimeout(…, 0): поток вставок из фреймворка не должен поднимать виджеты по одному на каждую мутацию. Поэтому в useEffect сразу после отрисовки экземпляра ещё нет, и GriffinJS.instance(node, 'slider') вернёт null. Если экземпляр нужен немедленно — поднимите его сами: GriffinJS.mount(node, 'slider') идемпотентен, и сканер, дойдя до этого узла, вернёт уже поднятый.

Семь оговорок

1. StrictMode монтирует дважды — и это безопасно

В режиме разработки React вызывает эффекты дважды подряд, чтобы поймать невычищенные подписки. Для слоя это не опасно: mount идемпотентен по паре «элемент + имя виджета» — повторный вызов не строит второй экземпляр, а возвращает живой. Двух слайдеров на одной дорожке не бывает.

useEffect(() => {
  const node = ref.current;
  const slider = GriffinJS.mount(node, 'slider');   // первый вызов строит
  GriffinJS.mount(node, 'slider') === slider;        // второй возвращает тот же

  return () => GriffinJS.unmount(node, 'slider');
}, []);

Снимать виджет только через GriffinJS.unmount. Соблазн написать в уборщике slider.destroy() велик, и это ловушка: destroy() у экземпляра вычистит слушатели, но запись в учёте ядра останется, и следующий mount на том же узле вернёт тот самый мёртвый экземпляр, а не построит новый. Проверено: после прямого destroy() фабрика виджета не вызывается повторно, после unmount — вызывается.

2. Перерисовка по ключу заменяет узел — виджет поднимается заново

Смена key — это не обновление узла, а удаление старого и вставка нового. Сканер поступит правильно: снимет виджет со старого и поднимет на новом. Но состояние виджета переедет только то, что записано в самой разметке: всё остальное живёт в замыкании экземпляра и умирает вместе с ним. Слайдер вернётся на первый слайд, открытая панель закроется.

// Было: смена ключа перезапускает слайдер с первого слайда
<div key={filter} data-gr-slider>…</div>

// Стало: узел живёт дольше набора, ключ — на детях
<div data-gr-slider>
  {items.map((it) => <div className="gr-slide" key={it.id}>…</div>)}
</div>

Второй вариант меняет содержимое дорожки, не трогая её саму. Дорожка переставляет себя после мутации содержимого сама — это её штатное поведение, а не обходной приём.

3. Управляемое поле и виджет спорят за значение

Виджеты, которые пишут в поле, пишут в input.value напрямую: комбобокс — выбранное значение, пара ползунков «от — до» — выравнивание порядка ручек. React про такую запись не узнаёт: его onChange висит на синтетическом событии, а присваивание value из чужого кода событий не порождает. Управляемое поле на следующей отрисовке вернёт своё значение, и выбор пропадёт.

Лечится это не борьбой с виджетом, а отказом от управляемости именно этого поля: слушайте griffin:select и держите значение у себя. События слоя — обычные CustomEvent со всплытием, поэтому JSX-свойства для них нет, и подписка идёт через addEventListener.

useEffect(() => {
  const node = ref.current;
  const onSelect = (e) => setCity(e.detail.item.value);

  node.addEventListener('griffin:select', onSelect);
  return () => node.removeEventListener('griffin:select', onSelect);
}, []);

// Поле неуправляемое: значение ведёт виджет, состояние догоняет событием
<input ref={ref} className="gr-input" data-gr-combobox defaultValue={city}>

4. На сервере слоя нет

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

Это то же требование, что библиотека предъявляет к себе: у каждого виджета есть «база без JS» — дорожка на scroll-snap, <dialog>, <details>. Серверная отрисовка отдаёт именно её, а слой поднимается поверх после гидратации. Проверить, что база жива, проще всего так же, как это делает проект: открыть страницу с отключённым скриптом.

5. Контейнер, отрисованный раньше детей, виджета не получит

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

// Ловушка: на первой отрисовке items пуст, дорожки нет,
// слайдер падает и больше не пробует
<div data-gr-slider>
  {items.map(…)}          // items приедут запросом
</div>
// Верно: контейнер появляется вместе со своим содержимым
{items.length > 0 && (
  <div data-gr-slider>
    {items.map(…)}
  </div>
)}

Тот же приём годится на все случаи: узел с data-gr-виджет вставляется в документ уже полным. Если это неудобно, остаётся ручной подъём — GriffinJS.init(node) после того, как содержимое приехало.

6. Класс контейнера раскладки — не ваш

Эта оговорка про рантайм ядра, а не про слой виджетов, и она единственная, где фреймворк и библиотека спорят за одно и то же свойство. На контейнер с data-gr-layout рантайм вешает два класса: gr-ready — им снимается защита от FOUC — и gr-l-хеш, на котором держится само правило сетки (детям достаются gr-area-*). React и Vue считают className своим и присваивают его целиком при изменении пропа: оба класса слетают разом.

Плата за это несоразмерна причине. Раскладка не просто рассыпается — контейнер снова попадает под защиту от FOUC, которая ловит его по атрибуту, и остаётся в opacity: 0. Пустое место на странице, ошибок в консоли нет.

// Ловушка: на каждом переключении active React переписывает
// className целиком, и gr-ready с gr-l-* уезжают вместе с ним
<div data-gr-layout="a3b4-c2d5" className={active ? 'x' : ''}>…</div>

С 0.22.0 рантайм это переживает: наблюдатель слушает и атрибут class, замечает пропажу маркеров на разложенном контейнере и возвращает их — класс фреймворка остаётся на месте, оба живут рядом. До 0.26.0 наблюдателя надо было включать самому (Griffincss.observe() при старте приложения); теперь он поднимается после init() сам, и вызов остался нужен только тому, кто выключил его через data-observe="false" на теге скрипта — тогда возвращать маркеры некому, как и раскладывать вставленные позже узлы.

Надёжнее не спорить вовсе. Возврат маркеров стоит лишнего прохода по контейнеру на каждой такой перерисовке. Меняющийся класс дешевле держать на обёртке, а data-gr-layout оставить узлу, чей className постоянен: <div className={active ? 'x' : ''}><div data-gr-layout="a3b4-c2d5">…

7. Виджет, который пишет value, против управляемого поля

Поля второго бандла — маска, телефон, дата, одноразовый код — идут дальше третьей оговорки: они пишут в input.value не разово по выбору, а на каждое нажатие, форматируя набранное. Управляемое поле React на следующей отрисовке вернуло бы неотформатированное значение, и маска с состоянием спорили бы на каждом знаке. Чтобы этого не было, у таких полей есть договор: после каждой своей записи виджет шлёт input — обычное, всплывающее, — а по уходу фокуса change. React вешает onChange на нативный input, поэтому узнаёт о значении так же, как о набранном руками, и дальше выбор один из двух.

// 1. Неуправляемое поле: значение ведёт маска, состояние догоняет событием
<input ref={ref} className="gr-input" data-gr-mask="+7 (000) 000-00-00"
       defaultValue={phone} onChange={(e) => setPhone(e.target.value)} />

// 2. Управляемое: в состояние кладётся то, что записала маска, — уже
//    отформатированное, и следующая отрисовка ничего не откатывает
<input className="gr-input" data-gr-mask="+7 (000) 000-00-00"
       value={phone} onChange={(e) => setPhone(e.target.value)} />

Второй путь работает, потому что на момент onChange в e.target.value уже стоит значение маски. Не работает только третий, соблазнительный: держать в состоянии «чистые» цифры и отдавать полю их — тогда отрисовка каждый раз стирает форматирование, и маска переделывает его заново, с каретой в конце. Дата ещё проще: форма отправляет ISO из скрытого спутника, и в состояние стоит класть его — из griffin:change на поле, где detail.value и есть ISO.

Хук, когда узлы живут недолго

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

import { useEffect, useRef } from 'react';

// Поднимает виджет на своём узле и снимает его на размонтировании.
// Пересобирается при смене deps — например, когда меняются параметры.
export function useGriffinWidget(name, options, deps = []) {
  const ref = useRef(null);

  useEffect(() => {
    const node = ref.current;
    if (!node || !window.GriffinJS) return undefined;

    // Идемпотентно: если сканер успел раньше, вернётся уже поднятый.
    GriffinJS.mount(node, name, options);

    // Только unmount — прямой destroy() оставил бы запись в учёте.
    return () => GriffinJS.unmount(node, name);
  }, deps);

  return ref;
}

// Применение
function Slider({ items }) {
  const ref = useGriffinWidget('slider', { loop: true });

  if (!items.length) return null;   // узел появляется уже полным

  return (
    <div ref={ref} className="gr-slider">
      <div className="gr-track">
        {items.map((it) => (
          <div className="gr-slide" key={it.id}>{it.title}</div>
        ))}
      </div>
    </div>
  );
}

Проверка window.GriffinJS обязательна, а не защитная: при серверной отрисовке хук выполнится и на сервере, где глобала нет. Порядок подключения тоже значим — griffinjs.js должен выполниться раньше, чем хук впервые вызовет mount. Обычный тег <script> в <head> это обеспечивает; сборщик, кладущий скрипт в конец с type="module", — нет.

Vue

Всё сказанное выше верно и здесь — меняются только имена. Vue заменяет узел при смене :key так же, как React, и так же не узнаёт о записи в value из чужого кода, если поле связано через v-model.

ReactVue
useEffect + useRefonMounted / onBeforeUnmount + ref
уборщик эффектаonBeforeUnmount
defaultValue:value без v-model
key на детях, не на контейнере:key на детях, не на контейнере
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';

const el = ref(null);

onMounted(() => {
  if (window.GriffinJS) GriffinJS.mount(el.value, 'slider');
});

onBeforeUnmount(() => {
  if (window.GriffinJS) GriffinJS.unmount(el.value, 'slider');
});
</script>

<template>
  <div ref="el" class="gr-slider" v-if="items.length">
    <div class="gr-track">
      <div class="gr-slide" v-for="it in items" :key="it.id">{{ it.title }}</div>
    </div>
  </div>
</template>

Классы и раскладки

С утилитами и компонентами никакой особенности нет: это обычный CSS, и className принимает его как любой другой. Раскладка data-gr-layout — тоже обычный атрибут, и рантайм ядра поднимет её на вставленном узле по тому же наблюдателю. Одна оговорка всё же есть, и она шестая выше: класс контейнера раскладки — не ваш.

<div data-gr-layout="a3b4-c2d5" className="gr-gap-4">
  <aside className="gr-area-a gr-p-4 gr-bg-surface">…</aside>
  <main className="gr-area-b gr-p-4">…</main>
</div>

Единственное, чего делать не стоит, — собирать имя класса из кусков в рантайме. Отсечение неиспользуемого (npm run purge) читает разметку статически, и {`gr-p-${n}`} оно не увидит: класс уедет из сборки, а страница молча останется без отступа. Полное имя класса в исходнике — условие того, что отсечение безопасно.

Отсечение компонентов

То же отсечение работает и для griffincss-ui.css: скрипт не привязан к утилитам, --css принимает любой файл библиотеки, и правила компонентов, чьих классов в разметке нет, уходят так же. Оговорка одна, и в сборке под фреймворк она важнее, чем в статике: часть классов ставит не ваш компонент, а скрипт библиотеки. Тост и его крестик (Griffincss.ui.toast()), список и теги комбобокса, лайтбокс, кнопки и точки слайдера, панель календаря, список файлов и сводка ошибок собираются в рантайме — в JSX их нет, и отсечение их не увидит. Для них есть пресет: --preset ui, тот же --safelist, только готовый и сверенный с исходниками скриптов тестом.

npm run purge -- --css node_modules/griffincss-ui/dist/griffincss-ui.css --preset ui --out public/ui.css src/
npm run purge -- --css node_modules/griffincss-ui/dist/griffinjs.css --preset ui --out public/griffinjs.css src/

Замер на странице примеров витрины (patterns.html, компоненты без виджетов слоя): griffincss-ui.css 12 541 → 7 546 Б gzip с пресетом, 6 868 без него — но уже без тостов; griffinjs.css 2 880 → 1 596 Б. Цена пресета — правила всех компонентов, которые строит скрипт, даже не использованных на странице: здесь 678 Б в первом файле и 1 457 во втором. Точнее — свой --safelist из списка PRESETS.ui в scripts/purge.mjs.

Пресет не знает про ось оформления. Значения data-gr-style, которые ставятся не в разметке, а из настроек или рантаймом (Griffincss.theme.style('compact')), в файлах не встречаются, и блоки этих стилей уходят вместе с неиспользованными классами. Перечислите их в --safelist явно — --safelist airy,compact; отчёт скрипта называет, что по оси оставлено и что выброшено.

Почему обёрток не будет

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

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