Разбираемся с интернационализацией в Next.js

Введение

Когда я делал этот блог, подумал, что было бы славно уйти от привязки к одному пользовательскому языку - так я решил добавить интернациональность. Первоначально я решил воспользоваться готовыми библиотеками, наподобие next-intl, но в настоящее время данная библиотека не поддерживает Partial Prerendering(PPR) - новой фишки Next js. Да и использование сторонних библиотек требует прочтения документации, осознания как работает API данной библиотеки, подстраивания под требования этой библиотеки - все это заставило меня задуматься и принять решение. Я решил, что не хочу использовать next-intl и подобное, лучше сделать что-то свое. Так что сейчас поделюсь опытом, по моему мнению весьма удачным. Многое я взял из из документации Next js https://nextjs.org/docs/app/building-your-application/routing/internationalization

Погнали

Маршрутизация для интернационализации будет осуществляться на основе подпутей.

/ru/profile
/en/profile
....
/[locale]/profile

Так что для начала нужно создать папку [locale] внутри папки app и все содержимое папки app нужно перенести в папку app/[locale]. Так мы сделаем глобальный динамический маршрут для нашего веб приложения. Также следует, для удобства, создать папку i18n в корне вашего проекта.

1. Создание LocaleMiddleware

/i18n
  LocaleMiddleware.ts

Код я взял с документации Next js, дополнив его комментариями, сгенерированными ChatGPT, но для начала установим необходимые npm пакеты

npm install @formatjs/intl-localematcher negotiator @types/negotiator

Данный этап самый сложный - вам предстоит разобраться в коде LocaleMiddlware.tsx, поменять его для своих потребностей и тд.

// LocaleMiddleware.ts

import 'server-only';
import { NextRequest, NextResponse } from 'next/server';
import Negotiator from 'negotiator';
import { match } from '@formatjs/intl-localematcher';
import { defaultLocale, locales, LocaleType } from './locales';
// Папку locales создадим чуть позже

// Название cookie, в которой хранится выбранная пользователем локаль
const LOCALE_COOKIE_NAME = 'locale';

/**
 * Определяет наиболее подходящую локаль для пользователя на основе заголовка Accept-Language в запросе.
 * @param {NextRequest} request - Объект запроса Next.js.
 * @returns {string} - Наиболее подходящая локаль (например, 'en', 'ru').
 */
function getPreferredLocale(request: NextRequest): string {
  // Создаем объект, содержащий заголовки запроса в формате, понятном для Negotiator.
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // Получаем список предпочитаемых языков от пользователя на основе заголовка Accept-Language.
  const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
  
  // Используем `match` для определения наиболее подходящей локали из списка поддерживаемых локалей.
  // Если ни одна из предпочитаемых локалей не поддерживается, возвращается `defaultLocale`.
  return match(languages, locales, defaultLocale);
}
/**
 * Middleware для обработки локализации.  Перенаправляет запросы на основе локали пользователя.
 * @param {NextRequest} request - Объект запроса Next.js.
 * @returns {NextResponse<unknown> | NextRequest} - Объект ответа Next.js (перенаправление) или оригинальный запрос.
 */

export function LocaleMiddleware(request: NextRequest): NextResponse<unknown> | NextRequest {
  const { pathname } = request.nextUrl;

  // Проверяем, содержится ли локаль в текущем пути (например, '/en/about').
  const pathnameHasLocale = locales.some((locale) =>
    pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  // Получаем значение куки с локалью пользователя, если она установлена.
  const localeCookie = request.cookies.get(LOCALE_COOKIE_NAME)?.value as LocaleType;

  // Проверяем, существует ли куки с локалью и является ли её значение валидной локалью. Куки являются приоритетом
  if (localeCookie && locales.includes(localeCookie)) {

    // Куки с локалью установлена, проверяем, содержится ли локаль также и в пути.
    if (!pathnameHasLocale) {
      // Локаль отсутствует в пути, нужно перенаправить пользователя, добавив префикс локали из куки.
      const newPath = `/${localeCookie}${pathname}`;
      return NextResponse.redirect(new URL(newPath, request.url));

    } else {
      // Локаль присутствует и в куки, и в пути.  Проверяем, совпадают ли они.
      const currentPathLocale = pathname.split('/')[1] as LocaleType;

	  if (localeCookie !== currentPathLocale) {
        // Локаль в куки не совпадает с локалью в пути.  Нужно перенаправить на путь с локалью из куки.

        // Удаляем текущий префикс локали из пути.
        const newPathWithoutLocale = pathname.slice(currentPathLocale.length + 1); // Учитываем слэш

        // Формируем новый путь с локалью из куки.
        const newPath = `/${localeCookie}${newPathWithoutLocale}`;
        return NextResponse.redirect(new URL(newPath, request.url));

      } else {
        // Локаль в куки и в пути совпадают.  Ничего не нужно менять, возвращаем оригинальный запрос.
        return request
      }
    }
  } else {
    // Куки с локалью отсутствует.
    if (!pathnameHasLocale) {
      // Локаль отсутствует и в пути, и в куки.  Определяем предпочитаемую локаль пользователя на основе заголовка Accept-Language.
      const preferredLocale = getPreferredLocale(request);

      // Формируем новый путь с предпочитаемой локалью.
      const newPath = `/${preferredLocale}${pathname}`;

      // Перенаправляем пользователя на новый путь.
      const response = NextResponse.redirect(new URL(newPath, request.url));

      // Устанавливаем куки с предпочитаемой локалью, чтобы в следующий раз использовать её.
      response.cookies.set(LOCALE_COOKIE_NAME, preferredLocale, { sameSite: 'lax' });
      return response;

	} else {
      // Локаль присутствует в пути, но отсутствует в куки.  Проверяем, является ли локаль в пути валидной.
      const currentPathLocale = pathname.split('/')[1] as LocaleType;

      if (locales.includes(currentPathLocale)) {
        // Локаль в пути является валидной.  Устанавливаем куки с этой локалью.
        const response = NextResponse.next();
        response.cookies.set(LOCALE_COOKIE_NAME, currentPathLocale, { sameSite: 'lax' });
        return response;
      } else {
        // Локаль в пути не является валидной.  Перенаправляем на путь с локалью по умолчанию.
        const newPath = `/${defaultLocale}${pathname}`;
        const response = NextResponse.redirect(new URL(newPath, request.url));
        response.cookies.set(LOCALE_COOKIE_NAME, defaultLocale, { sameSite: 'lax' });
        return response;
      }
    }
  }
}

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

Конечно же, этот LocaleMiddleware нужно добавить в основной middleware.ts. Основной middleware.ts добавляется в корневом каталоге вашего проекта ( на том же уровне, что и app или pages, или внутри src )

import { NextRequest, NextResponse } from "next/server"
import { LocaleMiddleware } from "./i18n/LocaleMiddleware"

// middleware.ts
export function middleware(request: NextRequest) {
    const newRequest = LocaleMiddleware(request)
    if (newRequest instanceof NextResponse) {
        return newRequest
    }
    //..... Любая ваша логика

    return NextResponse.next()
}

export const config = {
    matcher: [
        '/',
        '/((?!api|_next/static|_next/image|favicon.ico|.+[.](?:svg|png|jpg|jpeg|gif|webp)$).*)',
    ],
};

После проверки if вы можете добавить любую логику: заголовки, аналитику или другие middleware. Главное следите за config.matcher и обновляется для своих потребностей.

2. Создание файлов перевода

На этом шаге нам требуется создать папку со словарями переводов. Она может находиться в любом месте, например, я создал ее в корневой папке своего проекта. Название папки любое. В этой папке у нас будут храниться словари переводов в формате json

/locales
  en.json
  ru.json
  fr.json
  es.json

Пример файла ru.json

{
  "header": {
    "phone": "Телефон"
  },
  "hello": "Привет"
}

Дальше следует создать функцию для извлечения данных из этих файлов, а также вспомогательные функции и файлы. (в папке /i18n)

/i18n
  LocaleMiddleware.ts
  locales.ts
  getDictionary.ts
  InsertValues.ts

Для начала создадим файл locales.ts, в нем у нас будут содержаться актуальные локали и локаль по умолчанию

//locales.ts

export const locales = ['ru', 'en'] as const
export const defaultLocale = 'ru'
// Для удобства пользования создадим тип
export type LocaleType = typeof locales[number]

Дальше создадим функцию для извлечения данных с папки /locales

//GetDictionary.ts
import 'server-only'
import { LocaleType } from './locales'

// Удобный тип, позволяющий легко выбирать нужную иформацию с json файлов
export type DictionaryType = Awaited<ReturnType<typeof dictionaries[keyof typeof dictionaries]>>

const dictionaries = {
  en: () => import('../../locales/en.json').then((module) => module.default),
  ru: () => import('../../locales/ru.json').then((module) => module.default),
}

export const getDictionary = async (locale: LocaleType): Promise<DictionaryType> =>
  dictionaries[locale]()

Здесь вы можете создать любую вспомогательную функцию. Предположим, вам придётся осуществлять вставку по шаблону.

export function insertValues(text: string, data: any[], placeholder: string = '$'): string {
  try {
    const parts: string[] = text.split(placeholder);
    let result: string = "";

    if (parts.length - 1 > data.length) {
      return text;
    }
    for (let i = 0; i < parts.length; i++) {
      result += parts[i];
      if (i < data.length) {
        result += String(data[i]);
      }
    }

	return result;
  } catch (error) {
    console.error("Ошибка при вставке значений:", error);
    return text; // Возвращаем исходный текст при ошибке
  }
}

// insertValues('Привет $, как ты?',['Евгений'])
// log:  Привет Евгений, как ты?

Конечно же, можно дополнить эту функцию, добавив нумерацию, наподобие $1.

3. Настройка Redux

Для начала создадим папку _redux. Я создал ее в папке src. И устанавливаем react-redux и @reduxjs/toolkit.

npm install react-redux @reduxjs/toolkit

После установки react-redux и @reduxjs/toolkit настраиваем store.ts и создаем LocaleSlice.ts

/src
  /_redux
    store.ts
    /Slices
      LocaleSlice.ts

Настройка store.ts

//store.ts
import { configureStore } from '@reduxjs/toolkit'
import { useDispatch, useSelector, useStore } from 'react-redux'
import { LocaleSlice } from './Slices/LocaleSlice'
export const makeStore = () => configureStore({
    reducer: {
        [LocaleSlice.name]: LocaleSlice.reducer,
    },
})

export type AppStore = ReturnType<typeof makeStore>
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']

export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()

Настройка LocaleSlice.ts

//LocaleSlice.ts
import { LocaleType } from "@/i18n/locales";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

type InitialStateType = {
    locale: LocaleType | ''
}

const initialState: InitialStateType = { locale: '' }

export const LocaleSlice = createSlice({
    name: 'locale',
    initialState,
    reducers: {
        updateLocale(state: InitialStateType, action: PayloadAction<LocaleType | "">):any {
            state.locale = action.payload
        }
    },
})


export const { updateLocale } = LocaleSlice.actions

LocaleSlice.ts нужен для хранения текущей локали. В дальнейшем мы легко извлекаем локаль с помощью useAppSelector, созданного в store.ts

После настройки store.ts и LocaleSlice.ts остается создать StoreProvider.tsx

//StoreProvider.tsx
'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { AppStore, makeStore } from './store'
import { LocaleType } from '@/i18n/locales'
import { updateLocale } from './Slices/LocaleSlice'

export default function StoreProvider({
  children,
  locale,
}: {
  children: React.ReactNode,
  locale: LocaleType,
}) {
  const storeRef = useRef<AppStore>(undefined)
  if (!storeRef.current) {
    // Create the store instance the first time this renders
    storeRef.current = makeStore()
    storeRef.current.dispatch(updateLocale(locale))
  }

  return <Provider store={storeRef.current} >{children}</Provider>
}

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

4. Создание вспомогательных компонентов навигации

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

Начнем с функции переключения локали.

// useChangeLocale.ts
'use client';
import { locales, LocaleType } from '../locales';
import { changeLocale } from '../../../serverAction/changeLocale';
import useLocalizedRouter from './LocalizedUseRouter';
//useLocalizedRouter создадим чуть позже

export function useChangeLocale() {
  const router = useLocalizedRouter();

  return async (locale: LocaleType) => {
    if (locales.includes(locale)) {
      await changeLocale(locale)
      router.changeLocale(locale)
    }
  };
}

//-------------------------------------------------------------------------
//changeLocale.ts
'use server'
import { cookies } from 'next/headers'
import { locales, LocaleType } from '@/i18n/locales'

export async function changeLocale(newLocale: LocaleType) {
    if (locales.includes(newLocale)) {
        (await cookies()).set('locale', newLocale, { sameSite: 'lax', secure: true, httpOnly: true })
    }
}

Данный хук возвращает функцию, которая принимает локаль, которую предпочел пользователь. Разумеется вы легко можете написать свою функцию, либо поменять логику этой. Функция changeLocale - это Серверная функция, ее создаете в удобном для вас месте. Подробнее на https://react.dev/reference/rsc/server-functions

Следующим шагом будет создание LocalizedLink.tsx, что является усовершенствованной версией компонента Link

'use client'
import { useAppSelector } from "@/_redux/store";
import Link from "next/link";
import React, { ComponentProps } from 'react'

type LocalizedLinkProps = {
  href: string;
} & ComponentProps<typeof Link>

function LocalizedLink({ href, children, ...props }: LocalizedLinkProps) {

  const isAbsolute = href.startsWith('http://') || href.startsWith('https://');
  if (isAbsolute) {
    return <Link href={href} {...props}>{children}</Link>;
  }
  const locale = useAppSelector(state => state.locale.locale)
  let localizedHref = ''

  if (href.startsWith("/")) {
    localizedHref = `/${locale}${href}`
  } else {
    return <Link href={href} {...props}>{children}</Link>
  }

  if (href.startsWith('#')) {
    localizedHref = href; // Возвращаем исходный href для якорей
  }

  return (
    <Link href={localizedHref} {...props}>{children}</Link>
  );
}

export default LocalizedLink;

Можно видеть, что мы получаем локаль из хранилища Redux, настроенного в прошлой главе. Важно, что вместо Link, нужно везде использовать LocalizedLink, это уберет лишние редиректы - что добавит производительности, а также убережет от нежелательных эффектов, связанных с кешированием статических страниц.

Аналогично создадим подобную версию для хука UseRouter, но сначала установим sanitizeUrl

npm install @braintree/sanitize-url

И создадим UseRouter

import { useRouter } from 'next/navigation';
import { useCallback } from 'react';
import { useAppSelector } from '@/_redux/store';
import { defaultLocale } from '../locales';
var sanitizeUrl = require("@braintree/sanitize-url").sanitizeUrl;
  
interface LocalizedRouter {
  push: (href: string, options?: { scroll?: boolean; shallow?: boolean }) => void;
  replace: (href: string, options?: { scroll?: boolean; shallow?: boolean }) => void;
  prefetch: (href: string) => void;
  back: () => void;
  forward: () => void;
  refresh: () => void;
  currentLocale: string | undefined;
  changeLocale: (newLocale: string, options?: { scroll?: boolean; shallow?: boolean }) => void;

}

const getLocalizedHref = (href: string, locale: string) => {
  const normalizedHref = href.startsWith('/') ? href : `/${href}`;
  return href.startsWith(`/${locale}/`) || href === `/${locale}`
    ? href
    : `/${locale}${normalizedHref}`;
};

export const useLocalizedRouter = (): LocalizedRouter => {
  const router = useRouter();
  const cookiesLocale = useAppSelector(state => state.locale.locale);
  const locale = cookiesLocale || process.env.NEXT_PUBLIC_DEFAULT_LOCALE || defaultLocale;

  const createLocalizedHandler = (method: 'push' | 'replace' | 'prefetch') =>
    useCallback((href: string, options?: any) => {
      const hrefWithLocale = getLocalizedHref(href, locale);
      return router[method](sanitizeUrl(hrefWithLocale), options);
    }, [router, locale]);

  const changeLocale = useCallback((newLocale: string, options?: any) => {
    let currentPath = window.location.pathname;
    if (locale && currentPath.startsWith(`/${locale}`)) {
      currentPath = currentPath.slice(locale.length + 1) || '/';
    }

    const newPath = `/${newLocale}${currentPath.startsWith('/') ? currentPath : `/${currentPath}`}`;
    router.push(sanitizeUrl(newPath), options);
  }, [router, locale]);

  return {
    push: createLocalizedHandler('push'),
    replace: createLocalizedHandler('replace'),
    prefetch: createLocalizedHandler('prefetch'),
    back: router.back,
    forward: router.forward,
    refresh: router.refresh,
    currentLocale: locale,
    changeLocale,
  };
};

export default useLocalizedRouter;

5. Создание статических маршрутов

В последней главе нас ждем самый простой шаг, добавление функции generateStaticParams, чтобы добавить статику везде, где это возможно. Данную функцию рекомендую вызвать в корневом Layout.tsx, чтобы сразу добавить значение в атрибут lang тега html. А также добавить, созданный на 3 этапе, StoreProvider.

Пример корневого Layout.tsx

//Layout.tsx
import "./globals.css";
import { locales, LocaleType } from "@/i18n/locales";
import { notFound } from "next/navigation";
import StoreProvider from "@/_redux/StoreProvider";

export const dynamicParams = true
export async function generateStaticParams() {
  return locales.map((locale) => ({ locale }))
}

export default async function LocaleLayout({
  children,
  params
}: {
  children: React.ReactNode;
  params: Promise<{ locale: LocaleType }>;
}) {

  const { locale } = await params;
  if (!locales.includes(locale)) {
    notFound();
  }
  return (
    <html lang={locale} suppressHydrationWarning>
      <body>
          <StoreProvider locale={locale}>
            {children}
          </StoreProvider>
      </body>
    </html>
  );
}

Конечно можно поставить dynamicParams=false и не проверять локаль, но это заблокирует возможность динамической генерации страниц, параметры которых не были переданы в generateStaticParams, для всего приложения

Теперь для извлечения данных со словарей следуем шагам:

  1. Считывание локаль из params
  const { locale } = await params
  1. Передача локали в функцию getDictionary
  const dict = (await getDictionary((locale))).your.path.to.data

Важный момент, не обязательно считывать параметры в странице или в layout через await, особенно если они там не нужны - можно передать params, который является Promise, в нужную нам компоненту, и в ней уже выполнить извлечение локали из params

Пример

//page.tsx
export default function Page({
    params
}: {
    params: Promise<{ locale: LocaleType }>;
}) {
    return (
        <div className={s.main}>
            <Data params={params} />
        </div>
    )
}

//Data.tsx
export default async function Data({
    params
}: {
    params: Promise<{ locale: LocaleType }>;
}) {
    const { locale } = await params;
	//...
}

Если вы хотите считать данные на клиенте используйте useAppselector.

const locale = useAppSelector(state => state.locale.locale)

Вывод

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

Пример готового проекта можно найти на GitHub: https://github.com/zh-blog-ru/test_internationalization

Комментарии

    0 / 512
    Loading...