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

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

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

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

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

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

Зачем?

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

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

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

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

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

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

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

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

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

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

@/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

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

@/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>

// ...

Локализация

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

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

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

Комментарии (4)


  1. nin-jin
    23.01.2022 01:27

    То, что вы называете модулем - это на самом деле компонент и есть. А то, что вы называете компонентом - это на самом деле шаблон. Ну а то, к чему вы стремитесь, уже реализовано в MAM в куда более элегантной форме.


    1. ushliypakostnik Автор
      23.01.2022 13:16

      Здраствуйте, Дмитрий! Первый каммент под статьей от Вас это уже приятно! Говорю это без всякого сарказма [который может кому-то показаться], совершенно серьезно - я вами восхищаюсь как, имхо, самым ярким персонажем по больнице в теме. Первые две ссылки совершенно адекватные и вы правы - спорить с вами о терминах и теории очевидно глупо с моей стороны, только смиренно внимать - глубина ваших знаний и понимания несоизмерима с моей. В первом общении под моей самой первой статьей мы уже об этом говорили: я же больше "дизайнер" и практик - приходится много работать, даже читать все что хочется и нужно - некогда, выспаться бы иногда...

      Но, все-таки, позволю себе пару возражений. 1) Тем кому может быть полезна эта статья, скорее всего, будет понятнее, ближе, как вы говорите - такая "неправильная" терминология - "модуль/компонент". 2) "То к чему я стремлюсь" - кажется, мне и самому не до конца понятно... "Работу лучше работать" - все мои статьи и примеры (кроме статьи про Three) - это некие, можно сказать, "полевые заметки" на основе будничного рабочего опыта. Ну тоесть - выстраданные реальной конкретной практикой на рабочем месте. А там все такое - не будем...


  1. BlackStar1991
    23.01.2022 12:01

    Всё давным-давно придумано до вас. Ваш велосипед не уникален, и ездите вы по тем же рейкам что и другой мир. Вопрос только в том насколько корректно делаеться оптимизация и не тащите ли вы в каждый из своих проектов, то, что там не надо своей чудо библиоткой (если, да и отказаться или переделать это сложно, то вы тут переподаете плохую практику)


    1. ushliypakostnik Автор
      23.01.2022 13:28
      -1

      Спасибо, конечно, за комментарий, но думаю что он крайне неудачный и не содержит никакой полезной информации или адекватной критики. Давайте проведем небольшой разбор, чтобы была понятна такая моя реакция: коментарий состоит из двух предельно общих мыслей [еще и высказанных неоправданно резко]. В первой части вы, видимо, "открываете мне глаза" на то, что, подозреваю, спроецировали на меня и мою работу - "а я и не претендую" - ну совсем. В моем тексте разве есть хотя бы намек на то, что я считаю свою разработку какой-то особенной, уникальной, прорывной, передовой и так далее? Обычный "отчет о проблемах с которыми столкнулся, и о том как их решал"... Вторая часть еще более некорректные "домыслы". Кажется весь код примеров в открытом доступе - читайте, разворачивайте, тестируйте? В технической дискуссии так нельзя - сделать предположение что что-то не оптимально, "что-то не то импортируется", и что нечто - "плохая практика" - совершенно без пояснения и аргументации почему именно.