Привет, Хабр! С вами Вадим Райский, руководитель IT-проектов Fix Price, и сегодня я расскажу вам, как и зачем мы разработали отдельный корпоративный UI-кит для наших бэк-офисных систем.
В компании множество внутренних сервисов, и постоянно пишутся и планируются новые под различные бизнес-задачи. У части сервисов есть только api, другие имеют и клиентскую часть (фронт), которую далее мы и будем обсуждать. Разумеется, для разработки и поддержки сервисов требуется много людей. И всем понятно, что нет двух разработчиков, пишущих одинаково — каждый по-своему видит реализацию фронта. Но если каждый программист будет писать свою версию, это приведет к проблемам: например, увеличится время на разработку одного и того же функционала в разных сервисах, невозможно будет поменять функционал сразу в нескольких местах или же будет наблюдаться различное поведение в разных сервисах.
Для начала давайте обозначим важные особенности организации процесса выполнения задач на фронте на примере фреймворка (в нашем случае Vue 3). Представим, с какими проблемами может столкнуться разработчик при написании фронта для нового сервиса, и как их можно решить.
Проблемы и их решение
Скорее всего первое, о чём подумает программист, увидев макет: писать всё с нуля или взять похожие готовые реализации? Вероятно, ему укажут на существующий проект, в котором придется разбираться, искать нужные куски, дублировать код, а затем, возможно, еще и дописывать часть логики от себя.
Первый случай. В проекте может применяться библиотека наподобие Bootstrap 5 совсем без кастомизации, а может быть и пакет, заточенный под текущую версию Vue 3 — например, bootstrap-vue. Оба варианта неплохи изначально и подходят для быстрой разработки, но сильно теряют в последующем, поскольку, во-первых, это проекты сторонних вендоров, которые вряд ли оперативно исправят баги. А во-вторых, в них содержится намного больше, чем требуется, и очевидная кастомизация сопряжена с ухудшением качества кода и окажется просто невыгодна.
Второй случай. Это когда на проекте всё самописное. И это куда хуже, поскольку здесь разработчику остается только мечтать о том, как было бы удобно взять нужный компонент из библиотеки (желательно из библиотеки компании). Тогда, если бы пришлось что-то дописывать, то после недолгой консультации и согласования с командой программист быстро сделал бы это. Но в данном случае это оказывается невозможным.
Добавлю, что часто реализация новых фич в компоненте требует серьезного рефакторинга, а значит может измениться интерфейс взаимодействия с ним. И чтобы не сломать фронты использующих его сервисов, необходимо также ввести версионность.
Приближаясь к написанию своей библиотеки компонентов, жизненно важно определиться с подходом и моделями проектирования, продумать интерфейсы будущих «кирпичиков» страницы, предусмотреть документацию. Опыт разработки показывает, что:
простые компоненты лучше всего писать самостоятельно;
небольшие, но сложные, удобнее организовывать как обертки над сторонними решениями;
большие же дешевле писать полностью с нуля, осторожно внедряя сторонние пакеты.
Теперь о проблемах, связанных с ростом библиотеки:
Разработчики не всегда вносят изменения в сам ui-kit, порой компоненты просто подгоняются под текущие нужды (имею в виду постоянные изменения, которые производятся по несколько раз в рамках одного проекта или в разных).
Если разработчик всё же вносит изменения в саму библиотеку, некоторые компоненты могут начать работать неправильно после обновления (например, те, которые были локально «допилены» в одном из проектов). Это решается при помощью версионности.
Чем больше библиотека, тем больше компоненты могут «впитывать» ненужный функционал (например, кто-то добавил в компонент функционал, который нужен только на одной странице в одном проекте). Это приводит к раздуванию библиотеки и потенциально влечет за собой потери в производительности.
Для использования определенного компонента разработчику может понадобиться время на внесение его в библиотеку (либо ему нужно дождаться появления компонента там по запросу). Или придется писать временный вариант, который потом надо будет заменить, что тоже не продуктивно.
Разумеется, при должном уровне контроля за качеством библиотеки (особенно, если дать четкие указания разработчикам как действовать в тех или иных ситуациях) всех этих проблем можно избежать, однако их всегда стоит учитывать.
Что получилось
Такие мысли подтолкнули нас к написанию внутренней ui-библиотеки с нехитрым названием ui-kit, в которой мы реализовали достаточно компонентов форм и отдельные интерфейсы, покрывающие на данный момент все потребности формирования пользовательской стороны приложения. Для нормальной визуализации возможностей библиотеки мы взяли за основу платформу документирования StoryBook.
С тех пор, как мы начали интегрировать ui-kit в новые системы, увеличилась скорость разработки, многократно улучшилась поддержка сервисов и иные ключевые показатели. В данный момент под использование ui-kit переписывается несколько фронтов, уже работающих в продакшене.
Подключение UI-Kit
В каждом нашем проекте используется почти все компоненты из библиотеки, поэтому стили билдятся в общий файл styles.css. Для использования компонентов необходимо:
Импортировать стили:
@import '@vendor/ui-kit/dist/style.css';Импортировать собственно нужные компоненты:
<template>
<div>
<custom-button>Поднять ЗП!</custom-button>
</div>
</template>
<script setup>
import { CustomButton } from '@vendor/ui-kit';
</script>
Код компонента CustomButton.vue
<template>
<button
type="button"
class="custom-button"
:class="{
'custom-button_interactive': interactive,
'custom-button_persistent-active': isPersistentActive,
[themeClass]: true,
[borderRadiusClass]: true,
'custom-button_full-width': fullWidth,
'custom-button_no-padding': noPadding,
'custom-button_no-border': noBorder,
'custom-button_disabled': disabled,
}"
:disabled="disabled"
@mouseover="onMouseOver"
@mouseout="onMouseOut"
@focus="onFocus"
@blur="onBlur"
@click="onClick"
>
<span class="custom-button__content">
<icon-base
v-if="iconParams"
v-bind="iconParams"
class="custom-button__icon"
/>
<slot :is-dark-background="isComponentHaveDarkBackground" />
</span>
</button>
</template>
<script>
// UI components
import IconBase from '@/components/interfaces/Icons/IconBase.vue';
// Config
import themes from './themes';
export default {
name: 'CustomButton',
components: { IconBase },
emits: ['click', 'mouseover', 'mouseout', 'focus', 'blur'],
props: {
theme: {
type: String,
default: themes.GREY,
validator: (value) => [
themes.GREY, themes.GREEN, themes.RED, themes.HONEY, themes.WHITE,
].includes(value),
},
/**
* Заливка кнопки цветом текущей темы.
* Если "false" - цветом выделяются только текст и рамки.
*/
fill: Boolean,
/**
* Свойство используется, если "interactive = true"
*/
active: {
type: Boolean,
default: false,
},
/**
* При значении "true" фон кнопки может меняться
*/
interactive: {
type: Boolean,
default: false,
},
fullWidth: {
type: Boolean,
default: false,
},
noPadding: {
type: Boolean,
default: false,
},
noBorder: {
type: Boolean,
default: false,
},
borderRadiusSize: {
type: String,
default: 'middle',
validator: (value) => ['small', 'middle', 'big'].includes(value),
},
iconParams: {
type: Object,
default: () => null,
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
isOnHover: false,
isInFocus: false,
};
},
computed: {
isPersistentActive() {
return this.interactive && this.active;
},
/**
* Возвращает истину, когда текущий фон кнопки тёмный
*
* @returns {boolean}
*/
isComponentHaveDarkBackground() {
let backgroundColorWasChanged = false;
if (this.interactive) {
backgroundColorWasChanged = true;
if (!this.isOnHover && !this.isInFocus) {
backgroundColorWasChanged = false;
}
}
if (this.isPersistentActive && !this.isOnHover && !this.isInFocus) {
backgroundColorWasChanged = true;
}
// Темы "green" и "white" на данный момент имеют те же значения "background-color"
// в активном состоянии.
// Тема "grey" в активном состоянии меняет "background-color: grey -> green",
// из чего следует, что если фон изменился, значит он тёмный.
switch (this.theme) {
case themes.GREEN:
case themes.RED: return true;
case themes.GREY: return backgroundColorWasChanged;
case themes.WHITE: return false;
default: return false;
}
},
themeClass() {
return `custom-button_theme-${this.theme}`;
},
borderRadiusClass() {
return `custom-button_border-radius_${this.borderRadiusSize}`;
},
},
methods: {
setHover(value) {
if (this.interactive) {
this.isOnHover = value;
}
},
setFocus(value) {
if (this.interactive) {
this.isInFocus = value;
}
},
onMouseOver(event) {
this.$emit('mouseover', event);
this.setHover(true);
},
onMouseOut(event) {
this.$emit('mouseout', event);
this.setHover(false);
},
onFocus(event) {
this.$emit('focus', event);
this.setFocus(true);
},
onBlur(event) {
this.$emit('blur', event);
this.setFocus(false);
},
onClick(event) {
this.$emit('click', event);
},
},
};
</script>
<style lang="scss" scoped>
@import '@/assets/sass/colors';
$u-breakpoint-md: 768px;
$custom-button-themes: (
grey: (
color: $u-dark,
background-color: #EEF0EA,
border-color: #EEF0EA,
border-style: solid,
active-color: $u-white,
active-background-color: $u-green,
active-border-color: $u-green,
),
green: (
color: $u-white,
background-color: $u-green,
border-color: $u-green,
border-style: solid,
active-color: $u-white,
active-background-color: $u-green,
active-border-color: $u-green,
),
red: (
color: $u-white,
background-color: $u-danger,
border-color: $u-danger,
border-style: solid,
active-color: $u-white,
active-background-color: $u-danger,
active-border-color: $u-danger,
),
honey: (
color: $u-green,
background-color: #F6FCEB,
border-color: $u-green,
border-style: dashed,
active-color: $u-green,
active-background-color: #F6FCEB,
active-border-color: $u-green,
),
white: (
color: $u-green,
background-color: $u-white,
border-color: $u-green,
border-style: solid,
active-color: $u-green,
active-background-color: $u-white,
active-border-color: $u-green,
),
);
@mixin custom-button-themes($component) {
@each $theme, $custom-button-theme in $custom-button-themes {
&_theme-#{$theme} {
color: map-get($custom-button-theme, 'color');
background-color: map-get($custom-button-theme, 'background-color');
border: 1px map-get($custom-button-theme, 'border-style') map-get($custom-button-theme, 'border-color');
&#{$component}_interactive {
&#{$component}_persistent-active {
color: map-get($custom-button-theme, 'active-color');
background-color: map-get($custom-button-theme, 'active-background-color');
border: 1px solid map-get($custom-button-theme, 'active-border-color');
}
&:not(#{$component}_persistent-active) {
&:hover,
&:active,
&:focus {
color: map-get($custom-button-theme, 'active-color');
background-color: map-get($custom-button-theme, 'active-background-color');
border: 1px solid map-get($custom-button-theme, 'active-border-color');
}
}
}
}
}
}
// Other
$custom-button-border-radius-small: 4px;
$custom-button-border-radius-middle: 6px;
$custom-button-border-radius-big: 8px;
.custom-button {
$root: &;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 10px 16px;
font-size: 15px;
font-weight: 500;
line-height: 18px;
word-break: break-all;
border: 0;
cursor: pointer;
transition: 0.2s ease all;
@include custom-button-themes($root);
& > *,
& > *:after,
& > *:before {
pointer-events: none;
}
&_full-width {
width: 100%;
}
&_no-padding {
padding: 0 !important;
}
&_no-border{
border: none;
}
&_border-radius_small {
border-radius: $custom-button-border-radius-small;
}
&_border-radius_middle {
border-radius: $custom-button-border-radius-middle;
}
&_border-radius_big {
border-radius: $custom-button-border-radius-big;
}
&__content {
display: inline-flex;
align-items: center;
gap: 10px;
}
&_disabled {
opacity: 0.7;
user-select: none;
pointer-events: none;
}
@media (max-width: $u-breakpoint-md) {
padding: 3px 6px;
}
}
</style>
Навигация в StoryBook
Визуализация интерфейсов и поведения компонента в StoryBook на примере компонента Tree (Дерево).
Общий вид
Вид по умолчанию
С возможностью выбора узлов дерева
С фильтрацией
Список всех свойств для настройки нужного поведения и вида дерева
События и слоты
Например, слот nodeLabel${nodeId} используется для отображения желаемого содержимого узла дерева. А событие nodesSelected возвращает ids выбранных узлов.