В каждом 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.
