vue-i18n-kit — локализация для Vue 3 с ICU-плюрализацией, lazy loading и CLI

vue-i18n-kit — локализация для Vue 3 с ICU-плюрализацией, lazy loading и CLI
Знаете это чувство, когда открываешь новый Vue-проект и понимаешь, что сейчас снова будешь писать одно и то же? createI18n, обёртки для lazy loading, хранение локали в localStorage, обработка fallback, плюрализация — и всё это руками, как будто прошлого проекта не было.
Я сделал так несколько раз. Потом скопировал всё в общую папку и таскал оттуда. Потом подумал — ну и зачем? Оформил в пакет.
Встречайте: vue-i18n-kit — тонкий слой над vue-i18n, который берёт всю настроечную рутину на себя. Подключил плагин, используешь composables — и больше не думаешь о том, как это всё работает внутри.
Установка
bash
npm install vue-i18n-kit vue vue-i18n
vue и vue-i18n — peer dependencies, в бандл не лезут. Всё честно.
Файлы локалей и главная боль плюрализации
Начнём с того, что больше всего раздражает в стандартном подходе. Обычно плюрализацию в vue-i18n пишут так:
"items": "0 товаров | {count} товар | {count} товара | {count} товаров"
Позиционный формат. Первый элемент — для нуля, второй — для единицы, третий — для нескольких. Звучит логично, пока не приходит задача добавить арабский язык с шестью формами или не выясняется, что у разных языков совершенно разная логика раскладки по позициям. Про «21 товар» вместо «21 товаров» вспоминаешь уже после деплоя.
В vue-i18n-kit плюрализация работает через ICU MessageFormat. Формы именованные, выбираются по CLDR-правилам для активной локали автоматически:
json
// locales/en.json
{
"buttons": {
"submit": "Submit",
"cancel": "Cancel"
},
"greeting": "Hello, {name}!",
"items": "{count, plural, one {# item} other {# items}}"
}
json
// locales/ru.json
{
"buttons": {
"submit": "Отправить",
"cancel": "Отмена"
},
"greeting": "Привет, {name}!",
"items": "{count, plural, one {# товар} few {# товара} many {# товаров} other {# товаров}}"
}
# — это плейсхолдер числа внутри формы. other — единственная обязательная категория, используется как fallback. Остальные — one, few, many, zero, two — выбираются движком автоматически по правилам CLDR. Для русского будет три формы, для арабского — шесть, для японского — достаточно одной, потому что в японском с числами всё просто и без затей.
Никакого «посчитай позиции». Написал формы — работает правильно везде.
Подключение
Один раз в main.ts, и больше к этому файлу не возвращаешься:
ts
import { createApp } from 'vue'
import { createVueI18nPlugin } from 'vue-i18n-kit'
import App from './App.vue'
const app = createApp(App)
app.use(createVueI18nPlugin({
defaultLocale: 'en',
fallbackLocale: 'en',
locales: {
en: {
messages: () => import('./locales/en.json'),
meta: { display: 'English', flag: '🇬🇧' },
},
ru: {
messages: () => import('./locales/ru.json'),
meta: { display: 'Русский', flag: '🇷🇺' },
},
},
persistLocale: true,
}))
app.mount('#app')
Каждая локаль может быть задана тремя способами — и их можно свободно смешивать в одном конфиге:
- Объект — сразу в бандле, загружается синхронно
- Функция
() => import(...)— lazy, загружается только при переключении LocaleDefinition— объект или функция плюс произвольные метаданные через полеmeta
persistLocale: true — выбор пользователя живёт в localStorage и восстанавливается при следующем открытии страницы. Кажется, мелочь, но без этого всегда получаешь вопрос «а почему язык сбросился?».
Параметры плагина
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
defaultLocale |
string |
— | Локаль при запуске |
fallbackLocale |
string |
— | Используется когда ключ не найден в активной локали |
locales |
Record<string, LocaleEntry> |
— | Карта локалей |
persistLocale |
boolean |
false |
Сохранять выбор в localStorage |
storageKey |
string |
'vue3-i18n-locale' |
Ключ для localStorage |
vueI18nOptions |
object |
— | Дополнительные опции напрямую в createI18n |
useT — два метода на все случаи жизни
Основной composable. Возвращает t для простых переводов и tm для плюрализации.
ts
import { useT } from 'vue-i18n-kit'
const { t, tm } = useT()
t(key, vars?) — стандартный перевод по ключу. Умеет вложенные ключи через точку и именованные плейсхолдеры.
ts
t('buttons.submit') // → 'Submit'
t('greeting', { name: 'Alice' }) // → 'Hello, Alice!'
tm(key, vars) — плюрализация. Получает значение по ключу, обрабатывает как ICU-шаблон, выбирает форму через Intl.PluralRules.
ts
tm('items', { count: 1 }) // → '1 item'
tm('items', { count: 5 }) // → '5 items'
tm('items', { count: 21 }) // → '21 items'
То же самое, но уже русская локаль активна — вызов не меняется, меняется результат:
ts
tm('items', { count: 1 }) // → '1 товар'
tm('items', { count: 3 }) // → '3 товара'
tm('items', { count: 5 }) // → '5 товаров'
tm('items', { count: 11 }) // → '11 товаров' ← исключение, не «11 товар»
tm('items', { count: 21 }) // → '21 товар' ← и снова правильно
Это и есть главная ценность ICU + CLDR: написал один раз, работает для любой локали. Движок сам знает, что 11 — это «many» в русском, а 21 — снова «one».
tm также принимает шаблон напрямую, минуя файл локали — удобно для быстрых экспериментов или динамических строк:
ts
tm('{n, plural, one {# result} other {# results}}', { n: 7 })
// → '7 results'
Несколько переменных в одном шаблоне тоже работают:
ts
// locale: "{user} набрал {score} {score, plural, one {балл} few {балла} many {баллов} other {баллов}}"
tm('score_line', { user: 'Алекс', score: 21 })
// → 'Алекс набрал 21 балл'
Пример компонента — вывод переводов
Типичный компонент с текстом выглядит так:
vue
<script setup lang="ts">
import { useT } from 'vue-i18n-kit'
const { t, tm } = useT()
const userName = 'Alice'
const itemCount = 5
</script>
<template>
<div class="content">
<h1>{{ t('page.title') }}</h1>
<p>{{ t('greeting', { name: userName }) }}</p>
<p>{{ tm('items', { count: itemCount }) }}</p>
<button>{{ t('buttons.submit') }}</button>
</div>
</template>
При смене локали всё обновляется само — t и tm реактивны, никаких ручных перерисовок.
useLocale — переключение и состояние
ts
import { useLocale } from 'vue-i18n-kit'
const { locale, setLocale, isLoading, localeMeta } = useLocale()
| Значение | Тип | Описание |
|---|---|---|
locale |
Ref<string> |
Код активной локали |
setLocale |
(lang: string) => Promise<void> |
Переключить локаль. Загружает JSON если ещё не загружен |
isLoading |
Ref<boolean> |
true пока JSON тащится по сети |
localeMeta |
ComputedRef<TMeta | undefined> |
Метаданные активной локали |
setLocale не молчит о проблемах. Если передать незарегистрированный код — получишь нормальное сообщение, а не загадочный TypeError где-то глубоко:
[vue-i18n-kit] Locale "de" is not registered. Available locales: en, ru
Метаданные локалей — удобный селектор без отдельного словаря
Часто для селектора локали нужны человекочитаемые названия и флаги. Обычно их хранят отдельно — в константах, в самих же файлах локалей, или прямо в компоненте. LocaleDefinition решает это опрятнее: метаданные живут рядом с локалью прямо в конфиге.
ts
locales: {
en: {
messages: () => import('./locales/en.json'),
meta: { display: 'English', flag: '🇬🇧', author: 'Danil Lisin' },
},
}
meta — произвольный объект, никаких ограничений на форму. Читается обратно типизированно через generic, без приведения типов руками:
ts
interface AppLocaleMeta {
display: string
flag: string
author?: string
}
const { localeMeta } = useLocale<AppLocaleMeta>()
localeMeta.value?.display // string | undefined — и TypeScript это знает
Пример компонента — селектор локали
Вот как выглядит полноценный селектор с флагами, названиями и индикатором загрузки:
vue
<script setup lang="ts">
import { useLocale, useAvailableLocales } from 'vue-i18n-kit'
interface LocaleMeta {
display: string
flag: string
}
const { locale, setLocale, isLoading, localeMeta } = useLocale<LocaleMeta>()
const { availableLocales } = useAvailableLocales<LocaleMeta>()
async function handleChange(code: string) {
try {
await setLocale(code)
} catch (err) {
console.error('Failed to load locale:', err)
}
}
</script>
<template>
<div class="locale-selector">
<!-- Что сейчас активно -->
<span class="current">
{{ localeMeta?.flag }} {{ localeMeta?.display ?? locale }}
</span>
<!-- Список всех доступных локалей -->
<select
:value="locale"
:disabled="isLoading"
@change="handleChange(($event.target as HTMLSelectElement).value)"
>
<option
v-for="loc in availableLocales"
:key="loc.code"
:value="loc.code"
>
{{ loc.meta?.flag }} {{ loc.meta?.display ?? loc.code }}
</option>
</select>
<span v-if="isLoading" class="loader">Loading…</span>
</div>
</template>
useAvailableLocales возвращает все зарегистрированные локали в порядке объявления. Локали без meta тоже попадают в список — просто с meta: undefined, и ?? loc.code подхватит код как запасной вариант.
useFormat — даты, числа, валюты
Бонусом идёт форматирование. Всё через нативные Intl-API, всё автоматически в активной локали.
ts
import { useFormat } from 'vue-i18n-kit'
const { formatDate, formatNumber, formatCurrency } = useFormat()
ts
formatDate(new Date(), { dateStyle: 'long' })
// → '28 марта 2026 г.' (ru)
// → 'March 28, 2026' (en)
formatNumber(1_234_567.89)
// → '1 234 567,89' (ru) — пробел как разделитель тысяч, запятая как десятичная
// → '1,234,567.89' (en) — и наоборот
formatCurrency(1999.99, 'EUR')
// → '1 999,99 €' (ru)
// → '€1,999.99' (en)
Переключил локаль — все числа и даты в шаблоне пересчитались. Никаких дополнительных действий.
Как это работает под капотом
Технически интересного тут несколько вещей.
Состояние — модульный синглтон. Вместо provide/inject и глобальных объектов на window — обычная модульная переменная. Плагин при install вызывает setState, composables читают через getState. Это сознательное решение: SSR в задачи пакета не входит, зато код получается проще и предсказуемее.
Lazy loading с кешированием. При вызове setLocale('ru') смотрим — есть ли 'ru' в Set загруженных локалей? Если нет — вызываем загрузчик () => import('./locales/ru.json'), регистрируем результат в vue-i18n через setLocaleMessage, добавляем ключ в Set. При повторном вызове setLocale('ru') сетевого запроса не будет — только смена активной локали. Это важно, если пользователь туда-обратно переключает язык.
Синхронная предзагрузка. defaultLocale и fallbackLocale грузятся синхронно при install, если их messages — уже объект. Это нужно, чтобы vue-i18n с первого рендера мог корректно отдавать переводы и fallback, не ожидая промиса.
ICU-плюрализация. tm берёт сырой шаблон через vue-i18n t(), передаёт во внутренний pluralizeIcu. Там регулярками разбираются plural-конструкции {varName, plural, ...}, для каждой вызывается Intl.PluralRules.select(count) — нативный браузерный API, который знает правила для всех языков по стандарту CLDR. Категория определяет форму, # заменяется числом. Инстанс Intl.PluralRules кешируется через computed — пересоздаётся только при смене локали, не на каждый tm.
Различение типов локалей. Это, пожалуй, самое нетривиальное место. LocaleEntry — это union из трёх типов: обычный объект с сообщениями, функция-загрузчик, или LocaleDefinition. Проблема: обычный объект и LocaleDefinition выглядят похоже, особенно если в вашем словаре есть ключ messages. Логика различения намеренно консервативная: объект считается LocaleDefinition только если у него есть поле meta или messages является функцией. Строковое значение messages: "Your messages" не сломает определение — это просто обычный ключ перевода.
Vite-плагин — переводчик ничего не пропустит
В команде обычно бывает так: разработчик добавил новые ключи в английский файл, а в русский забыл. Или наоборот — удалил ключ, который ещё жив в паре других локалей. Всё это тихо живёт до тех пор, пока кто-нибудь не переключит язык.
ts
// vite.config.ts
import { vueI18nCheckPlugin } from 'vue-i18n-kit/vite'
export default defineConfig({
plugins: [
vue(),
vueI18nCheckPlugin({
localesDir: 'src/locales',
defaultLocale: 'en',
failOnMissing: true, // прерывает сборку если что-то пропущено
}),
],
})
Плагин сравнивает ключи всех JSON-файлов с эталонной локалью и репортит расхождения — при запуске сборки и при каждом изменении файла в dev-режиме. failOnMissing: true в CI гарантирует, что незаконченный перевод просто не уедет на прод.
Вывод выглядит так:
[vue-i18n-kit] Incomplete translations detected (reference: "en"):
Locale "ru":
Missing keys (2):
- buttons.cancel
- profile.title
CLI — для тех, кто любит терминал
Три команды на все случаи работы с файлами локалей.
bash
# Создать директорию и файлы-заготовки
vue-i18n-kit init --dir src/locales --locales en,ru,de
# Добавить новую локаль — скопировать структуру из существующей
vue-i18n-kit add fr --from en --empty
# Проверить полноту переводов
vue-i18n-kit check --default en --fail
add с флагом --empty создаёт файл с теми же ключами, но пустыми значениями — удобно отдать переводчику. check --fail возвращает код 1 если есть расхождения, что позволяет встроить проверку в любой CI-пайплайн без лишних телодвижений.
TypeScript — всё типизировано
Пакет написан в strict-режиме, все публичные типы реэкспортируются:
ts
import type {
I18nPluginOptions,
LocaleEntry,
LocaleDefinition,
LocaleInfo,
PluralVars,
UseTReturn,
UseLocaleReturn,
} from 'vue-i18n-kit'
useLocale<TMeta> и useAvailableLocales<TMeta> принимают generic — тип meta подхватывается везде без приведения типов.
Итог
Начиналось как «просто вынесу в общую папку», закончилось полноценным пакетом с 102 тестами, CLI, Vite-плагином и нормальной документацией. Иногда так бывает.
Если устали каждый раз с нуля настраивать локализацию — попробуйте. Пакет открытый, issues и PR приветствуются.
Пакет на npm: vue-i18n-kit