Модульный фронтенд для репликационного масштабирования или как перестать копировать репозитории целиком

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В этой статье будут изложены основные идеи и показаны простые примеры для  грамотной организации, скажем так — «репликационного масштабирования» проектов на фронтенде. То есть, само понятие масштабирования здесь будет рассматриваться скорее с той точки зрения и в одном из смыслов как это понимает бизнес, но, при этом, речь будет пойдет именно о технической стороне процесса, правда, сугубо в контексте браузерной клиентской части информационных систем. Ближе к реальной ситуации: предположим что ваша компания разрабатывает, условно — некий OLAP-продукт, и перед вами как фронтенд-разработчиком ставят задачи по развертыванию и поддержке более или менее сходных новых проектов фронтенда для самых разных клиентов. После скандальной критической статьи о, имхо, сомнительных дурных современных подходах и тенденция в верстке веб-интерфейсов — моя карма на Хабре, наконец-то упала ниже нуля, а я, если честно, не очень хорошо понимаю правила игры, увидят ли эту статью читатели… Но, с другой стороны, готов изложить все просто «в стол», так как считаю что лучшая мотивация для написания чего либо — это если «просто очень хочется написать», сформулировать, прежде всего — для себя самого.

Эта статья логично продолжает тематику первой статьи о модулях позволяющих сделать разработку фронтенда качественнее и эффективнее. Но если в первом материале речь шла, прежде всего, об замечательном атомарном тренде в вебдизайне и простом надежном способе доставки его в код компонентных фреймворков с помощью препроцессоров, построении простой кастомной библиотеки UI-компонент для единообразного оформления разных проектов, то новый пример станет немного сложнее — хочется сосредоточиться уже не на «внешних», «оформительских» моментах, а на функциональных и организационных. Для наглядной демонстрации практического применения изложенных в статье идей снова написаны примеры: небольшой модуль-библиотекадокументация к нему), а также использующий его проект, на этот раз с более актуальным стеком Vue3+TypeScript/Vuex4/VuePress2. В отличие от более примитивной либы из первой статьи, этот модуль:

  • Использует хранилище, то есть содержит состояние

  • Может запускаться в полноценном режиме разработки, как будто это собственно уже сам конечный проект

  • Поддерживает темизацию и локализацию

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

Зачем?

Пилите вы и без того малыми ресурсами фронтенд для некоего OLAP-продукта и поначалу у вашей фирмы всего один заказчик — все идет нормально. Но потом вдруг появляется еще один клиент и руководство требует от вас, конечно же, максимально быстро сделать еще один проект — «точно такой же, но немного другой». Что вы будете делать в этом случае? По опыту, особенно если как обычно «нужно еще вчера», а «рук все время не хватает» — вы просто скопируете легаси содержимое первого репозитория в новый. Со всеми его недостатками и недоработками.

Наверное понятно что будет происходить дальше:

Выражающий почти одно и тоже код в репозиториях начнет «разъезжаться», «расползаться». Важные фиксы с высокой вероятностью станут попадать только в один репо. А если вы будете стараться следить за этим — вам придется уныло доставлять одно и тоже в два разных места «ручками». Новый функционал — точно также. А если разработчиков несколько, проекты пилятся разными составами? А если проекта уже три, четыре?... Мрак, хаос и отчаяние…

Очевидно, что все работы на фронте если проектов основанных на одном визуальном языке (то есть в идеале — с почти полностью сходной кодовой базой) больше одного — должны вестись через единое универсальное решение-модуль. Только в этом случае можно говорить о какой-то эффективности и переиспользовании — фирменного стиля, дизайна и верстки . Но это как раз о проблеме которая решалась в первой статье — «модулем-библиотекой статичных UI-элементов» — «вьюх»:

Но «некий OLAP-продукт» скорее всего и на фронтенде требует более сложных компонент, чем просто получающих данные и модификаторы состояния по пропсам. Поэтому сама архитектура дочерних проектов в этом случае будет далеко не идеальной. Нам придется добавлять более сложные компоненты — сообщающиеся с хранилищем или запрашивающие данные с бэкенда в сами дочерние проекты, что по сути дела по-прежнему будет являться дублированием и будет по-прежнему сильно затруднять рефакторинг и модификацию, доставку новых фич:

Модуль-библиотека с состоянием, темезацией, локализацией, документацией и режимом разработки (на Vue3+TS/Vuex4/VuePress2/i18n)

Для решения этих проблем вы можете создать более продвинутый модуль-библиотеку:

Вот теперь, наконец-то можно расслабиться и смело смотреть в будущее. Такое решение позволяет надеяться на то что вы действительно сможете эффективно поддерживать большое количество схожих проектов малыми силами, при обычной нехватке рабочих рук, светлых голов, трудовых ресурсов. Кроме того — вы сможете быть уверены что код во всех ваших проектах оптимальный. Все изменения и улучшения которые необходимо распространить по всем копиям системы будут отправляться строго в одно единственное место:

Мы должны воспринимать как продукт — прежде всего сам модуль. И поэтому он должен обладать всей необходимой общей функциональностью которая затребована от вашей системы, то есть, вероятно — содержать хранилище. Также необходимо иметь возможность запустить и тестировать всю кухню как будто это конечный проект — и поэтому вероятно ей будет нужен собственный роутер. Хранилище мы экспортируем в дочернии проекты, а роутер — нет (так как роутинг реального проекта и для разработки-тестирования центрального ядра — библиотеки — разные сущности). Главная функция библиотеки — предоставление фирменного стиля, компонент и всего специфического функционала. Единственная в идеале функция дочернего проекта — запросы к бэкенду на видах (pages) и проксирование полученных данных в основные компоненты модуля. 

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

@/src/main.ts библиотеки
import { App } from 'vue';
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import store, { key } from './store';
import { createRouter, createWebHistory } from 'vue-router';

// UI Components
import * as components from './components';

// Dev and test components
import Development from './Development.vue';
import TestComponent from './components/TestComponent/TestComponent.vue';

// Constants
import { LANGUAGES, MESSAGES } from '@/utils/constants';

// Localization
const i18n = createI18n({
  legacy: true,
  locale: store.getters['layout/language']
    ? store.getters['layout/language']
    : LANGUAGES[0].name,
  fallbackLocale: LANGUAGES[0].name,
  messages: MESSAGES,
});

// UI Components library with store and localization
const ComponentLibrary = {
  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
  install(app: App) {
    // localization
    app.use(i18n);

    // store
    app.use(store, key);

    // components
    for (const componentName in components) {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const component = (components as any)[componentName];
      app.component(component.name, component);
    }
  },
};

// ATTENTION! Set to true if you want
// to develop a module (not documentation)
// and false before publishing for use in projects
const isDevelopmentModuleMode = false;
if (isDevelopmentModuleMode) {
  console.log('Start development module!');

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const routes: any = [
    {
      path: '/',
      name: 'TestComponent',
      component: TestComponent,
    },
    {
      path: '/route/:id',
      name: 'TestRoute',
      component: () =>
        import(
          /* webpackChunkName: "TestRoute" */ './components/TestRoute/TestRoute.vue'
        ),
    },
    {
      path: '/:catchAll(.*)',
      name: 'NotFound',
      component: () =>
        import(
          /* webpackChunkName: "NotFound" */ './components/NotFound/NotFound.vue'
        ),
    },
  ];

  const router = createRouter({
    history: createWebHistory(process.env.BASE_URL),
    routes,
  });

  createApp(Development).use(i18n).use(store, key).use(router).mount('#app');
}

export default ComponentLibrary;
@/src/main.ts проекта
import { createApp } from 'vue';
import App from './App.vue';

import ComponentLibrary from 'ui-library-starter-2';
import 'ui-library-starter-2/dist/ui-library-starter-2.css';

import { createRouter, createWebHistory } from 'vue-router';

import Home from './views/Home.vue';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const routes: any = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/route/:id',
    name: 'Test',
    component: () =>
      import(/* webpackChunkName: "TestRoute" */ './views/Test.vue'),
  },
  {
    path: '/:catchAll(.*)',
    name: 'NotFound',
    component: () =>
      import(/* webpackChunkName: "NotFound" */ './views/NotFound.vue'),
  },
];

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes,
});

export default router;

createApp(App).use(ComponentLibrary).use(router).mount('#app');

Для того чтобы запустить режим разработки нужно выставить флаг isDevelopmentModuleMode в значение true. А перед отправкой модуля на npm — переключить его обратно. Это, мягко говоря, не очень изящно, но как сделать лучше — я пока не придумал. Если у этой статьи будут читатели — может кто-нибудь подскажет более красивое решение.

Темизация

Очень часто может оказаться что очередной клиент «хочет кнопочки другого цвета». Сложно поверить, но может даже встретится реальный кейс (мне арт-директор сказал) когда темы всех проектов должны быть доступны в одном интерфейсе. Поэтому я организовал возможность простого добавления и простого переключения между любым количеством тем, каждая с двумя режимами (дневной и ночной). Переменные препроцессора предоставляют атомы единственной основной дефолтной темы:

@/src/stylus/utils/_variables.styl
// Palette
//////////////////////////////////////////////////////

$colors = {
  cat: #fed564,
  dog: #8bc24c,
  bird: #7e746e,
  wood: #515bd4,
  stone: #ffffff,
  sea: #13334c,
  sky: #0d2233,
  ball: #b1b1b1,
  rain: #efefef,
}
// Dependencies colors
$colors["text"] = $colors.sky
$colors["header"] = $colors.stone
$colors["content"] = $colors.rain
$colors["placeholder"] = rgba($colors.sea, $opacites.pop)

Добавление новых тем происходит в константах javascript — объект конкретного режима темы должен содержать поля с именами повторяющими набор атомов в препроцессоре:

@/src/utils/constants.ts
export const THEMES: TConfig = {
  theme1: 'theme1',
  theme2: 'theme2',
};

export const MODES: TConfig = {
  mode1: 'light',
  mode2: 'dark',
};

// Design constants
//////////////////////////////////////////////////////

export const DESIGN: TConfig = {
  V: '1.0.0',
  BREAKPOINTS: {
    tablet: 768,
    desktop: 1025,
  },
  THEMES: {
    [THEMES.theme1]: {
      // Light
      [MODES.mode1]: {
        // Palette
        cat: '#fed564',
        dog: '#8bc24c',
        bird: '#fd5f00',
        wood: '#515bd4',
        stone: '#ffffff',
        sea: '#13334c',
        sky: '#dddddd',
        ball: '#b1b1b1',
        rain: '#efefef',

        // Dependencies colors
        text: '#0d2233',
        header: '#ffffff',
        content: '#efefef',
      },
      // Dark
      [MODES.mode2]: {
        // Palette
        cat: '#fed564',
        dog: '#8bc24c',
        bird: '#fd5f00',
        wood: '#515bd4',
        stone: '#ffffff',
        sea: '#13334c',
        sky: '#dddddd',
        ball: '#b1b1b1',
        rain: '#efefef',

        // Dependencies colors
        text: '#ffffff',
        header: '#163C59',
        content: '#0d2233',
      },
    },
    [THEMES.theme2]: {
      // Light
      [MODES.mode1]: {
        // Palette
        cat: '#fd5f00',
        dog: '#8bc24c',
        bird: '#fed564',
        wood: '#515bd4',
        stone: '#ffffff',
        sea: '#3A0061',
        sky: '#f9f9f9',
        ball: '#b1b1b1',
        rain: '#efefef',

        // Dependencies colors
        text: '#1F0033',
        header: '#ffffff',
        content: '#efefef',
      },
      // Dark
      [MODES.mode2]: {
        // Palette
        cat: '#fd5f00',
        dog: '#8bc24c',
        bird: '#fed564',
        wood: '#515bd4',
        stone: '#ffffff',
        sea: '#3A0061',
        sky: '#f9f9f9',
        ball: '#b1b1b1',
        rain: '#efefef',

        // Dependencies colors
        text: '#ffffff',
        header: '#5D009C',
        content: '#1F0033',
      },
    },
  },
};

Теперь можно использовать Custom Properties c соответствующими именами, после переменных препроцессора остающихся в качестве фоллбэка:

.selector
  color $colors.text
  color var(--text)

Потому как в лейауте:

@/src/components/Layout/Layout.vue
// ...

<script>
import { defineComponent, computed, onBeforeMount, watch } from 'vue';
import { useStore } from '../../store';

import { DESIGN, THEMES, MODES } from '../../utils/constants';

import LangSwitch from './LangSwitch.vue';
import Menu from '../Menu';

export default defineComponent({
  name: 'Layout',

  components: {
    LangSwitch,
    Menu,
  },

  setup() {
    const store = useStore();

    let toggleLayout;
    let toggleMode;
    let toggleTheme;
    let setThemeOrMode;
    const isMenuOpen = computed(() => store.getters['layout/isMenuOpen']);
    const theme = computed(() => store.getters['layout/theme']);
    const mode = computed(() => store.getters['layout/mode']);

    toggleLayout = () => {
      store.dispatch('layout/setLayout', {
        field: 'isMenuOpen',
        value: !isMenuOpen.value,
      });
    };

    toggleMode = () => {
      store.dispatch('layout/setLayout', {
        field: 'mode',
        value: mode.value === MODES.mode1 ? MODES.mode2 : MODES.mode1,
      });
    };

    toggleTheme = (theme) => {
      store.dispatch('layout/setLayout', {
        field: 'theme',
        value: theme,
      });
    };

    watch(
      () => store.getters['layout/mode'],
      () => {
        setThemeOrMode();
      },
    );

    watch(
      () => store.getters['layout/theme'],
      () => {
        setThemeOrMode();
      },
    );

    setThemeOrMode = () => {
      for (const color in DESIGN.THEMES[theme.value][mode.value]) {
        document.documentElement.style.setProperty(
          `--${color}`,
          DESIGN.THEMES[theme.value][mode.value][color],
        );
      }
    };

    onBeforeMount(() => {
      setThemeOrMode();
    });

    return {
      THEMES,
      MODES,
      isMenuOpen,
      mode,
      theme,
      toggleLayout,
      toggleTheme,
      toggleMode,
    };
  },
});
</script>

// ...

Локализация

А вот локализация мне не совсем нравится. Я прикрутил ее до кучи в последний момент, так как такая возможность кажется очень важной-полезной в свете остальных качеств, целей и задач разработки. Но то что все переводы дочерних проектов должны скопом лежать в константах либы — кажется весьма сомнительным и не оптимальным. С другой стороны — у меня пока не было возможности проработать и улучшить это в реальной ситуации — конкретные проекты по мотивам которых написана статья и примеры — используют только один язык. У любой реализации всегда можно найти несовершенные моменты и точки роста.

Выводы которые желательно сделать в конце статьи на Хабре )

Все дизайнеры и разработчики фронтенда привычно используют различные сторонние готовые библиотеки и модули. Но существуют реальные коммерческие ситуации когда подобный модуль просто необходимо написать и внедрить самому. Бизнес и руководство, менеджеры часто стремятся любыми способами удовлетворить клиента, часто в условиях жесткой нехватки ресурсов. Ваше качество жизни на работе в этих ситуациях напрямую зависит от того насколько качественными, аккуратными, оптимальными будут подходы и архитектуры которые вы используете на своих боевых проектах. Творите!

Источник: https://habr.com/ru/post/647179/


Интересные статьи

Интересные статьи

Кто бы что ни говорил, но я считаю, что изобретение велосипедов — штука полезная. Использование готовых библиотек и фреймворков, конечно, хорошо, но порой стоит их отложить и создать ...
Для того, чтобы получить реалистичный мир внутри игры, необходимо учитывать взаимодействие различных форм рельефа между собой и с другими моделями. И если видимые линии пересечения между 3D-модел...
Если честно, к Д7 у меня несколько неоднозначное отношение. В некоторых местах я попискиваю от восторга, а в некоторых хочется топать ногами и ругаться неприличными словами.
В 1С Битрикс есть специальные сущности под названием “Информационные блоки, сокращенно (инфоблоки)“, я думаю каждый с ними знаком, но не каждый понимает, что это такое и для чего они нужны
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.