PROWEB

Свой Nuxt-модуль от идеи до npm: гайд на двух примерах

Как упаковать переиспользуемый код в Nuxt-модуль и опубликовать в npm: анатомия модуля, два реальных примера студии — фикс iOS 100vh и интеграция VK-пикселя на @nuxt/scripts, тесты на фикстурах @nuxt/test-utils и релиз одной командой через changelogen.

Муза Нейронова
9 минут чтения
Свой Nuxt-модуль от идеи до npm: гайд на двух примерах

В каждом Nuxt-проекте рано или поздно появляется код, который хочется переиспользовать: фикс платформенной особенности, интеграция со счётчиком, обёртка над сервисом. Копировать его между проектами — плохая стратегия: копии расходятся, правки теряются, а через полгода уже непонятно, где каноничная версия. Правильный план — упаковать код в Nuxt-модуль и опубликовать в npm. Мы в PROWEB сделали это на двух открытых модулях и покажем оба: от простого к продвинутому.

Зачем вообще упаковывать код в модуль

  • Один источник правды. Правка делается в модуле, в проекты приезжает обновлением версии — а не копипастой
  • Конфигурация вместо правок кода. Параметры модуля живут в nuxt.config.ts проекта, сам код трогать не нужно
  • Самостоятельное подключение. Модуль сам регистрирует плагины, авто-импорты и стили — интеграция это одна строка в modules
  • Версии и changelog. Понятно, что изменилось между версиями и на что можно безопасно обновиться
  • Польза за пределами студии. Пакет в npm может пригодиться чужим проектам — оба наших модуля открыты: nuxtjs-mobile-vh и nuxtjs-vk-pixel

Анатомия Nuxt-модуля

Минимальный модуль — это точка входа module.ts с defineNuxtModule: здесь задаются имя, ключ конфигурации, дефолты и регистрируется всё, что модуль привозит в приложение. Короткая версия из модуля mobile-vh:

export default defineNuxtModule<ModuleOptions>({
  meta: { name: 'mobile-vh', configKey: 'mobileVh' },
  defaults: { id: DEFAULT_ID },
  setup(_options, nuxt) {
    const resolver = createResolver(import.meta.url)
    addPlugin({ src: resolver.resolve('./runtime/plugin'), mode: 'client' })
  },
})

Дальше — три строительных блока, из которых собирается почти любой модуль:

  • Runtime-плагины — код, который выполняется в приложении пользователя: подписки на события, хуки жизненного цикла, provide-объекты. mode: 'client' означает «только в браузере» — там, где на сервере нет window
  • provide — плагин возвращает объект, который во всём приложении доступен как $имя
  • Авто-импорты — addImports делает композабл доступным в компонентах без строк импорта

Пример простой: nuxtjs-mobile-vh — фикс iOS 100vh

Классическая боль мобильной вёрстки: в iOS Safari адресная строка появляется и исчезает при скролле, а 100vh при этом не уменьшается — блоки получаются «выше» реального экрана, низ обрезается. Каноничное решение — CSS-переменная --vh, равная одному проценту реальной высоты окна. Мы собрали решение в модуль nuxtjs-mobile-vh (v1.1.0 в npm): подключили — и в любых стилях можно писать calc(var(--vh, 1vh) * 100).

Ядро — клиентский плагин: считает высоту, ставит переменную, обновляет её по resize со своим дебаунсом в 100 мс и отдаёт реактивный ref через provide:

const vh = ref(0)

const setVh = () => {
  vh.value = window.innerHeight * 0.01
  document.documentElement.style.setProperty('--vh', `${vh.value}px`)
}

const debouncedResize = debounce(() => setVh(), 100)

Класс __vh навешивается на контейнер приложения в хуке app:mounted, а дефолтный стиль модуля переключает высоту на переменную с аккуратным fallback:

.__vh {
  display: flex;
  min-height: 100vh;
  min-height: calc(var(--vh, 1vh) * 100);
}

Реактивный ref идёт через provide('vh', vh): если реальная высота окна нужна в логике компонента, её можно читать по-реактивному, а не парсить CSS. Единственная опция модуля — id для контейнера, всё остальное работает по умолчанию.

Пример продвинутый: nuxtjs-vk-pixel и @nuxt/scripts

Второй модуль — интеграция VK-пикселя (счётчика Top.Mail.Ru). Задача звучит как «вставь скрипт», но по-хорошему нужна вся обвязка: загрузка без вреда для скорости, noscript-фолбэк, типизированные события вместо сырого глобального объекта. Вместо ручной вставки модуль опирается на @nuxt/scripts — официальное решение для сторонних скриптов в Nuxt:

export default defineNuxtModule<ModuleOptions>({
  meta: { name: 'vk-pixel', configKey: 'vkPixel' },
  moduleDependencies() {
    return { '@nuxt/scripts': {} }
  },
  setup(options, nuxt) {
    nuxt.options.runtimeConfig.public.vkPixel = { ...defaultOptions, ...options }
    addPlugin(resolver.resolve('./runtime/plugin'))
    addImports({
      name: 'useVkPixel',
      as: 'useVkPixel',
      from: resolver.resolve('./runtime/composable'),
    })
  },
})
  • moduleDependencies — модуль сам гарантирует установку @nuxt/scripts, пользователю не нужно помнить про peer-зависимость
  • addImports — композабл useVkPixel доступен в компонентах без импортов
  • опции через runtimeConfig.public — id счётчика задаётся в конфиге проекта, а не в коде

В плагине — useScript с триггером onNuxtReady: пиксель стартует после гидратации и не задерживает первую отрисовку, а хук use() возвращает типизированный прокси вместо сырого window._tmr:

const { proxy } = useScript<IVkPixelTmr>(scriptSrc, {
  trigger: 'onNuxtReady',
  use() {
    ;(window._tmr || (window._tmr = [])).push({
      id,
      type: 'pageView',
      url: window.location.href,
      start: (new Date()).getTime(),
    })
    return window._tmr as IVkPixelTmr
  },
})

Для пользователей без JavaScript модуль добавляет noscript-фолбэк через useHead с tagPosition: 'bodyOpen' — счётчик-картинка попадает в начало body, как требует канон вставки:

useHead({
  noscript: [{
    tagPosition: 'bodyOpen',
    textContent: () =>
      `<div><img src="${noscriptSrc}?id=${id};js=na" style="position:absolute;left:-9999px;" alt="Top.Mail.Ru" /></div>`,
  }],
})

Типизированный прокси подставляет id счётчика и текущий URL в каждое событие: компоненты вызывают useVkPixel().push({ type: 'reachGoal', goal: 'purchase' }) и не думают о протоколе.

Тестирование: юнит-тесты и фикстуры

У модуля два уровня тестов. Первый — юнит-тесты плагина в vitest: window и document подменяются моками, а дебаунс проверяется на фейковых таймерах — «за 100 мс не больше одного вызова» фиксируется буквально. Второй уровень — e2e на фикстуре: в test/fixtures/basic живёт минимальное Nuxt-приложение с подключённым модулем, и @nuxt/test-utils поднимает его как настоящий проект:

await setup({
  rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)),
})

it('renders the index page', async () => {
  const html = await $fetch('/')
  expect(html).toContain('<div>basic</div>')
})

Фикстура ловит класс ошибок, который юнит-тесты не видят: неправильную регистрацию плагина, битые авто-импорты, конфликты опций. Если фикстура собирается и рендерится — модуль можно ставить в реальные проекты.

Публикация: сборка и релиз одной командой

  • nuxt-module-builder собирает модуль и типы: сборка висит в prepack (nuxt-module-build build), для разработки есть режим --stub
  • vitest + @nuxt/test-utils — прогон юнит-тестов и фикстуры до публикации
  • changelogen — ведёт CHANGELOG и версию по истории коммитов
  • lefthook + commitlint (во втором модуле) — не пускают в историю невнятные коммиты, changelogen должен понимать, что изменилось

Релиз в обоих модулях — один скрипт:

npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags

Линт, тесты, сборка, новая версия по коммитам, публикация и пуш тега — одной командой. Дешёвый автоматизм, который убирает самый частый источник ошибок релиза: «забыл собрать dist перед publish».

Коротко

Nuxt-модуль — это точка входа с defineNuxtModule, runtime-плагины с provide и авто-импорты. Даже утилитарная мелочь вроде фикса 100vh заслуживает модуля: одна строка в modules вместо копипасты между проектами. Для интеграций со сторонними скриптами берите @nuxt/scripts: moduleDependencies, useScript с триггером onNuxtReady и типизированный прокси вместо сырых глобальных объектов. Тестируйте на двух уровнях — юнит-тесты плагина и e2e на фикстуре, — а релиз соберите в один скрипт с changelogen: сборка, версия, публикация и тег одной командой. Оба модуля из статьи открыты: nuxtjs-mobile-vh и nuxtjs-vk-pixel ищутся в npm.

Поделиться

Доверьте профессионалам разработку вашего проекта