Каскада нет, наследования нет — кроме одного случая
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В прошлой статье стиль оказался обычным объектом, а массив в style — правилом «слева направо». Но это правило решает только одно: что лежит в пропсе самого элемента. В CSS стиль приходит к элементу ещё и со стороны — из правил по селекторам и по наследству от родителя (селекторы, каскад и наследование разобраны в прологе). В React Native селекторов нет, а значит, нет и каскада. Нет и наследования — почти: шрифт, заданный контейнеру, до текста не доходит. Исключение одно — текст внутри текста, и у него есть точная причина в устройстве платформы. Соберём своё разрешение стилей по дереву, которое объясняет и правило, и исключение, найдём места, где предпросмотр наследует больше телефона, и обрежем длинное название упражнения через numberOfLines. Этаж у статьи нижний: всё это сам React Native, Expo здесь ни при чём.
В части II итоговый стиль элемента собирал браузер — статья «getComputedStyle: какой стиль на самом деле» доставала его одним вызовом: правила по селекторам, наследование от родителя, значения по умолчанию. Из этого выросла привычка: шрифт и цвет задают один раз, на body или на карточке, а текст внутри их подхватит. color, font-size, font-family и line-height в CSS наследуются сами, об этом даже не думают.
Перенесём привычку в «Подход» буквально: карточка упражнения получает красный цвет и крупный шрифт, внутри — название и строка с подходами, где вес выделен вложенным Text. Вычисленные значения спросим у предпросмотра через computed — хелпер подложки, родственник peek из прошлой статьи: самому примеру document недоступен. Прогноз: какими будут цвет и размер у каждого из четырёх узлов?
Сама карточка цвет и размер получила, но рисовать ими ей нечего: символов у неё нет. Название вышло чёрным и четырнадцатого размера, как будто стиля контейнера не было вовсе. Строка с подходами серая — это её собственный стиль. А «60 кг» взяло серый цвет и размер 14 у строки, в которую вложено, и жирность у себя. Итог: от View к Text не дошло ничего, от Text к вложенному Text — всё текстовое.
Компилятор предупредил заранее. На color в стиле карточки он пишет «Ни одна перегрузка не соответствует этому вызову», а внутри, у каждой из двух перегрузок, — «Объектный литерал может использовать только известные свойства. "color" не существует в типе "ViewStyle | RecursiveArray<Falsy | ViewStyle>"». Про fontSize он промолчал лишь потому, что на литерал выдаёт одну такую ошибку — первую. В типе ViewStyle нет ни цвета текста, ни шрифта, и не по забывчивости: контейнеру некуда их деть. На телефоне рендерер и не передаст их нативному виду — неизвестные виду ключи он пропускает, как в прошлой статье пропустил magrinRight.
Как предпросмотр добивается того же в браузере, где CSS наследует всё подряд? Силой. Корневому Text react-native-web ставит явные color: black и font: 14px System, и наследование от div карточки обрывается. Вложенный Text он рисует уже не div, а span со стилем color: inherit; font: inherit, и тот честно наследует от внешнего. Иначе говоря, в вебе поведение телефона изображено: сброс на корне, inherit внутри. Запомните слово «изображено» — в четвёртом разделе изображение даст трещины.
Чёрный и 14 — не выдумка react-native-web. Те же два числа стоят в TextAttributes::defaultTextAttributes() у самого React Native: foregroundColor = blackColor(), fontSize = 14.0.
Чтобы понять, откуда правило и откуда исключение, посмотрим, во что дерево превращается на телефоне. Каскад и наследование в браузере делает движок стилей: он проходит по дереву и для каждого узла вычисляет все свойства. В React Native такого движка нет. Каждый View становится нативным видом и получает ровно те свойства, что лежат в его собственном style после слияния, — ни одного от родителя. Оговорка про View, который только раскладывает детей и сам ничего не рисует: рендерер вправе убрать его из нативного дерева (об этом пропс collapsable), но и тогда детям от него ничего не достаётся. Спрашивать у предка шрифт виду незачем: шрифта у вида нет.
С текстом иначе. Корневой Text — нативный текстовый вид, абзац. Рисует он одну строку с атрибутами — на iOS это NSAttributedString, на Android Spannable: символы, разбитые на куски, и у каждого куска свои цвет, шрифт, жирность. А вложенный Text вида не создаёт вовсе — он становится куском строки своего абзаца. В исходнике React Native это видно в двух местах:
Корневой Text кладёт в контекст true — «вы внутри текста». Вложенный читает его и вместо текстового вида отдаёт NativeVirtualText; у его нативной части и имя говорящее — RCTVirtualText, «виртуальный текст». А View, оказавшись внутри текста, кладёт в контекст false: текст под ним снова корневой, со своим абзацем. Контекст здесь тот же механизм, что в части IV, — просто им пользуется сам React Native.
Сама склейка происходит в C++-части рендерера, и она короче любого описания каскада. ParagraphShadowNode — узел корневого текста — начинает с TextAttributes::defaultTextAttributes(), то есть с чёрного и 14, и накладывает на них свои атрибуты. Дальше BaseTextShadowNode::buildAttributedString идёт по детям. Голая строка становится куском с текущими атрибутами. Для вложенного текста атрибуты копируются, и на копию накладываются его собственные — методом apply. Правило наложения одно на каждое поле: fontSize = !std::isnan(textAttributes.fontSize) ? textAttributes.fontSize : fontSize. Задал ребёнок размер — берётся его, не задал — остаётся родительский. Любой другой узел внутри текста, тот же View, становится в строке вложением — местом под отдельный вид.
Вот и весь ответ на вопрос из заголовка. «Наследование» в React Native — свойство не дерева, а одной строки: вложенный текст наследует, потому что он и есть кусок родительской строки. Шрифт с View не наследуется, потому что у View и Text нет общей строки — это два разных вида. И правило «строка только внутри Text» из первой главы — оттуда же: у символов вне текста нет абзаца, куда их вписать.
| В JSX | На телефоне | Откуда стиль |
|---|---|---|
<View style={card}> | нативный вид | только card |
<Text style={note}> в View | абзац, RCTText | умолчания (чёрный, 14) плюс note |
<Text style={bold}> в Text | кусок строки, RCTVirtualText | атрибуты абзаца плюс bold |
строка в Text | кусок строки | атрибуты ближайшего текста |
<View> в Text | вложение: отдельный вид в строке | только свой стиль; текст в нём — новый абзац |
Раз вложенный Text — не вид, у него нет и раскладки: его узел TextShadowNode унаследован от простого ShadowNode, а у абзаца ParagraphShadowNode основа — узел вида. Отступы, рамки и размеры задают абзацу, а куску строки — только то, что меняет символы.
Теперь перепишем правило своими руками. Резолвер пройдёт по дереву JSX — не отрисованному, а тому самому объекту, который возвращает разметка, — и для каждой строки скажет, с какими атрибутами её нарисуют. Из рендерера нужны три детали: список текстовых ключей, которые переходят к вложенному тексту, умолчания корневого абзаца и правило наложения. Наложение у нас уже есть: это flatten из прошлой статьи, он лежит в подложке, и поздний ключ в нём перекрывает ранний ровно как в apply.
Прогноз: сколько строк напечатает резолвер, какой размер получит «Жим лёжа» и что достанется тексту из своего компонента Weight?
Кусков четыре. «Жим лёжа» — корневой абзац: умолчания плюс свой размер 20. «3 подхода, » — другой абзац, со своим серым. «60» и « кг» — два куска, потому что число и строка в JSX — разные дети, но атрибуты у них одни: серый и 14 от абзаца, жирность от себя. Фон и отступ карточки резолвер не прочитал вовсе — ветка для View на стиль не смотрит и отдаёт детям null.
Обратите внимание на ветку со своим компонентом. Weight — не вид платформы, в нативном дереве его нет: React вызывает функцию, и её результат встаёт на место вызова. Поэтому вложенный текст наследует и через любое число своих компонентов между ним и абзацем — у React Native тем же путём идёт TextAncestorContext, который живёт в дереве React, а не в дереве видов. Прямой вызов функции у нас — упрощение: компонент с хуками так не развернуть, React вызывает его только внутри своего рендера.
Ветка, где строка получает null, — то же правило, что «Unexpected text node» из статьи «Ни div, ни класса, ни каскада»: строке вне текста негде жить. В резолвере это не ошибка, а пометка; она появится в задачах. Сама миниатюра — TEXT_KEYS, TEXT_DEFAULTS, pickText и resolve — с этого места лежит в подложке и едет в следующие статьи.
У настоящего рендерера список длиннее нашего: TextAttributes::apply перебирает цвета, шрифт, межбуквенное расстояние, высоту строки, выравнивание, украшения и тени — больше двух десятков полей. Принцип у всех один: задано у ребёнка — берётся его.
В первом разделе react-native-web изобразил поведение телефона: сброс на корневом тексте, inherit на вложенном. Но изображение сделано средствами CSS, а CSS наследует больше, чем сбрасывает react-native-web. Сброс касается цвета, шрифта целиком, выравнивания, переносов и подчёркивания. А letterSpacing и textTransform в CSS тоже наследуются — и их никто не сбрасывает. Вторая трещина — View внутри текста. На телефоне он рвёт цепочку, а react-native-web контекст «внутри текста» у View не сбрасывает, и текст под ним остаётся span с inherit.
Сверим модель с предпросмотром на одном экране. Шапка группы мышц сделана «как в CSS»: разрядку и капс получил контейнер. Прогноз: что скажет резолвер про «грудь» и «новое» — и что покажет предпросмотр?
Модель и предпросмотр разошлись дважды. «Грудь» на экране примера набрана капсом и вразрядку: CSS донёс letterSpacing и textTransform от div шапки. На телефоне будет обычное «грудь» — шапка это вид, и до абзаца от неё не доходит ничего. «Новое» в предпросмотре синее и двадцатого размера, а на телефоне будет чёрным и 14: View внутри текста начинает новый абзац с умолчаний.
И посмотрите на диагностики: их нет. В первом примере color на View компилятор поймал, а здесь letterSpacing и textTransform на том же месте прошли. Разница в пути. Там стиль был литералом прямо в пропсе, и работала проверка лишних свойств. Здесь он пришёл из StyleSheet.create, параметр которого принимает стили вида, текста и картинки вперемешку, а готовый объект { padding, letterSpacing, textTransform } сверяется с ViewStyle структурно: общее свойство padding есть, и этого достаточно. Объект совсем без свойств вида компилятор всё-таки остановит — «Тип "{ color: string; }" не может быть назначен для типа "StyleProp<ViewStyle>"», — а смешанный пропустит.
| Случай | Телефон | Предпросмотр |
|---|---|---|
цвет и шрифт на View | до текста не доходят | не доходят: корневой Text сбрасывает их сам |
letterSpacing, textTransform на View | не доходят | доходят через CSS |
Text в Text | кусок строки: наследует | span с inherit: наследует |
Text в View в Text | новый абзац с умолчаний | наследует от внешнего текста |
| свой компонент между текстами | наследует | наследует |
Text.defaultProps | не работает | работает |
Последняя строка таблицы — про самый частый обходной путь. Раз от контейнера ничего не доходит, «шрифт на всё приложение» в React Native задают не стилем, а компонентом: своей обёрткой над Text с базовым стилем, которой пользуются вместо Text везде. В «Подходе» это AppText. Как сделать, чтобы обёртка не ломала наследование вложенного текста, — задача номер один.
Старый рецепт «один раз задать Text.defaultProps.style» в предпросмотре сработает — проверено: песочница собирает JSX в React.createElement, а он умолчания пропсов всё ещё подставляет. На телефоне — нет, и это видно по исходникам: babel-preset-expo по умолчанию собирает JSX автоматическим рантаймом, jsx() из React 19.2 defaultProps не читает, рендерер подставляет их только классам, а Text в React Native 0.86 — функция.
Справочник «Подхода» полон названий вроде «Жим штанги лёжа на наклонной скамье узким хватом». В строке списка им тесно, и CSS-привычка тут — text-overflow: ellipsis вместе с white-space: nowrap и overflow: hidden. В React Native то же делает один пропс: numberOfLines={1} — не больше одной строки, лишнее уходит под многоточие. Число может быть и больше: заметку к тренировке в ленте удобно показывать двумя строками, а целиком — на экране тренировки.
Проверим на трёх блоках: название в одну строку, заметка целиком и та же заметка в ленте. Строки считаем по высоте блока — её сообщает onLayout, событие раскладки из самого React Native, — а высота строки задана явно: 20. Прогноз: сколько строк займёт каждый блок? В заметке три строки, разделённые \n.
numberOfLines не меняет текст, он меняет абзац: лишние строки просто не рисуются, а высота блока считается по оставшимся.Раз обрезка — дело абзаца, её место — на внешнем Text. В исходнике это видно по спискам атрибутов в TextNativeComponent.js: у нативного абзаца RCTText среди них есть numberOfLines и ellipsizeMode, а у RCTVirtualText нет ни того, ни другого. numberOfLines на вложенном тексте телефон просто не получит: у куска строки нет своих строк.
ellipsizeMode выбирает, где встанет многоточие: tail — в конце, по умолчанию; head — в начале; middle — посередине; clip — обрезать без многоточия, и в описании пропса сказано, что clip работает только на iOS. В предпросмотре выбирать бесполезно: react-native-web этот пропс не читает вовсе, и многоточие всегда в конце, хоть middle, хоть head. Не приходит там и onTextLayout — событие, в котором телефон отдаёт массив нарисованных строк lines. Узнать в предпросмотре, обрезался ли текст, этим способом не выйдет.
Название в ряду рядом с иконкой — отдельная ловушка. В предпросмотре Text в ряду сжимается и обрезается сам: у элемента ряда в CSS flex-shrink по умолчанию 1, и в нашей проверке текст сжался до 176 из 200. У Yoga, раскладки React Native, умолчание — 0, поэтому на телефоне без flexShrink: 1 текст не уступит место иконке и вылезет за край ряда. Это одно из четырёх расхождений следующей статьи.
- Каскада нет: селекторов нет, и стиль элемента — это только его собственный
styleпосле слияния. Наследования от контейнера тоже нет: цвет и шрифт, заданныеView, доTextне доходят, а в литерал стиля вида их не пустит компилятор. - Исключение одно — вложенный
Text. Он не вид, а кусок строки абзаца (NativeVirtualText), и его атрибуты накладываются на атрибуты абзаца: задал — берётся его, не задал — родительское. Корневой абзац начинает с чёрного и 14. - Своё разрешение по дереву — три десятка строк поверх
flatten:Textнакладывает свой стиль на полученный и передаёт вниз текстовые ключи,Viewрвёт цепочку, свой компонент прозрачен, строка вне текста остаётся без атрибутов. - Предпросмотр изображает это средствами CSS и наследует больше телефона:
letterSpacingиtextTransformсViewпротекают,Viewвнутри текста цепочку не рвёт,Text.defaultPropsсрабатывает. Текстовые ключи наView, пришедшие черезStyleSheet.create, компилятор не ловит. - Общий шрифт приложения — своя обёртка над
Text, которая даёт базу только внешнему тексту. Длинное название обрезаетnumberOfLinesна внешнемText;ellipsizeModeв предпросмотре не работает — многоточие всегда в конце.
1/4Карточка: <View style={{ fontSize: 20 }}><Text>Жим</Text></View>. Какого размера будет «Жим» на телефоне?
2/4Почему вложенный Text наследует стиль внешнего?
3/4<Text style={{ color: "blue" }}>Жим <View><Text>новое</Text></View></Text>. Какого цвета «новое» на телефоне?
4/4Где работает numberOfLines?
Шрифт приложения одним местом
В «Подходе» весь текст набран шестнадцатым размером и тёмно-серым цветом. Раз от контейнера ничего не доходит, базу даёт своя обёртка AppText, и пользуются ей вместо Text. Первая версия ниже работает, пока тексты не вложены. «60 кг» внутри серой строки вышло тёмным: обёртка поставила базовый цвет и вложенному тексту и перетёрла то, что тот унаследовал бы от строки. Поправьте AppText так, чтобы базу получал только внешний текст, а вложенный наследовал.
Обёртке нужно знать, есть ли над ней другой AppText. Ровно это делает сам Text с TextAncestorContext — повторим приём своим контекстом:
Вывод становится таким: у «60 кг» rgb(138, 133, 148) 16px 700 — серый пришёл от строки, размер 16 от её базы, жирность своя. Проверка «есть ли style» вместо контекста не помогла бы: вложенный текст передаёт стиль с одной жирностью, и база перетёрла бы цвет всё равно. Читать контекст можно и через useContext, как в части IV. Свой TextAncestorContext React Native отдаёт наружу только под именем unstable_TextAncestorContext — без обещаний между версиями, поэтому свой контекст надёжнее. В проекте AppText раскладывается по файлам так же, как чип из прошлой статьи: база — в app-text.styles.ts, пропсы — в app-text.types.ts.
Куски строки подхода
Резолвер из статьи лежит в подложке. Не запуская, скажите, сколько кусков он напечатает для строки подхода и с какими атрибутами, — особенно «× 10» и «отдых 90 с». Потом запустите и сверьтесь. И отдельно: что на экране предпросмотра выглядело бы иначе, чем на телефоне, если это дерево отрисовать?
Кусков шесть:
#{n} в JSX — три ребёнка: строка, число и пробел, отсюда три куска с одними атрибутами. SetNo прозрачен: его Text вложен в абзац и берёт у него размер и жирность, а цвет задаёт свой. «60 кг» лежит прямо в абзаце и получает его атрибуты с цветом по умолчанию.
В предпросмотре разошлись бы два куска. «× 10» унаследовало бы 16 и жирность 600: react-native-web не сбрасывает контекст у View, и span наследует по CSS. «Отдых 90 с» предпросмотр нарисовал бы, пожаловавшись в консоль «Unexpected text node: отдых 90 с. A text node cannot be a child of a <View>.», а на телефоне этой строки на экране не будет вовсе, и отладочная сборка покажет в LogBox «Text strings must be rendered within a <Text> component.». Лечится одинаково: строке нужен свой Text.
Резолвер теряет фрагменты
Фрагмент <>…</> внутри текста — обычное дело: так дописывают хвост по условию. Наш резолвер фрагментов не знает. React.Fragment — не функция, не Text и не View, поэтому всё, что внутри, пропадает. Допишите одну ветку. Какие атрибуты должны получить дети фрагмента?
Дети фрагмента получают те же атрибуты, что пришли к нему: фрагмент, как и свой компонент, не вид, и в нативном дереве от него не остаётся ничего — ни вида, ни куска строки. Вывод становится таким: «Жим лёжа» и «, » синие, «готово» синее и жирное. Главное здесь — передать дальше inherited, а не null: с null фрагмент порвал бы цепочку, как View, и «готово» потеряло бы синий цвет.