Первоначально опубликовано на https://www.gabe.pizza/notes-on-component-libraries/
Я работаю в команде инфраструктуры пользовательского интерфейса в DigitalOcean с ноября 2021 года. Одной из моих основных обязанностей является выполнение функций основного сопровождающего нашей внутренней библиотеки компонентов React, Walrus. Эта библиотека предшествовала моему участию на несколько лет, и было интересно увидеть, как сработали предыдущие варианты дизайна.
Этот документ представляет собой собрание моих мыслей о поддержке библиотеки компонентов как части существующей системы проектирования, используемой большим количеством интерфейсных приложений. Меня не особенно волнует визуальный дизайн — хотя я кажусь эффективным в его реализации — и меня больше интересуют программная инженерия и социальные проблемы, связанные с созданием крупномасштабной библиотеки компонентов.
Я, вероятно, обновлю эту страницу, поскольку у меня есть дополнительные сведения.
Философия
Поддерживая такую библиотеку, я пытаюсь понять тонкие стимулы, предлагаемые через интерфейс. Если разработчик может потянуть за рычаг, он потянется в конце концов. Иногда этот разработчик является самым старшим человеком в команде. Иногда это тот, кто только что вышел из учебного лагеря. Есть сотни тысяч строк кода, задача, которую нужно выполнить, и слишком много контекста, необходимого для того, чтобы знать Правильную Вещь. Эта проблема возникает из-за большой командной динамики и присутствует в каждой организации. Если рычаг существует, он дергается.
Но ответственность целиком и полностью не ложится на разработчика, увидевшего рычаг. Бремя также должно ложиться на разработчика, который его предложил. Хороший дизайн приводит к тому, что потребители библиотеки падают в яму успеха. Планирование такого исхода требует терпеливого рассмотрения, в чем я не тороплюсь. Вообще говоря, все в этом документе сводится к тому, что я максимизирую следующее:
- Должно быть легко взять дизайн и преобразовать его в код пользовательского интерфейса. Реквизиты должны интуитивно сопоставляться с документацией системы дизайна в Figma или иным образом. Компоненты должны выглядеть корректно без применения переопределений.
- Компонент должен по большей части действовать как непрозрачная коробка для родителя, потребляющего его. Он не должен раскрывать подробности о своих внутренних компонентах или позволять внедрять произвольный код/стили. Данные входят; выходит разметка.
- Очевидная вещь, простая вещь и правильная вещь должны пересекаться большую часть времени. Разработчик в условиях нехватки времени обычно стремится найти самое простое решение. В идеале самое простое решение — очевидное. И очевидным должно быть что я хотел, чтобы разработчик сделал в первую очередь.
- Делать неправильные вещи должно быть, по крайней мере, неудобно, а в худшем – невозможно. Оставляйте запасные люки, когда это необходимо, но заставляйте их чувствовать себя плохо. Разработчик должен подумать: «Я должен открыть задачу, чтобы мне не пришлось делать это снова».
Тем не менее, ни одно из правил в этом документе не является жестким и быстрым. У них есть свои компромиссы, которые обычно сводятся к тому, что я предпочитаю согласованность системы дизайна стилистической гибкости. Имейте это в виду, когда будете читать дальше.
Наконец, я не думаю, что принятые здесь компромиссы обязательно должны применяться (но, может быть, так и есть?) к библиотеке компонентов общего назначения с открытым исходным кодом, потому что мотивы разные. Эти библиотеки должны быть достаточно гибкими, чтобы компания А могла их использовать и не выглядела как компания Б. В моем случае Walrus просто нужно выглядеть как моя компания, и я не хочу, чтобы библиотека компонентов была в состоянии избежать выглядеть как моя компания.
Когда всем принадлежит, никто не владеет.
По моему исключительно твердому мнению, кто-то должен владеть библиотекой компонентов. Без владельца в библиотеке компонентов будут накапливаться разовые изменения типа «Мне просто нужно вот это», которые при группировании не отражают целостного представления о дизайн-системе. По крайней мере, одна должностная инструкция разработчика должна включать обслуживание библиотеки компонентов.
Например, предположим, что инженер по продукту получает какой-то новый дизайн, который он должен реализовать. Он может содержать вариант компонента, который может быть в дизайн-системе, но еще не реализован в библиотеке компонентов. Эта незавершенность является большой проблемой, потому что инженер по продукту должен что-то делать с библиотекой компонентов, за поддержку которой он не отвечает. Без выделенного владельца реализованное решение обычно является простой вещью (что обычно является неправильной вещью):
- Обновляйте компонент ровно настолько, чтобы получить желаемый результат, и не более того. Замедляет разработчиков в будущем, поскольку им приходится часто проверять внутренности компонента, чтобы понять различные одноразовые реквизиты, присутствующие в интерфейсе.
- Не обновляйте компонент и не оборачивайте его
styled-componentsили иначе. Создает фрагментацию, поскольку эти изменения редко возвращаются в библиотеку компонентов. - Идите напролом и реализуйте что-то еще с нуля. Часто не рассматривает решенные пограничные случаи, которые уже могут существовать в библиотеке.
Такие решения склонны к смешиванию. Изменения в компоненте теперь требуют особого внимания, потому что существующие переопределения могут стать местом нарушения визуальных изменений, поэтому его проще не трогать. И так продолжается. По мере применения большего количества переопределений стилистические изменения становятся все более и более рискованными для безопасного применения ⚰️. Если вы в настоящее время работаете в компании с библиотекой компонентов, которой никто не владеет, я уверен, что вы чувствуете эту боль.
TL;DR кто-то должен потерять работу, если библиотека компонентов отстой; в противном случае, это, вероятно, отстой.
Интерфейс компонента, лаконично представляющий варианты дизайн-системы, проще в использовании.
Просматривая проектный документ, я пытаюсь понять, могу ли я «визуализировать» все варианты, как если бы они были осями в N-мерном пространстве, где каждое измерение соотносится с одним свойством.

Важно понимать, какие визуальные различия действуют независимо, а какие нет. Например, реквизиты type и disabled Button независимы (ортогональны?) друг от друга. Дизайнер (будем надеяться) никогда не скажет, что «второстепенная кнопка не может быть отключена».
type Props = { type?: 'primary' | 'secondary' | 'tertiary'; disabled?: boolean; icon?: Icon;/* ... */ }export Button: React.FC<Props> = (props) => { /* ... */ };
Напротив, различия, которые зависят друг от друга, должны сливаться в единую опору — и эта единственная опора должна действовать как отдельное измерение. Например, TextInput, который необязательно имеет метку, где метка также может иметь всплывающую подсказку.

Для интерфейса не имеет смысла иметь два реквизита, label и labelTooltip, потому что всплывающая подсказка не будет отображаться без метки. Они должны быть объединены в один реквизит, чтобы выполнить это требование:
// ❌ Does not indicate that `labelTooltip` depends on `label`! type Props = { label?: string; labelTooltip?: string;/* ... */ }// ✅ `tooltip` cannot exist without `text`! type Props = { label?: string | { text: string; tooltip?: string }; }export TextInput: React.FC<Props> = (props) => { const label = props.label ? normalizeLabel(props.label) : null;/* ... */ };
Эта типизация напоминает один из моих самых любимых программных измов: «сделать недопустимые состояния непредставимыми». Если предположить, что дизайн-система представляет все возможные разрешенные визуальные состояния, то реквизит не должен допускать недопустимых визуальных состояний.
Кто-то может возразить: «Ну, если метки нет, то компонент не будет показывать всплывающую подсказку, и презентация останется в силе». Кроме того, кто-то может также добавить проверку во время выполнения, которая будет обеспечивать соблюдение инварианта.
Но зачем ждать до запуска? Зачем ждать, пока другой разработчик запутается? Эта лень перекладывает ответственность за правильность на разработчика (и каждого разработчика после). Согласно проверке типов, <TextInput labelTooltip="!"/> полностью действителен. В этом коде подразумевается правило, согласно которому всплывающая подсказка не может существовать без метки, четко обозначенной типом { text: string; tooltip?: string }.
В самом крайнем случае компоненту может потребоваться интерфейс prop, который включает одну клавишу, мало чем отличаясь от редуктора Redux, включающего action.type. В таком случае имеет смысл вместо этого создать несколько разных компонентов (возможно, с общим внутренним базовым компонентом).
Компоненты, вероятно, не должны позиционировать себя.
Рассмотрим изображение ниже. Компонент C отображает компоненты A и B. Между A и B есть пробел. Вопрос: кто декларирует этот интервал?

Рассмотрим результат, если он принадлежит A как margin-right. То есть интервал от внутреннего до A. Проблема с этим заключается в том, что представление по умолчанию для A включает в себя margin-right.
Учитывая это, что должно произойти на изображении ниже? У нас есть E, который отображает A рядом с D, но без интервала.

Если A имеет внутренний margin-right, нам придется заменить его на 0. Он фактически отменяет правило стиля, возвращая значение браузера по умолчанию. Такой шаг похож на запах кода.
const StyledA = style(A)`
margin-right: 0;
`;
Общий способ избежать этой проблемы состоит в том, чтобы сказать, что компонент не должен применять поля (т. е. интервалы) за пределами самого себя. Таким образом, правильный ответ на вопрос заключается в том, что C всегда объявляет интервал. Мне еще предстоит найти достойный контрпример этому утверждению.
Компоненты обычно должны занимать все предоставленное горизонтальное пространство.
В большинстве случаев компонент должен занимать всю ширину, отданную ему родителем. То есть состояние большинства компонентов по умолчанию — занимать всю ширину того, внутри чего они находятся. Когда компонент не занимает всю ширину страницы, обычно это происходит потому, что он отображается в контейнере (flex/grid/spacer/и т. д.), где контейнер выполняет ограничения.

Применение этого правила упрощает реализацию адаптивных страниц, поскольку почти все CSS медиазапросов могут существовать (там, где должны) в компонентах-контейнерах (flex/grid/spacer/и т. д.).
Компоненты, вероятно, не должны выставлять свойства className или style.
className и style нарушают стилистическую инкапсуляцию компонента. Эти атрибуты позволяют кому-то применять произвольные стилистические переопределения по прихоти. Этот прием, вероятно, не то, что вы хотите сделать, когда спецификация дизайн-системы уже присутствует в реализации.
В идеальном сценарии родительский компонент должен видеть дочерний компонент как непрозрачную коробку с очень специфическими рычагами, за которые нужно тянуть (потому что все рычаги будут задействованы). У кого-то не должно быть возможности (или необходимости) «залезть внутрь» компонента, чтобы коренным образом и произвольно изменить представление.
Если мы должны предложить аварийный люк для переопределения пользовательского стиля, лучше указать их как UNSAFE_className и UNSAFE_style.
Не совсем разумно настаивать на том, что «стиль никогда не переопределяется!» Если должен быть аварийный люк, это должно быть ужасно и легко найти. Решение, которое я украл у друга, состоит в том, чтобы добавить к обоим реквизитам префикс UNSAFE_.
// ❌ Nothing to see here. const btn1 = <Button className="a b c" />;// ✅ Feels terrible. Looks gross. Easy to grep. const btn2 = <Button UNSAFE_className="a b c" />;
className — это хук для таких библиотек, как styled-components, для внедрения произвольного стиля. Замена className на UNSAFE_className устраняет «соблазн» завернуть что-то в styled-components. Я вижу в этом большую победу.
Это также открывает двери для правил линтера или другого инструмента для предотвращения чрезмерного использования переопределений. Эту проверку было бы невозможно сделать с className.
Как правило, старайтесь избегать расширения реквизита базового элемента.
Расширение базового типа, такого как React.HTMLAttributes<HTMLButtonElement>, расширит интерфейс компонента на несколько сотен ключей. Из того, что я обнаружил, если мы делаем это, мы, вероятно, пытаемся перенаправить их все на какой-то базовый элемент (button) внутри вашего компонента (Button). То есть:
interface Props extends React.HTMLAttributes<HTMLButtonElement> { type?: "primary" | "secondary" | "tertiary"; disabled?: boolean; icon?: Icon; /* */ }const Button: React.FC<Props> = (props) => { const { type, disabled, icon, ...rest } = props;/* ... */return <button {...rest} />; };
При построении интерфейса компонента я хочу четко указать варианты, которые он допускает. Я не хочу включать расширение от базы по той же причине, по которой мне не нужны пропсы className или style. Дверь открывается для произвольного изменения.
Избегание распространения JSX на внешние данные иногда предотвращает появление странных ошибок.
То есть я избегаю любого оператора распространения при работе с внешними данными. Да, я не хочу иметь возможность пересылать пропсы из одного компонента в другой. (Честно говоря, я считаю, что это отличное общее правило для работы с реквизитами.) Использование спреда на внешних данных имеет несколько недостатков:
- Может быть неясно, откуда берется конкретная опора. Grepping на самом деле не работает.
type AProps = { thing?: string; other?: number; disabled?: boolean; /* ... */ };const A: React.FC<AProps> = (props) => { const [disabled, setDisabled] = React.useState(false);/* ... */return <B {...props} disabled={props.disabled || disabled} />; };type BProps = { thing?: string; other?: number; disabled?: boolean; /* ... */ };const B: React.FC<CProps> = (props) => { const disabled = React.useContext(DisabledContext);/** * Whether `C` is `disabled` depends on whether * `disabled` was passed into `A`. */ return <C disabled={disabled} {...props} />; };
- Это позволяет пересылать непреднамеренные реквизиты. TypeScript этого не обнаружит.
// button.tsxtype Props = { children: React.ReactNode; onClick(): void; };export const Button = (props: Props) => { return <button {...props} />; };// account.tsximport { Button } from "./button";const Account = () => { // ...const buttonProps = { onClick() { /* ... */ }, style: { /* Oops... */ }, };return <Button {...props}>Save</Button>; };
Я рекомендую, когда это возможно, деструктурировать объект реквизита и пересылать ключи по мере необходимости. Разбивка реквизита удаляет лишние ключи, позволяет устанавливать значения по умолчанию и упрощает поиск кода.
Ограничение «сквозных» реквизитов для дочерних компонентов, вероятно, лучше масштабируется.
Представьте себе компонент Modal с двумя кнопками. Может быть заманчиво сохранить модальное универсальное и позволить кнопкам быть настраиваемыми с их полными реквизитами:
type Props = { // ...primaryButtonProps?: React.ComponentProps<Button>; secondaryButtonProps?: React.ComponentProps<Button>; };const Modal: React.FC<Props> = (props) => { /* ... */ };
Я думаю, что это нормально для некоторого внутреннего компонента Modal базового уровня, который приложения обычно не используют. Тем не менее, это может быть несогласованным, если только один и тот же primaryButtonProps большой двоичный объект не находится на каждом сайте вызова. Кроме того, этот явный вызов всех реквизитов Button передает информацию о кнопке родительскому компоненту — я думаю конкретно о том, станет ли Button отключенным.
Вместо этого Modal должны иметь вариации, описывающие различные визуальные состояния. Реквизиты Button (теперь называемые реквизитами «Действие»), как правило, должны быть ограничены небольшим количеством вещей, которые могут разумно различаться между экземплярами.
type Props = { type: "alert" | "info" | "confirm"; disabled?: boolean; primaryActionProps: { onClick(): void; children: string; icon?: Icon; /* ... */ }; secondaryActionProps?: { onClick(): void; children: string; icon?: Icon; /* ... */ }; };const Modal: React.FC<Props> = (props) => { /* ... */ };
На мой взгляд, это улучшение, которое перемещает управление в Modal. В будущем мы можем решить, что второстепенным действием в модальном окне «информация» будет компонент, похожий на ссылку, а не кнопка. В первом случае это теперь существенное изменение. Однако дело в том, что с этим новым интерфейсом такие детали отодвигаются во внутренности Modal.
В большинстве случаев рекомендуется использовать контекст React для компонентов, которые зависят друг от друга.
Насколько я понимаю, изначально предполагаемый вариант использования контекста — связывать данные зависимых компонентов без потоковой передачи реквизитов повсюду.
Например, давайте создадим собственные компоненты SelectMenu и SelectOption. Без использования контекста нам придется передавать один и тот же обработчик onSelect и логическое значение selected для каждой опции:
import { SelectMenu, SelectOption } from "some-walrus-lib";const Thing = () => { const [selected, setSelected] = React.useState<string>(null);return ( <SelectMenu> <SelectOption value="a" onSelect={setSelected} selected={selected === "a"} > Option A </SelectOption> <SelectOption value="b" onSelect={setSelected} selected={selected === "b"} > Option B </SelectOption> <SelectOption value="c" onSelect={setSelected} selected={selected === "c"} > Option C </SelectOption> <SelectOption value="d" onSelect={setSelected} selected={selected === "d"} > Option D </SelectOption> </SelectMenu> ); };
С помощью контекста мы можем сообщить SelectMenu о текущем выбранном значении вместо того, чтобы указывать каждому SelectOption, выбраны ли они в данный момент:
import { SelectMenu, SelectOption } from "some-walrus-lib";const Thing = () => { const [selected, setSelected] = React.useState<string>(null);return ( <SelectMenu onSelect={setSelected} selected={selected}> <SelectOption value="a">Option A</SelectOption> <SelectOption value="b">Option B</SelectOption> <SelectOption value="c">Option C</SelectOption> <SelectOption value="d">Option D</SelectOption> </SelectMenu> ); };
Еще одно: контекст должен храниться внутри библиотеки. Разрешение импортировать и обрабатывать открытый контекст приложениями создает хрупкую связь, которая легко сломается при будущих обновлениях.
Группировка логических компонентов в единый объект — это удобство, практически не требующее дополнительных затрат.
Хорошо, если компоненты SelectMenu и SelectOption экспортируются вместе, поскольку их контекст требует, чтобы они отображались вместе. Эта всегда-совместность представляет собой сгусток данных, поэтому сгруппируйте их в один объект.
export const Select = {
Menu: SelectMenu,
Option: SelectOption,
// ...
};
И тогда мы получаем:
import { Select } from "some-walrus-lib";const instance = ( <Select.Menu onClick={handleClick}> <Select.Option value="a">Option A</Select.Option> <Select.Option value="b">Option B</Select.Option> <Select.Option value="c">Option C</Select.Option> <Select.Option value="d">Option D</Select.Option> </Select.Menu> );
Эта группировка является почти нулевым удобством для других. Он говорит им: «Пожалуйста, используйте их вместе».
Это хорошая идея, чтобы не создавать собственные безголовые абстракции для браузерных API.
API-интерфейсы браузера JavaScript обычно достаточно детализированы, поэтому вам не следует пытаться изобретать велосипед. Положитесь на безголовые абстракции, которые эффективны и устраняют крайние случаи.
У меня были проблемы, прежде чем я подумал, что «все, что мне нужно, это 20 строк кода», прежде чем получил отчет об ошибке, в котором говорилось, что это не работает в Safari. Не будь как я.
Отправка только устаревших версий с ошибками основных версий намного менее напряженная.
Walrus публикуется во внутреннем реестре пакетов, чтобы его можно было использовать во многих наших внешних приложениях. Доставка критических изменений может затруднить обновление Walrus и затруднить работу продуктовых команд. Если приложение отстает на несколько версий, но в билете требуется самая последняя версия, критическое изменение в рамках обновления может стать огромным блокировщиком, в зависимости от перерыва.
Вместо этого мы следуем модели управления версиями в стиле Ember, где при обновлении основных версий устаревают API/реквизиты/помощники/и т. д. Они также включают один или несколько модов кода для исправления 95%+ устаревших версий.
Таким образом, команды могут немедленно обновить Walrus и просто страдать через громкую консоль, пока они не будут готовы выполнять работу.
Кодмоды сделали меня быстрее (после того, как я стал хорош в кодмодах).
В той конкретной ситуации, в которой я нахожусь, написание codemods позволило нам быстро вносить существенные масштабные изменения во все кодовые базы.
Версии Walrus, содержащие устаревание, обычно имеют мод для исправления этого устаревания. Запуск codemod часто означает, что команды даже не видят сообщения об устаревании до тех пор, пока проблемный код не будет удален из их приложения.
Идемпотентные кодмоды вызывают гораздо меньше стресса.
Я слежу за тем, чтобы кодмоды были идемпотентными. Кодмод, запущенный разработчиком несколько раз в одном и том же модуле, будет иметь тот же результат, как если бы разработчик запустил его один раз. Это дополнительное требование для того, чтобы кодмод не приводил к ошибкам, если он запускается несколько раз.
Много раз? Зачем их запускать несколько раз? Рассмотрим следующий сценарий:
- Кодмод запускается, и результат объединяется с основной веткой.
- Другой запрос на вытягивание, предшествующий codemod, вводит новый, теперь недействительный модуль в кодовую базу.
- Запрос на извлечение объединен.
- Слияние вводит регрессию в основную ветвь.
- Мы перезапускаем codemod, чтобы исправить проблемы.
Существует потенциал для введения регрессии, если codemod не является идемпотентным!
Допустим, Морж показывает объект colors, и мы решили добавить ко всем цветам суффикс Base (по какой-то причине). Итак, colors.blue и colors.red становятся colors.blueBase и colors.redBase соответственно. Если кодмод добавляет только суффикс, у нас скоро может быть colors.blueBaseBase, если он запускается несколько раз. Конечно, это не скомпилируется, но вернуться и исправить это больно.
Кодмоды могут избежать этой проблемы, если они идемпотентны.
Инструменты для автоматического обновления сэкономили мне недели работы.
Я написал инструментарий, который запускает Walrus, запускает все новые кодмоды и открывает запрос на извлечение во всех приложениях. Если тесты пройдены, обновление может быть быстро объединено. Инженерам по продуктам иногда даже не нужно знать, что произошел сбой.
Эта работа сэкономила мне и другим людям сотни часов утомительной работы, и именно это мы хотим автоматизировать.
Статический анализ — король.
Больше инструментов! Знание того, как разработчики используют библиотеку компонентов, необходимо для того, чтобы решить, над чем нам нужно работать в первую очередь. Наша команда разработала инструменты для анализа всех интерфейсных приложений в организации и ответов на основные вопросы.
Инструменты работают примерно так:
- Клонируйте все фронтенд-приложения React.
- Glob для каждого исходного модуля для каждого приложения.
- Анализируйте каждый в AST и собирайте данные, запрашивая AST.
- Объедините данные из всех приложений в один большой двоичный объект.
- Проанализируйте большой двоичный объект для понимания.
Мы делаем возможным проверку запросов, которые отвечают на такие вопросы, как сколько раз компонент Walrus X заворачивался в styled-component? Какие свойства CSS изменяются больше всего? Некоторые компоненты импортируются только вместе с другими? И т. д. (Вопросы, которые вы можете задавать, произвольны, но мы говорим здесь о библиотеках компонентов.) Представьте, что вы пытаетесь угадать ответы на них по сотням тысяч строк кода! Вы должны опираться на инструменты.
Кроме того, предоставление доступа к этим запросам через конечную точку REST упрощает настройку информационных панелей и показателей. (И еще больше инструментов для написания тикетов JIRA.) Приятно видеть графики, характеризующие технический долг, который движется вниз и вправо.
Визуальные регрессионные тесты более ценны, чем модульные тесты.
Тестирование снимков изображения с помощью Jest бесценно. Перебирайте все варианты и состояния компонента, делайте снимок изображения на каждом этапе и сравнивайте их с последней версией компонента. Пакет настраивается таким образом, что любое отличие в представлении приведет к сбою. Вы ожидали увидеть разницу? Нет? Думаю, тебе нужно что-то исправить.
Визуальные регрессионные тесты сложны в настройке (но они того стоят).
Тестовая система, используемая для создания моментального снимка изображения, довольно корявая. требуется, чтобы ОС, выполняющая тесты моментальных снимков на вашем компьютере, была той же ОС в CI. Если вы используете образ Linux в CI (вероятно, так оно и есть), вы должны запускать тесты моментальных снимков в контейнере Docker на своем MacBook. Это связано с тем, что запуск тестов в CI не удастся, поскольку в Linux используется другое сглаживание шрифтов, чем в MacOS. Это еще не решено? Кто-нибудь напишите мне!
Кроме того, они могут работать очень медленно, а непредвиденный сбой может утомительно отлаживать.
Это все на данный момент.
Это все, о чем я могу думать прямо сейчас. Если у вас есть отзывы, вы можете твитнуть, DM или отправить электронное письмо.