Как стать автором
Обновить

Азбука вкуса, Nuxt и наш тернистый путь к микрофронтам

Время на прочтение11 мин
Количество просмотров9.1K

Привет, Хабр! Я - Senior Frontend Developer в Азбуке вкуса. В данный момент мы переносим наш сайт с легаси на новый движок, и мне довелось стать архитектором этого переноса.

Переход с легаси (jQuery + Java или PHP) был необходим по нескольким причинам. Самое очевидное - множество разного стэка (где-то Bitrix, где-то что-то еще), у которого нет чётких требований, на чем и как делать.

А ещё - весь HTML генерировался сервером, и фронтендеру нужно было поднимать собственно бэкенд и разрабатывать в его архитектуре. Это сильно усложняло разработку.

Ну и конечно разрабатывать на jQuery в 2021 году не очень классно, особенно с видневшимися на горизонте перспективами создания UI Kit.

Новая архитектура представляет из себя Vue 2 + Nuxt 2 с поддержкой Typescript.

Проблематика

В начале переноса все понимали, что структура будет разрастаться. Ещё на этом этапе я начал готовить микрофронты, но сейчас не об этом. Основные проблемы, которые возникли во время работы над проектом в «монорепе» без разделения, заключались в:

  1. Vuex. При передаче информации с SSR на CSR, Nuxt передаёт все модули Vuex, которые у него есть.

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

  2. Структуризация. Хочется, чтобы было чёткое разделение на подмодули в проекте: это помогает организовать процесс работы, ограничить скоуп задач и разделять архитектуру. Микрофронты это скорее вытекающее - для хорошей структуры это не столь необходимо;

  3. Версионирование. При работе над множеством страниц, хочется, чтобы при ошибке в одной можно было откатить только её, а не весь релиз. Разумеется, если этого позволяет совместимость с API и другими глобальными методами в данном релизе;

  4. Разделение сборки:

    1. Разработчику не должно быть обязательно собирать вообще всё, даже то, чем он не будет пользоваться для разработки: например, для разработки какого-то лендинга внутри проекта ему не нужно собирать главную страницу, и наоборот

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

  5. Разделение иконок. Мы сделали отличный плагин для иконок, которому я не нашел аналогов в открытом доступе (возможно, я плохо искал). Проблема была лишь в том, что в первичной реализации в сборку попадали все иконки сразу (require(`./icons/${name}.svg`) и вуаля, Webpack собирает все иконки в один бандл)

Попытка номер раз. Дробим репозитории

В самом начале разработки я видел только проблемы 2, 3 и 4. Работа с иконками реализована не была, Vuex был маленьким.

Решили идти от концепции разделения на репозитории. Один репозиторий - один проект.

Мгновенно столкнулись с проблемой: документированность разработки под Nuxt. Такой же проблемой страдает и сам Vue. Вы когда-нибудь задумывались о том, как передавать параметры при регистрации плагина через модуль? А если в параметрах есть объект? Искать пришлось по репозиториям от разработчиков Nuxt.

Благо, в Nuxt 3 будет Nuxt Kit и проблем станет меньше.

Конфигурация

  1. Набор routes для использования в extend для Vue Router.

  2. Набор SCSS файлов.

  3. Набор плагинов.

  4. Набор Vuex Store.

Кратко пройдемся по реализации:

  1. Делаем this.nuxt.extendRoutes и закидываем пути в routes, не забыв вызвать resolve с переданным путём к компоненту.

  2. Добавляем пути с lang: 'scss' в this.nuxt.options.styleResources.scss и this.nuxt.options.css.

  3. Вообще всё легко:

    this.nuxt.addPlugin({
        src: plugin.path,
        ssr: plugin.ssr,
    });

На Vuex остановимся поподробнее. На этом этапе мне стало казаться, что я иду куда-то не туда.

this.nuxt.addPlugin({
   src: join(__dirname, 'nuxt-vuex.js'),
   options: {
      keys: Object.keys(this.config.vuexStore).join(','),
      values: this.config.vuexStore,
   },
});

А теперь посмотрим на сам nuxt-vuex.js

const vuexPlugin = async (context) => {
<% options.keys.split(',').forEach((key) => { %>
    context.store.registerModule('<%= key %>', {
    ...require('<%= serializeFunction(options.values[key]).replace('"', '').replace('"', '') %>'),
    namespaced: true,
    }, { preserveState: context.isClient });
<% }); %>
};

export default vuexPlugin;

Давайте разберем, что тут происходит:

  1. <% и %> нужны для того, чтобы брать переданные настройки. По-другому не работает.

  2. Object.entries, for in и т.д. использовать на объекте я не смог. На этапе добавления преобразовали ключи в массив и проходимся по ним.

  3. Строка 4. Вызываем не документированную serializeFunction, убираем две кавычки, которые почему-то появляются, а затем делаем require нашего объекта (без этого не работает).

  4. Строка 7. Закрывать цикл, открытый в template tags, надо в них же.

Опустим время, которое я потратил на это, оно работало и регистрировало Vuex. Регистрация нового модуля этого выглядит так:

new AVPlatformConfig({
	nuxt: this,
	config: {
		routes: [{path: '/', component: join(__dirname, 'src/pages/index.vue')}],
		scss: [
			{
				//А тут join не работает
				src: `${ __dirname }/../scss/variables.scss`,
				//Чтобы вставлять CSS в начало и конец
				strategy: 'unshift',
			},
    ],
		plugins: [
			{
				path: join(__dirname, 'nuxt-plugin.js'),
				ssr: true,
			},
    ],
		vuexStore: {
			myModule: {
				state, 
				actions,
				namespaced: true,
				modules: {
					myNestedModule: {...},
				}
			}
 		},
	},
}).init();

Получилось не столь оптимально. Надо передавать nuxt: this, подмодули регистрируются глобально отдельным плагином при передаче в момент инициализации в myModule, можно регистрировать Vuex с любым названием. Но это работало.

Проблемы этого решения

  1. Как подключать проект для локальной разработки под Hot Reload? Прокинули Volume в Docker Compose на релятивный путь для разработчика к его проекту. А если нужно несколько проектов?

  2. Как работать с ассетами? По какому пути их получать? Только релятивно, выходит, потому что компоненты не собираются, а подключаются как есть (чтобы работал Code Splitting).

  3. Как получать доступ к проектам, которые нужны для сборки? Это ведь нужно делать при yarn install. Как избежать конфликтов с модулем, подключенным локально?

  4. Откуда получать конфигурацию tsconfig?

  5. Как использовать глобальные компоненты? Или не глобальные, а смежные? Делать отдельный репозиторий с UI Kit?

  6. Как использовать layout для конкретно этого проекта?

  7. Как писать тесты? Откуда брать конфигурацию к ним?

  8. Если тесты писать локально, как их собирать? Откуда брать конфигурацию Nuxt Config? Костылями доставать из основного?

  9. Как делать autocomplete для SCSS переменных? Выносить их в отдельную библиотеку?

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

Стоит также упомянуть, что мы всё-таки сделали отдельную библиотеку с набором компонентов, SCSS переменных и т.д. Не сказать, что это решило оставшиеся проблемы, и это всё еще местами было странным решением для наших проблем.

Попытка номер два

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

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

Шаг 1. Переменные

Раз уж у нас всё локально, надо понимать, что нам собирать. Лучшим решением стала переменная.

За набор проектов отвечает переменная PROJECTS в environment. Она предполагает следующие вариации:

  • Пустая строка. В этой конфигурации берутся все проекты из ключа "projects" в package.json или папки src/projects в случае локальной разработки
    PROJECTS=

  • Строка начинающаяся на "!" (без кавычек). В этой конфигурации будут также включены все возможные проекты, но без тех, которые указаны после восклицательного знака (проекты разделяются запятой без пробелов)
    PROJECTS=!micromodule-test,something-else (dev) или PROJECTS=!@av.ru/micro-module-test,@av.ru/something-else ( prod)

  • Перечень проектов через запятую без пробелов. Включаются только указанные проекты
    PROJECTS=micromodule-test,something-else,@av.ru/if-you-need-specific-version-from-npm@0.0.2 (dev) или PROJECTS=@av.ru/micro-module-test,@av.ru/something-else (prod)

  • false. Проекты не будут подключены
    PROJECTS=false

Что мы тут предусмотрели:

  • Можно включать все проекты.

  • Можно включать все, кроме.

  • Локально, можно включать как определенные локально, так и с определенной версией в npm (ситуации, когда надо комбинировать локальные проекты с загруженными версиями, будут явно очень редкими).

  • Можно не включать ничего (например, чтобы протестировать обособленно функционал).

Как будто для продакшн-окружения не хочется прописывать версии для каждого пакета в env - хочется написать пустую строку (или исключить определенные модули) и остановиться на этом. Для этого прямо в package.json сделали такой ключ:

{
  "projects": {
    "@av.ru/micro-module-test": "0.0.3"
  }
}

При сборке проекты отсюда мёржатся с dependencies, при необходимости фильтруя проекты.

Шаг 2. Версионирование

У каждого проекта (мы их назвали так) в папке есть два файла: config.ts и package.json. Остановимся пока на втором.

{
  //Название проекта
  "name": "@av.ru/micro-module-test",
  //Версия
  "version": "0.0.3",
  //Можно писать beta и т.д.
  "tag": "latest",
  //Пока не используем
  "main": "./config.ts",
  //Для авторизации при публикации
  "publishConfig": {
    "@av.ru:registry": "https://AV_GITLAB_DOMAIN/api/v4/projects/PROJECT_ID/packages/npm/"
  }
}

Версии мы публикуем в Gitlab по действию разработчика (надо нажать на кнопочку в CI/CD). Чтобы не прописывать конфигурацию вручную для каждого проекта, делаем генерацию CI/CD Jobs, используя Parent-child pipelines:

for (const path of packageJsons) {
    const json = require(join(__dirname, '../../src/projects', path, 'package.json'));
    configs += `
push:npm:${ json.name.replace('@av.ru/', '') }-${ json.version }-${ json.tag || 'latest' }:
    script:
        - npm config set @av.ru:registry https://AV_GITLAB_URL/api/v4/packages/npm/
        - npm config set //AV_GITLAB_URL/api/v4/packages/npm/:_authToken "\${CI_JOB_TOKEN}"
        - cd src/projects/${ path }
        - echo '//AV_GITLAB_URL/api/v4/projects/\${CI_PROJECT_ID}/packages/npm/:_authToken=\${CI_JOB_TOKEN}'>.npmrc
        - npm publish${ json.tag ? ` --tag=${ json.tag }` : '' }
    when: manual
    allow_failure: true
    image: AV_DOCKER_REGISTRY_URL/base/node:16.14
`;
}

writeFileSync('projects-config.gitlab-ci.yml', configs, 'utf-8');

После этого у нас создаётся набор Jobs, готовых к публикации вручную.

Шаг 3. Конфигурация

В этот раз получилось поинтереснее:

  1. Название проекта (пока что используется только в Vuex).

  2. extendRoutes (в этот раз разработчик передает функцию в синтаксисе Nuxt, а не массив routes).

  3. scssVariables: аналогично тому, что было ранее.

  4. routesRegExp: остановимся чуть позже.

  5. vuex: объект с ключами, где каждый ключ равен подмодулю с названием проекта. Есть зарезервированный ключ index, который равен содержимому модуля с названием проекта.

  6. mixins: набор глобальных функций (использует Nuxt функцию inject).

const config: IProjectConfig<'microModuleTest'> = {
    name: 'microModuleTest',
    extendRoutes: (routes, resolve) => {
        routes.push({
            path: '/2.0/test',
          	//Именно вызов resolve подключает этот файл для build
            component: resolve(__dirname, 'pages/index.vue'),
        });
        return routes;
    },
    scssVariables: [
        {
          	//join всё также нельзя
            src: __dirname + '/scss/microModuleVariables.scss',
            strategy: 'push',
        },
    ],
    routesRegExp: {
        '2.0/test': /^\/2.0\/test/,
    },
    vuex: {
        index: indexStore,
        test: testStore,
    },
    mixins: [
        {
          	//Это наш синтаксис регистрации глобальных классов, под капотом - inject
            key: '$microModuleTest',
            mixin: microModuleTest,
            initAndBind: true,
        },
    ],
};

export default config;

Шаг 4. Типизация

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

  1. В данный момент перенос страниц сайта на новый движок еще в процессе, и нам нужно:

    1. Вести пользователя на внутреннюю страницу (nuxt-link) или на внешнюю/легаси (a href);

    2. В момент замены легаси страницы на новый движок, не меняя ничего в коде поменять ссылки на nuxt-link;

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

  2. Я упоминал разделение иконок, об этом позже. Нам надо понимать, какие иконки есть у каждого проекта, и делать автокомплит и валидацию.

Исходя из описанного, получается такой интерфейс:

export interface IProjects {
    microModuleTest: {
        routes: '2.0/test',
      	//Набор иконок
        //Тут вообще стоит Type, это я для наглядности
        icons: 'icon-name' | 'another-icon', 
    };
    catalog: {
  			//Набор страниц
        routes: 'catalog' | 'search' | 'discount' | 'brands' | 'collections'
  			//У этого проекта (пока) нет иконок, но ключ должен присутствовать,
  			//чтобы TS не сломался
        icons: never,
    };
}

export type IProjectsPaths = IProjects[keyof IProjects]['routes']
export type IProjectsList = keyof IProjects
export type IProjectsIcons = {
  	//Автокомлпит будет выглядеть как microModuleTest/icon-name
    [K in IProjectsList as `${ K }/${ IProjects[K]['icons'] }`]: true
}

IProjectsList используется в качестве обязательного входного параметра для интерфейса настроек.

Шаг 5. Иконки

Признаться честно, это я делал последним, ибо сложновато. Надо сделать так, чтобы иконки собирались, но в отдельных чанках. В качестве обманщика Webpack у нас есть Nuxt, который помогает нам с Code Splitting.

Компоненты, значит, делятся при resolve? Ну и отлично.

//projects/micromodule-test/pages/index.vue
export default Vue.extend({
    name: 'TestIndex',
    mixins: [
        createProjectIconsMixin({
            project: 'microModuleTest', //Из IProjectsList
            requireFunction: (icon: string) => require(`./../assets/icons/${ icon }.svg?advanced`),
        }),
    ]
});

Миксин:

export function createProjectIconsMixin({
    project,
    requireFunction,
}: {
    project: IProjectsList,
    requireFunction: (icon: string) => any,
}): ComponentOptions<Vue> {
    return {
        beforeCreate() {
          	//commonIconsList - это глобальный объект
         		//Костыль для использования внутри компонента иконки
          	//При отсутствии иконки компонент крашится с ошибкой
            if (!commonIconsList[project]) {
                commonIconsList[project] = (icon: string) => {
                  	//Потому что, как было сказано выше,
                  	//иконки передаются как microModuleTest/icon-name
                  	//icon-name соответствует названию svg-файла
                    return requireFunction(icon.replace(`${ project }/`, ''));
                };
            }
        },
    };
}

В компоненте:

        const [projectName, secondPart] = this.type.split('/');
        let component: any;
        if (projectName && secondPart) {
            if (getProjects(this.$config).find(x => x.name === projectName)) {
                component = commonIconsList[projectName as IProjectsList]?.(this.type);
            }
        }

        if (!component) 
        	component = require(`../../assets/svg/${ this.type }.svg?advanced`);

Шаг 6. Vuex

Наша основная задача: регистрировать модули при заходе на страницу, но до начала рендера, и убирать их (Unregister) из памяти пользователя при уходе, но после начала рендера следующей страницы. Это нужно, чтобы ничего не сломалось.

Вставляем вызов метода в middleware и в plugin, чтобы вызвалось при загрузке страницы. А так как у нас есть регулярки, проблем с тем, чтобы понять, какой модуль грузить, просто нет!

    isCurrentProjectPath(config: IProjectConfig<IProjectsList>, path = this.ctx.route.path): boolean {
        return Object.values(config.routesRegExp).some(x => x.test(path));
    }

    processProjectsVuex(register: boolean) {
        for (const config of getProjects(this.ctx.$config)) {
            if (!config.vuex) continue;

            const isCurrentPath = this.isCurrentProjectPath(config);

            if (isCurrentPath && register) {
                if (Object.keys(config.vuex).length && !this.ctx.store.hasModule(config.name)) {
                    this.ctx.$accessorRegisterModule(config.name, {
                        namespaced: true,
                        ...(config.vuex.index || {}),
                    });
                }

                for (const [key, value] of Object.entries(config.vuex)) {
                    if (key === 'index' || !value || this.ctx.store.hasModule([config.name, key])) continue;
                    this.ctx.$accessorRegisterModule([config.name, key], value);
                }
            }
            else if (!isCurrentPath && !register) {
                if (!this.ctx.store.hasModule(config.name)) continue;
                this.ctx.$accessorUnregisterModule(config.name);
            }
        }
    }

Несмотря на то, что для регистрации мы используем обертки крутого typed-vuex, они работают на API Vuex.

Что касается регистрации вовремя.

//src/plugins/projects.ts
context.app.router?.afterEach(async () => {
	 //Ждём полного рендера на всякий случай
   await Vue.nextTick();
   //Убираем старые модули
   context.$baseHelpers.processProjectsVuex(false);
});
//src/middleware/projects.ts
import { Middleware } from '@nuxt/types';

const projectsMiddleware: Middleware = (context) => {
    context.$baseHelpers.processProjectsVuex(true);
};

export default projectsMiddleware;

middleware вызывается рано, afterEach - поздно. То, что нам нужно.

Что не получилось

По итогам реализации всего этого не получилось сделать несколько моментов:

  1. Разделение глобальных функций. inject не работает на CSR и не позволяет нормально дробить на файлы, так что собираться и грузиться будут все разом.

  2. Разделение layouts. Я не нашел, как можно регистрировать layout на уровне page, так что собираться они все будут в один файл (как это реализовано по умолчанию).

Что получилось

  1. Разделить код.

  2. Сделать версионирование.

  3. Сделать поддержку удобной локальной разработки.

  4. Раздробить сборку иконок.

  5. Сделать динамическую (де-)регистрацию модулей Vuex.

  6. Обеспечить движку понимание, на каких страницах мы находимся.

Под капотом

Итоги

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

Я уверен, что местами я мог чего-то не увидеть, местами сделать не оптимально, местами сделать отлично - так что пишите свои мысли обо всём, что получилось!

Под конец хотел бы добавить, что, надеюсь, эта статья поможет другим, кто хочет сделать подобное на Vue/Nuxt, или даже других фреймворках. В частности, для Nuxt я аналогично описанного решения в общем доступе не нашел.

Спасибо!

Теги:
Хабы:
Всего голосов 2: ↑2 и ↓0+2
Комментарии13

Публикации

Истории

Работа

Ближайшие события

Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область