Loghorrean 13 февраля 2023

🌎💬 Привет, 你好, Bonjour: как реализовать мультиязычность на Typescript и React

В этой статье я расскажу вам о реализации мультиязычности на языке Typescript. Реализация поддерживает различные способы получения переводов строк как с сервера, так и из заранее подготовленных файлов в самом приложении. Данный способ не привязан к конкретному фреймворку, но в статье будет приведен пример его использования с библиотекой React.
🌎💬 Привет, 你好, Bonjour: как реализовать мультиязычность на Typescript и React

Приложениями пользуются люди из самых разных точек мира, и далеко не всегда эти люди знают разговорный язык, на котором это самое приложение написано. Чтобы клиентам вашей системы было комфортнее ею пользоваться, вы, как программист, можете добавить поддержку мультиязычности. Количество поддерживаемых языков отталкивается от масштаба вашей системы и вашего собственного желания, но зачастую вы будете ориентироваться на конкретный рынок сбыта вашего приложения.

Также рекомендуется отвязывать эту самую реализацию от фреймворка, которым пользуется ваша команда, потому что мультиязычность – вещь достаточно универсальная и востребованная, так что при возможной миграции или переходе на проект с другим стеком технологий вы обойдетесь малой кровью.

Определим модели

Для начала нам потребуются модели самого переводимого сообщения и языка перевода. Модель языка:

        export interface Language {
    readonly name: string;
    readonly short: string;
    readonly flag: string;
    readonly isDefault: boolean;
}

    

Интерфейс включает в себя его название, полное и сокращенное, ссылку на изображение флага. Также надо узнать, является ли данный язык дефолтным для вашей системы. Последнее потребуется нам, чтобы установить язык в случае, если пользователь еще не выбрал его сам. Модель переводимого сообщения же выглядит так:

        export interface Translation {
    readonly languageCode: string,
    readonly text: string
}

    

Интерфейс включает в себя код языка и как на этом языке читается данное сообщение.

Создадим провайдер языков

Теперь нам нужен класс, который будет отдавать нам массив языков, которые поддерживает система. Пример такого класса:

        import {Language} from "../api-client";
import {invariant} from "../utils";

export abstract class LanguageProvider {
    private cachedLanguages: Array<Language> = [];

    abstract provideLanguages(): Array<Language>;

    public getLanguages(): Array<Language> {
        if (this.cachedLanguages.length === 0) {
            this.cachedLanguages = this.provideLanguages();
        }
        return this.cachedLanguages;
    }

    public getSupportedLanguageCodes(): Array<string> {
        return this.cachedLanguages.map(language => language.short);
    }

    public getDefaultLanguage(): Language {
        const filtered = this.getLanguages().filter(language => language.isDefault);
        invariant(filtered.length === 1, "More than one default language provided!");
        return filtered[0];
    }

    public getByShort(short: string): Language {
        const filtered = this.getLanguages().filter(language => language.short === short);
        invariant(filtered.length === 1, "Found more than one language with given short code");
        return filtered[0];
    }
}

    

Функция invariant просто проверяет выполнение условия в первом аргументе и выбрасывает ошибку с текстом из второго при его несоблюдении.

Класс провайдера является абстрактным для возможного переопределения метода получения переводов (с сервера, из кода, со стороннего хранилища). В статье будет рассмотрено хранение поддерживаемых языков на уровне кода приложения. Конкретная реализация:

        import {LanguageProvider} from "./LanguageProvider";
import {Language} from "../api-client";
import russianFlag from "../assets/images/flags/russia.png";
import americanFlag from "../assets/images/flags/united-states.png";
import armenianFlag from "../assets/images/flags/armenia.png";

export class SystemLanguageProvider extends LanguageProvider {
    provideLanguages(): Array<Language> {
        return [
            {
                name: "Русский",
                short: "ru",
                flag: russianFlag,
                isDefault: true
            },
            {
                name: "English",
                short: "en",
                flag: americanFlag,
                isDefault: false
            },
            {
                name: "Armenian",
                short: "am",
                flag: armenianFlag,
                isDefault: false
            }
        ]
    }
}

    

Осталось разобраться с инстанцированием текущего провайдера. Например, таким способом:

        let cachedProvider: LanguageProvider;

export function createLanguageProvider(): LanguageProvider {
    if (cachedProvider !== undefined) {
        return cachedProvider;
    }
    return new SystemLanguageProvider();
}

    

Что насчет хранения выбранного языка?

Когда пользователь перезагружает страницу, язык должен оставаться тем же самым, который был выбран им ранее. Для этого напишем класс-обертку над браузерным хранилищем:

        export class LanguageStorage {
    private readonly storageKey = "lang";

    constructor(private readonly storage: Storage) {}

    public setPreferredLanguage(language: string): void {
        this.storage.setItem(this.storageKey, language);
    }

    public getPreferredLanguage(): string | null {
        return this.storage.getItem(this.storageKey);
    }
}

    

Саму модель языка данный класс не хранит, а предоставляет нам только наименование его кода. Создавать это хранилище будем не напрямую, а через отдельную функцию:

        export function createLanguageStorage(): LanguageStorage {
    if (typeof window === "undefined") {
        throw new Error("Language storage cannot be created outside of browser");
    }
    return new LanguageStorage(window.localStorage);
}

    

Теперь нам понадобится класс, отвечающий за выбор начального языка, с которым будет работать пользователь:

        import {LanguageStorage} from "./LanguageStorage";
import {LanguageProvider} from "./LanguageProvider";
import {invariant} from "../utils";
import {Language} from "../api-client";

export class LanguageResolver {
    constructor(private readonly storage: LanguageStorage, private readonly provider: LanguageProvider) {}

    public resolveUserLanguage(): Language {
        const storedLanguage = this.storage.getPreferredLanguage();
        if (storedLanguage !== null) {
            return this.provider.getByShort(this.formatLanguageCode(storedLanguage));
        }

        const browserLanguage = this.formatLanguageCode(window.navigator.language);
        if (this.provider.getSupportedLanguageCodes().includes(browserLanguage)) {
            return this.provider.getByShort(browserLanguage);
        }

        return this.provider.getDefaultLanguage();
    }

    private formatLanguageCode(lang: string): string {
        return lang.toLowerCase().substring(0, 2);
    }
}

    

Метод resolveUserLanguage() возвращает нам текущий язык в следующем порядке: сначала проверяет, есть ли у пользователя сохраненный код языка в хранилище, если да, то отдает язык из провайдера по этому ключу. Если сохраненного языка нет, класс смотрит настройки браузера через window.navigator.language (если вы работаете с NextJS, потребуется проверить, в какой среде выполняется код – в браузере или на сервере). Если язык браузера не входит в перечень поддерживаемых языков, то просто используется дефолтный язык приложения, фильтруя список языков по ключу isDefault.

Создается данный класс тоже через отдельную функцию, передавая в аргументах функции: конкретное хранилище и конкретный провайдер.

        export function createLanguageResolver(): LanguageResolver {
    return new LanguageResolver(
        createLanguageStorage(),
        createLanguageProvider()
    );
}

    
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека фронтендера»

С языками разобрались, а как же переводы?

С переводами похожая ситуация: какие-то переводы приходят с сервера, какие-то – с заранее подготовленных файлов в самом приложении. Объединим их логику под общим интерфейсом:

        import {Translations} from "../types";

export interface TranslationProviderInterface {
    get(languageCode: string): Translations;
}

    

Не путайте тип Translations с моделью Translation, первая просто представляет из себя словарь Record<string, string> для хранения переводов в формате ключ-значение.

В данной статье также будем рассматривать реализацию через файлы в коде приложения. Для этого под каждый поддерживаемый язык создадим по директории с названием, отражающим код языка. В каждой из них будут храниться файлы формата .ts, экспортирующие объекты с типом Translations. Пример такого файла:

        import {Translations} from "../../types";

export const auth: Translations = {
    "auth.login": "Login",
    "auth.login.enter": "Enter",
    "auth.login.forgot_password": "Forgot password?",
    "auth.create_account": "Create new account",
    "auth.register": "Register",
    "auth.register.have_account": "I already have an account",
    "auth.register_lender": "Register new lender",
    "auth.register.password": "Password (:length or more letters)",
    "auth.register.confirm_password": "Confirm password",
    "auth.register_lender.information_exchange_agree": "I agree to information exchange",
    "auth.register_lender.platform_interaction_rules": "I agree to platform interaction rules",
    "auth.register_borrower": "Register new borrower",
    "auth.register_borrower.as": "Register as",
    "auth.register_borrower.data": "Account data",
    "auth.register_borrower.agree_with": "Agree with",
    "auth.register_borrower.platform_rules": "platform rules",
    "auth.register_borrower.register_as": "register as a general director of a company or an individual entrepreneur",
    "auth.register_borrower.familiar_with": "Familiar with",
    "auth.register_borrower.processing_policy": "policy of processing personal data",
}

    

Каждой уникальной строке соответствует уникальный ключ, по которому мы будем в дальнейшем переводить. Чтобы создать общий словарь для одного языка, мы объединим все подобные объекты в один через spread operator:

        import {Translations} from "../../types";
import {languageSelect} from "./languageSelect";
import {auth} from "./auth";
import {common} from "./common";
import {errors} from "./errors";
import {navigation} from "./navigation";
import {header} from "./header";
import {deposit} from "./deposit";
import {withdraw} from "./withdraw";
import {market} from "./market";
import {my_money} from "./my_money";
import {finances} from "./finances";
import {pagination} from "./pagination";
import {profile} from "./profile";
import {analytics} from "./analytics";
import {date} from "./date";
import {my_investments} from "./my_investments";
import {detailed_project} from "./detailed_project";
import {confirm_email} from "./confirm_email";
import {identification} from "./identification";
import {confirm_phone} from "./confirm_phone";
import {funding_request} from "./funding_request";
import {payments_schedule} from "./payments_schedule";
import {my_loans} from "./my_loans";
import {word_case} from "./word_case";
import {my_detailed_project} from "./my_detailed_project";
import {history} from "./history";
import {borrower_profile} from "./borrower_profile";
import {investment_confirmation} from "./investment_confirmation";
import {guarantor} from "./guarantor";
import {selling_confirmation} from "./selling_confirmation";
import {archive} from "./archive";

export const en: Translations = {
    ...auth,
    ...common,
    ...date,
    ...deposit,
    ...withdraw,
    ...header,
    ...languageSelect,
    ...analytics,
    ...finances,
    ...market,
    ...my_investments,
    ...archive,
    ...detailed_project,
    ...investment_confirmation,
    ...selling_confirmation,
    ...profile,
    ...borrower_profile,
    ...my_money,
    ...my_loans,
    ...my_detailed_project,
    ...history,
    ...identification,
    ...confirm_email,
    ...confirm_phone,
    ...funding_request,
    ...navigation,
    ...payments_schedule,
    ...pagination,
    ...errors,
    ...word_case,
    ...guarantor
};

    

Каждый из деструктурированных объектов представляет собой набор тех же ключей, что и в примере выше. Важно, чтобы все ключи были уникальны, чтобы старые переводы не перезаписались новыми.

Все объекты аккумулируются в один общий словарь следующим образом:

        import { en } from "./en";
import { ru } from "./ru";
import { am } from "./am";
import {Translations} from "../types";

export const translations: { [code: string]: Translations } = {
    en,
    ru,
    am
};

export * from "./SystemTranslationProvider";
export * from "./TranslationProviderInterface";

    

Теперь можно написать конкретную реализацию под провайдер переводов из файлов приложения:

        import {TranslationProviderInterface} from "./TranslationProviderInterface";
import {translations} from "./index";
import {Translations} from "../types";

export class SystemTranslationProvider implements TranslationProviderInterface {
    get(languageCode: string): Translations {
        return translations[languageCode];
    }
}

    

И функцию создания провайдера:

        let cachedTranslationsProvider: TranslationProviderInterface;

export function createTranslationProvider(): TranslationProviderInterface {
    if (cachedTranslationsProvider === undefined) {
        cachedTranslationsProvider = new SystemTranslationProvider();
    }
    return cachedTranslationsProvider;
}

    

Функция перевода

Мы добрались до главного – функции, отвечающей за перевод. Ее код ниже:

        import {createTranslationProvider} from "../di";
import {Language} from "../api-client";

const translationProvider = createTranslationProvider();

export const t = (language: Language | string, message: string, params: { [code: string]: string } = {}) => {
    const messages = translationProvider.get(typeof language === "string" ? language : language.short);
    const text = messages[message] !== undefined ? messages[message] : message;
    return text.replace(/:(\\w+)/g, (search, name) =>
        params[name] !== undefined ? params[name] : search
    );
}

    

В функцию передается язык, на который переводятся сообщения, ключ перевода и параметры (про это чуть позже). Запрашивается список сообщений по ключу языка, откуда потом берется сообщение по ключу перевода. А затем идет замена динамических параметров на значения, передаваемые в аргументе params.

Возможно, вы заметили в файле с переводами "auth" страницы строку

        "auth.register.password": "Password (:length or more letters)"

    

Перевод данной строки требует параметра :length, в который передается строковое значение минимальной длины пароля. Объект параметров будет выглядеть таким образом:

        { length: CONSTANTS.MIN_PASSWORD_LENGTH // 6 }

    

Применение в React

Для реализации на библиотеке React нам потребуется Redux, чтобы хранить текущую модель языка. Напишем slice, отвечающий за это:

        import {Language} from "../api-client";
import {createSlice, PayloadAction} from "@reduxjs/toolkit";

export interface LanguageState {
    language?: Language;
}

const initialState: LanguageState = {
    language: undefined
}

export const languageSlice = createSlice({
    name: "language",
    initialState,
    reducers: {
        set(state: LanguageState, { payload }: PayloadAction<Language>) {
            state.language = payload;
        }
    }
});

export const { set } = languageSlice.actions;

export default languageSlice.reducer;

    

И хук, предоставляющий нам текущий язык из redux-хранилища:

        import {useAppSelector} from "./store";
import {LanguageState} from "../store/language";

export const useCurrentLanguage = () => {
    return useAppSelector<LanguageState>((state) => state.language).language;
}

    

Затем нам нужен хук-обертка над функцией перевода, чтобы не передавать каждый раз в нее текущий язык, а получать его из хранилища. Код хука:

        import {t} from "../i18n";
import {Language} from "../utils";
import {useCurrentLanguage} from "./useCurrentLanguage";

export const useTranslator = () => {
    const currentLanguage = useCurrentLanguage();
    return (message: string, params: { [code: string]: string } = {}, explicitLanguage?: Language) => {
        return t(explicitLanguage ?? currentLanguage!, message, params);
    }
}

    

Сам хук получает текущий язык и возвращает нам функцию, которой не требуется язык перевода напрямую (но который можно передать его опционально третьим параметром для явного перевода на сторонний язык), принимающую ключ сообщения и параметры и работающую по той же логике, что функция t.ts.

Код небольшого компонента, использующего данный хук:

        import React from "react";
import AuthFooter from "../../AuthFooter";
import {RouteDictionary} from "../../../../../utils";
import {ProjectLink} from "../../../../common";
import "./AuthFooterLogin.scss";
import {useTranslator} from "../../../../../hooks";

const AuthFooterLogin = () => {
    const t = useTranslator();
    return (
        <AuthFooter>
            <ProjectLink to={RouteDictionary.REGISTER} className="auth_footer_login__link">
                { t("auth.create_account") }
            </ProjectLink>
        </AuthFooter>
    );
}

export default AuthFooterLogin;

    
Русская версия
Русская версия
Английская версия
Английская версия
Армянская версия
Армянская версия

Изменение языка как в Redux state, так и в хранилище, происходит в одной и той же функции компонента (я не буду вдаваться в подробности его реализации, т. к. в контексте статьи это не важно). Код функции:

        const languageStorage = createLanguageStorage();

const handleSelect = (language: Language) => {
    dispatch(set(languageProvider.getByShort(language.short)));
    languageStorage.setPreferredLanguage(language.short);
}

    

При вызове функции у нас переводится текущая страница проекта благодаря реактивности Redux, и в браузерное хранилище записывается новый код языка.

Динамические переводы

Когда речь заходит о переводах, определяемых со стороны пользователя (например, администратора), алгоритм перевода немного меняется. Как пример – в нашем проекте есть сущности в базе данных, у которых есть название на нескольких языках сразу. Хранятся они в JSON-формате следующим образом:

        [{"languageCode":"ru","text":"Проект 1"},{"languageCode":"en","text":"Project 1"}]

    

Как видите, это массив моделей Translation, определенных ранее. Для переводов подобного вида определим в React компонент DynamicTranslation:

        import React from "react";
import {useCurrentLanguage, useTranslator} from "../../../hooks";
import {Translation} from "../../../api-client";

interface Props {
    translations: Array<Translation>;
}

const DynamicTranslation = ({ translations }: Props) => {
    const t = useTranslator();
    const currentLanguage = useCurrentLanguage();
    const validTranslations = translations.filter((translation) =>
        translation.languageCode.toLowerCase() === currentLanguage!.short.toLowerCase()
    );
    if (validTranslations.length === 0) {
        return <>{ t("common.no_translation", { lang: currentLanguage!.short }) }</>;
    }
    return <>
        { validTranslations[0].text }
    </>;
}

export default DynamicTranslation;

    

Данный компонент получает массив моделей, фильтрует их в соответствии с текущим языком приложения и возвращает текст только одной из них. Если же перевода для данного языка не найдено (как в примере с массивом выше, где только два языка из трех), возвращается сообщение, что перевод для данного языка не найден.

Итоги

Данная реализация не слишком привязана к фреймворку/библиотеке, хотя в способах получения языка и присутствует немало логики, специфичной для React (хуки, Redux). При желании можно переписать данную реализацию под другой проект, затрагивая только малые ее части. Конечно, существуют определенные сложности при переводе различных сообщений на языки с другими правилами построения предложения (например, падежная система и система трех родов на русском языке). В будущем напишу отдельную статью про такие моменты и другие подводные камни при переводах.

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ