Перейти к содержимому

locales —— интернационализация

Интернационализация в семействе lh-ui — это чистый генератор событий: lhlocale не несёт никакой реактивности фреймворка, а адаптеры UI-библиотек превращают его номер версии version в сигнал перерендера своего фреймворка. Так один языковой пакет может одновременно обслуживать Vue3 / Vue2 / React / uniapp, а слой core не зависит ни от одного фреймворка.

import { lhlocale, zhCN, enUS, formatMessage } from '@lh-ui/share/locales'
import type { LhLocalePath, LhLocaleType, LhLocaleMessages } from '@lh-ui/share/locales'
Член Сигнатура Назначение
version get version(): number Номер версии: увеличивается при любом изменении языка (адаптер UI подписывается на него и запускает перерендер)
subscribe (listener: LhLocaleListener) => () => void Подписка на изменения языка; возвращает функцию отписки
use (options: { locale: LhLocaleType; messages: LhLocaleMessages }) => void Регистрирует язык и переключается на него
t (path: LhLocalePath, fallbackOrParams?) => string Получает переведённый текст по пути
tm (path: string, fallback?: string[]) => string[] Получает тексты в виде массива (названия месяцев / дней недели)
getLocale () => LhLocaleType Возвращает текущий язык
setLocale (locale: LhLocaleType) => void Переключает текущий язык
add (locale: string, messages: LhLocaleMessages) => void Добавляет пользовательский языковой пакет (глубокое слияние)
const t = lhlocale.t // свойство-стрелочная функция: при деструктуризации this не теряется
t('pagination.total') // '共 {total} 条'
t('pagination.total', { total: 42 }) // '共 42 条'
t('not.exists', 'резервный текст') // 'резервный текст'
t('not.exists') // 'not.exists' (возврат к самому пути)

Тип path — LhLocalePath: объединение всех строковых путей до листьев, выведенное из структуры as const языкового пакета zh-CN (например, 'input.placeholder'). При опечатке в имени ключа редактор сообщит об ошибке. Динамическое построение пути (t(\pagination.${key}`)) уходит в перегрузку послабее, где тип сужается до string`.

Второй параметр принимает две формы: строка — fallback-значение; объект — параметры интерполяции {key} (под капотом — formatMessage).

lhlocale.tm('datepicker.months')
// ['1月', '2月', …, '12月']
lhlocale.tm('datepicker.weeks') // ← неверно: weeks — объект, а не массив
lhlocale.tm('datepicker.weeks.sun', ['日']) // ← тоже неверно: до строковых листьев объекта добирается только t()

t() возвращает только строки; доступ к листам-массивам (months) осуществляется через tm(). При промахе или не-массиве возвращается fallback (по умолчанию []).

use add
Действие Регистрирует и переключается Только регистрирует (рассылка происходит лишь при переключении на этот язык)
Хранение messages Записывается в пользовательский реестр userLocales, не перезаписывает встроенный реестр То же
Способ слияния Устанавливается весь пакет Глубокое слияние с «текущим активным пакетом»

Почему add делает глубокое слияние: на стороне бизнеса обычно хотят изменить лишь несколько ключей (заменить «确认» на «提交»), а остальные ключи должны остаться доступными из встроенного пакета. Прежняя реализация заменяла пакет целиком, и забытые при передаче ключи просто исчезали.

Почему use не перезаписывает встроенный реестр: прежняя реализация locales[locale] = messages навсегда уничтожала встроенный языковой пакет (вернуться через последующий setLocale было уже нельзя). Теперь встроенные пакеты доступны только для чтения, а пользовательские имеют приоритет.

lhlocale.setLocale('en-US') // языковой пакет не был зарегистрирован
lhlocale.getLocale() // 'en-US' ← сообщается запрошенный язык
lhlocale.t('pagination.total')// текст из встроенного пакета по умолчанию, а не из en-US

Выглядит противоречием, но это сделано осознанно: порядок разрешения — userLocales ?? встроенный locales ?? пакет по умолчанию. При отсутствии регистрации getLocale() сообщает запрошенный язык, а t() берёт содержимое пакета по умолчанию — оба значения не рассинхронизируются (не возникает странного сочетания «сообщается английский, а текст — китайский»), и сторона бизнеса понимает, что нужно вызвать add или use.

const unsubscribe = lhlocale.subscribe((version: number) => {
// язык изменился — запускаем перерендер
})
unsubscribe() // вызывается при размонтировании компонента

subscribe возвращает функцию отписки (стиль RxJS). Внутренний bump() через try/catch изолирует плохих подписчиков и выводит [lhlocale] listener error: — если слушатель одного компонента выбросит исключение, другие компоненты это не затронет.

Как каждая платформа подписывается на version

Заголовок раздела «Как каждая платформа подписывается на version»
// Vue3 / Vue2 / uniapp
const version = ref(lhlocale.version)
lhlocale.subscribe(() => (version.value = lhlocale.version))
// В шаблоне зависим от version: при изменении происходит перерендер
// React
useSyncExternalStore(lhlocale.subscribe, () => lhlocale.version)
Файл Статус
zh-CN.ts ✅ Язык по умолчанию (as const, источник вывода LhLocalePath)
en-US.ts ✅ Включён
ja-JP.ts / ko-KR.ts / de-DE.ts / fr-FR.ts Зарезервированные скелеты языков, fallback-перевод на английский; переключаться через setLocale можно сразу

Импорт по подпути: @lh-ui/share/locales/lang/zh-CN.

common / input / datepicker / timepicker / cascader / tag / badge / select / form / switch / slider / progress / avatar / descriptions / empty / alert / dialog / message / breadcrumb / pagination / tabs / steps / popconfirm / drawer / notification / loading / collapse / rate / backtop / image / menu / table / tree / treeSelect / transfer / upload / anchor / ariaLabels

Во всех шести языковых файлах число ключей верхнего уровня одинаково (по 38), структуры выровнены. Среди них ariaLabels — ключи текстов доступности (около 40 штук); каждый компонент читает их и генерирует aria-label.

Типичные тексты с интерполяцией:

pagination.total: '共 {total} 条'
pagination.sizeChanger: '{size} 条/页'
pagination.page: '第 {page} 页'
steps.step: '第 {index} 步'
rate.stars: '{n} 星'
cascader.columnLabel: '第 {n} 级'
collapse.toggle: '展开或折叠 {title}'
datepicker.panelTitle: '{year}年{month}'
menu.submenuLabel: '子菜单 {title}'

Три шага (те же инструкции — в src/locales/lang/README.md):

  1. Создать src/locales/lang/<lang>.ts, экспортирующий структуру LhLocaleMessages (ключи верхнего уровня должны быть выровнены по 38 штук; проверить можно через satisfies).
  2. Добавить константу в LH_LANGUAGE_CONST в src/constants/index.ts.
  3. Смонтировать файл во встроенном реестре locales в src/locales/locale.ts.

Затем запустить pnpm test (в theme.spec и кейсах, связанных с locale, есть регрессия структурной согласованности).

Тип Определение Назначение
LhLocaleType Выводится из LH_LANGUAGE_CONST Объединение поддерживаемых кодов языков
LhLocaleMessages Record<string, LhLocaleMessageValue> Тип языкового пакета целиком
LhLocaleMessageValue string | readonly LhLocaleMessageValue[] | { readonly [key: string]: LhLocaleMessageValue } Листовые узлы языкового пакета (рекурсивно только для чтения)
LhLeaves<T> Рекурсивный вывод Объединение точечных путей всех строковых листьев, выводимое из структуры языкового пакета
LhLocalePath LhLeaves<typeof zhCN> Тип литерального ключа для t()
LhTranslate Тип с перегрузками Сигнатура вызова t()
LhTranslateArray (path: string, fallback?: string[]) => string[] Сигнатура вызова tm()
LhLocaleListener (version: number) => void Слушатель изменения языка
LhLocaleOptions { locale: LhLocaleType; messages: LhLocaleMessages } Аргумент use()
  • LhLeaves возвращает never для ключей-массивов: datepicker.months — массив, он не адресуется по ключам, и точечный путь t() по нему не попадёт — для массивов всегда используется tm().
  • t можно деструктурировать: это свойство-стрелочная функция, поэтому const t = lhlocale.t не теряет this.
  • Компоненты core не вызывают lhlocale напрямую: rate / pagination обрабатывают интерполяцию через formatMessage, а поиск текстовых ключей по-прежнему выполняется на стороне UI — core не зависит от побочных эффектов синглтона.
  • add делает глубокое слияние: перекрываются только переданные вами ключи, остальные сохраняются.
API实际调用返回值
lhlocale.t(path)——
lhlocale.t(path, params)——
lhlocale.tm(path)——
lhlocale.getLocale()()—
lhlocale.version(任何语言变更自增)—

pagination.total 这类带 {total} 占位符的文案,第二参传对象即做插值;datepicker.months 是数组叶子,只能经 tm() 取(t() 非字符串会回落 path 本身); 切到 ja-JP 等骨架语言时,缺失键由英文兜底,getLocale() 仍如实报告 'ja-JP'。

Copyright © 2024-2026 FengMo WenXue