В данной статье повествуется история успешного внедрения дизайн-системы в компании одного из крупнейших DIY-ритейлеров. Описываются принципы и подходы кросс-платформенной разработки UI компонентов с использованием библиотек React и React Native, а также решение задачи переиспользования кода между проектами для разных платформ.

Для начала несколько слов о том, как все это начиналось, и почему вообще возникла идея реализации дизайн системы. А начиналось все с мобильного Android приложения для продавцов в магазинах. Приложение строится на фреймворке React-Native. Стартовый функционал был представлен всего несколькими модулями, таких как поиск товаров по каталогу и карточка товара, документ продажи. К слову, сейчас это достаточно мощное приложение, которое уже во многом заменило функционал информационных стоек в магазинах.

Далее стартовали проекты web-приложений для сотрудников отдела логистики, а также различные конфигураторы.

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

Для систематизации UI/UX было решено разработать дизайн систему. Не буду вдаваться в подробности о том что это такое. На просторах интернета можно найти множество статей на эту тему. Например, на Хабре можно рекомендовать к прочтению труд Андрея Сундиева.

Почему именно дизайн система и в чем ее преимущества? Первое — единый опыт и ощущение от использования продуктов. Пользователи получают знакомый интерфейс вне зависимости от приложения: кнопки выглядят и работают так, как они привыкли, меню открывается в нужном месте и с нужной динамикой, поля ввода работают привычным образом. Второе преимущество — введение неких стандартов и общих подходов как со стороны дизайна, так и со стороны разработки. Каждый новый функционал разрабатывается по уже отработанным канонам и подходам. Новые сотрудники с первых дней получают четкое направление работы. Следующее — переиспользование компонентов и упрощение разработки. Нет нужды каждый раз “изобретать велосипед”. Можно строить интерфейсы из готовых блоков с ожидаемым конечным результатом. Ну и основное преимущество в первую очередь для заказчика — экономия денег и времени.

Итак, что же сделали мы. На самом деле мы создали не просто библиотеку компонентов, а целый кросс-платформенный фреймворк. В основе фреймворка лежит пакетная схема. У нас 5 основных npm пакетов. Это ядро для разворачивания кросс-платформенных приложения для web и Android. Пакеты модулей, утилит и сервисов. И пакет компонентов, о котором и пойдет речь далее.
Ниже представлена UML схема пакета компонентов.

image

В него входят собственно сами компоненты, некоторые из которых независимы (элементы), а некоторые связаны друг с другом, а также внутреннее ядро или “подъядро”.

Рассмотрим более подробно что же входит в “подъядро”. Первое — это визуальный слой дизайн системы. Здесь все что касается цветовой палитры, типографики, системы отступов, сетки и т.д. Следующий блок — сервисы, необходимые для работы компонентов, как то: ComponentsConfig (конфиг компонентов), StyleSet (далее я расскажу более подробно об этом понятии) и Device (метода для работы с api устройства). И третий блок — это всевозможные хелперы (резолверы, генераторы стилей и др.).

image

При разработке библиотеки мы использовали атомарный подход к проектированию компонентов. Началось все с создания элементарных компонентов или элементов. Они представляют собой элементарные “частицы”, которые не зависят друг от друга. Основные из них это View, Text, Image, Icon. Далее идут более сложные компоненты. Каждый из них использует один или несколько элементов для построения своей структуры. Например, кнопки, поля ввода, селекты и т.д. Следующий уровень — это паттерны. Представляют собой комбинацию компонентов для решения какой-либо UI-задачи. Например, форма авторизации, шапка с параметрами и настройками или карточка товара, спроектированная дизайнером, которую можно использовать в разных модулях. Последний и наиболее сложный и в то же время важный уровень — так называемые поведения. Это готовые к использованию модули, имплементирующие определенную бизнес логику и, возможно, включающие в себя необходимый набор запросов к back-end.

image

Итак, перейдем к реализации библиотеки компонентов. Как я упоминал раньше, у нас две целевые платформы — web и Android (react-native). Если на web это хорошо знакомые всем web-разработчикам элементы типа div, span, img, header и т.д., то в react-native — это компоненты View, Text, Image, Modal. И первое о чем мы договорились — это наименование компонентов. Решили использовать систему в стиле react-native, т.к. во-первых, уже была реализована некоторая компонентная база в проектах, а во-вторых, эти названия наиболее универсальны и понятны как web, так и react-native разработчикам. Для примера рассмотрим компонент View. Условный рендер метод компонента для web выглядит примерно вот так:

render() {
	return(
		<div {...props}>
			{children}
		</div>
	)
}

Т.е. под капотом это ничто иное, как div с необходимыми пропсами и потомками. В react-native структура очень похожа, только вместо див используется компонент View:

render() {
	return(
		<View {...props}>
			{children}
		</View>
	)
}

Возникает вопрос: как же объединить это в один компонент и в то же время разделить рендеринг?

Тут на помощь приходит React паттерн под названием HOC или Higher Order Component. Если попытаться изобразить UML диаграмму этого паттерна, то получится примерно следующее:

image

Таким образом, каждый компонент состоит из так называемого делегата, который получает пропсы извне и отвечает за общую для обеих платформ логику, и двух платформенных частей, в которых уже инкапсулированы специфичные для каждой платформы методы и самое главное рендеринг. Для примера рассмотрим код делегата кнопки:

export default function buttonDelegate(ReactComponent: ComponentType<Props>): ComponentType<Props> {
    return class ButtonDelegate extends PureComponent<Props> {
        
        // Button common methods

        render() {
           const { onPress, onPressIn, onPressOut } = this.props;
            const delegate = {
                buttonContent: this.buttonContent,
                buttonSize: this.buttonSize,
                iconSize: this.iconSize,
                onClick: onPress,
                onMouseUp: onPressIn,
                onMouseDown: onPressOut,
                onPress: this.onPress,
                textColor: this.textColor,
            };
            return (<ReactComponent {...this.props} delegate={delegate} />);
        }
    };
}

Делегат получает в качестве аргумента платформенную часть компонента, реализует общие для обеих платформ методы и передает их в платформенную часть. Сама платформенная часть компонента выглядит следующим образом:

class Button extends PureComponent<WebProps, State> {
    
   // Web specific methods

    render() {
        const { delegate: { onPress, buttonContent } } = this.props;
        return (
            <button
                className={this.classes}
                {...buttonProps}
                onClick={onPress}
                style={style}
            >
                {buttonContent(this.spinner, this.iconText)}
            </button>
        );
    }
}

export default buttonDelegate(Button);

Здесь располагается рендер метод со всеми своими платформенными особенностями. Общий функционал из делегата приходит в виде объекта через пропс delegate. Пример платформенной части кнопки для react-native реализации:

class Button extends PureComponent<NativeProps, State> {

    // Native specific methods

    render() {
        const { delegate: { onPress, buttonContent } } = this.props;
        return (
            <View styleSet={this.styles} style={style}>
                <TouchableOpacity
                    {...butonProps}
                    onPress={onPress}
                    style={this.touchableStyles}
                    {...touchableProps}    
                >
                    {buttonContent(this.spinner, this.iconText)}
                </TouchableOpacity>
            </View>
        );
    }
}

export default buttonDelegate(Button);

В данном случае логика похожа, но используются компоненты react-native. В обоих листингах buttonDelegate — HOC с общей логикой.

При таком подходе в реализации компонентов встает вопрос о разделении платформенных частей при сборке проекта. Необходимо сделать так, чтобы webpack, используемый нами в проектах под web собирал только части компонентов, предназначенные для web, тогда как metro bundler в react-native должен “цеплять” свои платформенные части, не обращая внимания на компонент для web.

Для решения данной задачи воспользовались встроенной возможностью metro bundler, позволяющей указать платформенный префикс расширения файлов. В нашем случае metro.config.js выглядит так:

module.exports = {
    resolver: {
        useWatchman: false,
        platforms: ['native'],
    },
};

Таким образом, при сборке бандла metro сначала ищет файлы с расширением native.js, а затем, если такового не находится в текущем каталоге, цепляет файл с расширением .js. Данная функциональность дала возможность разместить платформенные части компонентов в отдельных файлах: часть для web размещается в файле .js, react-native часть размещается в файле с расширением .native.js.

Кстати, аналогичным функционалом обладает и webpack с помощью NormalModuleReplacementPlugin.

Еще одной задачей кросс-платформенного подхода было обеспечить единый механизм стилизации компонентов. В случае с web приложениями нами был выбран препроцессор sass, который в конечном итоге компилируется в обычный css. Т.е. для web компонентов мы использовали знакомые react разработчикам className.

Компоненты react-native стилизуются через инлайн стили и пропс style. Необходимо было совместить эти два подхода, дав возможность использовать стилевые классы для Android приложений. С этой целью было введено понятие styleSet, которое есть ничто иное, как массив строк — имен классов:

styleSet: Array<string>

При этом для react-native был реализован одноименный сервис StyleSet, который позволяет регистрировать имена классов:

export default StyleSet.define({
    'lmui-Button': {
        borderRadius: 6,
    },
    'lmui-Button-buttonSize-md': {
        paddingTop: 4,
        paddingBottom: 4,
        paddingLeft: 12,
        paddingRight: 12,
    },
    'lmui-Button-buttonSize-lg': {
        paddingTop: 8,
        paddingBottom: 8,
        paddingLeft: 16,
        paddingRight: 16,
    },
})

Для web компонентов styleSet — массив имен css классов, которые “склеиваются” с помощью библиотеки classnames.

Поскольку проект является кросс-платформенным, то очевидно, что с ростом кодовой базы растет и число внешних зависимостей. При этом зависимости различные для каждой платформы. К примеру, для web компонентов необходимы такие библиотеки, как style-loader, react-dom, classnames, webpack и др. Для react-native компонентов используется большое количество своих “нативных” библиотек, например сам react-native. Если проект, в котором предполагается использовать библиотеку компонентов имеет только одну целевую платформу, то устанавливать все зависимости от другой платформы иррационально. Для решения этой задачи воспользовались postinstall хуком самого npm, в котором был прописан скрипт для установки зависимостей для указанной платформы. Сами зависимости прописывались в соответствующем разделе package.json пакета, а целевая платформа должна указываться в проектном package.json в виде массива.
Однако, у этого подхода обнаружился недостаток, который впоследствии несколько раз оборачивался проблемами при сборке в системе CI. Корень проблемы оказался в том, что при наличии package-lock.json скрипт, указанный в postinstall не устанавливал всех прописанных зависимостей.

Пришлось искать другое решение данной задачи. Решение оказалось простым. Была применена двух-пакетная схема, при которой все платформенные зависимости выносились в раздел dependencies соответствующего платформенного пакета. К примеру, в случае с web пакет называется components-web, в котором один единственный файл package.json. В нем прописаны все зависимости для платформы web, а также основной пакет с компонентами components. Такой подход позволил сохранить разделение зависимостей и сохранить функциональность package-lock.json.

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

<View row>
   <View
      col-xs={12}
      col-md={8}
      col-lg={4}
      col-xl={4}
      middle-xs
      col-md-offset-3
   />
     <Text size=”fs1”>Sample text</Text>
   </View>
</View>

Данный фрагмент кода является кросс-платформенным и работает одинакова в react приложении для web и в Android приложении на react-native. При необходимости этот же код можно “завести” и под iOS.

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