Пишем переиспользуемые компоненты, соблюдая SOLID
Статья содержит более детальный разбор принципов и подробные примеры из практики, которые не поместились в доклад. Рекомендую прочитать, если вы хотите глубоко погрузиться в тему и узнать, как мы пишем переиспользуемые компоненты. Если же вы хотите познакомиться с миром переиспользуемых компонентов в общих чертах, то, по моему мнению, вам больше подойдёт запись доклада.
Все знают, что дублирование кода — это плохо, потому что об этом часто говорят: в книге по вашему первому языку программирования, на курсах по программированию, в книгах, посвящённых написанию качественного кода, таких как «Совершенный код» и «Чистый код».
Давайте разберёмся, почему так сложно избегать дублирования во фронтенде и как правильно писать переиспользуемые компоненты. И помогут нам принципы SOLID.
Почему сложно перестать дублировать код?
Казалось бы, принцип звучит просто. И в то же время легко проверить, соблюдается ли он: если в кодовой базе нет дублирования, значит, всё хорошо. Почему же на практике получается так сложно?
Разберём пример с библиотекой компонентов Я.Учебника. Когда-то давно проект был монолитом. Позже для удобства разработчики решили вынести переиспользуемые компоненты в отдельную библиотеку. Одним из первых туда попал компонент кнопки. Компонент развивался, со временем появились новые «умения» и настройки для кнопки, увеличилось количество визуальных кастомизаций. Спустя некоторое время компонент стал настолько навороченным, что использовать его для новых задач и расширять дальше стало неудобно.
И вот, в очередной итерации появилась копия компонента — Button2. Это произошло очень давно, точных причин появления уже никто и не помнит. Тем не менее, компонент был создан.
Казалось бы, ничего страшного — пусть будет две кнопки. В конце концов, это всего лишь кнопка. Однако на самом деле наличие двух кнопок в проекте имело очень неприятные долговременные последствия.
Каждый раз, когда нужно было обновить стили, было непонятно, в каком компоненте это делать. Приходилось проверять, где какой компонент используется, чтобы случайно не сломать стили в других местах. Когда появлялся новый вариант отображения кнопки, мы решали, какой из компонентов расширять. Каждый раз, когда пилили новую фичу, задумывались, какую из кнопок использовать. А иногда в одном месте нужно было несколько разных кнопок, и тогда в один компонент проекта мы импортировали сразу два компонента кнопки.
Несмотря на то, что в долгосрочной перспективе существование двух компонентов кнопки оказалось болезненным, мы не сразу поняли серьёзность проблемы и успели сделать нечто похожее с иконками. Создали компонент, а когда поняли, что он нам не очень удобен, сделали Icon2, а когда и он оказался неподходящим для новых задач, написали Icon3.
Почти весь набор негативных последствий дублирования кнопки повторился в компонентах иконки. Было немного легче потому, что иконки используются в проекте реже. Хотя, если быть честным, тут всё зависит от фичи. Причём и для кнопки, и для иконки старый компонент не удалялся при создании нового, потому что удаление требовало большого рефакторинга с возможным появлением багов по всему проекту. Что же объединяет случаи с кнопкой и иконкой? Одинаковая схема появления дубликатов в проекте. Нам было сложно переиспользовать текущий компонент, адаптировать его к новым условиям, поэтому мы создавали новый.
Создавая дубликат компонента, мы усложняем себе дальнейшую жизнь. Нам хотелось собирать интерфейс из готовых блоков, как конструктор. Чтобы делать это удобно, нужны качественные компоненты, которые можно просто взять и использовать. Корень проблемы в том, что компонент, который мы планировали переиспользовать, был написан неправильно. Его было сложно расширять и применять в других местах.
Компонент для переиспользования должен быть достаточно универсальным и в то же время простым. Работа с ним не должна вызывать боль и напоминать стрельбу из пушки по воробьям. С другой стороны, компонент должен быть достаточно кастомизируемым, чтобы при небольшом изменении сценария не выяснилось, что проще написать «Компонент2».
SOLID на пути к переиспользуемым компонентам
Чтобы написать качественные компоненты, нам пригодится набор правил, скрывающихся за аббревиатурой SOLID. Эти правила объясняют, как объединять функции и структуры данных в классы и как классы должны сочетаться друг с другом.
Почему же именно SOLID, а не любой другой набор принципов? Правила SOLID говорят о том, как правильно выстроить архитектуру приложения. Так, чтобы можно было спокойно развивать проект, добавлять новые функции, изменять существующие и при этом не ломать всё вокруг. Когда я попытался описать, каким, по моему мнению, должен быть хороший компонент, то понял, что мои критерии близки к принципам SOLID.
- S — принцип единственной ответственности.
- O — принцип открытости/закрытости.
- L — принцип подстановки Лисков.
- I — принцип разделения интерфейсов.
- D — принцип инверсии зависимостей.
Какие-то из этих принципов хорошо подходят для описания компонентов. Другие же выглядят более притянутыми за уши в контексте фронтенда. Но все вместе они хорошо описывают моё видение качественного компонента.
Мы пойдём по принципам не по порядку, а от простого к сложному. Вначале рассмотрим базовые вещи, которые могут пригодиться в большом количестве ситуаций, а затем — более мощные и специфичные.
В статье приведены примеры кода на React + TypeScript. Я выбрал React как фреймворк, с которым больше всего работаю. На его месте может быть любой другой фреймворк, который вам нравится или подходит. Вместо TS может быть и чистый JS, но TypeScript позволяет явно описывать контракты в коде, что упрощает разработку и использование сложных компонентов.
Базовое
Принцип open/close
Программные сущности должны быть открыты для расширения и закрыты для изменения. Другими словами, мы должны иметь возможность расширять функционал с помощью нового кода без изменения существующего. Почему это важно? Если каждый раз для добавления нового функционала придётся редактировать кучу существующих модулей, весь проект станет нестабильным. Появится много мест, которые могут сломаться из-за того, что в них постоянно вносятся изменения.
Рассмотрим применение принципа на примере кнопки. Мы создали компонент-кнопку, и у него есть стили. Пока всё работает хорошо. Но тут приходит новая задача, и выясняется, что в одном конкретном месте для этой кнопки нужно применить другие стили.
Кнопка написана так, что её нельзя изменить без редактирования кода
Чтобы применить другие стили в текущей версии, придётся отредактировать компонент кнопки. Проблема заключается в том, что в компонент не заложена кастомизируемость. Вариант написать глобальные стили рассматривать не будем, так как он ненадёжен. Всё может сломаться при любой правке. Последствия легко представить, если на место кнопки поставить что-то более сложное, например, компонент выбора даты.
Согласно принципу открытости/закрытости мы должны написать код так, чтобы при добавлении нового стиля не пришлось переписывать код кнопки. Всё получится, если часть стилей компонента можно прокинуть снаружи. Для этого заведём проп, в который пробросим нужный класс для описания новых стилей компонента.
// утилита для формирования класса, можно использовать любой аналог
import cx from 'classnames';
// добавили новый проп — mix
const Button = ({ children, mix }) => {
return (
}
Готово, теперь для кастомизации компонента не нужно править его код.
Этот довольно популярный способ позволяет кастомизировать внешний вид компонента. Его называют миксом, потому что дополнительный класс подмешивается к собственным классам компонента. Отмечу, что проброс класса — не единственная возможность стилизовать компонент извне. Можно передавать в компонент объект с CSS-свойствами. Можно использовать CSS-in-JS решения, суть не изменится. Миксы используют многие библиотеки компонентов, например: MaterialUI, Vuetify, PrimeNG и другие.
Какой вывод можно сделать о миксах? Они просты в реализации, универсальны и позволяют гибко настраивать внешний вид компонентов с минимальными усилиями.
Но у такого подхода есть и минусы. Он даёт очень много свободы, из-за чего могут возникнуть проблемы со специфичностью селекторов. А ещё такой подход нарушает инкапсуляцию. Для того, чтобы сформировать правильный css-селектор, нужно знать внутреннее устройство компонента. А значит, такой код может сломаться при рефакторинге компонента.
Изменчивость компонентов
У компонента есть части, являющиеся его ядром. Если их изменить, мы получим другой компонент. Для кнопки — это набор состояний и поведение. Пользователи отличают кнопку от, например, чекбокса благодаря эффекту при наведении и нажатии. Есть общая логика работы: когда пользователь кликает, срабатывает обработчик события. Это — ядро компонента, то, что делает кнопку кнопкой. Да, бывают исключения, но в большинстве сценариев использования всё работает именно так.
А ещё в компоненте есть части, которые могут меняться в зависимости от места применения. Стили как раз относятся к этой группе. Может, нам понадобится кнопка другого размера или цвета. С другой обводкой и скруглением или с другим ховер эффектом. Все стили — изменяемая часть компонента. Мы не хотим переписывать или создавать новый компонент каждый раз, когда кнопка выглядит по-другому.
То, что часто меняется, должно настраиваться без изменения кода. Иначе мы окажемся в ситуации, когда проще создать новый компонент, чем настраивать и дописывать старый, который оказался недостаточно гибким.
Темизация
Вернёмся к кастомизации визуала компонента на примере кнопки. Следующий способ — применение тем. Под темизацией я подразумеваю способность компонента отображаться в нескольких режимах, по-разному в разных местах. Эта интерпретация шире, чем темизация в контексте светлой и тёмной темы.
Использование тем не исключает предыдущий способ с миксами, а дополняет его. Мы явно говорим, что компонент имеет несколько способов отображения и при использовании требует указать желаемый.
import cx from 'classnames';
import b from 'b_';
const Button = ({ children, mix, theme }) => (
)
Темизация позволяет избежать зоопарка стилей, когда, например, у вас в проекте 20 кнопок и все выглядят немного по-разному из-за того, что стили каждой кнопки задаются в месте применения. Применять подход можно для всех новых компонентов, не опасаясь оверинжиниринга. Если вы понимаете, что компонент может выглядеть по-разному, лучше с самого начала явно завести темы. Это упростит дальнейшую разработку компонента.
Но есть и минус — способ подходит только для кастомизации визуала и не позволяет влиять на поведение компонента.
Вложенность компонентов
Я перечислил не все способы избежать изменения кода компонента при добавлении новых функций. Другие будут продемонстрированы при разборе остальных принципов. Здесь же мне хотелось бы упомянуть про дочерние компоненты и слоты.
Веб-страница представляет собой древовидную иерархию компонентов. Каждый компонент сам решает, что и как отрисовать. Но это не всегда так. Например, кнопка позволяет указать, какой контент будет отрисован внутри. В React основной инструмент — проп children и render-пропы. Во Vue есть более мощная концепция слотов. При написании простых компонентов с использованием этих возможностей не возникает никаких проблем. Но важно не забывать, что даже в сложных компонентах можно использовать пробрасывание части элементов, которые должен отобразить компонент сверху.
Продвинутое
Описанные ниже принципы подойдут для более крупных проектов. Соответствующие им приёмы дают большую гибкость, но увеличивают сложность проектирования и разработки.
Single Responsibility Principle
Принцип единственной ответственности означает, что модуль должен иметь одну и только одну причину для изменения.
Почему это важно? Последствия нарушения принципа включают:
- Риск при редактировании одной части системы сломать другую.
- Плохие абстракции. Получаются компоненты, которые умеют выполнять несколько функций, из-за чего сложно понять, что именно должен делать компонент, а что нет.
- Неудобная работа с компонентами. Очень сложно делать доработки или исправлять баги в компоненте, который делает всё сразу.
Вернёмся к примеру с темизацией и посмотрим, соблюдается ли там принцип единственной ответственности. Уже в текущем виде темизация справляется со своими задачами, но это не значит, что у решения нет проблем и его нельзя сделать лучше.
Один модуль редактируется разными людьми по разным причинам
Допустим, мы положили все стили в один css-файл. Он может редактироваться разными людьми по разным причинам. Получается, что принцип единственной ответственности нарушен. Кто-то может отрефакторить стили, а другой разработчик внесёт правки для новой фичи. Так можно легко что-то сломать.
Давайте подумаем, как может выглядеть темизация с соблюдением SRP. Идеальная картина: у нас есть кнопка и отдельно — набор тем для неё. Мы можем применить тему к кнопке и получить темизированную кнопку. Бонусом хотелось бы иметь возможность собрать кнопку с несколькими доступными темами, например, для помещения в библиотеку компонентов.
Желаемая картина. Тема — отдельная сущность и может примениться к кнопке
Тема оборачивает кнопку. Такой подход используется в Лего, нашей внутренней библиотеке компонентов. Мы используем HOC (High Order Components), чтобы обернуть базовый компонент и добавить ему новые возможности. Например, возможность отображаться с темой.
HOC — функция, которая принимает компонент и возвращает другой компонент. HOC с темой может прокидывать объект со стилями внутрь кнопки. Ниже представлен скорее учебный вариант, в реальной жизни можно использовать более элегантные решения, например, прокидывать в компонент класс, стили которого импортируются в HOC, или использовать CSS-in-JS решения.
Пример простого HOC для темизации кнопки:
const withTheme1 = (Button) =>
(props) => {
return (
)
}
const Theme1Button = withTheme1(Button);
HOC может применять стили, только если указана определённая тема, а в остальных случаях не делает ничего. Так мы можем собрать кнопку с комплектом тем и активировать нужную, указав проп theme.
Использование нескольких HOC«ов для сбора кнопки с нужными темами:
import "./styles.css";
// Базовый компонент кнопки. Принимает содержимое кнопки и стили
const ButtonBase = ({ style, children }) => {
console.log("styl123e", style);
return ;
};
const withTheme1 = (Button) => (props) => {
// HOC применяет стили, только если выбрана тема "theme1"
if (props.theme === "theme1") {
return ;
}
return ;
};
const withTheme2 = (Button) => (props) => {
// HOC применяет стили, только если выбрана тема "theme2"
if (props.theme === "theme2") {
return ;
}
return ;
};
// ф-я для оборачивания компонента в несколько HOC
const compose = (...hocs) => (BaseComponent) =>
hocs.reduce((Component, nextHOC) => nextHOC(Component), BaseComponent);
// собираем кнопку, передав нужный набор тем
const Button = compose(withTheme1, withTheme2)(ButtonBase);
export default function App() {
return (
);
}
И тут мы приходим к выводу, что нужно разделить области ответственности. Даже если кажется, что у вас один компонент, подумайте — так ли это на самом деле? Возможно, его стоит разделить на несколько слоёв, каждый из которых будет отвечать за конкретную функцию. Почти во всех случаях визуальный слой можно отделить от логики компонента.
Выделение темы в отдельную сущность даёт плюсы к удобству использования компонента: можно поместить кнопку в библиотеку с базовым набором тем и разрешить пользователям писать свои при необходимости; темы можно удобно шарить между проектами. Это позволяет сохранить консистентность интерфейса и не перегружать исходную библиотеку.
Существуют разные варианты реализации разделения на слои. Выше был пример с HOC, но композиция также возможна. Однако я считаю, что в случае с темизацией HOC более уместны, так как тема не является самостоятельным компонентом.
Выносить в отдельный слой можно не только визуал. Но я не планирую подробно рассматривать вынесение бизнес-логики в HOC, потому что вопрос весьма холиварный. Моё мнение — вы можете так поступить, если понимаете, что делаете и зачем вам это нужно.
Композитные компоненты
Перейдём к более сложным компонентам. Возьмём в качестве примера Select и разберёмся, в чём польза принципа единственной ответственности. Select можно представить как композицию более мелких компонентов.
- Container — связь между остальными компонентами.
- Field — текст для обычного селекта и инпут для компонента CobmoBox, где пользователь что-то вводит.
- Icon — традиционный для селекта значок в поле.
- Menu — компонент, который отображает список элементов для выбора.
- Item — отдельный элемент в меню.
Для соблюдения принципа единственной ответственности нужно вынести все сущности в отдельные компоненты, оставив каждому только одну причину для редактирования. Когда мы распилим файл, возникнет вопрос: как теперь кастомизировать получившийся набор компонентов? Например, если нужно задать тёмную тему для поля, увеличить иконку и изменить цвет меню. Есть два способа решить эту задачу.
Overrides
Первый способ — прямолинейный. Все настройки вложенных компонентов выносим в пропы исходного. Правда, если применить решение «в лоб», окажется, что у селекта огромное количество пропов, в которых сложно разобраться. Нужно как-то удобно их организовать. И тут нам поможет override. Это конфиг, который пробрасывается в компонент и позволяет настроить каждый его элемент.
Я привёл простой пример, где мы переопределяем пропы. Но override можно рассматривать как глобальный конфиг — он настраивает всё, что поддерживает компоненты. Увидеть, как это работает на практике, можно в библиотеке BaseWeb.
Итого, с помощью override можно гибко настраивать композитные компоненты, а ещё такой подход отлично масштабируется. Из минусов: конфиги для сложных компонентов получаются очень большими, а мощь override имеет и обратную сторону. Мы получаем полный контроль над внутренними компонентами, что позволяет делать странные вещи и выставлять невалидные настройки. Также, если вы не используете библиотеки, а хотите реализовать подход самостоятельно, придётся научить компоненты понимать конфиг или написать обёртки, которые будут читать его и настраивать компоненты правильно.
Dependency Inversion Principle
Чтобы разобраться в альтернативе override-конфигам, обратимся к букве D в SOLID. Это принцип инверсии зависимостей. Он утверждает, что код, реализующий верхнеуровневую политику, не должен зависеть от кода, реализующего низкоуровневые детали.
Вернёмся к нашему селекту. Container отвечает за взаимодействие между другими частями компонента. Фактически он представляет собой корень, управляющий рендером остальных блоков. Для этого он должен их импортировать.
Так будет выглядеть корень любого сложного компонента, если не использовать инверсию зависимостей:
import InputField from './InputField';
import Icon from './Icon';
import Menu from './Menu';
import Option from './Option';
Разберём зависимости между компонентами, чтобы понять, что может пойти не так. Сейчас более высокоуровневый Select зависит от низкоуровневого Menu, потому что импортит его в себя. Принцип инверсии зависимостей нарушен. Это создаёт проблемы.
- Во-первых, при изменении Menu придётся править Select.
- Во-вторых, если мы захотим использовать другой компонент меню, нам тоже придётся вносить правки в компонент селекта.
Непонятно, что делать, когда понадобится Select с другим меню
Нужно развернуть зависимость. Сделать так, чтобы компонент меню зависел от селекта. Инверсия зависимостей делается через инъекцию зависимостей — Select должен принимать компонент меню как один из параметров, пропов. Здесь нам поможет типизация. Мы укажем, какой компонент ожидает Select.
// теперь вместо прямого импорта Select принимает одним из параметров компонент меню
const Select = ({
Menu: React.ComponentType
}) => {
return (
...
...
)
...
}
Так мы декларируем, что селекту нужен компонент меню, пропы которого удовлетворяют определённому интерфейсу. Тогда стрелки будут направлены в обратную сторону, как и предписывает принцип DI.
Стрелка развёрнута, так работает инверсия зависимостей
Мы решили проблему с зависимостями, но немного синтаксического сахара и вспомогательные инструменты здесь не помешают.
Каждый раз прокидывать все зависимости в компонент в месте рендера утомительно, но в библиотеке bem-react есть реестр зависимостей и процесс композиции. С их помощью можно упаковать зависимости и настройки один раз, а дальше просто использовать готовый компонент.
import { compose } from '@bem-react/core'
import { withRegistry, Registry } from '@bem-react/di'
const selectRegistry = new Registry({ id: cnSelect() })
...
selectRegistry.fill({
'Trigger': Button,
'Popup': Popup,
'Menu': Menu,
'Icon': Icon,
})
const Select = compose(
...
withRegistry(selectRegistry),
)(SelectDesktop)
В примере выше показана часть сборки компонента на примере bem-react. Полный код примера и песочницу можно посмотреть в сторибуке yandex UI.
Что мы получаем от использования Dependency Inversion?
- Полный контроль — свободу в настройке всех составляющих компонента.
- Гибкую инкапсуляцию — возможность сделать компоненты очень гибкими и полностью кастомизируемыми. При необходимости разработчик переопределит все блоки, из которых состоит компонент, и получит то, что хочет. При этом всегда есть вариант создать уже настроенные, готовые компоненты.
- Масштабируемость — способ хорошо подходит для библиотек любых размеров.
Мы в Яндекс.Учебнике пишем собственные компоненты, используя DI. Внутренняя библиотека компонентов Лего тоже использует этот подход. Но один существенный минус у него есть — гораздо более сложная разработка.
Сложности разработки переиспользуемых компонентов
В чём же сложность разработки переиспользуемых компонентов?
Во-первых, долгое и тщательное проектирование. Нужно понять, из каких частей состоят компоненты и какие части могут меняться. Если сделать все части изменяемыми, мы получим огромное количество абстракций, в которых сложно разобраться. Если же изменяемых частей будет слишком мало, компонент получится недостаточно гибким. Его нужно будет дорабатывать во избежание будущих проблем с переиспользованием.
Во-вторых, высокие требования к компонентам. Вы поняли, из каких частей будут состоять компоненты. Теперь нужно написать их так, чтобы они ничего не знали друг о друге, но могли использоваться вместе. Это сложнее, чем разработка без оглядки на переиспользуемость.
В-третьих, сложная структура как следствие предыдущих пунктов. Если требуется серьёзная кастомизация, придётся пересобрать все зависимости компонента. Для этого нужно глубоко понимать, из каких частей он состоит. Важную роль в процессе играет наличие хорошей документации.
В Учебнике есть внутренняя библиотека компонентов, где находятся образовательные механики — часть интерфейса, с которой взаимодействуют дети во время решения заданий. И ещё есть общая библиотека образовательных сервисов. Туда мы выносим компоненты, которые хотим переиспользовать между разными сервисами.
Перенос одной механики занимает несколько недель при условии, что у нас уже есть работающий компонент и мы не добавляем новый функционал. Большая часть этой работы — распилить компонент на независимые куски и сделать возможным их совместное использование.
Liskov Substitution Principle
Предыдущие принципы были о том, что нужно делать, а последние два будут о том, что нужно не сломать.
Начнём с принципа подстановки Барбары Лисков. Он говорит, что объекты в программе должны быть заменяемыми на экземпляры их подтипов без нарушения правильности выполнения программы.
Мы обычно не пишем компоненты как классы и не используем наследование. Все компоненты взаимозаменяемы «из коробки». Это — основа современного фронтенда. Не совершать ошибок и поддерживать совместимость помогает строгая типизация.
Как же заменяемость «из коробки» может сломаться? У компонента есть API. Под API я понимаю совокупность пропов компонента и встроенных во фреймворк механизмов, таких, как механизм обработчика событий. Строгая типизация и линтинг в IDE способны подсветить несовместимость в API, но компонент может взаимодействовать с внешним миром и в обход API:
- читать и писать что-то в глобальный стор,
- взаимодействовать с window,
- взаимодействовать с cookie,
- читать/писать local storage,
- делать запросы в сеть.
Всё это небезопасно, потому что компонент зависит от окружения и может сломаться, если перенести его в другое место или в другой проект.
Чтобы соблюдать принцип подстановки Лисков нужно:
- использовать возможности типизации,
- избегать взаимодействия в обход API компонента,
- избегать побочных эффектов.
Как избежать взаимодействия не через API? Вынести всё, от чего зависит компонент, в API и написать обёртку, которая будет пробрасывать данные из внешнего мира в пропы. Например, так:
const Component = () => {
/*
К сожалению, использование хуков приводит к тому, что компонент много знает о своем окружении.
Например, тут он знает о наличии стора и его внутренней структуре.
При переносе в другой проект, где стор отсутствует, код может сломаться.
*/
const {userName} = useStore();
// Тут компонент знает о куках, что не очень хорошо для переиспользования (может сломаться, если в другом проекте это не так).
const userToken = getFromCookie();
// Аналогично — доступ к window может стать проблемой при переиспользовании компонента.
const {taskList} = window.ssrData;
const handleTaskUpdate = () => {
// Компонент знает об API сервера. Это допустимо только для верхнеуровневых компонентов.
fetch(...)
}
return {'...'};
};
/*
Здесь компонент принимает только необходимый ему набор данных.
Его можно легко переиспользовать, потому что только верхнеуровневые компоненты будут знать все детали.
*/
const Component2 = ({
userName, userToken, onTaskUpdate
}) => {
return {'...'};
};
Interface Segregation Principle
Много интерфейсов специального назначения лучше, чем один интерфейс общего назначения. Мне не удалось так же однозначно перенести принцип на компоненты фронтенда. Поэтому я понимаю его как необходимость следить за API.
Нужно передавать в компонент как можно меньшее количество сущностей и не передавать данные, которые им не используются. Большое количество пропов в компоненте — повод насторожиться. Скорее всего, он нарушает принципы SOLID.
Где и как переиспользуем?
Мы обсудили принципы, которые помогут в написании качественных компонентов. Теперь разберём, где и как мы их переиспользуем. Это поможет понять, с какими ещё проблемами можно столкнуться.
Контекст может быть разным: вам нужно использовать компонент в другом месте той же страницы или, например, вы хотите переиспользовать его в других проектах компании — это совсем разные вещи. Я выделяю несколько вариантов:
Переиспользование пока не требуется. Вы написали компонент, считаете, что он специфичный и не планируете нигде больше его использовать. Можно не предпринимать дополнительных усилий. А можно сделать несколько простых действий, которые окажутся полезны, если вы всё-таки захотите к нему вернуться. Так, например, можно проверить, что компонент не слишком сильно завязан на окружение, а зависимости вынесены в обёртки. Также можно сделать запас для кастомизации на будущее: добавить темы или возможность изменять внешний вид компонента извне (как в примере с кнопкой) — это не займёт много времени.
Переиспользование в том же проекте. Вы написали компонент и почти уверены, что захотите его переиспользовать в другом месте текущего проекта. Здесь актуально всё написанное выше. Только теперь обязательно нужно убрать все зависимости во внешние обёртки и крайне желательно иметь возможность кастомизации извне (темы или миксы). Если компонент содержит много логики, стоит задуматься, везде ли она нужна, или в каких-то местах её стоит модифицировать. Для второго варианта предусмотрите возможность кастомизации. Также здесь важно подумать над структурой компонента и разбить его на части при необходимости.
Переиспользование в похожем стеке. Вы понимаете, что компонент будет полезен в соседнем проекте, у которого тот же стек, что и у вас. Здесь всё сказанное выше становится обязательным. Кроме этого, советую внимательно следить за зависимостями и технологиями. Точно ли соседний проект использует те же версии библиотек, что и вы? Использует ли SASS и TypeScript той же версии?
Отдельно хочу выделить переиспользование в другой среде исполнения, например, в SSR. Решите, действительно ли ваш компонент может и должен уметь рендериться на SSR. Если да, заранее удостоверьтесь, что он рендерится, как ожидается. Помните, что существуют другие рантаймы, например, deno или GraalVM. Учитывайте их особенности, если используете.
Библиотеки компонентов
Если компоненты нужно переиспользовать между несколькими репозиториями и/или проектами, их следует вынести в библиотеку.
Стек
Чем больше технологий используется в проектах, тем сложнее будет решать проблемы совместимости. Лучше всего сократить зоопарк и свести к минимуму количество используемых технологий: фреймворков, языков, версий крупных библиотек. Если же вы понимаете, что вам действительно нужно много технологий, придётся научиться с этим жить. Например, можно использовать обёртки над веб-компонентами, собирать всё в чистый JS или использовать адаптеры для компонентов.
Размер
Если использование простого компонента из вашей библиотеки добавляет пару мегабайт к бандлу — это не ок. Такие компоненты не хочется переиспользовать, потому что перспектива написать свою лёгкую версию с нуля кажется оправданной. Решить проблему можно с помощью инструментов контроля размера, например, size-limit.
Не забываем про модульность — разработчик, который захочет использовать ваш компонент, должен иметь возможность взять только его, а не тащить весь код библиотеки в проект.
Важно, чтобы модульная библиотека не собиралась в один файл. Также нужно следить за версией JS, в которую собирается библиотека. Если вы собираете библиотеку в ES.NEXT, а проекты — в ES5, возникнут проблемы. Ещё нужно правильно настроить сборку для старых версий браузеров и сделать так, чтобы все пользователи библиотеки знали, во что она собирается. Если это слишком сложно, есть альтернатива — настроить собственные правила сборки библиотеки в каждом проекте.
Обновление
Заранее подумайте о том, как будете обновлять библиотеку. Хорошо, если вы знаете всех клиентов и их пользовательские сценарии. Это поможет лучше думать о миграциях и ломающих изменениях. Например, команде, использующей вашу библиотеку, будет крайне неприятно узнать о мажорном обновлении с ломающими изменениями накануне релиза.
Вынося компоненты в библиотеку, которую использует кто-то ещё, вы теряете лёгкость рефакторинга. Чтобы груз рефакторинга не стал неподъёмным, советую не тащить в библиотеки новые компоненты. Они с высокой вероятностью будут меняться, а значит, придётся тратить много времени на обновление и поддержку совместимости.
Кастомизация и дизайн
Дизайн не влияет на переиспользуемость, но является важной частью кастомизаци. У нас в Учебнике компоненты не живут сами по себе, их внешний вид проектируют дизайнеры. У дизайнеров есть дизайн-система. Если компонент в системе и репозитории выглядит по-разному — проблем не избежать. У дизайнеров и разработчиков не совпадут представления о внешнем виде интерфейса, из-за чего могут быть приняты неверные решения.
Витрина компонентов
Упростить взаимодействие с дизайнерами поможет витрина компонентов. Одно из популярных решений для витрины — Storybook. С помощью этого или другого подходящего инструмента можно показать компоненты проекта любому человеку не из разработки.
Добавьте в витрину интерактивность — у дизайнеров должна быть возможность взаимодействовать с компонентами и видеть, как они отображаются и работают с разными параметрами.
Не забудьте настроить автоматическое обновление витрины при обновлении компонентов. Для этого нужно вынести процесс в CI. Теперь дизайнеры всегда смогут посмотреть, какие готовые компоненты есть в проекте, и воспользоваться ими.
Дизайн-система
Для разработчика дизайн-система — набор правил, регулирующих внешний вид компонентов в проекте. Чтобы зоопарк компонентов не разрастался, можно ограничить кастомизируемость её рамками.
Другой важный момент