REEEL Architecture Best Practices
Правила построения приложений на REEEL. Синтаксис описан в спеке формата (
packages/workspace/reeel_element_parser/docs/reeel_toon_format.md
) — здесь
когда что выбирать
. Правила визуальной вёрстки — в
index.md
. Ярусы:
MUST
— нарушение даёт баг или техдолг;
SHOULD
— дефолт стиля, отступай осознанно.
0. Сигилы несут семантику (MUST)
$name
— чтение/подписка на переменную.
@name
на стороне значения — объявление выходной переменной, в которую пишет элемент. Это работает и для именованных (
data:@products
), и для позиционных аргументов (
MapValue $routePath @tab
). Направление потока данных всегда видно из сигилов — не из имён параметров.
Поток данных
1. Данные между экранами — только через маршрут (MUST)
Идентичность — path-параметром, объект — вторым аргументом
navigate
, чтение — генератором
RouteData
. Путь собирается вложенным template string прямо в вызове:
// main.reeel
AppRoute "/detail/:id"
// карточка
ListTile onTap:(){navigator.show($"/detail/${item['id']}", item)}
// detail.reeel
RouteData id:@productId extra:@product
Text {product['title'] ?? ""}
Тем же генератором читается и то, КАКОЙ маршрут открыт, — этим оболочка подсвечивает активный пункт. Работает по обе стороны навигатора: и на экране маршрута, и в
@Macros id:shell
над ним. Путь статический (
/detail/:id
→
/detail
), поэтому таблица не зависит от значений параметров:
@AppNavBar
RouteData path:@routePath
MapValue $routePath @tab "/home":0 "/catalog":1
NavigationBar selectedIndex:$tab
Переменная, которую файл не объявил, — всегда ошибка: если путь не прочитан
RouteData
, взяться ему неоткуда.
Антипаттерн — глобальная Storage-переменная как «передатчик» (
onTap:(){selected_product=index; navigator.show("/detail")}
): скрытая связность, ломает deep link, требует повторного парсинга данных.
2.
extra
эфемерален — без фолбэк-инфраструктуры (MUST)
extra
есть только при навигации внутри приложения. Экран читает поля как
{product['field'] ?? ""}
— на прямом заходе они пустые, и это норма. НЕ добавляй на экран деталей JsonData-фолбэк «достань по id, если extra нет»: экран не должен повторно загружать данные ради edge-кейса.
3. Списки: данные → данные, не «скрой строку» (MUST)
Фильтрация и трансформация — генераторами, список получает уже готовые данные:
FilterData from:$products data:@filtered count:@filteredCount
where:(item){isEmpty(query) || contains(lowerCase(item['title'] ?? ""), lowerCase(query))}
ListView.builder itemCount:$filteredCount
@Macros id:itemBuilder index:0
$ProductCard item:{filtered[index]}
Visibility visible:{...}
внутри itemBuilder для фильтрации запрещён: ломает счётчики, скролл и состояния «пока пусто».
4. Каждый сабскрипт на данных — с
??
(MUST)
Выходы генераторов равны
null
, пока данные не загрузились; сабскрипты на
null
деградируют в
null
, а не падают. Поэтому каноническая форма —
{products[index]['title'] ?? ""}
; голый сабскрипт в вёрстке — баг по построению.
5. Карточке данных — объект целиком (SHOULD)
$ProductCard item:{products[index]}
— поля распаковываются внутри макроса (
{item['title'] ?? ""}
). Не протаскивай по пять параметров через каждый call site. Касается макросов-представлений данных; макросу-кнопке объект не нужен.
Выражения
6. Маппинг вместо тернарных цепочек, в обе стороны (SHOULD)
Тернарник глубже одного уровня — сигнал, что нужен
MapValue
. Короткая форма — дефолт: первый позиционный — вход, второй — выход, остальные
key:value
— таблица, несовпавшее падает на ПЕРВУЮ запись:
MapValue $routePath @tab "/home":0 "/catalog":1 "/favorites":2 "/profile":3
Обратное направление (индекс → значение в колбэках) —
pick()
:
onDestinationSelected:(index){navigator.show(pick(index, "/home", "/catalog", "/favorites", "/profile"))}
Длинные таблицы выноси в
#map
на отдельной строке; именованная форма (
value:
/
to:
/
default:
) допустима, когда нужна явность —
default:
перекрывает фолбэк первой записи.
7. Коллекции в Storage — литералы и иммутабельные операции (MUST)
Динамические ключи в Storage невозможны — коллекция живёт в одном ключе. Объявляй литералом, мутируй функциями коллекций (каждая возвращает НОВУЮ коллекцию — присваивание нотифицирует подписчиков и персистит):
Storage @favorites:[]
IconButton onPressed:{favorites=toggle(favorites, productId)}
Icon {has(favorites, productId) ? "favorite" : "favorite_border"}
Скалярные элементы сравниваются по значению (
has
/
toggle
/
remove
); списки объектов — по ключу через
*Where
-варианты:
Storage @cities:[]
ListTile onTap:(){cities=add(cities, city)}
IconButton onPressed:{cities=removeWhere(cities, "name", city['name'])}
Живая переменная — настоящий List/Map (сабскрипты работают напрямую), в бекинг-хранилище она уходит JSON-строкой автоматически. Литералы:
[a, b]
— список,
[key:value, …]
— map,
[]
/
[:]
— пустые; литералы — для констант,
$ref
внутри не разворачивается.
Тема и локализация
Цвета — токенами
$theme.*
, не hex (SHOULD)
color:$theme.primary
, не
color:#9B8AFB
. Токены ambient (доступны в любом файле без объявления), следуют теме проекта/бренда и переключаются (
theme.set("dark")
). Hex-литерал допустим лишь там, где цвет действительно одноразовый и вне темы.
Строки — ключами
$strings.*
, не литералами (SHOULD)
Text $strings.welcome
, аргументы/плюрал —
{strings.t("greeting", name:$user)}
. Строки живут в
data/strings/<locale>.arb
, язык переключается
strings.set("ru")
. Литералы — только для не-текста (символы, эмодзи) или временных заглушек.
Ambient-чтения прикрывать
??
(MUST)
Dotted-чтение неизвестного токена/ключа даёт
null
:
{theme.accent ?? "#000000"}
,
{strings.title ?? "—"}
. Не объявляй свои
@theme
/
@strings
— затенят ambient. Подробнее:
Тема и локализация
.
Каркас приложения
8. Роут = файл; пути с
:
— в кавычках (MUST)
AppRoute "/detail/:id"
резолвится в
detail.reeel
по статическим сегментам. Без кавычек
:
парсится как разделитель именованного параметра.
9. Двойной Scaffold с фиксированными ролями (SHOULD)
Внешний Scaffold — в shell-макросе
AppMainBuilder
: только персистентный chrome (
bottomNavigationBar
, фон), не пересоздаётся при переходах. Внутренний — на каждом экране: его собственный
AppBar
. AppBar в shell не поднимать — пришлось бы реактивно перестраивать его по маршруту.
backgroundColor
дублируется на обоих уровнях осознанно: внутренний Scaffold без явного цвета закрасит фон темой Material.
10. В шаблонах и примерах нет мёртвого UI (MUST для шаблонов)
Каждый контрол делает что-то наблюдаемое. Декоративный «выбор темы» без эффекта хуже отсутствия контрола: шаблон — учебник, мёртвый контрол учит неправильному.
11. Ссылка на файл — от корня проекта (MUST)
// ✗ относительно файла — работает в редакторе, ломается после публикации
Image src:"../shared/logo.png"
// ✓ от корня проекта
Image src:"data/images/logo.png"
Путь от корня — единственная форма, о которой договорились все, кто его читает: загрузчик приложения, компилятор сайта, статический экспорт и манифест ревизии. «Текущий файл» такой точкой отсчёта быть не может: раскрытие макроса переносит разметку между файлами, и на момент резолва это уже не тот файл, где строка написана.
Отдельно про
..
: он не просто не рекомендуется — он
не синхронизируется
. И клиент, и сервер отклоняют путь с dot-сегментом, так что такая ссылка называет файл, которого в опубликованном проекте быть не может. При этом в редакторе он открывается с диска, поэтому ошибку видно только после публикации — и поэтому анализатор сообщает о ней как об
error
.
Картинки живут в
data/images/
(вложенность любая). Что уже есть в проекте, показывает
reeel_assets
— вместе с размерами и весом.
12. Файл длиннее 250 строк или 12 КБ — делится на компоненты (SHOULD)
Что наступит раньше. Самостоятельные куски — секция экрана, бит ролика — уходят в
reeel/components/<name>.reeel
как
@
-макросы с
import
там, где используются; внешние переменные, которые кусок читает, передаются параметрами. Причина практическая: файл такого размера не переписать целиком в один ответ агента вместе с рассуждением, а каждая правка в нём — поиск по чужому коду. Анализатор предупреждает
file_too_long
; исправление (
reeel_diagnostics {fix:true, file:"…"}
— без
file
файл не режется) или
reeel_extract_component
выносят кусок, расставляют импорт и сообщают, что куда переехало.
@Name
на верхнем уровне файла объявляет макрос;
@Macros index:0
— слот шаблона одного элемента
*Repeat
/
itemBuilder
, а не объявление.
@Macros Ball …
— ошибка (
macro_slot_as_declaration
), правильно
@Ball …
.
Сцена motion держит в корне часы,
*Layer in:/out:
, звук и субтитры; каждый бит — свой макрос.
JsonData
— РОДИТЕЛЬ
*Scene
, не сосед: два корня превью показывает галереей.