Данная статья будет, прежде всего, полезна разработчикам, которые не работают с готовыми наборами компонентов, такими как, material-ui, а реализуют свои. Например, для продукта разработан дизайн, отражающий то, как должны выглядеть кнопочки, модальные окна и т.п. Чтобы грамотно реализовать такую дизайн-систему, потребуется всем её атомам добавлять хорошую поддержку их композиции. Иными словами, нужно добиться того, чтобы любой отдельно взятый компонент мог интегрироваться и идеально вписываться в больший составной компонент. А если он не вписывается, то было бы неплохо иметь простую поддержку его кастомизации. Как бы то ни было, это отдельная большая тема, и, возможно, я вернусь к ней в другой раз.

Лирика


Всем привет. Начинаю свой путь на хабре с простой, но полезной статьи. Как для меня, получилось слишком подробно, но всё же я пытался подстроиться под читателя, а не под себя. Перед написанием следующей статьи (если она будет) на более сложную тему, имею желание скорректировать своё изложение по фидбэку (если он будет).

Используемые термины:

  • Визуальный компонент — компонент, который возвращает элемент, встраивающиеся в DOM. Например, return (<div />). Компонент, который возвращает только другой компонент, визуальным трактовать не следует.

Введение


Когда вы разрабатываете компонент, вы не можете сделать его полностью универсальным. В голове вы всегда отталкиваетесь от конкретных вариантов его использования. Часто получается, что после разработки, ваши коллеги начинают «пихать этот компонент куда попало». Вы на них сердитесь: «Ну я же разрабатывал его не для этого! Он не предназначен для этой задачи!». Конечно, доработки неизбежны, и даже нужны. Но это не должны быть доработки по типу прокинуть новый пропс, чтобы увеличить отступ с 4px до 8px, который будет использоваться в одном-двух случаях из пятидесяти. Компоненты должны иметь настраиваемую внешнюю геометрию.

TypeScript, выручай


Рассмотрим интерфейс, который по смыслу нужно располагать, например, в src/Library/Controls.ts. К полям приведены краткие комментарии, ниже мы разберём их подробнее.

export interface VisualComponentProps {
	// Вложенные компоненты. Определено для компонента-функции
	children?: React.ReactNode;
	// Как и в css
	className?: string;
	// Активный флаг означает, что компонент не будет отрисован.
	doNotRender?: boolean;
	// Это будет отрисовано при doNotRender true
	fallback?: JSX.Element;
	// Как и в css
	style?: React.CSSProperties;
}

Это интерфейс пропсов компонентов. Каких? Всех визуальных компонентов. Они должны применяться на его корневой элемент.

Пропсы каждого разрабатываемого визуального компонента следует расширять, используя этот интерфейс.

Сразу прошу обратить внимание на то, что все эти пропсы опциональны. Рассмотрим их.

  • children есть в компонентах-классах React.Component, компонентах-функциях React.FC, но их нет в обычных функциях без задания типизации React.FC. Поэтому задаём его.
  • className/style используем аналогичные названия, как в обычном JSX'ном <div />. Не плодим семантику. Этот принцип тождественности названия используется, например, в пропсе для задания ссылки ref.
  • doNotRender используется как альтернатива наболевшему костыльному в JSX рендеру по условию. Применяя это решение, нам не нужно городить фигурные скобки в render методах, ухудшая читаемость кода. Сравните 2 фрагмента кода:

    Virgin conditional rendering:

    App.tsx

    renderComponent() {
    	const {props, state} = this;
    	const needRender = state.something;
    
    	return (
    		<PageLayout>
    			<UIButton
    				children={'This is a button'}
    			/>
    
    			{needRender && 
    				<UIButton
    					children={'This is another button'}
    				/>
    			}
    		</PageLayout>
    	);
    }
    

    Chad пропс doNotRender:

    App.tsx

    renderComponent() {
    	const {props, state} = this;
    	const needRender = state.something;
    
    	return (
    		<PageLayout>
    			<UIButton
    				children={'This is a button'}
    			/>
    			<UIButton
    				children={'This is another button'}
    				doNotRender={!needRender}
    			/>
    		</PageLayout>
    	);
    }
    

    В первом варианте мы увеличиваем уровень вложенности нижней кнопки, хотя по смыслу её вложенность на том же уровне, что и верхняя. Это смотрится плохо в моём редакторе, где я использую tab шириной в 2 пробела, а здесь и того хуже.

    Во втором варианте имеем равную вложенность, за минусом, что doNotRender может не броситься в глаза и разработчик не поймёт что происходит. Но, если в вашем проекте каждый визуальный компонент сделан по этому принципу, то эта проблема сразу уходит.
  • fallback нужен, если мы не хотим рендерить null при doNotRender true, а какой-то кастомный элемент. Используется по аналогии React Suspense, так как имеет схожий смысл (не плодим семантику)

Хочу показать, как это правильно использовать. Сделаем простенькую кнопочку.

Примечание: в коде ниже я также использую css-modules, sass и classnames.

UIButton.tsx

import * as React from 'react';
import { VisualComponentProps } from 'Library/Controls';
import * as css from './Button.sass';
import cn from 'classnames';

// Расширяем (наследуем) пропсы
export interface ButtonBasicProps {
	disabled?: boolean;
}
export interface ButtonProps extends ButtonBasicProps, VisualComponentProps {}

export function UIButton(props: ButtonProps) {
	// Не возвращаем undefined, иначе реакт будет материться
	// "Nothing was returned from render."
	if (props.doNotRender) return props.fallback || null;

	// Объединяем все классы в строку
	const rootClassNames = cn(
		// Класс, описанный в sass
		css.Button,
		// Классы, которые можно прокинуть через props
		props.className,
		// Обычные классы компонента по условию
		props.disabled && css._disabled
	);

	return (
		<div
			children={props.children}
			className={rootClassNames}
			style={props.style}
		/>
	)
}

App.tsx

renderComponent() {
	const {props, state} = this;
	const needRenderSecond = true;

	return (
		<PageLayout>
			<UIButton
				children={'This is a button'}
				style={{marginRight: needRenderSecond ? 5 : null}}
			/>
			<UIButton
				disabled
				children={'This is another button'}
				doNotRender={!needRenderSecond}
			/>
		</PageLayout>
	);
}

Результат:



Рефлексия и заключение


Такими компонентами удобно оперировать, как div'ами, создавая различные обертки, композиции, специализации, выходя за рамки заложенного в них изначального функционала.

Можно возразить, что раз в дизайн-системе нет условных желтых кнопок, а разработчику нужно их сделать, значит проблема не в компонентах, а в том, что эту надобность создаёт. Тем не менее, реалии таковы, что такие ситуации возникают, и довольно часто. "… А жить-то надо! Надо жить." К тому же, принцип каскада css не всегда можно реализовать на практике, и у вас могут возникать случаи, когда ваши стили просто-напросто перекрываются более высокой специфичностью другого селектора (или описаны выше). Тут как раз выручает style.

Напоследок, добавлю пару (буквально) моментов.

  1. Учитывайте, что doNotRender не полностью повторяет поведение conditional rendering. У вас также будут выполняться методы жизненного цикла, просто render будет возвращать fallback или null. В некоторых сложных компонентах вы захотите избежать исполнения методов жизненного цикла. Для этого, вам просто нужно сделать built-in специализацию вашего компонента.

    На примере UIButton: переименуйте UIButton в UIButtonInner и добавьте под ним следующий код:

    UIButton.tsx

    export function UIButton(props: ButtonProps) {
    	if (props.doNotRender) return props.fallback || null;
    	return <UIButtonInner {...props} />;
    }
    

    P.S. Не совершайте ошибку рекурсивного вызова UIButton в данной функции!
  2. В редких случаях, когда могут независимо меняться стили на обертке и на оборачиваемом компоненте, вам может пригодиться следующий интерфейс

    Library/Controls.ts

    export interface VisualComponentWrapperProps extends VisualComponentProps {
    	wrappedVisual?: VisualComponentProps;
    }
    

    И его использование
    UIButton.tsx

    interface ButtonSomeWrapperProps extends ButtonBasicProps, VisualComponentWrapperProps {
    	myCustomProp?: number;
    }
    
    export function UIButtonSomeWrapper(props: ButtonSomeWrapperProps) {
    	if (props.doNotRender) return props.fallback || null;
    
    	const {
    		// Пропсы VisualComponentProps обертки
    		style, className, children, fallback, doNotRender,
    		// VisualComponentProps оборачиваемого компонента
    		wrappedVisual,
    		// Собственные пропсы обертки
    		myCustomProp,
    		// Собственные пропсы оборачиваемого компонента
    		...uiButtonProps
    	} = props;
    
    	return (
    		<div
    			style={style}
    			className={className}
    		>
    			{myCustomProp}
    			<UIButton
    				{...wrappedVisual}
    				{...uiButtonProps}
    			/>
    			{children}
    		</div>
    	);
    }
    

Разработка приложения с использованием этого подхода значительно повысит реюзабельность ваших компонентов, снизит количество лишних костыльных стилей (речь о стилях, описанных в файлах стиля компонента исключительно для нужды других компонентов) и пропсов, добавит коду структурированности. На этом всё. В следующей статье начнем решать проблемы реюзабельности компонентов больше с точки зрения кода, нежели css. Либо напишу о чём-нибудь более интересном. Спасибо за внимание.

Комментарии (28)


  1. JustDont
    11.12.2019 15:42
    +1

    Каждый раз не перестаю поражаться, как же изобретательно люди могут решать проблемы, которые сами себе и создали (это я про doNotRender, но впрочем передача классов и стилей через реактовские пропсы под это тоже прекрасно подходит).


    1. ihaveataletotell Автор
      11.12.2019 20:04

      Не понял, какие проблемы вы имеете в виду. Есть задача: скрывать элемент формы, если другой элемент имеет значение. Не вижу тут проблемы, которую «я создал сам себе».
      В теории можно не прибегать к использованию conditional rendering внутри блоков JSX. Решение: для каждого такого случая делать обертку компонента. Но на практике это превращается в бесполезную трату времени для разработчика.


      1. JustDont
        11.12.2019 20:32

        Не понял, какие проблемы вы имеете в виду.

        Проблема: JSX выразительно убог.
        Решение: сделаем компоненты, которые на самом деле могут себя не рендерить вообще, во имя победы над убогостью JSX.

        Вы серьезно не видите, что это классическое «поищем ключи под фонарём, не потому, что их под фонарем потеряли, а потому что светло»? Впрочем, в нынешние времена полвёба так задачи решает… В css есть известные проблемы модульности? А давайте стили в яваскрипте писать! Протокол HTTP/1.1 имеет определенные проблемы с масштабируемостью? Давайте напишем миллионы бандлеров, RPC, проксирующих нод, наконец — всё, кроме решения проблем собственно HTTP/1.1.

        PS: А потом, когда HTTP/1.1 уже медленно, но верно отправляется на свалку истории, и я предлагаю людям уже наконец не страдать бессмысленной переупаковкой ресурсов, на меня смотрят круглыми глазами и вопрошают «но как?! браузер же будет долго-предолго грузить много ресурсов!!».


        1. ihaveataletotell Автор
          11.12.2019 21:00

          Какие еще есть api для написания компонентов, кроме React.createElement и синтаксиса JSX?


          1. JustDont
            11.12.2019 21:09

            Осподи. Тысячи их. Модуляризируется всё, начиная от ангуляра (впрочем я бы конечно не советовал тащить ангуляр, чтоб слепить с ним один компонент, но это безусловно возможно) и vue, и заканчивая вебкомпонентами и чем-нибудь типа svelte.
            Вы как будто в 2010 году ушли в анабиоз и проснулись вчера.

            Вот например на этой странице хоть и далеко не всё имеет отношение к компонентной разработке, но я бы сказал, что как минимум процентов 70% страшных названий из тех таблиц — или позволяют напрямую писать компоненты, или предлагают какой-то почти пригодный для этого API. А вы спрашиваете «а что есть-то?».


          1. Fragster
            12.12.2019 12:26

            Vue? Ему хоть свои шаблоны, хоть jsx, хоть рендер-функции…


        1. vintage
          12.12.2019 06:33

          А давайте стили в яваскрипте писать!

          В яваскрипте их писать, конечно, глупо, а вот в тайпскрипте — имеет свои преимущества. Такие как подсказки и тайпчекинг с учётом иерархии компонент.


          1. JustDont
            12.12.2019 09:28

            а вот в тайпскрипте — имеет свои преимущества

            Имеет. Но всё так же избавляется от большинства преимуществ собственно css.


            1. vintage
              12.12.2019 10:55

              Каких, например?


              1. JustDont
                12.12.2019 11:51

                Гораздо легче перечислить то, что останется: останется работа со стилями а-ля инлайновая подстановка. Практически всё, связанное с каскадностью цсс — будет потеряно без очень вольного допущения, что всё, что желает перекрыть ваши стили адекватным образом — будет обязано использовать то же CSS-in-JS решение, что и использовано у вас в коде. Впрочем, про перекрытие, скажем, в конкретном юзерском браузере через user stylesheet всё равно можно будет забыть.


                1. vintage
                  12.12.2019 12:45

                  Я просто оставлю это здесь: https://github.com/eigenmethod/mol/tree/master/style


                  1. JustDont
                    12.12.2019 12:53

                    Я не вижу там ничего, что не было бы уже решено через инструменты статического анализа css.


                    1. vintage
                      12.12.2019 13:15

                      Внимательнее присмотритесь. Там все имена чекаются и подсказываются тайпскриптом. Анализом css такого не добиться. К Реакту такое не прикрутить.


        1. vintage
          12.12.2019 06:41

          Давайте напишем миллионы бандлеров

          Загрузили один скрипт, там импорт, загрузили следующий, там тоже импорт, и так 20 раз. Это очень медленно. Любое решение этой проблемы предполагает сборку бандла в каком-либо виде.


          1. JustDont
            12.12.2019 09:27

            Загрузили один скрипт

            Вы ошибаетесь уже в первом пункте.
            Скрипт не обязательно «загрузить», чтоб обнаружить в нем импорт. Импорты сверху. Достаточно лишь начать его грузить.


            1. vintage
              12.12.2019 11:00

              Не важно, для глубины в 20 уровней это будет пинг*20.


              1. JustDont
                12.12.2019 11:46

                Во-первых, пинг и latency — это не одно и то же. Во-вторых — конечно будет. И? Это далеко не всегда имеет какое-то значение даже если не вспоминать про локальный кеш. Или про то, что глубина имеет практический предел роста.


                1. vintage
                  12.12.2019 12:40

                  В данном случае именно пинг — надо дождаться ответа от сервера, чтобы понять что грузить. Вот у меня сейчас на 4G пинг 80мс, что для глубины 20 даёт полторы секунды задержки. Тупо из-за отсутствия бандлинга.


                  1. JustDont
                    12.12.2019 12:54

                    Вот у меня сейчас на 4G пинг 80мс, что для глубины 20 даёт полторы секунды задержки. Тупо из-за отсутствия бандлинга.

                    А у меня бывает вайфай с 10% потерей пакетов, из-за чего загрузка бандлов больше мегабайта имеет задержку в «бесконечность». Что дальше?


                    1. vintage
                      12.12.2019 13:00

                      80мс — это очень хороший пинг для мобильного интернета, так что 1.5.с — это очень оптимистичная оценка.


                      1. JustDont
                        12.12.2019 13:03

                        Невозможность на практике загрузить любой блоб больше мегабайта — это тоже оптимистичная оценка, я видел ситуации и похуже.

                        Еще раз, что и кому вы пытаетесь доказать? Что отсутствие бандлинга имеет свою цену? Бандлинг тоже имеет свою цену. Вообще всё имеет свою цену.


                        1. vintage
                          12.12.2019 13:21

                          Что никакой HTTP/2/3/4 принципиально не может решить проблемы, решаемые бандлингом.


                          1. JustDont
                            12.12.2019 14:17

                            Это высказывание а-ля «теплое не решает проблем, решаемых круглым».
                            Транспортный слой и способ упаковки ресурсов — это две разные вещи, и, чёрт возьми, невероятно логично, что проблемы, решаемые первым и вторым — они разные.

                            Транспортный слой не может вас заставить привести ваши ресурсы в оптимальный для скорости передачи вид; и поэтому вам конечно же никто не запрещает фантазировать про глубину в 20 модулей. Но бандлинг — это не более чем один из частных способов приведения ресурсов к таковому виду. Есть и другие: например, любую структуру модулей добавлением импортов можно всегда привести к глубине 2. А размещение этого основного блока импортов инлайново в html — даст глубину 1.


                            1. vintage
                              12.12.2019 15:36

                              И мы получаем бандл со списком всех модулей. И создавать его будет бандлер.


                              1. JustDont
                                12.12.2019 17:03

                                И создавать его будет бандлер.

                                Нет, совершенно необязательно. Можно просто иметь сорсы с глубиной не более 2.


                                1. vintage
                                  12.12.2019 19:03

                                  Совершенно не обязательно пытаться победить в споре любой ценой.


                                  1. JustDont
                                    12.12.2019 19:06

                                    Не обязательно, но вас это никак не останавливает.


  1. vintage
    11.12.2019 20:05

    Когда вы разрабатываете компонент, вы не можете сделать его полностью универсальным.

    Я целый фреймворк построил на идее полностью универсальных компонент, которые можно собирать в любых комбинациях...