Реалізація кастомної локалізації з 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-систему локалізації, яка не має зайвої функціональності, а спрямована саме на наші потреби.
Це рішення не універсальне — завжди можна знайти кращі шляхи. Але в нашому випадку воно спрацювало.
Сподіваюся, мій досвід буде корисним і ви зможете його застосувати чи вирішити схожі проблеми.
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів