Реалізація кастомної локалізації з Nuxt 3 без i18n

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Привіт! Мене звати Михайло Кухарський, я 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()?

  1. SSR-Friendly. useState створений спеціально для SSR, адже його значення коректно збережеться під час ініціалізації на SSR і під час клієнтської гідрації. Звичайний ref() цього не гарантує — значення на боці клієнта може бути перезаписане після SSR-стадії.
  2. Поширюваність. useState створює значення, яке можна поширювати між компонентами застосунку, і воно не буде змінюватись, адже має одну інстанцію. Водночас ref() створює копію для кожного компоненту, де він використовується.
  3. Уникнення витоків памʼяті. Виклик ref() поза межами композиції чи script setup (як це часто можна побачити в спробах зробити значення ref() поширюваним) під час використання SSR призведе до поширюваного стану поміж усіма користувачами застосунку. А useState вирішує цю проблему, адже правильно виділяє скоуп стану і правильно індексує цей скоуп.
  4. Серіалізація. Так називається процес конвертування 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, інший — з нашою кастомною реалізацією.

  • 1. npm run build у проєкті з @nuxtjs/i18n.

    До вимірювання:

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

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

  • 2. npm run build у проєкті з власною локалізацією.
  • До вимірювання:

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

    Тут різниця сягає 1,34 GB, тобто показник більш як удвічі нижчий.

    Для вищої точності вимірювань запустимо команду:

    command time -l npm run build
    

    Вона дає змогу отримати розширені метрики та статистики, такі як час, використання памʼяті й CPU.

    Показник

    Запуск з @nuxtjs/i18n

    Запуск без @nuxtjs/i18n

    Висновок

    Реальний час
    (real time)

    86,81 с

    13,19 с

    🟥 Запуск 1 у 6,5 разів повільніший

    Користувацький час
    (user time)

    132,86 с

    37,32 с

    🟥 Запуск 1 спожив у 3,5 разів більше CPU

    Системний час
    (sys time)

    17,18 с

    2,96 с

    🟥 Запуск 1 виконав у ~6 разів більше системної роботи

    Максимальний обсяг резидентної памʼяті

    4168 Мб

    2007 Мб

    🟥 Запуск 1 використав у 2 рази більше RAM

    Повернення сторінок
    (page reclaims)

    552 183

    188 576

    🟥 У запуску 1 у 3 рази більше навантаження на памʼять

    Виклики сторінок
    (page faults)

    12

    11

    ➖ Майже однаково

    Надіслано / отримано повідомлень

    10 019 / 15 625

    1108 / 1563

    🟥 У запуску 1 у ~10 разів більше IPC

    Отримано сигналів

    15 137

    2442

    🟥 У запуску 1 у 6 разів більше сигналів

    Контекстні перемикання
    (vol/invol)

    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-систему локалізації, яка не має зайвої функціональності, а спрямована саме на наші потреби.

    Це рішення не універсальне — завжди можна знайти кращі шляхи. Але в нашому випадку воно спрацювало.

    Сподіваюся, мій досвід буде корисним і ви зможете його застосувати чи вирішити схожі проблеми.

    👍ПодобаєтьсяСподобалось22
    До обраногоВ обраному10
    LinkedIn
    Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
    Ctrl + Enter
    Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
    Ctrl + Enter

    Підписатись на коментарі