Именованные хуки
Обобщённые хуки useEvent / useCondition / useAction нормально читаются в небольших файлах. Но как только у триггера четыре-пять портов, каждый компонент-соединитель начинает выглядеть одинаково: тут useEvent(trigger, 'foo'), там useCondition(trigger, 'bar', …). Имя триггера и строковый литерал повторяются и превращаются в шум. Именованные хуки — эргономичное лекарство: один хелпер превращает схему в плоский объект пер-портовых хуков, без codegen и дополнительных шагов сборки.
createNamedHooks(trigger) возвращает объект, ключи которого выведены из схемы. Для такого триггера:
ты получаешь пять хуков — по одному на порт:
Каждый оборачивает соответствующий обобщённый хук с заранее подставленными в него триггером и именем порта. Никаких type-ассершенов, никакого рантайм-парсинга — имена статичны и выведены из схемы системой template-literal-типов TypeScript.
Как это выглядит в компоненте
Заголовок раздела «Как это выглядит в компоненте»Сравни одно и то же подключение, написанное двумя способами. С обобщёнными хуками:
С именованными хуками:
На три строковых литерала меньше, messageTrigger не повторяется, а места вызова читаются как обычные доменные хуки. Переименуй new-message → message-received в схеме — TypeScript сломает useNewMessageEvent в месте импорта.
TS-механика (один mapped-тип, один template-literal)
Заголовок раздела «TS-механика (один mapped-тип, один template-literal)»Имена хуков вычисляются системой типов, а не генерируются на диск. Релевантный кусок @triggery/core/types.ts небольшой:
Три mapped-типа поверх трёх карт схемы, каждый перемапленный через as в ключ-template-literal. Правила преобразования:
| Ключ схемы | Имя хука |
|---|---|
'new-message' (event) | useNewMessageEvent |
'app:ready' (event) | useApp:readyEvent — избегай таких ключей (используй обобщённый хук для 'app:ready') |
'user' (condition) | useUserCondition |
'currentUserId' (condition) | useCurrentUserIdCondition |
'showToast' (action) | useShowToastAction |
'play-sound' (action) | usePlaySoundAction |
Двухшаговое правило для kebab-case: каждый - удаляется, следующая буква переводится в верхний регистр, после чего вся строка проходит CapFirst. У camelCase-ключей первая буква переводится в верхний регистр; двоеточия, точки и слэши проходят как есть — поэтому ключи с двоеточием порождают странные имена (см. следующий раздел).
Подводные камни именования
Заголовок раздела «Подводные камни именования»Некоторые валидные JS-строки порождают имена хуков, которые невозможно набрать в месте вызова:
- Ключи с двоеточием, точкой или слэшем (
'app:ready','router.transition') — template-literal сохраняет пунктуацию. Тип существует, но вызвать свойство какuseApp:readyEventв исходниках нельзя. Используй обобщённыйuseEvent(trigger, 'app:ready'). - Ключи, начинающиеся с цифры (
'2fa-required'), — та же проблема. Избегай. - Зарезервированные JS-идентификаторы в
as-ключах ('class'→useClassCondition) — нормально, потому что итоговый идентификаторuseClassCondition, а неclass.
ESLint-правило prefer-named-hook предлагает именованный хук только когда итоговое имя — валидный JS-идентификатор; на ключах с пунктуацией оно молчит.
Когда использовать именованные хуки
Заголовок раздела «Когда использовать именованные хуки»Грубое правило:
- 2–3 порта в триггере — обобщённые хуки читаются нормально, именованные добавляют файл реэкспортов ради небольшого выигрыша.
- 4+ порта или два компонента, трогающие один триггер — именованные хуки окупаются. Плоский список импортов гораздо проще искать grep’ом и рефакторить, чем россыпь вызовов
useEvent(trigger, 'literal'). - Триггер живёт в shared-пакете — всегда экспортируй именованные хуки. Потребители пакета получают хук-эргономику, не зная самого объекта триггера.
- Inline и одноразовые триггеры — не стоит; триггер и так локален. Используй обобщённые хуки или
useInlineTrigger.
Масштабируемый паттерн: положи триггер и его именованные хуки рядом, реэкспортируй оба из barrel-файла фичи:
Consumer-код больше не реимпортит messageTrigger напрямую, кроме вызова trigger.disable() для feature-флага или в тестах.
Подсказка от ESLint
Заголовок раздела «Подсказка от ESLint»@triggery/eslint-plugin поставляет правило prefer-named-hook, которое флагает такое:
…и предлагает автофикс:
Правило срабатывает только когда (а) в том же графе модулей есть named-hooks-экспорт и (б) итоговое имя хука — валидный JS-идентификатор. Отключай пер-файлом через // eslint-disable-next-line, если у тебя намеренное исключение (например, динамические имена событий в собственной обёртке-хуке).
Производительность — стоит ли беспокоиться о Proxy?
Заголовок раздела «Производительность — стоит ли беспокоиться о Proxy?»Нет. Proxy — это одна аллокация на вызов createNamedHooks(trigger) (как правило, один раз на приложение), и каждое обращение к свойству кеширует получившийся хук, так что React видит одну и ту же функцию между рендерами. В точке вызова именованный хук делает буквально одно: зовёт обобщённый хук с одним дополнительным строковым аргументом. Стоимость теряется на фоне самого React-рендера.
В двух словах о реализации:
- Один
ProxyнаcreateNamedHooks(trigger). - Один
Mapпо имени хука; первый lookup создаёт функцию хука, каждый последующий возвращает закешированную ссылку. - Функция хука — однострочный форвардер в
useEvent/useCondition/useAction.
Никакого codegen, никакого eval, никакого парсинга строк на горячем пути.
Solid и Vue: та же форма
Заголовок раздела «Solid и Vue: та же форма»Биндинги Solid и Vue экспортируют createNamedHooks с идентичной типизацией. Хуки внутри зовут фреймворк-специфичные useEvent / useCondition / useAction — поверхность остаётся той же:
Кроссфреймворковые проекты (React-оболочка с микрофронтендами на Solid, например) получают одинаковые имена именованных хуков с обеих сторон — удобно при поиске по кодовой базе.
Антипаттерны
Заголовок раздела «Антипаттерны»- Сборка Proxy внутри компонента.
createNamedHooks(trigger)должен запускаться один раз на верхнем уровне модуля, какcreateTrigger. Вызов внутри функционального компонента ломает стабильность имени хука между рендерами. - Смешение именованных и обобщённых хуков в одном файле без причины. Выбирай одно на файл. Цена — несогласованность.
- Реэкспорт самого proxy (
export const messageHooks = createNamedHooks(messageTrigger)). Тогда места вызова превращаются вmessageHooks.useNewMessageEvent()— почти то, с чего мы начинали. Деструктурируй на месте экспорта.