Привет, Хабр! Я — 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 я аналогично описанного решения в общем доступе не нашел.
Спасибо!
ссылка на оригинал статьи https://habr.com/ru/post/663260/
Добавить комментарий