Азбука вкуса, Nuxt и наш тернистый путь к микрофронтам
- среда, 1 июня 2022 г. в 00:43:08
Привет, Хабр! Я - 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 по-хорошему тоже бы разделять и не грузить лишнего, и что у нас появился плагин для работы с иконками. Задачи те же, реализация должна быть другая.
Раз уж у нас всё локально, надо понимать, что нам собирать. Лучшим решением стала переменная.
За набор проектов отвечает переменная 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
, при необходимости фильтруя проекты.
У каждого проекта (мы их назвали так) в папке есть два файла: 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, готовых к публикации вручную.
В этот раз получилось поинтереснее:
Название проекта (пока что используется только в 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;
Как видно по коду выше, в интерфейс 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
используется в качестве обязательного входного параметра для интерфейса настроек.
Признаться честно, это я делал последним, ибо сложновато. Надо сделать так, чтобы иконки собирались, но в отдельных чанках. В качестве обманщика 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`);
Наша основная задача: регистрировать модули при заходе на страницу, но до начала рендера, и убирать их (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 я аналогично описанного решения в общем доступе не нашел.
Спасибо!