Больше библиотек богу библиотек или как я переосмыслил i18n [next.js v14]
- среда, 14 февраля 2024 г. в 00:00:13
Для интернационализации сделаны десятки по-своему потрясающих библиотек, такие как i18n, react-intl, next-intl. Все они отлично справляются со своей задачей - добавляют переводы в приложение или на сайт. Большинство из них проверены, отлажены и стабильно поддерживаются.
Но все они устарели.
Ведь всё это время развивалось и экосистема реакта. Так, последняя версия next.js включила крупные обновления из react.js - cache, taint, новые хуки и, конечно же, серверные компоненты. Команда самого React.js, вероятно, представит эти изменения уже в мае.
В этой статье я расскажу о ключевых изменениях, личном опыте, проблемах существующих решений, необходимым обновлениях, решениях, к которым я пришёл и, конечно же, отвечу на вопросы зачем, а самое главное - зачем?
Первое, с чего стоит начать - как так вышло, что изменения React.js сделали библиотеки переводов устаревшими.
Несмотря на то, что последняя стабильная версия React.js вышла почти два года назад, он имеет и 2 других канала - canary и experimental, где canary также считается стабильным каналом и рекомендуется для использования библиотеками.
Именно этим каналом и пользуется Next.js. Next.js запустил серверные компоненты без дополнительных флагов внутри так называемого App Router-а - это новая директория в альтернативу pages, которая использует свои конвенции и различный сахар (об изменениях и проблемах которого я писал в недавней статье).
Серверные компоненты однозначно решают ряд проблем и являются новой вехой оптимизаций. В том числе и для переводов. Без серверных компонент переводы хранились как в собранном html так и крупным объектом в клиентском скрипте. Теперь же можно сразу получить готовый html, которому ничего не нужно на клиенте.
Next.js уделил этой возможности особое внимание.
Добавить переводы (по документации Next.js) можно следующим образом:
// app/[lang]/dictionaries.js
import 'server-only'
const dictionaries = {
en: () => import('./dictionaries/en.json').then((module) => module.default),
nl: () => import('./dictionaries/nl.json').then((module) => module.default),
}
export const getDictionary = async (locale) => dictionarieslocale
// app/[lang]/page.js
import { getDictionary } from './dictionaries'
export default async function Page({ params: { lang } }) {
const dict = await getDictionary(lang) // en
return <button>{dict.products.cart}</button> // Add to Cart
}
Данное решение описывается как готовое и полностью оптимизированное. Оно работает полностью на сервере, а клиент получает уже готовый HTML. Однако команда Next.js опустила одну важную деталь — как передавать язык в глубокую вложенность в серверных компонентах.
Большая проблема серверных компонент — в них не доступны контексты. Команда Next.js объясняет отсутствие этих функций тем, что Layout не ререндерится, а всё что зависит от пропсов должно быть клиентским.
Пожалуй больше всего это коснулось библиотеки переводов. В качестве временного решения они предлагают определять язык в middleware и добавлять его в куки. Затем при построении страницы читать его в нужных местах. Но чтение куков означает включение серверного рендеринга, что подходит далеко не всем.
В целом же главная проблема существующих решений — большинство из них не сделаны под серверные компоненты. Компоненты и функции разрабатывались под рантайм, с использованием хуков и синхронностью.
Ещё одной неприятностью стало кеширование в Next.js. А именно — оно полноценно работает только для GET запросов, а также если вес переводов больше лимита в 2МБ - они не будут закешированы.
Цели и задачи:
Библиотека должна иметь полный функционал как в клиентских комопонентах, так и в серверных;
Использование должно быть простым (без лишних пропсов);
Вся сложная логика должна быть перенесена на сервер;
Переводы должны загружаться только по необходимости и без лишних запросов;
Поддержка обновления переводов без пересборки (ISR/SSR);
Всё должно работать в статическом сайте (а не с переключением в SSR);
Должны поддерживаться html entities.
К моему удивлению нет ни одной библиотеки, которая удовлетворила бы все эти запросы.
Первое, что нужно - функционал. В стандартном варианте это хук, возвращающий функцию t
и компонент Trans
для более сложных переводов. Однако такой функционал нужен и в серверных компонентах, а они имеют множество своих особенностей.
Основной функционал делится на две версии - для клиентских компонент и для серверных и включает в себя:
useTranslation
, getTranslation
- которые возвращают функцию t
внутри ДОМ-а и язык;
import getTranslation from 'next-translation/getTranslation'
export default function ServerComponent() {
const { t } = getTranslation()
return (
<p>{t('intro.title')}</p>
)
}
'use client';
import useTranslation from 'next-translation/useTranslation'
export default function ClientComponent() {
const { t } = useTranslation()
return (
<p>{t('intro.title')}</p>
)
}
Получился достаточно привычным интерфейс, функции поддерживают namespace
и query
. Его рекомендуется использовать по умолчанию, так как он прост в использовании и в логике. Возвращает готовую строку.
Для более сложных же переводов нужно использовать компоненты ClientTranslation
и ServerTranslation
. Они умеют заменять псевдо-компоненты реальными.
import ServerTranslation from 'next-tranlation/ServerTranslation';
export default function ServerComponent() {
return(
<ServerTranslation
term='intro.description'
components={{
link: <a href='#' />
}}
/>
)
}
"use client";
import ClientTranslation from 'next-tranlation/ClientTranslation';
export default function ClientTranslation() {
return(
<ClientTranslation
term='intro.description'
components={{
link: <a href='#' />
}}
/>
)
}
Также есть случаи, когда переводы нужно добавить и вне react-дерева. Для этого в любом месте можно использовать createTranslation
.
import createTranslation from 'next-translation/createTranslation'
// ...
export async function generateMetadata({ params }: { params: { lang: string } }) {
const { t } = await createTranslation(params.lang);
return {
title: t('homePage.meta.title'),
}
}
Теперь о настройке страницы. Для работы с переводами нужно знать язык. Однако в серверных компонентах нельзя использовать контекст. Для решения этого была сделана альтернатива createContext
для серверных компонент в пакете next-impl-getters - createServerContext
и getServerContext
.
Чтобы его использовать нужно создать NextTranslationProvider
. Это рекомендуется делать на уровне страницы, чтобы избежать проблем с ререндером Layout.
import NextTranlationProvider from 'next-translation/NextTranlationProvider'
export default function HomePage({ params }: { params: { lang: string } }) {
return (
<NextTranlationProvider lang={params.lang} clientTerms={['shared', 'banking.about']}>
{/* ... */}
</NextTranlationProvider>
)
}
Также нужно обозначить какие переводы нужны именно на клиенте и передать туда только их. Для этого в NextTranslationProvider
можно передать массив клиентских ключей или групп с помощью пропа clientTerms
.
Также иногда возникают ситуации, когда компоненту нужны разные переводы или рендерятся разные блоки в зависимости от условий. В таких случаях на клиент нужно передать и разные переводы. Варианты условия можно обернуть в NextTranslationTransmitter
и передать в него клиентские термины.
import NextTranslationTransmitter from 'next-tranlation/NextTranslationTransmitter';
import ClientComponent from './ClientComponent';
const ServerComponent: React.FC = () => (
<NextTranslationTransmitter terms={['header.nav']}>
<ClientComponent />
</NextTranslationTransmitter>
)
Как итог, в клиентские компоненты будут переданы только те термины, которые были указаны выше в NextTranslationProvider
или NextTranslationTransmitter
.
Перед работой с переводами их нужно загрузить. Для этого нужно создать конфигурационный файл в корне проекта. Минимальная его конфигурация - функция load
, которая вернёт актуальные переводы и массив languages
с допустимыми языками. Функция load
вызывается в серверных компонентах, а нужные ключи будут переданы на клиент.
Очень важным пунктом было отсутствие лишних запросов, то есть нужно полноценное кеширование.
Здесь стоит немного отойти в сторону. Next.js с последней версии собирает приложение параллельно в несколько процессов. Если бы каждый процесс жил со своим кешем - из каждого послылались бы запросы. Вероятно именно во избежание этого команда Next.js переделала fetch - теперь он работает с общим кешем.
Таким же путём решает проблему и пакет - он создаёт общий кеш и работает из каждого процесса уже с ним. Чтобы это работало нужно использовать withNextTranslation
в next.config.js
.
Решение получилось по-настоящему настроенное под next.js - учитывающее все его возможности и проблемы. Также оно включает все возможности оптимизаций, которые дают серверные компоненты. Пакет полностью оптимизирован под next.js, их концепции и взгляды, которые я полностью разделяю.
Я встал перед проблемой переводов и мне пришлось сделать своё решение, которое будет работать именно так, как ожидается. Несмотря на значительное преимущество в оптимизациях, по возможностям самих переводов пакет пока ещё уступает крупным библиотекам. Впереди много работы.
Пакет с переводами: https://github.com/vordgi/next-translation
Пакет с серверным контекстами и прочими геттерами: https://github.com/vordgi/next-impl-getters
P.S. Буду благодарен, если опишите, чего вам не хватало в существующих решениях или какой функционал считаете самым важным.