StyleSheet.create: пишем свой, смотрим настоящий
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Почти любой экран на React Native начинается со строки const styles = StyleSheet.create({ … }), и выглядит она так, будто стили здесь — особая сущность, таблица, которую фреймворк как-то компилирует. В React Native 0.86 create возвращает ровно тот объект, который получил. Напишем его сами, а заодно flatten — правило, по которому массив [a, условие && b, props.style] превращается в один стиль. А потом посмотрим, что настоящий StyleSheet делает сверх этого — в предпросмотре и на телефоне. Этаж у всей статьи нижний: StyleSheet — это сам React Native, Expo здесь ни при чём.
Со статьи «Ни div, ни класса, ни каскада» стиль живёт не в CSS-файле, а в пропсе style — объектом с ключами в camelCase. В примерах первой главы, как и во всей документации, этот объект сперва проходит через StyleSheet.create, а разбор его был отложен до этой статьи. Первый вопрос простой: что выходит с другой стороны? Номер, класс, скомпилированная таблица?
Проверим тем, что можно спросить у любого значения: тот ли это объект, что за тип внутри, можно ли его менять. Прогноз перед запуском: что напечатают первые три строки и чем кончится попытка поменять размер шрифта?
create вернул тот же самый объект — не копию и не номер. Внутри лежат обычные объекты с теми же полями. Изменилось одно: каждый стиль заморожен Object.freeze, а сам лист — нет. Поэтому запись в fontSize падает с TypeError (код примера выполняется в строгом режиме, где запись в замороженное свойство — ошибка, а не тишина), а новый ключ extra в лист добавляется без звука.
Посмотрите и на компилятор. На extra он возразил: такого ключа в типе листа нет. А на fontSize = 24 промолчал — в объявлении create возвращает тип аргумента как есть, без Readonly, и о заморозке типы не знают. Её ловит только рантайм, и только в отладочной сборке.
В исходнике React Native так и написано. В react-native/Libraries/StyleSheet/StyleSheetExports.js над create стоит комментарий «An identity function for creating style sheets» — функция тождества. Тело — цикл, который морозит каждое значение, обёрнутый в if (__DEV__), и return obj. В выпускной сборке не остаётся и заморозки: запись в стиль пройдёт молча, а экран не изменится, потому что никто не попросил перерисовку — как с объектом состояния, изменённым на месте, в части IV.
Рядом в том же исходнике лежит TODO: результат create когда-то был непрозрачным номером, и чужой код до сих пор описывает его числом. Отсюда живучее поверье, что create «отправляет стили на нативную сторону один раз». В 0.86 отправлять нечего — это тот же объект.
Рантаймовая часть create — три строки: пройти по ключам, заморозить, вернуть. Всё интересное — в типе. У стилей React Native неприятное свойство: рантайм молчит, и неверный ключ ловит только компилятор. Но ловит он лишь тогда, когда тип параметра написан с умом, — и это видно на двух почти одинаковых объявлениях.
Ниже createLoose и create отличаются только параметром. Прогноз: на каких из трёх вызовов с опечаткой magrinRight компилятор возразит и что напечатает рантайм?
Рантайм взял опечатку во всех трёх случаях: объекты напечатались с magrinRight, и ни одного предупреждения. А компилятор разделил вызовы: на b и c он пишет «Объектный литерал может указывать только известные свойства… Вы хотели записать "marginRight"?», а a пропускает.
Причина — в том, как компилятор проверяет дженерик. В createLoose он сначала выводит T из аргумента, а потом сверяет выведенный тип с ограничением. Это обычная структурная проверка из части III: лишнее свойство рядом с правильным ей не мешает, { marginLeft, magrinRight } — вполне себе ViewStyle. Проверка лишних свойств срабатывает только на свежем литерале, который сверяется с целевым типом напрямую. Пересечение T & Named<any> даёт такую цель: у параметра появляется индексная сигнатура, и каждое значение литерала сверяется с ViewStyle | TextStyle как литерал.
Это не наша находка. В StyleSheet.d.ts над настоящим create комментарий объясняет лишнее & NamedStyles<any> ровно этим примером — marginLeft рядом с magrinRight. Опечатку в одиночку, вроде { magrinRight: 1 }, поймал бы и простой вариант — проверьте, заменив аргумент у a. Опасна именно опечатка по соседству с верными ключами: на ней простой вариант молчит.
А что будет с такой опечаткой дальше? На телефоне рендерер собирает для нативного вида только известные ему ключи и неизвестные молча пропускает: в ReactNativeAttributePayload.js на каждый ключ, которого нет в списке атрибутов, стоит continue. В предпросмотре react-native-web превратит magrinRight в CSS-класс с несуществующим свойством, и его молча выбросит уже браузер. Отступа нет, сообщения нет — остаётся компилятор.
Поэтому StyleSheet.create полезен даже как функция тождества: он проверяет ключи там, где стиль объявлен, а не там, где его наконец применили.
Пропс style принимает не только объект, но и массив. Так в React Native склеивают стили — вместо строки из нескольких классов: [styles.chip, выбран && styles.on, props.style]. У массива есть правило, по которому он превращается в один объект. Напишем его сами — это flatten.
Правил три: идём слева направо и копируем поля, более поздний ключ перекрывает ранний; ложные элементы пропускаются; вложенный массив разворачивается тем же способом. Третье нужно не для красоты — пропс style от родителя сам бывает массивом.
Прогноз: какими будут padding, backgroundColor и color в каждой строке, совпадут ли строки попарно — и что покажет последняя?
Строки совпали попарно: наш flatten и настоящий сливают одинаково. color в обоих случаях жёлтый — стиль родителя стоит последним и перекрывает и базовый, и активный. padding тоже от родителя, хотя пришёл он во вложенном массиве. А backgroundColor родитель не задавал, поэтому его решает isActive. Заметьте и порядок ключей: он по первому появлению — от base, а значения — от последнего, кто их задал. Object.assign ведёт себя именно так.
Ложные элементы — это ровно четыре значения: false, null, undefined и пустая строка. Такой же список стоит в типе Falsy у React Native, и именно на нём держится запись условие && стиль: пока условие ложно, в массиве лежит false, и слияние его пропускает.
Последняя строка — различие, которое в предпросмотре не увидеть, если о нём не знать. Здесь StyleSheet.flatten от react-native-web: он всегда собирает новый объект, поэтому false. У React Native на телефоне в flattenStyle.js стоит проверка: не массив — вернуть как есть, поэтому там было бы true. Наш вариант копирует, как веб.
На телефоне массив из пропса style сливает сам рендерер, по тому же правилу: addNestedProperty проходит массив слева направо, и поздний ключ затирает ранний.
Теперь то же правило в компоненте. Чип упражнения из «Подхода» — кнопка мышечной группы в фильтре справочника. У него свои стили, стиль «выбран» и стиль, который может передать родитель. В подложке примера лежит наш flatten — им компонент и печатает, какой фон у него вышел. Как useState держит выбор — как в части IV, onPress — как в первой статье части; новое здесь только массив в style.
Прогноз: оба чипа нажимают по разу. Какой фон будет у каждого после нажатия?
«Спина» выбрана, а фон остался розовым: стиль родителя стоит в массиве последним и перекрывает styles.on. Нажатие сработало — состояние сменилось, компонент перерисовался, — просто итоговый объект снова решил в пользу родителя. На экране такой чип выглядит мёртвым.
Какой порядок правильный — вопрос договорённости, а не синтаксиса. Обычно props.style ставят последним: родитель вправе переопределить что угодно. Если же состояние обязано победить, оно идёт после стиля родителя. Это задача номер один в конце статьи.
Вторая ловушка — в условии. выбран && стиль безопасно, пока слева логическое значение. Стоит поставить туда число, и при нуле в массив ляжет 0. Прогноз: что напечатает рантайм и что скажет компилятор?
Рантайму ноль безразличен: flatten его пропустил, и настоящее слияние пропускает любое не-объектное значение. А компилятор не пускает ни в style, ни в наш flatten: «Тип "0" не может быть назначен…» — в типе Falsy нуля нет. Это тот случай, когда типы строже рантайма, и хорошо, что строже. Среди детей View тот же sets && опаснее. В предпросмотре он пройдёт незамеченным: проверка react-native-web из статьи «Ни div, ни класса, ни каскада» ругается только на строки, а ноль — число. На телефоне рендерер React Native создаст для нуля текстовый узел вне Text и напишет «Text strings must be rendered within a <Text> component.». Привычка sets > 0 && лечит оба места сразу.
В проекте тот же чип раскладывается по файлам: стили — в chip.styles.ts, типы — в chip.types.ts, разметка — в chip.tsx. Так разложены модули в мобильном приложении автора: там рядом с note-list.tsx лежат note-list.styles.ts и note-list.types.ts. Типы стиля приходят не через import("react-native"), как в песочнице, а обычным import type.
Лист стилей объявлен вне компонента, поэтому его объекты одни и те же в каждом рендере. Литерал style={{ … }} внутри JSX — новый объект на каждый вызов. Стабильность ссылки даёт место объявления, а не сам create.
В песочнице StyleSheet не из React Native, а из react-native-web, и его create делает три вещи вместо одной: проверяет каждый стиль, морозит его и компилирует в CSS-классы. Проверка и заморозка есть только в отладочной сборке, компиляция — всегда. Проверка — функция validate: среди прочего она ругается на !important и на сокращения с несколькими значениями, которые в CSS привычны, а здесь не поддерживаются.
Прогноз: что останется в styles.card и styles.row — и что будет с тем же стилем, если не пропускать его через create?
Две подлинные строки console.error из react-native-web — и стили похудели: validate не просто предупреждает, а удаляет неверный ключ из вашего объекта. Так что в вебе create уже не функция тождества — объект тот же, но содержимое другое. А стиль, не прошедший через create, никто не проверял: flatten отдал его с !important как есть.
Компилятор здесь поймал только одно из двух: "8px 16px" не подходит под тип DimensionValue, а "red !important" — обычная строка, и для типа цвета её достаточно. На телефоне не будет ни того, ни другого сообщения: там create только морозит. Цвет с !important React Native не разберёт: normalizeColor вернёт null, processColor — undefined, и цвет просто не применится. Предупреждения в этом коде нет.
| Ошибка в стиле | Компилятор | create в предпросмотре | create на телефоне |
|---|---|---|---|
magrinRight рядом с верными ключами | ловит, если параметр — T & NamedStyles<any> | пропускает: уедет в CSS-класс, браузер его выбросит | не проверяет; рендерер молча пропустит ключ |
margin: "8px 16px" | ловит: не DimensionValue | console.error и удаляет ключ | не проверяет |
color: "red !important" | не ловит: цвет — строка | console.error и удаляет ключ | не проверяет; цвет не разберётся и не применится |
запись в стиль после create | не ловит: тип без Readonly | TypeError: стиль заморожен | TypeError в отладочной сборке, тишина в выпускной |
Третья работа веб-версии create — компиляция в атомарные CSS-классы: по классу на пару «свойство — значение». Самому примеру document недоступен, поэтому в подложке лежит хелпер peek: он находит узел по testID и отдаёт его класс и атрибут style. Прогноз: чем отличатся три одинаковых квадрата?
Стиль из create стал классами, литерал — атрибутом style. Третий квадрат показывает правило «слева направо» в исполнении веба: класс фона из styles.box выброшен, потому что правее в массиве фон задан заново, и этот фон уехал в атрибут. На телефоне ничего этого нет — ни CSS, ни классов: массив сливает рендерер, и нативный вид получает готовые свойства.
Остальное в StyleSheet — мелочи поверх тех же объектов. absoluteFill — готовый объект «растянуть на всего родителя». compose(a, b) склеивает два стиля в массив. А hairlineWidth в вебе всегда 1 — почему, разберём в статье про размеры.
StyleSheet.createв React Native 0.86 — функция тождества: возвращает тот же объект, в отладочной сборке заморозив каждый стиль. Номеров и «отправки на нативную сторону» больше нет.- Пользу
createдаёт тип: параметрT & NamedStyles<any>включает проверку лишних свойств, и опечатку рядом с верными ключами ловит компилятор. Рантайм её не ловит нигде. - Массив в
styleсливается слева направо: поздний ключ перекрывает ранний,false,null,undefinedи""пропускаются, вложенные массивы разворачиваются. Свойflatten— десяток строк. - Чей стиль победит, решает только позиция: обычно
props.styleставят последним, а если состояние должно быть главнее, оно идёт после. Число в условии (sets && …) типы не пропустят — пишитеsets > 0 &&. - В предпросмотре
createот react-native-web делает больше: проверяет стиль, удаляя!importantи составные значения, и компилирует его в CSS-классы. На телефоне этого нет — там только заморозка, а неизвестные ключи и неразобранные цвета пропадают молча.
1/4Что возвращает StyleSheet.create в React Native 0.86 на телефоне?
2/4Что даст flatten([{ color: "red", padding: 4 }, false, [{ color: "blue" }, null], { margin: 2 }])?
3/4Компонент собирает [styles.chip, on && styles.on, props.style], родитель передал style={{ backgroundColor: "pink" }}. Какой фон у выбранного чипа?
4/4Почему createLoose<T extends Named<T>>(styles: T) не замечает magrinRight рядом с marginLeft, а настоящий StyleSheet.create замечает?
Выбор должен побеждать
Родитель красит чип в розовый и увеличивает ему отступ. Нужно, чтобы выбранный чип всё равно становился синим, а отступ родителя сохранялся. Поправьте только одну строку компонента.
Стиль выбора переезжает правее стиля родителя:
Теперь вывод «не выбран → #fca5a5 | отступ 20», затем «выбран → #2563eb | отступ 20». Родитель по-прежнему переопределяет всё, что задано в styles.chip, — поэтому отступ остался 20. А styles.on стоит последним и перекрывает родителя, но только тем, что задаёт сам, то есть фоном. Такой порядок — осознанное решение компонента: «выбор важнее внешнего вида». Его стоит записать комментарием рядом с массивом, потому что обычная договорённость обратная.
Три уровня обёрток
Экран передаёт стиль карточке, карточка — тегу, и каждый добавляет к нему своё. Не запуская, скажите, что напечатает Tag: как выглядит массив, который он получил, и какой объект из него выйдет. Потом запустите и сверьтесь.
Массив получается вложенным в три слоя — каждый уровень заворачивает полученный стиль в свой массив, а не распаковывает его. Слияние идёт по нему слева направо, заходя во вложенные массивы:
Итог — { padding: 0, backgroundColor: "#fef3c7", borderRadius: 8 }. Порядок ключей — по первому появлению: padding и backgroundColor пришли от тега, borderRadius — от карточки. false пропущен. Ноль в padding: 0 — не ложное значение массива, а значение свойства, поэтому оно честно перекрывает двенадцать. Раскрывать вложенность руками не нужно: и наш flatten, и настоящий делают это рекурсией.
Свой compose
У React Native есть StyleSheet.compose(a, b): он возвращает [a, b], но если один из стилей null или undefined, отдаёт другой как есть, без нового массива. Допишите такой же compose.
Вывод становится true true, вторая строка прежняя. Смысл не в экономии одного массива, а в ссылке. Если компонент пишет compose(styles.row, props.style), а родитель стиля не передал, наружу уходит сам styles.row — один и тот же объект в каждом рендере. Ребёнок, обёрнутый в memo, сравнит пропсы по ссылке, как в части IV, и перерисовываться не станет. Новый массив [styles.row, undefined] на каждый рендер эту проверку сорвал бы. Кстати, в react-native-web compose такой проверки не делает и всегда возвращает массив.