Nano Stores I18n
一个小巧且灵活的 JS 库,用于让您的 Web 应用可翻译。 使用 Nano Stores 状态管理器和 JS Internationalization API。
- 小巧。 约 1 KB(压缩并 Brotli 编码后)。零依赖。
- 支持 React、Preact、Vue、Svelte 以及纯 JS。
- 支持 tree-shaking 和翻译的按需下载。
- 兼容在线翻译服务(如 Weblate)的纯扁平 JSON 翻译。
- 开箱即用的翻译 TypeScript 支持。
- 灵活的变量翻译。您可以更改翻译, 例如,根据屏幕大小而定。
// components/post.jsx
import { params, count } from '@nanostores/i18n' // You can use own functions
import { useStore } from '@nanostores/react'
import { i18n, format } from '../stores/i18n.js'
export const messages = i18n('post', {
title: 'Post details',
published: params('Was published at {at}'), // TypeScript will get `at` type
comments: count({
one: '{count} comment',
other: '{count} comments'
})
})
export const Post = ({ author, comments, publishedAt }) => {
const t = useStore(messages)
const { time } = useStore(format)
return (
<article>
<h1>{t.title}</h1>
<p>{t.published({ at: time(publishedAt) })}</p>
<p>{t.comments(comments.length)}</p>
</article>
)
}
// stores/i18n.js
import { createI18n, localeFrom, browser, formatter } from '@nanostores/i18n'
import { persistentAtom } from '@nanostores/persistent'
export const setting = persistentAtom<string | undefined>('locale', undefined)
export const locale = localeFrom(
setting, // User’s locale from localStorage
browser({
// or browser’s locale auto-detect
available: ['en', 'fr', 'ru'],
fallback: 'en'
})
)
export const format = formatter(locale)
export const i18n = createI18n(locale, {
get(code) {
return fetchJSON(`/translations/${code}.json`)
}
})
// public/translations/ru.json
{
"post": {
"title": "Данные о публикации",
"published": "Опубликован {at}",
"comments": {
"one": "{count} комментарий",
"few": "{count} комментария",
"many": "{count} комментариев",
}
},
// Translations for all other components
}
Nano Stores I18n 由 Evil Martians 开发,这是一家专注于 开发者工具、AI 和网络安全初创企业 的美国设计与工程咨询公司。
安装
npm install nanostores @nanostores/i18n
对于 Astro,你还需要 astro-nanostores-i18n。
用法
我们将 locale、时间/数字格式化函数和翻译 存储在 Nano Stores 的 atoms 中。请参阅 Nano Stores docs 以了解如何在你的框架中使用 atoms。
区域设置
区域设置是用户语言和方言的代码,例如 hi(印地语)、de-AT
(奥地利使用的德语)。我们使用 Intl locale format。
当前区域设置应存储在 store 中。我们有 localeFrom() store
构建器,用于在第一个可用来源中查找用户的区域设置:
import { localeFrom } from '@nanostores/i18n'
export const locale = localeFrom(store1, store2, store3)
我们有一个来自浏览器设置的 locale 的 store。你需要传递应用程序可用翻译的列表。如果 store 找不到公共 locale,它将使用回退 locale(en,但可以通过 fallback 选项更改)。
import { localeFrom, browser } from '@nanostores/i18n'
export const locale = localeFrom(
…,
browser({ available: ['en', 'fr', 'ru'] as const })
)
在 browser 存储之前,你可以放置一个存储,这将允许用户手动覆盖
区域设置。例如,你可以在 localStorage 中保留区域设置的覆盖。
import { persistentAtom } from '@nanostores/persistent'
const LOCALES = ['en', 'fr', 'ru'] as const
type Locale = (typeof LOCALES)[number]
export const localeSettings = persistentAtom<Locale>('locale', 'en')
export const locale = localeFrom(
localeSettings,
browser({ available: LOCALES })
)
或者你可以从 URL 路由器中获取用户的区域设置:
import { computed } from 'nanostores'
import { router } from './router.js'
const LOCALES = ['en', 'fr', 'ru'] as const
type Locale = (typeof LOCALES)[number]
function validate(locale: string): Locale {
return LOCALES.includes(locale) ? locale : 'en'
}
const urlLocale = computed(router, page => validate(page?.params.locale))
export const locale = localeFrom(urlLocale, browser({ available: LOCALES }))
您可以将 locale 用作任何 Nano Store:
import { useStore } from '@nanostores/react'
import { locale } from '../stores/i18n.js'
// Pure JS example
locale.listen(code => {
console.log(`Locale was changed to ${code}`)
})
// React example
export const CurrentLocale = () => {
let code = useStore(locale)
return `Your current locale: ${code}`
}
对于测试,你可以使用简单的原子:
import { atom } from 'nanostores'
const locale = atom('en')
locale.set('fr')
日期、数字与相对时间格式
formatter() 创建一个用于格式化数字和时间的 store。
import { formatter } from '@nanostores/i18n'
export const format = formatter(locale)
该商店将具有 time()、number() 和 relativeTime() 功能。
import { useStore } from '@nanostores/react'
import { format } from '../stores/i18n.js'
export const Date = date => {
let { time } = useStore(format)
return time(date)
}
这些函数接受 Intl.DateTimeFormat、Intl.NumberFormat
和 Intl.RelativeTimeFormat 选项。
time(date, {
hour12: false,
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric'
}) //=> "November 1, 01:56:33"
relativeTime(-1, 'day', { numeric: 'auto' }) //=> "yesterday"
I18n 对象
I18n 对象用于定义新组件,并在语言环境变更时下载翻译。
import { createI18n } from '@nanostores/i18n'
export const i18n = createI18n(locale, {
async get(code) {
return await fetchJSON(`/translations/${code}.json`)
}
})
在每个组件中,你都会拥有包含函数和类型的基础翻译。
此翻译不会从服务器下载。默认情况下,你应该
使用英语。你可以在组件中通过 baseLocale 选项更改基础语言环境。
翻译
我们有两种类型的翻译:
基础翻译。 开发者在组件源代码中编写它。它用于
TypeScript 类型和翻译函数(count()、params() 等)。
export const messages = i18n('post', {
title: 'Post details',
published: params('Was published at {at}'),
comments: count({
one: '{count} comment',
other: '{count} comments'
})
})
其他翻译 它们使用 JSON 格式,将由翻译者创建。
{
"post": {
"title": "Данные о публикации",
"published": "Опубликован {at}",
"comments": {
"one": "{count} комментарий",
"few": "{count} комментария",
"many": "{count} комментариев"
}
}
}
翻译应采用扁平结构(键 → 译文),不包含嵌套键。复数形式(count())和其他辅助功能不会引入额外的嵌套,因为它们被视为一种翻译。
参数
params() translation transform 替换 translation string 中的参数。
import { useStore } from '@nanostores/react'
import { params } from '@nanostores/i18n'
import { i18n } from '../stores/i18n.js'
export const messages = i18n('hi', {
hello: params('Hello, {name}')
})
export const Robots = ({ name }) => {
const t = useStore(messages)
return t.hello({ name })
}
你可以使用 time()、number() 和 relativeTime() [格式化函数]。
您还可以使用 count() 函数:
import { count, params } from '@nanostores/i18n'
import { i18n } from '../stores/i18n'
export const messages = i18n('pagination', {
page: params<{ category: string }>(
count({
one: 'One page in {category}',
other: '{count} pages in {category}'
})
)
})
export const RobotsListInfo = ({ count }) => {
const t = useStore(messages)
return t.page({ category: 'robots' })(count)
}
复数形式
在许多语言中,文本可能因项目数量不同而有所差异。
请比较英语中的 1 robot/2 robots 与
波兰语中的 1 robot/2 roboty/2.5 robota/10 robotów。
我们通过 count() 翻译转换来隐藏这种复杂性:
import { useStore } from '@nanostores/react'
import { count } from '@nanostores/i18n'
import { i18n } from '../stores/i18n.js'
export const messages = i18n('robots', {
howMany: count({
one: '{count} robot',
other: '{count} robots'
})
})
export const Robots = ({ robots }) => {
const t = useStore(messages)
return t.howMany(robots.length)
}
{
"robots": {
"howMany": {
"one": "{count} robot",
"few": "{count} roboty",
"many": "{count} robotów",
"other": "{count} robota"
}
}
}
count() 使用 Intl.PluralRules 获取每个区域设置的复数规则。
自定义变量翻译
除了 params() 和 count(),你还可以定义自己的翻译
转换。或者,你可以通过替换
count() 和 params() 来更改复数形式或参数语法。
import { transform, strings } from '@nanostores/i18n'
// Add parameters syntax like hello: "Hi, %1"
export const paramsList = transform((locale, translation, ...args) => {
return strings(translation, str => {
return str.replace(/%\d/g, pattern => args[pattern.slice(1)])
})
})
import { paramsList } from '../lib/paramsList.ts'
export const messages = i18n('hi', {
hello: paramsList('Hello, %1')
})
翻译流程
良好的 I18n 支持不在于 I18n 库, 而在于翻译流程。
-
开发者在组件源码中创建基础翻译,并将其导出 为
messages。export const messages = i18n('welcome', { hello: params('Hello, %1') }) -
CI 运行脚本以将基础翻译提取为 JSON。
import { messagesToJSON } from '@nanostores/i18n' const components = await glob('./src/*.tsx', { absolute: true }) const translations = await Promise.all( components.map(async file => { return await import(file).messages // Replace import if you export // i18n() result with a different name }) ) const json = messagesToJSON(...translations) -
CI 将包含基础翻译的 JSON 上传到在线翻译服务。
-
译员在该服务上翻译应用程序。
-
CI 或翻译服务将翻译 JSON 下载到项目中。
懒加载
在一般情况下,开发者像这样传递 get 函数,以在语言环境变更时获取所有
翻译。
export const i18n = createI18n(locale, {
async get(code) {
return fetchJSON(`/translations/${code}.json`)
}
})
然后使用 i18n 定义 post 组件。
export const messages = i18n('post', {
post: 'Post details'
})
许多应用部件很少使用,因此存在一种方式可以部分获取它们的翻译。
-
我们可以使用诸如
main/post或settings/user这样的组件名称。export const messages = i18n('main/post', { post: 'Post details' }) -
我们可以定义组件更常用,并赋予它们相同的 前缀,例如
main/heading、main/post和main/comment。 -
翻译应命名为:
// public/translations/ru/main.json { "main/post": { "post": "Данные о публикации" }, "main/heading": { "heading": "Заголовок" }, "main/comment": { "comment": "Комментарий" } } // public/translations/ru/settings.json -
在渲染期间,
i18n会保存所有已使用的组件名称。 当语言环境更改时,i18n将这些名称发送给get函数。 -
我们可以传递一个
get函数,该函数用于拆分前缀、过滤其中的唯一值, 并为所需的翻译发起请求。```ts export const i18n = createI18n(locale, { async get(code, components) { let prefixes = components.map(name => name.split('/')[0]) let unique = Array.from(new Set(prefixes)) return Promise.all( unique.map(chunk => fetchJSON(`/translations/${code}/${chunk}.json`) ) ) } }) ``` -
在每次新的渲染后,
i18n会检查缓存中的翻译。 如果不在缓存中: _ 拆分组件的唯一前缀,或获取不带前缀的名称。 _ 检查是否已请求其翻译,但尚未收到响应。 * 如有需要,为组件名称调用get函数 -main或settings。 -
对于所有具有唯一名称的新渲染组件,都会调用 Fetch。为了防止这种情况,我们可能希望为它们设置相同的前缀。
Server-Side Rendering
对于 SSR,你可能希望使用自己的 locale store,并在自定义的 i18n 中设置 cache 选项,以避免加载翻译:
import { createI18n } from '@nanostores/i18n'
import { atom } from 'nanostores'
let locale, i18n
if (isServer) {
locale = atom(db.getUser(userId).locale || parseHttpLocaleHeader())
i18n = createI18n(locale, {
async get () {
return {}
},
cache: {
fr: frMessages
}
})
} else {
…
}
export { locale, i18n }
仅服务端渲染
当完全在服务端渲染内容而不进行客户端水合时,
你可以创建一个 loadTranslations 辅助函数。它确保翻译
在你使用它们之前已加载。
// components/post.jsx
import { loadTranslations } from '@nanostores/i18n'
import { i18n } from '../stores/i18n.js'
export const messages = i18n('post', {
post: 'Post details'
})
async function Post() {
const t = await loadTranslations(messages)
return <span>{t.post}</span>
}
预处理器
你可以通过预处理器更改翻译中的所有消息。
例如,你可以应用排版规则。
import { createI18n, eachMessage } from '@nanostores/i18n'
export const i18n = createI18n(locale, {
…
preprocessors: [
eachMessage(str => str.toLocaleLowerCase())
]
})
处理器
你可以注册自定义类型,用于根据某些状态选择翻译 (并在状态变化时更改翻译)。
例如,以下是一个根据屏幕大小更改翻译的示例:
// stores/i18n.js
import { atom, onMount } from 'nanostores'
import { createI18n, createProcessor } from '@nanostores/i18n'
const screenSize = atom('big')
onMount(screenSize, () => {
let media = window.matchMedia('(min-width: 600px)')
const check = () => {
screenSize.set(media.matches ? 'big' : 'small')
}
media.addEventListener('change', check)
return () => {
media.removeEventListener('change', check)
}
})
export const size = createProcessor(screenSize)
export const i18n = createI18n(locale, {
get: …,
processors: [
size
]
})
// components/send-to-user.jsx
import { i18n, size } from '../stores/i18n.js'
export const messages = i18n({
send: size({
big: 'Send message',
small: 'send'
}),
name: 'User name'
})
export const SendLabel = () => {
const t = useStore(messages)
return t.send()
}