Каскада нет, наследования нет — кроме одного случая

стиль≈ 15 минут

Здесь будет встроен видео-разбор темы (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 недоступен. Прогноз: какими будут цвет и размер у каждого из четырёх узлов?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Сама карточка цвет и размер получила, но рисовать ими ей нечего: символов у неё нет. Название вышло чёрным и четырнадцатого размера, как будто стиля контейнера не было вовсе. Строка с подходами серая — это её собственный стиль. А «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 это видно в двух местах:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Корневой 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?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Кусков четыре. «Жим лёжа» — корневой абзац: умолчания плюс свой размер 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»: разрядку и капс получил контейнер. Прогноз: что скажет резолвер про «грудь» и «новое» — и что покажет предпросмотр?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Модель и предпросмотр разошлись дважды. «Грудь» на экране примера набрана капсом и вразрядку: 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.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
Название — одна строка с многоточием. Заметка целиком — три строки, в ленте — две, и многоточие стоит в конце второй. 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 так, чтобы базу получал только внешний текст, а вложенный наследовал.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Обёртке нужно знать, есть ли над ней другой AppText. Ровно это делает сам Text с TextAncestorContext — повторим приём своим контекстом:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится таким: у «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 с». Потом запустите и сверьтесь. И отдельно: что на экране предпросмотра выглядело бы иначе, чем на телефоне, если это дерево отрисовать?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Кусков шесть:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

#{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, поэтому всё, что внутри, пропадает. Допишите одну ветку. Какие атрибуты должны получить дети фрагмента?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Дети фрагмента получают те же атрибуты, что пришли к нему: фрагмент, как и свой компонент, не вид, и в нативном дереве от него не остаётся ничего — ни вида, ни куска строки. Вывод становится таким: «Жим лёжа» и «, » синие, «готово» синее и жирное. Главное здесь — передать дальше inherited, а не null: с null фрагмент порвал бы цепочку, как View, и «готово» потеряло бы синий цвет.