Реалізація кастомної локалізації з Nuxt 3 без i18n
Привіт! Мене звати Михайло Кухарський, я Front-end Engineer у Futurra Group — продуктовій IT-компанії, що працює в нішах EdTech та Lifestyle.
Основний фронтенд-стек наших проєктів — це Vue та Nuxt. Такий набір технологій оптимально підходить до наших вимог та допомагає зберігати баланс між якістю, швидкістю й стабільністю. Раніше я вже ділився лайфгаками та відкриттями щодо розробки на Nuxt 3 — зокрема, про оптимізацію web-перформансу та проблеми в реалізації SSR-архітектури з Nuxt 3.
Наші проєкти зростають, але ми не втрачаємо пильність: постійно шукаємо слабкі місця, які потенційно можуть створити проблеми. Одне з них — збільшене навантаження на RAM і довший build time через використання i18n у Vue/Nuxt.
Простішими словами, коли проєкт швидко масштабується і кількість мов інтерфейсу зростає, зростають і JSON-файли з усіма перекладами — і в кількості, і в обсязі. Як наслідок, погіршується development experience та загальний стан проєкту, на що впливають такі фактори:
- збільшення build time;
- ускладнення пошуку помилок та дебагінгу;
- проблеми з витоками памʼяті.
І хоча сама бібліотека vue-i18n пропонує способи оптимізації цієї проблеми для проєктів із SSR (наприклад, використання бібліотеки @intlify/unplugin-vue-i18n), з плином часу ефект від цих рішень зникає. До того ж постійне використання сторонніх бібліотек не є панацеєю.
Наш стек складається з молодих технологій, що активно розвиваються. І сторонні рішення, як-от розширення i18n для Vue/Nuxt, часто можуть не встигати за цими змінами. Наприклад, у випадку з @nuxtjs/i18n ми стикалися з витоками памʼяті — я вже згадував про це в попередній статті. Проблема актуальна й досі, адже під час спроби збілдити проєкт видає помилку:
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
Можна було б масштабувати ресурси для проєкту, однак це — тимчасове і неідеальне рішення: як з боку розробки, так і з боку бізнесу. Детальніше про схожі кейси можна почитати тут і тут.
Варто також зазначити, що в нашому контексті використання i18n для локалізації не було критично необхідним. З усіх можливостей широкої конфігурації модуля нам потрібний був лише базовий набір: функція перекладу, можливість отримувати поточну локаль та встановлювати нову — і все.
Після невеликого дослідження ми дійшли висновку: у нашій ситуації доцільніше відмовитись від використання @nuxtjs/i18n і всіх суміжних бібліотек — і натомість реалізувати власну, простішу, але адаптовану під наші потреби систему локалізації.
Перехід на власну локалізацію
Ось які функції мала виконувати наша альтернатива i18n:
- перекладати з однієї мови на іншу (хто б міг подумати);
- працювати реактивно та коректно із Server-Side Rendering;
- не впливати негативно на user і developer experience, зокрема не сповільнювати роботу застосунку та не ускладнювати процес розробки й пошуку помилок;
- отримувати та змінювати локалі максимально зручним чином.
Для початку нам потрібно визначити локаль браузера користувача, щоб одразу встановити правильне значення, якщо ми його підтримуємо:
export const supportedLanguages = ["en", "es", "fr", "pt", "de", "tr"] as const;
// Список локалей, які ми пропонуємо користувачу
export default function useLocale() {
const locale = computed(() => {
if (import.meta.server) { // Для SSR
const headers = useRequestHeaders(["accept-language"]);
const locale = (headers["accept-language"]?.split(",")[0]?.split("-")[0] || "en") as Locale;
return supportedLanguages.includes(locale) ? locale : "en";
} else if (import.meta.client) { // Для CSR
const locale = (navigator.language || "en") as Locale;
return supportedLanguages.includes(locale) ? locale : "en";
}
return "en" as Locale;
});
return {
locale,
};
}
Як можна побачити з прикладу вище, для клієнтського рендерингу ми використовуємо звичний Navigator API — щоб отримати мову браузера.
На стороні серверного рендерингу застосовуємо вбудовану композицію useRequestHeaders, яка повертає обʼєкт з усіма заголовками початкового запиту, який надійшов до застосунку. Після цього ми форматуємо значення і шукаємо, чи є локаль користувача в нашому списку доступних локалей.
Значення *locale* ми будемо широко використовувати далі — під час безпосередньої імплементації локалізації.
Щоб уникнути участі всіх JSON-файлів у процесі білду проєкту, використовуємо директорію /public.
Тепер переходимо безпосередньо до локалізації. Спочатку оголосимо базові типи:
import en from "@/public/locales/en.json"; // Імпортуємо файл з англійськими ключами та перекладами, // адже він буде використовуватись як значення за-вмовчуванням export const supportedLanguages = ["en", "es", "fr", "pt", "de", "tr"] as const; export type Locale = (typeof supportedLanguages)[number]; // Initial type: "en" | "es" | "fr" | "pt" | "de" | "tr" export type LocaleKeys = typeof en; // Ключ і його значення, позначене його типом (string) export type LocaleKey = keyof LocaleKeys; // Безпосередньо ключі файлів локалізації // (строки, зі значенням ключа, наприклад - "Hello world")
Саме такий набір типів потрібен нам для:
- коректного підвантаження мов у разі зміни значення поточної мови;
- підвантаження ключів локалізації — щоб не втратити ніякий переклад під час розробки.
Далі перейдемо до функціональності:
export default function useLocalization() {
const persistStore = useStore();
const { language: userSelectedLocale } = storeToRefs(persistStore);
// 1)
// Дістанемо з Pinia сховища локаль, яку обрав користувач самостійно,
// якщо така присутня
const { locale: userBrowserLocale } = useLocale();
// Дістанемо вищезгадану локаль браузера
const loadedLanguages = useState<Partial<Record<Locale, LocaleKeys>>>("loaded-languages", () => {
return { en };
});
// 2)
// Завдяки хуку useState оголосимо змінну loaded-languages
const userPrefferableLocale = computed(() => {
return userSelectedLocale.value ? userSelectedLocale.value : userBrowserLocale.value;
});
// 3)
// Створимо computed змінну userPrefferableLocale, яка буде повертати локаль,
// яку обрав користувач, а якщо така відсутня - підставляємо локаль браузера
const locale = useState<Locale>(() => {
return userPrefferableLocale.value || "en";
});
// 4)
// Завдяки тому ж хуку useState створимо реактивне значення локалі користувача
const t = (key: LocaleKey, variables?: Record<string, string>) => {
const localeKeys = loadedLanguages.value[locale.value];
let translatedText = localeKeys?.[key] || key;
if (variables) {
Object.entries(variables).forEach(([varKey, value]) => {
translatedText = translatedText.replace(`{${varKey}}`, value); });
}
return translatedText; };
// 5)
// Функція t працюватиме тим же чином, як і відома всім $t
// Якщо буде знайдений переклад за ключем, повернемо переклад
// Якщо ні, повернемо лише ключ
// Також, надаємо можливість підставляти в строку динамічні значення,
// завдяки опціональному аргументу
const setLocale = async (newLocale: Locale) => {
if (!loadedLanguages.value[newLocale] && supportedLanguages.includes(newLocale)) {
const loadedLocale: LocaleKeys = await import(`@/public/locales/${newLocale}.json`).then(
module => module.default || module,
);
loadedLanguages.value[newLocale] = loadedLocale;
}
await persistStore.setLanguage(newLocale);
locale.value = newLocale;
};
// 6)
// Функція встановлення локалі, яка перевіряє значення та статус обраної локалі (чи вона присутня і завантажена)
// І встановлює її для користувача, як в Pinia сховище, так і в раніше оголошену змінну
const initLocalization = async () => {
await setLocale(userPrefferableLocale.value);
};
watch(
() => locale.value,
async val => {
if (val !== userSelectedLocale.value) {
await persistStore.setLanguage(val);
}
},
);
// 7)
// Для додаткового контролю, створюємо спостерігач, який перевіряє значення
// locale на зміни, аби встановити це значення в Pinia сховище
return {
t,
setLocale,
initLocalization,
locale,
};
}
Хочу окремо звернути увагу на використання хука useState у пунктах 2 і 4 вище — і пояснити, чому потрібно використовувати саме його для коректної роботи із SSR.
useState — це Nuxt-композиція, яка створює реактивний та дружній до SSR поширюваний стейт. Вона приймає такі аргументи:
- унікальний ключ, за яким Nuxt буде розпізнавати ці дані та дедублювати запити за ними;
- початкове значення, його ініціалізація.
Чому в цьому випадку потрібно скористатися саме useState, а не ref()?
- SSR-Friendly. useState створений спеціально для SSR, адже його значення коректно збережеться під час ініціалізації на SSR і під час клієнтської гідрації. Звичайний ref() цього не гарантує — значення на боці клієнта може бути перезаписане після SSR-стадії.
- Поширюваність. useState створює значення, яке можна поширювати між компонентами застосунку, і воно не буде змінюватись, адже має одну інстанцію. Водночас ref() створює копію для кожного компоненту, де він використовується.
- Уникнення витоків памʼяті. Виклик ref() поза межами композиції чи script setup (як це часто можна побачити в спробах зробити значення ref() поширюваним) під час використання SSR призведе до поширюваного стану поміж усіма користувачами застосунку. А useState вирішує цю проблему, адже правильно виділяє скоуп стану і правильно індексує цей скоуп.
- Серіалізація. Так називається процес конвертування JS-даних, включно зі станом, у формат, який може безпечно переміщатись від сервера до клієнта (найчастіше — JSON), зберігаючи цілісність на всіх етапах — від сервера до клієнтської гідрації, а потім трансформується назад, у початковий формат. useState серіалізує дані, які надсилаються між сервером та клієнтом, щоб ці дані залишались цілі та неушкоджені, тоді як ref() цього не робить узагалі.
Наступний крок — ініціалізувати локалізацію відповідною функцією, щоб установити користувачу вибрану локаль або ж локаль браузера. Зробимо це в app.vue:
<script lang="ts" setup>
const { initLocalization } = useLocalization();
initLocalization();
</script>
Тепер використання нашої локалізації виглядає ось так, звично для всіх:
<script lang="ts" setup>
const { t } = useLocalization();
const { appName } = useAppName();
</script>
<template>
<h1>{{ t("Welcome to") }} {{ appName }}</h1>
</template>
Найбільша перевага цього підходу полягає в легкому пошуку помилок. У прикладах вище ми створили типізацію, яка перебирає кожен ключ наших JSON-файлів, а це своєю чергою означає, що ми дізнаємось, якщо ключ відсутній у відповідних файлах або він введений неправильно.
Окрім того, під час розробки ми отримуємо підказки до всіх можливих ключів.
Ці дрібниці суттєво спрощують роботу розробника і покращують його досвід: не потрібно щоразу перевіряти, чи правильно написаний ключ і чи є він узагалі.
Порівняння продуктивності: @nuxtjs/i18n проти кастомної локалізації
Повернімося до проблем з продуктивністю, які виникали в нас раніше. Я порівняв два запуски команди npm run build на локальній машині — один із використанням @nuxtjs/i18n, інший — з нашою кастомною реалізацією.
До вимірювання:

Під час (максимум споживання памʼяті):

Різниця — 3,7 GB використаної пам’яті. Це суттєве навантаження, особливо під час локальних тестів.
До вимірювання:

Під час (максимум споживання памʼяті):

Тут різниця сягає 1,34 GB, тобто показник більш як удвічі нижчий.
Для вищої точності вимірювань запустимо команду:
command time -l npm run build
Вона дає змогу отримати розширені метрики та статистики, такі як час, використання памʼяті й CPU.
|
Показник |
Запуск з @nuxtjs/i18n |
Запуск без @nuxtjs/i18n |
Висновок |
|
Реальний час |
86,81 с |
13,19 с |
🟥 Запуск 1 у 6,5 разів повільніший |
|
Користувацький час |
132,86 с |
37,32 с |
🟥 Запуск 1 спожив у 3,5 разів більше CPU |
|
Системний час |
17,18 с |
2,96 с |
🟥 Запуск 1 виконав у ~6 разів більше системної роботи |
|
Максимальний обсяг резидентної памʼяті |
4168 Мб |
2007 Мб |
🟥 Запуск 1 використав у 2 рази більше RAM |
|
Повернення сторінок |
552 183 |
188 576 |
🟥 У запуску 1 у 3 рази більше навантаження на памʼять |
|
Виклики сторінок |
12 |
11 |
➖ Майже однаково |
|
Надіслано / отримано повідомлень |
10 019 / 15 625 |
1108 / 1563 |
🟥 У запуску 1 у ~10 разів більше IPC |
|
Отримано сигналів |
15 137 |
2442 |
🟥 У запуску 1 у 6 разів більше сигналів |
|
Контекстні перемикання |
24 891 / 1 243 557 |
3063 / 147 364 |
🟥 У запуску 1 у 8 разів більше обох типів |
|
Виконано інструкцій |
931,75 млн |
930,89 млн |
➖ Практично однаково (~0,1 % різниці) |
|
Циклів процесора |
372,61 млн |
371,74 млн |
➖ Практично однаково (~0,2 % різниці) |
|
Пік використання памʼяті |
25,6 Мб |
25,4 Мб |
➖ Практично однаково |
Після тривалого тестування в production-режимі наша система локалізації показала як технічні покращення, так і відсутність непередбачених проблем. Таким чином, постало питання розповсюдження цієї локалізації на суміжні репозиторії.
Очевидно, що рішення копіювати та вставляти майже однакові ділянки коду в різні репозиторії — далеко не найкраще. Тому ми прийшли до ідеї створити власну бібліотеку та опублікувати її, аби не лише ми могли користуватися цим функціоналом, а й інші розробники, які стикаються зі схожими проблемами.
Після кількох днів активної розробки та покращення описаних рішень я нарешті можу спрямувати вас на NPM та корпоративний GitHub Futurra Group, де ви можете ознайомитися з документацією нашої бібліотеки та протестувати її самостійно — за бажанням або потребою.
Висновки
Наше рішення замінити i18n на кастомну, власну систему локалізації виявилось успішним:
- Ми зменшили build time проєкту та оптимізували технічні показники — тепер не потрібно постійно збільшувати ресурси.
- Полегшили процес розробки додатковою типізацією, що спрощує пошук помилок.
- Реалізували дружню до SSR-систему локалізації, яка не має зайвої функціональності, а спрямована саме на наші потреби.
Це рішення не універсальне — завжди можна знайти кращі шляхи. Але в нашому випадку воно спрацювало.
Сподіваюся, мій досвід буде корисним і ви зможете його застосувати чи вирішити схожі проблеми.
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів