Привет, Хабр! Я - Senior Frontend Developer в Азбуке вкуса. В данный момент мы переносим наш сайт с легаси на новый движок, и мне довелось стать архитектором этого переноса.
Переход с легаси (jQuery + Java или PHP) был необходим по нескольким причинам. Самое очевидное - множество разного стэка (где-то Bitrix, где-то что-то еще), у которого нет чётких требований, на чем и как делать.
А ещё - весь HTML генерировался сервером, и фронтендеру нужно было поднимать собственно бэкенд и разрабатывать в его архитектуре. Это сильно усложняло разработку.
Ну и конечно разрабатывать на jQuery в 2021 году не очень классно, особенно с видневшимися на горизонте перспективами создания UI Kit.
Новая архитектура представляет из себя Vue 2 + Nuxt 2 с поддержкой Typescript.
Проблематика
В начале переноса все понимали, что структура будет разрастаться. Ещё на этом этапе я начал готовить микрофронты, но сейчас не об этом. Основные проблемы, которые возникли во время работы над проектом в «монорепе» без разделения, заключались в:
Vuex. При передаче информации с SSR на CSR, Nuxt передаёт все модули Vuex, которые у него есть.
Если идти от концепции, что одна страница -> один, а то и больше модулей, а также учитывать отдельные модули для сложной бизнес-логики (например, выбор времени доставки), эта структура начинает есть всё больше места в оперативке пользователя;Структуризация. Хочется, чтобы было чёткое разделение на подмодули в проекте: это помогает организовать процесс работы, ограничить скоуп задач и разделять архитектуру. Микрофронты это скорее вытекающее - для хорошей структуры это не столь необходимо;
Версионирование. При работе над множеством страниц, хочется, чтобы при ошибке в одной можно было откатить только её, а не весь релиз. Разумеется, если этого позволяет совместимость с API и другими глобальными методами в данном релизе;
-
Разделение сборки:
Разработчику не должно быть обязательно собирать вообще всё, даже то, чем он не будет пользоваться для разработки: например, для разработки какого-то лендинга внутри проекта ему не нужно собирать главную страницу, и наоборот
Если мы не хотим выпускать какую-то страницу в продакшн (она не готова или это техническая страница), нам не нужно её собирать при сборке для прода
Разделение иконок. Мы сделали отличный плагин для иконок, которому я не нашел аналогов в открытом доступе (возможно, я плохо искал). Проблема была лишь в том, что в первичной реализации в сборку попадали все иконки сразу (require(`./icons/${name}.svg`) и вуаля, Webpack собирает все иконки в один бандл)
Попытка номер раз. Дробим репозитории
В самом начале разработки я видел только проблемы 2, 3 и 4. Работа с иконками реализована не была, Vuex был маленьким.
Решили идти от концепции разделения на репозитории. Один репозиторий - один проект.
Мгновенно столкнулись с проблемой: документированность разработки под Nuxt. Такой же проблемой страдает и сам Vue. Вы когда-нибудь задумывались о том, как передавать параметры при регистрации плагина через модуль? А если в параметрах есть объект? Искать пришлось по репозиториям от разработчиков Nuxt.
Благо, в Nuxt 3 будет Nuxt Kit и проблем станет меньше.
Конфигурация
Набор routes для использования в extend для Vue Router.
Набор SCSS файлов.
Набор плагинов.
Набор Vuex Store.
Кратко пройдемся по реализации:
Делаем
this.nuxt.extendRoutes
и закидываем пути вroutes
, не забыв вызватьresolve
с переданным путём к компоненту.Добавляем пути с
lang: 'scss'
вthis.nuxt.options.styleResources.scss
иthis.nuxt.options.css
.-
Вообще всё легко:
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;
Давайте разберем, что тут происходит:
<% и %> нужны для того, чтобы брать переданные настройки. По-другому не работает.
Object.entries, for in и т.д. использовать на объекте я не смог. На этапе добавления преобразовали ключи в массив и проходимся по ним.
Строка 4. Вызываем не документированную serializeFunction, убираем две кавычки, которые почему-то появляются, а затем делаем require нашего объекта (без этого не работает).
Строка 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 с любым названием. Но это работало.
Проблемы этого решения
Как подключать проект для локальной разработки под Hot Reload? Прокинули Volume в Docker Compose на релятивный путь для разработчика к его проекту. А если нужно несколько проектов?
Как работать с ассетами? По какому пути их получать? Только релятивно, выходит, потому что компоненты не собираются, а подключаются как есть (чтобы работал Code Splitting).
Как получать доступ к проектам, которые нужны для сборки? Это ведь нужно делать при yarn install. Как избежать конфликтов с модулем, подключенным локально?
Откуда получать конфигурацию tsconfig?
Как использовать глобальные компоненты? Или не глобальные, а смежные? Делать отдельный репозиторий с UI Kit?
Как использовать layout для конкретно этого проекта?
Как писать тесты? Откуда брать конфигурацию к ним?
Если тесты писать локально, как их собирать? Откуда брать конфигурацию Nuxt Config? Костылями доставать из основного?
Как делать 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. Конфигурация
В этот раз получилось поинтереснее:
Название проекта (пока что используется только в Vuex).
extendRoutes (в этот раз разработчик передает функцию в синтаксисе Nuxt, а не массив routes).
scssVariables: аналогично тому, что было ранее.
routesRegExp: остановимся чуть позже.
vuex: объект с ключами, где каждый ключ равен подмодулю с названием проекта. Есть зарезервированный ключ index, который равен содержимому модуля с названием проекта.
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 требуется передать генерик. Типизация должна помочь решить следующие проблемы:
-
В данный момент перенос страниц сайта на новый движок еще в процессе, и нам нужно:
Вести пользователя на внутреннюю страницу (nuxt-link) или на внешнюю/легаси (a href);
В момент замены легаси страницы на новый движок, не меняя ничего в коде поменять ссылки на nuxt-link;
Делать это решено с помощью регулярных выражений: дробим каждую страницу на регулярки и проверяем. Кроме того, это позволяет нам задавать отдельные групповые правила роутинга для страниц.
Я упоминал разделение иконок, об этом позже. Нам надо понимать, какие иконки есть у каждого проекта, и делать автокомплит и валидацию.
Исходя из описанного, получается такой интерфейс:
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 - поздно. То, что нам нужно.
Что не получилось
По итогам реализации всего этого не получилось сделать несколько моментов:
Разделение глобальных функций. inject не работает на CSR и не позволяет нормально дробить на файлы, так что собираться и грузиться будут все разом.
Разделение layouts. Я не нашел, как можно регистрировать layout на уровне page, так что собираться они все будут в один файл (как это реализовано по умолчанию).
Что получилось
Разделить код.
Сделать версионирование.
Сделать поддержку удобной локальной разработки.
Раздробить сборку иконок.
Сделать динамическую (де-)регистрацию модулей Vuex.
Обеспечить движку понимание, на каких страницах мы находимся.
Под капотом
Итоги
По ощущениям, получилось создать неплохую систему микрофронтов. На данный момент мы уже ведем на ней разработку, а от разработчиков не было плохого фидбека (кроме того, что был исправлен на момент выхода этой статьи).
Я уверен, что местами я мог чего-то не увидеть, местами сделать не оптимально, местами сделать отлично - так что пишите свои мысли обо всём, что получилось!
Под конец хотел бы добавить, что, надеюсь, эта статья поможет другим, кто хочет сделать подобное на Vue/Nuxt, или даже других фреймворках. В частности, для Nuxt я аналогично описанного решения в общем доступе не нашел.
Спасибо!
Комментарии (11)
NFI
01.06.2022 11:04Здравствуйте. Спасибо за статью.
Сейчас тоже пробуем микрофронт, но не бeз SSR (он нам не нужен на проекте).А почему вы переехали с легаси на vue2 именно? Почему не 3 версии?
Может ли у вас отдельная команда (микрофронт) быть написан на другом фреймворке?
Хотелось бы больше информации о масштабах проекта и сложностях, что его пришлось на микрофронт разбивать. Сколько у вас в итоге получилось проектов? Сколько сейчас команд?
Как решается проблема шаринга либ между проектами? Юзеру приходится несколько раз загружать условно лодаш?
Можно подключить несколько разных микрофронтов на одну страницу?
Не совсем понял момент с подключением всех микрофронтов в один. Это происходит на этапе сборки проекта или ран тайм?
А вы пробовали module federation?
daniluk4000 Автор
01.06.2022 11:15На момент начала разработки vue3 не был продакшн-реди, не была сформирована экосистема, а Nuxt 3 не существовало. Начинали разработку с пониманием, что придется переходить на Vue 3, и это нам еще предстоит
Нет, разве что если это будет отдельный проект (вне основного av.ru). Но это крайне не рекомендуется, хотим всё писать на плюс минус одинаковой архитектуре
Микрофронты сделаны для контроля роста проекта. В данный момент над проектом работает одна команда, но уже постепенно подключается вторая, которая делает совсем другие задачи, и это не предел. Решили сделать микрофронты, пока не поздно. Ну а также все причины в начале статьи, где "Проблематика" - они все в совокупности.
По проектам: пока что их всего два, хотя по правилам должно быть больше. Это связано с тем, что архитектура свежая, и мы не успели старый код перенести на эту архитектуру. После переноса будет шесть.Никак не решается, либы везде одни и те же. Лодаш не используем, в целом у нас сейчас нет никаких проблем с использованием библиотек, необходимость использовать разные не возникает.
Нет, вообще, в идеальном мире, одна страница = один микрофронт. Либо одна группа страниц = один микрофронт (например, спецпроект).
На этапе сборки проекта происходит, собственно, сборка всех подключенных фронтов, тогда же происходит сборка глобальных зависимостей проектов (inject-глобальные функции, SCSS). На Runtime подключается Vuex, в зависимости от текущего пути страницы, для экономии ОЗУ и улучшения производительности.
На начало разработки такое нужно не было, но в целом есть запрос от одного из разработчиков на похожее. Обдумываем, но пока не применяли.
Спасибо за интересные вопросы!
NFI
01.06.2022 11:35+1Спасибо за ответ. Интересно было бы узнать во что у вас это все вырастит (через год, два). Удачи!
SergeyPeskov
01.06.2022 23:42extendRoutes: (routes, resolve) => { routes.push({ path: '/2.0/test', //Именно вызов resolve подключает этот файл для build component: resolve(__dirname, 'pages/index.vue'), }); return routes; },
А если вам надо добавить вложенный маршрут(в рамках сервиса нужно выделить несколько модулей)? Находите нужный элемент в routes и делаете push в children?
scssVariables: [ { //join всё также нельзя src: __dirname + '/scss/microModuleVariables.scss', strategy: 'push', }, ],
scssVariables доступны глобально по всему приложению или только в рамках модуля в котором они подключаются?
daniluk4000 Автор
02.06.2022 12:10Находите нужный элемент в routes и делаете push в children
Пока что с таким не сталкивались.
В худшем случае да, но как будто можно вместо Nested Routes просто задавать строкой нужный путь, в противном случае либо появятся созависимые модули, либо какая-то глобальная нерабочая конфигурация (если использовать router-view).
scssVariables доступны глобально по всему приложению
К сожалению, да, ведь эти файлы вставляются в prependData и поэтому являются глобальными. И поэтому же там не может быть CSS селекторов и тд, то есть только SCSS и ничего больше.
SergeyPeskov
02.06.2022 21:27В худшем случае да, но как будто можно вместо Nested Routes просто задавать строкой нужный путь
Иногда бывает необходимо сделать именно несколько уровней вложенных маршрутов.
К сожалению, да, ведь эти файлы вставляются в prependData и поэтому являются глобальными. И поэтому же там не может быть CSS селекторов и тд, то есть только SCSS и ничего больше.
Я на проекте делал похожее разделение на модули, думал над таким вариантом добавления scss, но отказался из-за того что могут быть пересечения переменных между сервисами. Решил что лучше просто импортировать файл с глобальными scss переменными сервиса в нужный компонент.
daniluk4000 Автор
03.06.2022 11:29Решил что лучше просто импортировать файл с глобальными scss переменными сервиса в нужный компонент
Как один из вариантов, но когда переменная встречается в огромном количестве компонентов не поможет.
Но в целом - как вариант для нас на обсуждение и полный запрет глобального импорта.
rdo
Вы написали свой фронтенд поверх SAP Hybris, который используется у вас в магазине?
daniluk4000 Автор
Да, но живут отдельно и общаются по API. Встроенный фронт, сделанный внутри Hybris, отныне легаси.
rdo
Очень интересно, а что вы делали с CMS? Вам пришлось написать свои апи для получения компонентов, страниц и т.д? Или вы вообще отказались от cms на стороне бэка?
Или вы просто дополнили апи вашего мобильного приложения и используете его?
И почему не перешли на новый фронтенд от сапа, на спартакус?
daniluk4000 Автор
На базе уже имеющейся CMS сделали API, да
Нет, это новое
Этот вопрос оставлю без ответа