Griffincss в React и Vue
Пакетов-обёрток нет и не планируется. Не потому, что до них не дошли руки, а потому, что работу обёртки уже делает сканер: узел, вставленный кем угодно, получает свои раскладки и виджеты, удалённый — теряет их вместе с destroy.
Обёрток нет, и это не заготовка под будущий пакет.
Обёртка над библиотекой без состояния делает ровно одно: ловит момент,
когда фреймворк вставил узел, и говорит библиотеке «подними тут своё»,
а на размонтировании — «сними». Оба рантайма 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.
| React | Vue |
|---|---|
useEffect + useRef | onMounted / 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;
отчёт скрипта называет, что по оси оставлено и что выброшено.
Почему обёрток не будет
Решение записано и не меняется: экосистема — отдельный продукт с постоянным сопровождением, и проекту одного автора она не по силам. Но к биндингам это относится по другой причине, и она сильнее: их работа уже сделана. Обёртка ловила бы вставку и удаление узла — сканер ловит их сам, без пакета, без версии, совместимой с мажором фреймворка, и без второго места, где может разойтись жизненный цикл.
Что действительно нужно потребителю — не пакет, а шесть оговорок выше и тридцать строк хука, которые можно скопировать себе и держать под своим контролем. Они на этой странице.