Привет, хабровчанин! Дизайнеры люди идейные, а заказчики с их бизнес-требованиями, тем более.
Представь, что ты сваял свой самый лучший UIkit на свете на самом крутом %вставить свое% JS фреймворке. Казалось бы, там есть все, что нужно проекту. Теперь-то ты сможешь пить кофе, а все новые задачи закрывать накидыванием компонентов на страницу. Еще лучше, если ты нашел такой UIkit на помойке на просторах NPM и он идеально соответствует текущему UX/UI и его потребностям. Фантастика!
И действительно… кого я обманываю? Твое счастье, скорее всего, будет недолгим. Ведь когда дизайнер прибежит с талмудом новых UI-решений для очередной страницы или «спец-проекта», что-то по-любому пойдет не так.
В этот момент перед разработчиком встает вопрос «DRY или не DRY»? Стоит ли как-то кастомизировать существующие компоненты? Да еще так, чтобы не отхватить регрешн на существующих кейсах. Или же действовать по принципу «работает — не трогай» и написать новые компоненты с нуля. При этом, раздувая UIkit и усложняя поддержку.
Если ты, как и многие, бывал в такой ситуации, загляни под кат!
Несмотря на развернутое вступление, идея написания данной статьи пришла ко мне после прочтения одного из тредов комментариев на Хабре. Там ребята не на шутку распылились по поводу того, как же нужно кастомизировать компонент кнопки на React. Ну а после того, как я понаблюдал за парочкой подобных холиваров в Телеграм, необходимость написать об этом окончательно укрепилась.
Для начала, попробуем представить какие именно «кастомизации» нам может понадобиться применять к компоненту.
Стили
Прежде всего, это кастомизация стилей компонента. Банальный пример — кнопка серая, а нужна синяя. Или кнопка без закругленных углов и вдруг они нужны. Исходя из прочтенных мною холиваров, я сделал вывод что существует примерно 3 подхода для этого:
1. Глобальные стили
Использовать всю мощь глобальные стили CSS, изрядно переправленные !important, чтобы снаружи, глобально, попробовать перекрыть стили изначального компонента. Решение, мягко говоря, спорное и уж слишком прямолинейное. К тому же, такой вариант просто не всегда возможен и при этом отчаянно нарушает любую инкапсуляцию стилей. Если конечно она применяется в ваших компонентах.
2. Передача классов (стилей) из вышестоящего контекста
Также довольно спорное решение. Получается, мы как бы создает специальный prop, к примеру, назовем его classes и прямо сверху погружаем нужные классы в компонент.
<Button classes="btn-red btn-rounded" />
Естественно работать такой подход будет лишь в том случае, когда компонент поддерживает применение стилей к своему содержимому таким образом. Кроме того, если компонент чуть более сложный и состоит из вложенной структуры HTML-элементов, то очевидно применить стили ко всем будет проблематично. А значит, они будут применены к корневому элементу компонента, а далее с помощью CSS правил каким-то образом распространяться дальше. Уныло.
3. Настройка компонента по средствам пропсов
Выглядит как самое толковое, но при этом наименее гибкое решение. Проще говоря, мы рассчитываем, что автор компонента какой-то гений и заранее продумал все варианты. То есть все что нам может понадобиться и определил все необходимые пропсы для всех желаемых результатов:
<Button bgColor="red" rounded={true} />
Звучит не очень правдоподобно, да? Пожалуй.
Поведение
Тут все еще более неоднозначно. Прежде всего потому, что сложности в кастомизации поведения компонента идут от задачи. Чем сложнее компонент и заложенная в нем логика, а также чем сложнее то изменение, которое мы хотим сделать, тем сложнее сделать это изменение. Тавтология какая-то получилась… Короче вы поняли! ;-)
Однако даже в здесь существует набор средств, которые либо помогут нам кастомизировать компонент, либо нет. Так как мы говорим именно о компонентном подходе, то я бы выделил следующие полезные инструменты:
1. Удобная работа с пропсами
Прежде всего нужно иметь возможность мимикрировать под набор пропсов компонента без необходимости повторного описания этого набора и удобно проксировать их дальше.
Далее, если мы пытаемся добавить некое поведение к компоненту, то, скорее всего, нужно будет задействовать дополнительный набор пропсов, которые не нужны изначальному компоненту. Поэтому неплохо иметь возможность отсекать часть пропсов и передавать в изначальный компонент только необходимое. При этом сохраняя все свойства синхронизированными.
Обратная сторона — когда мы хотим реализовать частный случай поведения компонента. Как-то зафиксировать часть его стейта на определенной задаче.
2. Отслеживание жизненного цикла и событий компонента
Иными словами, все что происходит внутри компонента не должно быть полностью закрытой книгой. Иначе это реально усложнит кастомизацию его поведения.
Я не имею в виду нарушение инкапсуляции и бесконтрольное вмешательство внутрь. Управление компонентом должно осуществляться через его публичный api (обычно это пропсы и/или методы). Но иметь возможность как-то «узнать» о том, что творится внутри и отслеживать изменение его состояния, все же необходимо.
3. Императивный способ управления
Будем считать, что я вам этого не говорил. И все же, иногда, неплохо иметь возможность получить инстанс компонента и императивно «подергать за ниточки». Лучше этого избегать, но в особо сложных кейсах, без этого бывает не обойтись.
Ок, вроде как с теорией разобрались. По большому счету, все очевидно, но не все понятно. Поэтому стоит рассмотреть хоть какой-то реальный кейс.
Кейс
Выше я упомянул, что идея написания статьи возникла из-за холивара о кастомизации кнопки. Поэтому я подумал что будет символично решить подобный кейс. Тупо менять цвет или закруглять углы было бы слишком просто, поэтому я постарался придумать чуть более сложный кейс.
Представим, что у нас есть некий компонент базовой кнопки, который используется в хуилионе мест приложения. Кроме того, он реализует некое базовое поведение для всех кнопок приложения, а также набор базовых, инкапсулированных стилей, которые, время от времени, синхронизируются с UI гайдами и все такое.
Далее, возникает необходимость иметь дополнительный компонент для кнопки отправки данных на сервер (submit button), который кроме стилевых изменений, требует реализации дополнительное поведение. К примеру, это может быть рисование прогресса отправки, а также визуальное представление результата данного действия — успешно или не успешно.
Примерно так это может выглядеть:
Не трудно догадаться, что слева располагается базовая кнопка, а справа кнопка отправки в состоянии успешного завершения запроса. Что ж, если кейс понятен — приступим к реализации!
Решение
Мне так и не удалось разобраться с тем, что именно вызвало холивар в решении на React. Видимо там не так все однозначно. Поэтому я не буду испытывать судьбу и воспользуюсь более привычным для себя инструментом — SvelteJS — исчезающим фреймворком нового поколения, который практически идеально подходит для решения подобных задач.
Сразу договоримся, мы не будем никаким образом вмешиваться в код базовой кнопки. Будем считать, что она вообще написана не нами и ее код закрыт для исправлений. В данном случае компонент базовой кнопки будет выглядеть как-то так:
Button.html
<button
{type}
{name}
{value}
{disabled}
{autofocus}
on:click
>
<slot></slot>
</button>
<script>
export default {
data() {
return {
type: 'button',
disabled: false,
autofocus: false,
value: '',
name: ''
};
}
};
</script>
<style>
/* scoped styles */
</style>
И использоваться таким образом:
<Button on:click="cancel()">Cancel</Button>
Обратите внимание, компонент кнопки действительно очень базовый. Он не содержит совершенно никаких вспомогательных элементов или пропсов, которые могли бы помочь в реализации расширенного вариант компонента. Данный компонент даже не поддерживает передачу стилей пропсами или хоть какую-то встроенную кастомизацию, а все стили строго изолированы и не протекают наружу.
Создать на базе этого компонента другой, с расширенной функциональностью, да еще без внесения изменений, может показаться не простой задачей. Но только не тогда, когда вы используете Svelte.
Теперь давайте определим что должна уметь кнопка отправки:
- Прежде всего рамка и текст кнопки должны быть зелеными. При наведении фон также должен быть зеленым, вместо темно-серого.
- Далее, при нажатии на кнопку она должна «охлопываться» в круглый прогресс-индикатор.
- По завершении процесса (который управляется снаружи), нужно чтобы статус кнопки можно было сменить на успешный (success) или не успешный (error). При этом, кнопка из индикатора должна превратиться либо в зеленый бейдж с галкой, либо в красный бейдж с крестиком.
- Также необходимо иметь возможность задать время, через которое соответствующий бейдж снова превратиться в кнопку в изначальном состоянии (idle).
- Ну и конечно же, необходимо сделать все это поверх базовой кнопки с сохранением и применением всех стилей и пропсов оттуда.
Фух, не легкая задача. Давайте сначала создадим новый компонент и обернем им базовую кнопку:
SubmitButton.html
<Button>
<slot></slot>
</Button>
<script>
import Button from './Button.html';
export default {
components: { Button }
};
</script>
Пока это точно такая же кнопка, только хуже — она даже не умеет проксировать пропсы. Это не беда, вернемся к этому позднее.
Стилизуем
А пока, давайте подумаем как же нам стилизовать новую кнопку, а именно — поменять цвета, согласно задаче. К сожалению, похоже мы не можем использовать ни один из подходов, описанных выше.
Так как стили изолированы внутри кнопки, могут возникнуть проблемы с глобальными стилями. Прокинуть стили внутрь тоже не получится — базовая кнопка просто не поддерживает этой возможности. Так же как и кастомизацию внешнего вида с помощью пропсов. Кроме того, нам бы хотелось, чтобы все стили, написанные для новой кнопки также были бы инкапсулированы внутри этой кнопки и не протекали наружу.
Решение невероятно простое, но только в том случае, если вы уже используете Svelte. Итак, просто пишем стили для новой кнопки:
<div class="submit">
<Button>
<slot></slot>
</Button>
</div>
...
<style>
.submit :global(button) { border: 2px solid #1ECD97; color: #1ECD97; }
.submit :global(button:hover) { background-color: #1ECD97; color: #fff; }
</style>
Один из лейтмотивов Svelte — простые вещи должны решаться просто. Специальный модификатор :global в этом исполнении сгенерирует CSS таким образом, что только кнопки внутри блока с классом submit, находящиеся в данном компоненте получат указанные стили.
Даже если в любом другом месте приложения вдруг возникнет разметка такого же вида:
<div class="submit">
<button>Button</button>
</div>
стили из компонента SubmitButton никаким образом не «протекут» туда.
Используя этот метод, Svelte позволяет легче легкого кастомизировать стили вложенных компонентов, сохраняя при этом инкапсуляцию стилей обоих компонентов.
Прокидываем пропсы и фиксируем поведение
Что ж, со стилизацией мы разобрались практически мгновенно и без всяких дополнительных пропсов и передачи CSS классов напрямую. Теперь нужно проксировать все пропсы компонента Button через новый компонент. При этом не хотелось бы описывать их заново. Однако для начала, давайте решим какие собственные свойства будет иметь новый компонент.
Судя по задаче, SubmitButton должен отслеживать состояние, а также давать возможность указать временную задержку между автоматической сменой успешного/ошибочного состояния в начальное:
<script>
...
export default {
...
data() {
return {
delay: 1500,
status: 'idle' // loading, success, error
};
}
};
</script>
Итак, наша новая кнопка будет иметь 4 состояния: покоя, загрузки, успеха или ошибки. Кроме того, по-умолчанию 2 последних состояния будут автоматически сменяться на состояние покоя через 1,5 секунды.
Для того, чтобы прокинуть все переданные пропсы в компонент Button, но при этом отсечь заведомо невалидные для него status и delay мы напишем специальное вычисляемое свойство. А после просто используем spread-оператор, чтобы «размазать» оставшиеся пропсы по вложенному компоненту. Кроме того, так как мы делаем именно кнопку отправки, нам необходимо зафиксировать тип кнопки, чтобы его нельзя было сменить снаружи:
<div class="submit">
<Button {...attrs} type="submit">
<slot></slot>
</Button>
</div>
<script>
...
export default {
...
computed: {
attrs: data => {
const { delay, status, ...attrs } = data;
return attrs;
}
},
};
</script>
Довольно просто и элегантно.
В итоге мы получили полностью рабочую, версию базовой кнопки с видоизмененными стилями. Пора заняться реализацией нового поведения кнопки.
Меняем и отслеживаем состояние
Итак, при нажатии на кнопку SubmitButton мы должны не только выкинуть ивент наружу, чтобы пользовательский код мог его обработать (как это сделано в Button), но и осуществить дополнительную бизнес-логику — выставить состояние загрузки. Для этого просто перехватим ивент из базовой кнопки в собственный обработчик, сделаем то что нужно и отправим его дальше:
<div class="submit">
<Button {...attrs} type="submit" on:click="click(event)">
<slot></slot>
</Button>
</div>
<script>
...
export default {
...
methods: {
click(e) {
this.set({ status: 'loading' });
this.fire('click', e);
}
},
};
</script>
Далее, родительский компонент данной кнопки, который управляет самим процессом отправки данных, может выставить соответствующий статус отправки (success/error) через пропсы. При этом, кнопка должна отслеживать подобное изменение статуса, и через заданное время автоматически изменить состояние на idle. Для этого воспользуемся life-cycle хуком onupdate:
<script>
...
export default {
...
onupdate({ current: { status, delay }, changed }) {
if (changed.status && ['success', 'error'].includes(status)) {
setTimeout(() => this.set({ status: 'idle' }), delay);
}
},
};
</script>
Последние штрихи
Остались еще 2 момента, которые не очевидны из задачи и возникают во время реализации. Во-первых, чтобы анимация метаморфозов кнопки была плавной, придется стилями изменять саму кнопку, а не какой-то другой элемент. Для этого мы можем использовать все тот же :global, поэтому тут проблем нет. Но кроме того нужно, чтобы разметка внутри кнопки скрывалась во всех статусах, кроме idle.
Стоит отдельно упомянуть, что разметка внутри кнопки может быть любой и прокидывается она в изначальный компонент базовой кнопки через вложенные слоты. Однако, хотя звучит угрожающе, решение более чем примитивное — нужно лишь обернуть слот, внутри нового компонента в дополнительный элемент и применить к нему необходимые стили:
<div class="submit">
<Button {...attrs} type="submit" on:click="click(event)">
<span><slot></slot></span>
</Button>
</div>
...
<style>
...
.submit span { transition: opacity 0.3s 0.1s; }
.submit.loading span, .submit.success span, .submit.error span { opacity: 0; }
...
</style>
Кроме того, раз кнопка не скрывается со страницы, а морфирует вместе со статусами, хорошо бы отключать ее на время отправки. Иными словами, если кнопке отправки было выставлено свойство disabled через пропсы, либо если status не idle, необходимо отключать кнопку. Для решения этой задачи напишем еще одно небольшое вычисляемое свойство isDisabled и применим его к вложенному компоненту:
<div class="submit">
<Button {...attrs} type="submit" disabled={isDisabled}>
<span><slot></slot></span>
</Button>
</div>
<script>
...
export default {
...
computed: {
...
isDisabled: ({ status, disabled }) => disabled || status !== 'idle'
},
};
</script>
Все бы хорошо, но в базовой кнопке прописан стиль, который делает ее полупрозрачной в disabled-состоянии, а нам это не нужно, если кнопка лишь на время отключена из-за смены статусов. На помощь приходит все тот же :global:
.submit.loading :global(button[disabled]),
.submit.success :global(button[disabled]),
.submit.error :global(button[disabled]) { opacity: 1; }
Вот и все! Новая кнопка прекрасна и готова к работе!
Я умышленно опущу детали реализации анимаций и всего этого. Не только потому что это не имеет прямого отношения к теме статьи, но и потому что в этой части демо получилось не таким, как хотелось бы. Я не стал усложнять себе задачу и реализовывать полностью готовое решение для подобной кнопки и довольно тупо портировал пример, найденный на просторах интернета.
Поэтому не советую использовать данную реализация в работе. Помните, это лишь демо пример для данной статьи.
→ Интерактивная демка и полный код примера
Если вам понравилась статья и захотелось больше узнать про Svelte, читайте и другие статьи. Например, «Как сделать поиск пользователей по GitHub без React + RxJS 6 + Recompose». Слушайте предновогодний подкаст RadioJS#54, где я довольно подробно рассказывал по то, что такое Svelte, как он «исчезает» и почему это не «yet another js framework».
Заглядывайте в русскоязычный телеграм канал SvelteJS. Нас уже больше двух сотен и мы будем рады вам!
P/S
Внезапно UI гайдлайны поменялись. Теперь надписи во всех кнопках приложения должны быть в верхнем регистре. Однако нам такой поворот событий не страшен. Добавим text-transform: uppercase; в стили базовой кнопки и продолжим пить кофе.
Хорошего рабочего дня и вам!
Автор: PaulMaly