javascript

Азбука вкуса, Nuxt и наша большая экосистема

  • суббота, 28 февраля 2026 г. в 00:00:08
https://habr.com/ru/articles/1001002/

Всем привет! Кажется, настала пора поделиться изменениями в Азбуке, которые произошли с 2022 года, когда я выпускал прошлую статью. Несмотря на то, что в данный момент я уже не руководитель фронта, интересно рассказать, к чему мы пришли за это время. Я, конечно, рекомендую ознакомиться с прошлой статьей, но в целом буду рассказывать "с нуля".

Наш текущий стэк: Vue 3, Nuxt 4, TS. На данный момент, мы практически полностью переехали с jQuery, оставив за собой относительно небольшое количество страниц, над которыми тоже будем работать по мере приоритетов.

В данной статье я расскажу про проблемы, с которыми столкнулись, как решали, и поделюсь примерами кода.

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

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

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

Ну это никуда не годится. Но основы заложили
Ну это никуда не годится. Но основы заложили

Выше приложена часть гайда по миграции на V3 внутренней библиотеки. А ниже - уже V5.

Вырезки из гайда миграции на V5 (Nuxt 3)
Ну вот это уже лучше
Ну вот это уже лучше
Премиум
Премиум

Ниже я расскажу про то, какие возможности и как мы сделали в нашем внутреннем модуле. Что касается микрофронтов - мы сдались и у нас монорепа, спасибо Vite, что собирает только то, что сейчас нужно. На организации основного репозитория (av.ru) я отдельно останавливаться сегодня не буду.

Здесь и далее, "модуль" - это глобальная самописная библиотека, используемая внутри проектов Азбуки.

Nuxt 3

Начнём с того, что значительная часть изменений стала возможна за счет переезда на Nuxt 3. Nuxt Kit представляет отличный инструментарий, позволяя писать модули практически без костылей.

Как видно в вырезках выше, мы заменили всю конфигурацию Nuxt на конфигурацию модуля. Для достижения этого:

  1. Используем глобально расширяемый nuxt.config.ts

  2. Используем модуль

По первому пункту: кладем куда-нибудь в вашу библиотеку файлик с содержимым вида export default defineNuxtConfig({...}); , а в самих проектах в том же конфиге пишем: extends: ['@misc/base-vue-module/nuxt-config']. Вы восхитительны: конфигурация конфига по умолчанию создана успешно. Ранее, для достижения той же цели, приходилось производить больше манипуляций.

Далее у нас идет модуль для тех настроек, которые не получится сделать так же легко и просто. Например, глобальные типы, которые надо подсунуть в tsconfig. Ничего сложного - спасибо Nuxt Kit!

// Внутри Nuxt Module
nuxt.hook('prepare:types', ({ tsConfig, references }) => {
    references.push({ types: 'vitest/globals' });
    references.push({ types: 'vitest' });
    references.push({ types: 'vite/client' });
    references.push({ types: '@misc/base-vue-module/runtime/svg-declarations.d.ts' });

    if (!tsConfig.compilerOptions!.types) {
        tsConfig.compilerOptions!.types = [];
    }

    tsConfig.include!.unshift('@misc/base-vue-module/module');

    tsConfig.compilerOptions!.types.push('vitest/globals');
});

Аналогичным образом, задаем всю конфигурацию, которая зависит от чего-то динамического:

nuxt.options.alias = {
    '@av': process.env.NODE_ENV === 'development'
        ? join(process.cwd(), '.global-components')
        : resolve(__dirname, '../runtime/components'),
    ...nuxt.options.alias || {},
};

А как раньше было сложно подключать плагины! А какой там был синтаксис! Теперь таких проблем нет:

addPlugin('@misc/base-vue-module/runtime/plugins/plugin.ts');

addRouteMiddleware({
    name: 'vue-module-route',
    path: '@misc/base-vue-module/runtime/plugins/middleware.ts',
    global: true,
});

// Ручной точечный автоимпорт наших методов
const exports = await scanExports(join(__dirname, 'src/exports.js'), true);
addImportsSources({
    from: join(__dirname, 'src/exports.js'),
    imports: exports.filter(x => x.type).map(x => x.name),
});

// Автоматическая запись внутрь .nuxt
addTemplate({
    filename: 'vue-module/feature-flags.ts',
    getContents: () => template,
    write: true,
});

addImportsSources({
    from: '#build/vue-module/feature-flags',
    imports: ['AVFeatureFlags'],
});

// Можно также взять опции при подключении плагина или использовать useRuntimeConfig
if (nuxt.options.runtimeConfig.avModule.proxy) {
    addServerHandler({
        handler: join(__dirname, '../runtime/plugins/proxy.ts'),
    });
}

// Ручное подключение компонента
addComponent({
    name: file.name.replace('.vue', ''),
    filePath: join(dir, file.name),
});

Можно также нормально задать настройки и типизацию к ним:

export default defineNuxtModule<ModuleOptions>({
    meta: {
        name: 'avModule',
        configKey: 'avModule',
        compatibility: {
            nuxt: '>=4.0.0',
            builder: {
                webpack: false,
                rspack: false,
            },
        },
    },
    async setup(options, nuxt) {
        await initAVModule(options, nuxt);
    },
});

declare module 'nuxt/schema' {
    interface NuxtConfig {
        ['avModule']?: Partial<AVPluginInternalConfig>;
    }

    interface NuxtOptions {
        ['avModule']?: AVPluginInternalConfig;
    }

    interface PublicRuntimeConfig {
        avModule: AVPluginInternalConfig;
    }

    interface RuntimeConfig {
        avModule: AVPluginInternalConfig;
    }
}

Дальше - больше.

Глобальные компоненты

Когда мы начали это делать, самым сложным оказалось собирать глобальные компоненты. Ключевая цель - разработчик должен иметь возможность редактировать их на лету. Например, мы отлаживаем всплывающее окно или поле ввода в конкретном проекте. Мы хотим сразу внести изменения и проверить их работоспособность, перед тем, как переносить в модуль.

Очевидно, собранные компоненты редактировать сложно - но возможно. При этом, надо лезть в node_modules, а перенести их 1 в 1 нереально. Вопрос, как собирать компоненты так, чтобы не потерять возможность их красиво редактировать?

Короткий ответ: их просто не надо собирать.

Опа...
Опа...

Выше я кидал конфигурацию алиаса "@av". Корень к решению проблемы в том, чтобы при каждом запуске проекта копировать/линковать компоненты в какую-то корневую папку, которую я назвал .global-components. В продуктиве она нам не нужна - там алиас оставляем, но он будет ссылаться внутрь node_modules.

Проходимся по компонентам, кидаем куда требуется. Регистрируем алиас просто в конфиге Nuxt, как было указано выше. Так, мы получаем удобное расположение, Hot Reload, и возможность тупо перенести код в модуль, ничего не меняя. Несмотря на то, что такое решение может выглядеть топорно, оно оказалось самым простым и эффективным. Компоненты лежат без всякой сборки в папке "runtime" модуля и регистрируются автоматически.

Все ассеты, картинки, также выносим в ту же папку runtime.

Линтеры

  1. С ESLint v9 стало намного удобнее писать плагины. Проще всего - не полениться и написать их. Можно даже сделать наследование из .nuxt/eslint.config.mjs (официальный плагин линтера), как сделали мы (withNuxt.default([]))

  2. То же самое со Stylelint (конфигурация extends)

Докер

Это тоже стало неким вызовом. Хотелось не только Dockerfile унифицировать, но и саму конфигурацию. Кроме того, есть отдельный конфиг и образ для запуска в arm64: это тоже надо поддержать. При этом, много методов сразу отсекаются из-за того, что либо не та директория, либо не подтягиваются файлы .env, так как докер ожидает их в той же папке, где лежит compose-файл.

Решение: использовать include.

include:
    - path: ./packages/vue-module/runtime/docker/docker-compose.yml
      project_directory: .
      env_file: ./.env

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

Для arm64 можно использовать профили.

services:
    nuxt: &nuxt
        build:
            context: .
            dockerfile: ./node_modules/@misc/base-vue-module/runtime/docker/images/default/dev.Dockerfile
            args:
                - NPM_TOKEN=${NPM_TOKEN}
        image: av-vue-6.0
        #...

    nuxt-arm64:
        <<: *nuxt
        build:
            dockerfile: ./node_modules/@misc/base-vue-module/runtime/docker/images/default/dev-arm64.Dockerfile
        profiles:
            - arm64

За счет унификации, мы смогли отказаться от использования Nginx (было рудиментом ради https сертификатов) без необходимости внесения изменений локально, просто обновлением версии модуля.

Глобальные иконки

Мы используем глобальный компонент со всеми иконками. При этом, иконки могут находиться в разных местах. Подозреваю, что тут было решение лучше, например, подключать иконки сканом директории при инициализации, но мы остановились на передаче функции для импорта из того места, где эти иконки встраиваются, используя import.meta.glob.

 const _requireFunction = function(icon: string): any {
    let component: any;

    try {
        // Иконки, специфичные для конкретного проекта
        component = requireFunction(icon);
    }
    catch { /* empty */ }
    // Импортируем иконки по-умолчанию
    if (!component) {
        const imports = import.meta.glob(['../runtime/assets/icons/*.svg'], {
            query: '?advanced',
            eager: true,
        });

        component = imports[`../runtime/assets/icons/${ icon }.svg`];

        if (!component) {
            throw new AVException(`Icon ${ icon } was not found`);
        }
        else {
            component = component.default;
        }
    }

    return component;
};
// Внутри конкретного проекта
setup(props, { attrs }) {
    return useCommonIcon({
        requireFunction: (icon: string) => {
            return import.meta.glob('../../assets/icons/*.svg', {
                query: '?advanced',
                import: 'default',
                eager: true,
            })[`../../assets/icons/${ icon }.svg`];
        },
        props,
        attrs,
    });
},

Pinia

В старой статье были описаны костыли, которые потребовались, чтобы вовремя подключать Vuex.

Сейчас нам эти костыли не нужны, но не работала типизация после сборки. Пришлось сделать небольшой костыль уже на типизацию, но по сравнению с тем, что было - это мелочи.

// Костыль для типизации после сборки
const buildStore = <Id extends string, S extends StateTree = {}, G extends _GettersTree<S> = {}, A = {}>(id: Id, options: Omit<DefineStoreOptions<Id, S, G, A>, 'id'>): StoreDefinition<Id, S, G, A> => {
    return defineStore(id, options);
};

export const useAVStore = buildStore('__avStore', { ... });

Глобальные Meta-тэги и слушатели

Мы также решили унифицировать Meta-тэги, используя useHead . У нас также есть глобальные проверки по интервалу и установка слушателей по типу scroll, resize.

Про последнее: были проблемы с гидрацией в связи с "внезапной" сменой девайса (например, внезапно поняли, что перед найми айпад - по юзер агенту этого можно не понять). Прикладываю оптимальные хуки, которые нам помогли. При этом, если мы используем spa, нам достаточно только одного хука (смотрим по payload.serverRendered).

export default defineNuxtPlugin({
    hooks: {
        'vue:setup'() {
            if (isServer() || useNuxtApp().payload.serverRendered) return;
            useAVMediaSetupWatchers();
        },
        'app:suspense:resolve'() {
            if (!useNuxtApp().payload.serverRendered) return;
            useAVMediaSetupWatchers();
        },
        // Если кто-то не знал, там можно настроить checkOutdatedBuildInterval
        // И показывать попап после выхода новой версии вашего ресурса
        // Не связано с темой, но может пригодиться
        'app:manifest:update'() {
            useAVStore().updateRequired = true;
        },
    },
    setup(nuxtApp) {
      //...
    }
  });

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

const app = useNuxtApp();

useHead(() => {
   style: [
      // safeComputed - наша обертка для утечек памяти на SSR
      // Создает фиктивный геттер. Несмотря на фиксы Vue, все еще ловили утечки
      safeComputed(() => {
          // Главный костыль. Нет проверки на getActivePinia из-за того, что оно спамит в консоль
          if (app.$pinia) {
              setActivePinia(app.$pinia as Pinia);
          }
          return useAVStylesOverwriteCSSVariables(settings.value.variables?.(), useAVStylesCurrentCSSVariables().value);
      }),
  ],
});

Итоги

На этом, на самом деле, всё. Наш гигантский конфиг по итогу сократился вот до такого:

export default defineNuxtConfig({
    ssr: true,
    extends: ['@misc/base-vue-module/nuxt-config'],
    runtimeConfig: {
        public: {
            // От Axios к слову отказались, сидим на своей обертке под ofetch
            API_URL: process.env.API_URL,
            STAND_TYPE: process.env.STAND_TYPE || 'production',
        },
    },
    avModule: {
        mixinsPrependSCSSFile: '~/scss/variables.scss',
        // Все что ниже при желании тоже можно убрать, но для наглядности
        addSvgLoaders: true,
        localComponents: true,
        stylesOptions: {
            defaultFont: 'proxima',
        },
    },
    app: {
        head: {
            title: 'Habr',
        },
    },
    modules: [
        '@misc/base-vue-module/module',
    ],
});

Или конфиг линтера

import useAVEslint from '@misc/base-vue-module/eslint';

export default useAVEslint();

Или даже layouts/default.vue

<template>
  <default-layout id="__av-root">
      <slot/>
  </default-layout>
</template>

<script lang="ts" setup>
import DefaultLayout from '@av/layouts/AvLayout.vue';

defineSlots<{
    default(): unknown;
}>();
</script>

Или ошибок!

<template>
    <nuxt-layout>
        <common-error-page :error="error"/>
    </nuxt-layout>
</template>

<script lang="ts" setup>
import CommonErrorPage from '@av/layouts/AvErrorPage.vue';
import type { AVNuxtError } from '@misc/base-vue-module';
import type { PropType } from 'vue';

defineProps({
    error: {
        type: Object as PropType<Partial<AVNuxtError>>,
        required: true,
    },
});
</script>

Далеко улетели те времена, когда вместо всего, что выше, я показал бы вам огромную портянку кода. И даже в CI/CD теперь используются унифицированные шаблоны, стандартные для практически всех проектов.

Помимо всего, что выше, у нас есть огромная конфигурация, позволяющая настраивать:

  1. Глобальные файлы с SCSS переменными под проект

  2. Автоматическую передачу кук, таймауты, авто-перезагрузку, авто-рефреш токенов и многое другое для всех запросо

  3. Sentry (два файла в корень требуется добавить вручную, правда)

  4. Свои цвета и темы

  5. Стандартные мета-теги

  6. Проксирование для среды разработки и не только

  7. Авто-импорты конкретно наших компонентов и функций

  8. Фиче-флаги

  9. Авто-подключение (моей) vue-yandex-maps

  10. С недавнего времени глобальные пропсы по умолчанию для 14 компонентов

  11. Яндекс Капчу

  12. Многое другое

На всём этом останавливаться смысла не вижу - но если вас интересует то, как мы сделали что-то из вышеперечисленного - пишите! Отвечу в комментариях или новой статьей.

Библиотека компонентов

Так как Азбука не ежедневно скажем так здесь публикуется, полагаю, вам интересно посмотреть еще и на нашу библиотеку компонентов.

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

Сколько я пилил инпуты я не буду говорить. Веселая была задачка
Сколько я пилил инпуты я не буду говорить. Веселая была задачка

Так как нам надоело каждый раз делать макет под админки, а Vuetify мы похоронили, мы также сделали набор компонентов еще и под админки, чтобы клепать их по мокапам. Админки это, конечно, отдельная библиотека.

Ну вот теперь точно всё. Спасибо за то, что дочитали до конца! Мы прошли долгий путь от первой версии нашего модуля, теперь он стал достаточно интересной штукой, которую мы продолжаем развивать и обновлять.