react-native-i18njs 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2024-present, wangws
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,97 +1,76 @@
1
1
  # react-native-i18njs
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/react-native-i18njs.svg)](https://www.npmjs.com/package/react-native-i18njs)
4
+ [![License](https://img.shields.io/npm/l/react-native-i18njs.svg)](https://www.npmjs.com/package/react-native-i18njs)
5
+ [![Platform](https://img.shields.io/badge/platform-react--native-blue.svg)](https://reactnative.dev)
6
+ [![TypeScript](https://img.shields.io/badge/types-included-blue.svg)](https://www.typescriptlang.org)
7
+
3
8
  一个轻量级、类型安全、零心智负担的 React Native 国际化解决方案。
4
9
 
5
- ## 特性
10
+ 专为 React Native 设计,集成了最佳实践,解决了常见的国际化痛点:繁琐的配置、类型缺失、复杂的 API 以及系统语言跟随问题。
6
11
 
7
- - 🚀 **开箱即用**:专为 React Native 设计,API 简单直观。
8
- - 📦 **自动语言检测**:基于 `react-native-localize` 自动匹配设备语言。
9
- - 🔄 **自动重渲染**:语言切换时,UI 自动更新。
10
- - 🛡️ **TypeScript 支持**:完全使用 TypeScript 编写,提供完整的类型提示。
11
- - 🧩 **基于 `i18n-js`**:轻量封装,默认使用 `i18n-js` 作为翻译引擎。
12
- - 🔌 **Hooks 支持**:提供 `useI18n` Hook,方便在组件中使用。
13
- - 📝 **富文本支持**:支持在翻译中嵌入组件。
14
- - 🔢 **格式化支持**:基于 JS 内置 `Intl` 的数字/货币/日期格式化(可传 options 自定义;环境不支持会自动降级)。
12
+ ## 特性
15
13
 
16
- ## 安装
14
+ - � **零配置启动**:内置智能默认值,安装即用。
15
+ - 🛡️ **极致类型安全**:完全 TypeScript 编写,提供从 Key 到插值参数的完整类型推导。
16
+ - 📱 **自动跟随系统**:基于 `react-native-localize`,自动检测并响应设备语言变更。
17
+ - ⚡ **高性能**:基于 `i18n-js` 核心,轻量高效,无多余运行时开销。
18
+ - 🔌 **灵活 API**:同时支持 Hook (`useI18n`)、高阶组件 (`withI18n`) 和全局函数 (`t`)。
19
+ - 📝 **富文本支持**:`Trans` 组件轻松处理嵌套样式和组件插值。
20
+ - 🌍 **格式化内置**:开箱即用的数字、货币、日期格式化支持。
17
21
 
18
- 首先安装本库及其依赖:
22
+ ## 📦 安装
19
23
 
20
24
  ```bash
21
- npm install react-native-i18njs i18n-js react-native-localize
25
+ npm install react-native-i18njs
22
26
  # 或者
23
- yarn add react-native-i18njs i18n-js react-native-localize
27
+ yarn add react-native-i18njs
24
28
  ```
25
29
 
26
- > 插值语法说明:本库基于 `i18n-js`,默认使用 `%{name}`(不是 `{{name}}`)。
27
-
28
- ## 上手方式(两种都支持)
30
+ > **注意**:本库已内置 `i18n-js` `react-native-localize` 的稳定版本,无需手动安装 peer dependencies。
29
31
 
30
- - **方式 A:不使用 `I18nProvider`(推荐)**:任何地方都能直接 `t()`,没有 React 心智负担;适合工具层/启动阶段/非 UI 代码。
31
- - **方式 B:使用 `I18nProvider`(可选)**:需要“切换语言后 UI 自动刷新”时再用。
32
+ ## 🚀 快速开始
32
33
 
33
- ## 模块 1:初始化与全局函数(不需要 Provider)
34
+ ### 1. 定义翻译资源
34
35
 
35
- ### 1) 准备翻译资源
36
+ 建议在单独的文件中管理翻译资源,例如 `src/locales/index.ts`:
36
37
 
37
38
  ```ts
39
+ // src/locales/index.ts
38
40
  export const translations = {
39
- en: { welcome: 'Welcome', hello: 'Hello, %{name}!' },
40
- zh: { welcome: '欢迎', hello: '你好,%{name}!' },
41
+ en: {
42
+ welcome: 'Welcome',
43
+ hello: 'Hello, %{name}!',
44
+ },
45
+ zh: {
46
+ welcome: '欢迎',
47
+ hello: '你好,%{name}!',
48
+ },
41
49
  };
42
- ```
43
-
44
- ### 2) 初始化(只做一次)
45
-
46
- ```ts
47
- import { initI18n } from 'react-native-i18njs';
48
- import { translations } from './locales';
49
-
50
- initI18n(translations, { defaultLocale: 'en', enableFallback: true });
51
- ```
52
50
 
53
- ### 3) 在任何地方直接使用 `t()`
54
-
55
- ```ts
56
- import { t, setLocale, getLocale } from 'react-native-i18njs';
57
-
58
- setLocale('zh');
59
- getLocale(); // 'zh'
60
- t('hello', { name: 'Trae' }); // '你好,Trae!'
61
- t('missing.key', { defaultValue: '默认文案' });
51
+ // 导出类型以获得类型提示
52
+ export type Translations = typeof translations.en;
62
53
  ```
63
54
 
64
- ### 不用 Provider 的典型场景
55
+ ### 2. 初始化
65
56
 
66
- - 网络层(如 axios 拦截器)拼接错误文案
67
- - 表单校验/工具函数/Toast 文案
68
- - Redux/Saga/Store 初始化阶段
69
- - React 组件之外(比如 navigation 配置、原生桥接回调)
70
-
71
- > 注意:不用 Provider 时,切换语言不会自动触发页面重渲染;如果你需要 UI 自动刷新,请看“模块 2”。
72
-
73
- ## 模块 2:React 自动重渲染(I18nProvider + useI18n)
57
+ 在你的 App 入口文件(如 `App.tsx`)中初始化:
74
58
 
75
59
  ```tsx
76
60
  import React from 'react';
77
- import { Text, Button } from 'react-native';
78
- import { I18nProvider, useI18n } from 'react-native-i18njs';
61
+ import { initI18n, I18nProvider } from 'react-native-i18njs';
62
+ import { translations } from './src/locales';
63
+ import Home from './src/Home';
79
64
 
80
- function Home() {
81
- const { t, locale, setLocale } = useI18n();
82
- return (
83
- <>
84
- <Text>{t('welcome')}</Text>
85
- <Text>{t('hello', { name: 'User' })}</Text>
86
- <Text>{locale}</Text>
87
- <Button title="EN" onPress={() => setLocale('en')} />
88
- <Button title="中文" onPress={() => setLocale('zh')} />
89
- </>
90
- );
91
- }
65
+ // 初始化配置
66
+ initI18n(translations, {
67
+ defaultLocale: 'en',
68
+ enableFallback: true, // 找不到翻译时回退到默认语言
69
+ });
92
70
 
93
71
  export default function App() {
94
72
  return (
73
+ // 使用 Provider 以支持语言切换时的自动重渲染
95
74
  <I18nProvider>
96
75
  <Home />
97
76
  </I18nProvider>
@@ -99,149 +78,188 @@ export default function App() {
99
78
  }
100
79
  ```
101
80
 
102
- ### `I18nProvider` 参数
81
+ ### 3. 在组件中使用
82
+
83
+ ```tsx
84
+ // src/Home.tsx
85
+ import React from 'react';
86
+ import { Text, Button, View } from 'react-native';
87
+ import { useI18n } from 'react-native-i18njs';
88
+ import { Translations } from './locales';
89
+
90
+ export default function Home() {
91
+ // 传入泛型 Translations 以获得 key 的自动补全和类型检查
92
+ const { t, locale, setLocale } = useI18n<Translations>();
93
+
94
+ return (
95
+ <View>
96
+ <Text>{t('welcome')}</Text>
97
+ {/* 这里的 name 参数会有类型提示 */}
98
+ <Text>{t('hello', { name: 'Trae' })}</Text>
99
+
100
+ <Text>当前语言: {locale}</Text>
101
+
102
+ <Button title="Switch to English" onPress={() => setLocale('en')} />
103
+ <Button title="切换到中文" onPress={() => setLocale('zh')} />
104
+ </View>
105
+ );
106
+ }
107
+ ```
108
+
109
+ ## 📖 核心功能详解
110
+
111
+ ### 1. 非组件环境使用(Global API)
103
112
 
104
- - `readyGate?: boolean`:为 `true` 时,未 ready 前不渲染 children
105
- - `fallback?: React.ReactNode`:`readyGate` 期间显示的占位
113
+ Redux、Axios 拦截器、工具函数等非组件环境中,你可以直接使用全局导出的 API。
106
114
 
107
- ### `useI18n<T>()`(类型安全 key)
115
+ #### 基础用法
108
116
 
109
117
  ```ts
110
- type MyTranslations = typeof translations.en;
111
- const { t } = useI18n<MyTranslations>();
112
- t('welcome');
113
- t('home.title');
118
+ import { t, getLocale, setLocale } from 'react-native-i18njs';
119
+
120
+ // 获取当前语言
121
+ const current = getLocale();
122
+
123
+ // 切换语言
124
+ setLocale('zh');
125
+
126
+ // 直接翻译
127
+ const message = t('errors.network_timeout');
114
128
  ```
115
129
 
116
- ## 模块 3:动态加载翻译(loadTranslations)
130
+ #### 进阶:监听语言变化
131
+
132
+ 如果你需要在组件外监听语言变更(例如同步更新全局状态),可以使用顶层 `subscribe` 函数:
117
133
 
118
134
  ```ts
119
- import { loadTranslations } from 'react-native-i18njs';
135
+ import { subscribe } from 'react-native-i18njs';
120
136
 
121
- loadTranslations({ fr: { welcome: 'Bonjour' } });
137
+ // 订阅语言变更
138
+ const unsubscribe = subscribe((locale) => {
139
+ console.log('Language changed to:', locale);
140
+ // 更新 API 默认 Header 或其他全局状态
141
+ });
142
+
143
+ // 取消订阅
144
+ // unsubscribe();
122
145
  ```
123
146
 
124
- ## 模块 4:富文本(Trans)
147
+ #### 进阶:重置为跟随系统
125
148
 
126
- ```tsx
127
- import React from 'react';
128
- import { Text } from 'react-native';
129
- import { Trans } from 'react-native-i18njs';
149
+ 用户手动调用 `setLocale` 后会锁定语言,不再自动跟随系统。如果需要恢复跟随系统语言:
130
150
 
131
- // translations.en:
132
- // { description: 'This is <bold>bold</bold> text.', agree: 'I agree to %{terms}' }
151
+ ```ts
152
+ import { resetToSystem } from 'react-native-i18njs';
133
153
 
134
- <Trans i18nKey="description" components={{ bold: <Text style={{ fontWeight: 'bold' }} /> }} />
135
- <Trans i18nKey="agree" values={{ terms: <Text style={{ fontWeight: 'bold' }}>Terms</Text> }} />
154
+ // 撤销用户锁定,重新跟随系统语言
155
+ resetToSystem();
136
156
  ```
137
157
 
138
- ### `Trans` 参数
158
+ #### 进阶:RTL 检测
139
159
 
140
- - `i18nKey: string`
141
- - `values?: Record<string, any>`:插值值(字符串/数字/ReactElement 都支持)
142
- - `components?: Record<string, React.ReactNode>`:标签映射(如 `bold/link`)
160
+ ```ts
161
+ import { isRTL } from 'react-native-i18njs';
143
162
 
144
- ### `Trans` 能覆盖的场景
163
+ if (isRTL()) {
164
+ // 当前为从右到左语言(如阿拉伯语、希伯来语)
165
+ }
166
+ ```
145
167
 
146
- - 句子内局部加粗/斜体/变色/可点击链接(用 `components` 映射 `<bold></bold>` / `<link></link>`)
147
- - 插值里插入组件(用 `values` 传 ReactElement,例如把 “Terms” 做成可点击文本)
148
- - 嵌套标签(例如 `<bold>...<italic>...</italic>...</bold>`)
149
- - 自闭合标签(例如 `<br/>`),可映射成 `components={{ br: <Text>{'\n'}</Text> }}`
168
+ #### 实战示例:Axios 拦截器
150
169
 
151
- ### `Trans` 的边界(不适合的场景)
170
+ ```ts
171
+ import axios from 'axios';
172
+ import { getLocale } from 'react-native-i18njs';
173
+
174
+ axios.interceptors.request.use((config) => {
175
+ // 动态获取当前语言,确保每次请求都携带最新的语言标识
176
+ config.headers['Accept-Language'] = getLocale();
177
+ return config;
178
+ });
179
+ ```
152
180
 
153
- - 不支持标签属性/HTML(例如 `<a href="...">` / `<span style="...">`),需要你把“样式/点击行为”放到 `components` 或 `values` 传入的组件里
154
- - `components` 目前接收的是“元素”(例如 `<Text />`),不是组件函数(例如 `Text` / `Bold`),因为内部是 `cloneElement`
155
- - `Trans` 总是返回一个外层 `<Text>`;并且被插入的组件也必须是 `Text` 可渲染的内容(不要把 `<View>` 直接塞进文本里)
156
- - 不提供 ICU MessageFormat(select/plural/gender)语法;更复杂的语言结构建议拆 key + 用 JS 逻辑组合,或引入 ICU 方案
157
- - 翻译里如果出现不成对/不匹配的标签,会按纯文本保留(避免静默吞字),建议在翻译侧保持标签成对
181
+ ### 2. 富文本翻译 (`Trans` 组件)
158
182
 
159
- ## 模块 5:Class 组件(withI18n)
183
+ 当翻译内容中包含样式或组件时,使用 `Trans` 组件:
160
184
 
161
185
  ```tsx
162
- import React from 'react';
186
+ import { Trans } from 'react-native-i18njs';
163
187
  import { Text } from 'react-native';
164
- import { withI18n } from 'react-native-i18njs';
165
188
 
166
- class Screen extends React.PureComponent<{ t: any }> {
167
- render() {
168
- return <Text>{this.props.t('welcome')}</Text>;
169
- }
170
- }
189
+ // 翻译资源:
190
+ // zh: { agreement: '我同意 <link>服务条款</link>' }
171
191
 
172
- export default withI18n(Screen);
192
+ <Trans
193
+ i18nKey="agreement"
194
+ components={{
195
+ link: <Text style={{ color: 'blue' }} onPress={openTerms} />,
196
+ }}
197
+ />
173
198
  ```
174
199
 
175
- ## 模块 6:初始化就绪(readyI18n / isI18nReady)
200
+ ### 3. 动态加载翻译
201
+
202
+ 适用于大型应用的分包加载场景:
176
203
 
177
204
  ```ts
178
- import { readyI18n, isI18nReady } from 'react-native-i18njs';
205
+ import { loadTranslations } from 'react-native-i18njs';
179
206
 
180
- await readyI18n();
181
- isI18nReady();
207
+ // 异步加载法语包
208
+ async function loadFrench() {
209
+ const fr = await import('./locales/fr');
210
+ loadTranslations({ fr: fr.default });
211
+ }
182
212
  ```
183
213
 
184
- ## 模块 7:数字/货币/日期格式化(formatNumber / formatCurrency / formatDate)
214
+ ### 4. 格式化工具
185
215
 
186
- `Intl` JavaScript 自带的一套“国际化格式化”能力(浏览器和 Node 都有,React Native 取决于引擎/是否带完整 Intl)。
187
-
188
- 这三个方法基于 `Intl`,默认使用“当前 locale”来格式化,并且都支持传入 `options` 自定义格式。
216
+ 利用 `Intl` 标准进行格式化,在组件中通过 Hook 使用:
189
217
 
190
218
  ```ts
191
- import { useI18n } from 'react-native-i18njs';
192
-
193
219
  const { formatNumber, formatCurrency, formatDate } = useI18n();
194
220
 
195
- formatNumber(1234567.891, { maximumFractionDigits: 2 });
196
- formatCurrency(99.9, 'CNY', { currencyDisplay: 'narrowSymbol' });
197
- formatDate(Date.now(), { dateStyle: 'medium', timeStyle: 'short' });
198
- ```
221
+ // 数字
222
+ formatNumber(1234.56); // "1,234.56"
199
223
 
200
- 通用性边界:
224
+ // 货币
225
+ formatCurrency(99.99, 'USD'); // "$99.99"
201
226
 
202
- - 若运行环境没有完整 `Intl`(部分 RN/Hermes 场景),库会自动降级:
203
- - number:`String(n)`
204
- - currency:``${n} ${currency}``
205
- - date:`toISOString()`
206
- - 如果你希望所有平台表现完全一致,请在你的 App 侧引入 `Intl` polyfill。
207
- - 如果你想用“非当前 locale”做格式化,请直接使用 `new Intl.NumberFormat('xx', options)` 自行处理。
227
+ // 日期
228
+ formatDate(new Date(), { dateStyle: 'full' }); // "Tuesday, October 10, 2023"
229
+ ```
208
230
 
209
- ## 配置参数(I18nOptions)
231
+ 在非组件环境中,也可以直接使用顶层导出:
210
232
 
211
- ### `initI18n(translations, options?)`
233
+ ```ts
234
+ import { formatNumber, formatCurrency, formatDate } from 'react-native-i18njs';
212
235
 
213
- - `translations: Record<string, any>`:翻译资源(locale 为 key)
214
- - `options?`:
215
- - `defaultLocale?: string`:默认 `'en'`
216
- - `enableFallback?: boolean`:默认 `true`
217
- - `followSystem?: boolean`:默认 `true`;用户手动 `setLocale` 后不再跟随系统
218
- - `fallbackLocales?: string[] | ((locale: string) => string[])`:自定义回退链(按顺序)
219
- - `missingBehavior?: 'key' | 'empty' | 'throw'`:默认 `'key'`
220
- - `onMissingKey?: (key: string, locale: string) => void`:缺失 key 回调
221
- - `onLocaleChange?: (locale: string) => void`:语言变更回调
236
+ formatNumber(1234.56);
237
+ formatCurrency(99.99, 'USD');
238
+ formatDate(new Date());
239
+ ```
222
240
 
223
- ## API 速查(按模块)
241
+ ## ⚙️ 配置选项 (I18nOptions)
224
242
 
225
- - **初始化/函数**
226
- - `initI18n`, `t`, `setLocale`, `getLocale`, `loadTranslations`, `readyI18n`, `isI18nReady`
227
- - **React 绑定(可选)**
228
- - `I18nProvider`, `useI18n`, `I18nContext`, `withI18n`
229
- - **组件**
230
- - `Trans`
231
- - **类型**
232
- - `I18nOptions`, `Translations`, `Path`
233
- - **默认导出**
234
- - `i18nService`:服务单例(`formatNumber/formatCurrency/formatDate/subscribe/updateLocale` 等)
243
+ `initI18n` 接受的第二个参数对象:
235
244
 
236
- ## 常见问题
245
+ | 属性 | 类型 | 默认值 | 说明 |
246
+ |------|------|--------|------|
247
+ | `defaultLocale` | `string` | `'en'` | 默认语言 |
248
+ | `enableFallback` | `boolean` | `true` | 是否启用回退机制 |
249
+ | `followSystem` | `boolean` | `true` | 是否初始化时自动跟随系统语言 |
250
+ | `fallbackLocales` | `string[]` \| `func` | - | 自定义回退链 |
251
+ | `missingBehavior` | `'key'` \| `'empty'` \| `'throw'` | `'key'` | 缺失翻译时的行为 |
252
+ | `onMissingKey` | `function` | - | 缺失 key 的回调 |
253
+ | `onLocaleChange` | `function` | - | 语言变更回调 |
237
254
 
238
- ### 1) `Invalid hook call` / 多个 React 实例
255
+ ## 🧩 常见问题
239
256
 
240
- 如果你在 monorepo / workspace 中把本库以源码方式链接到示例或业务 App,可能出现重复 React 导致的 Hook 报错。参考本仓库示例的 Metro 配置做依赖指向与屏蔽:
257
+ ### TypeScript 类型提示不工作?
258
+ 确保你在使用 `useI18n<MyTranslations>()` 时传入了你的翻译类型定义。
241
259
 
242
- - 示例配置:[example/metro.config.js](./example/metro.config.js)
260
+ ### 安卓上语言检测不准确?
261
+ 请确保你的 `android/app/src/main/res` 目录下有对应的语言资源文件夹(如 `values-zh`),React Native 有时依赖这些原生配置来正确识别系统语言。
243
262
 
244
- ## 平台支持
263
+ ## 📄 License
245
264
 
246
- - **iOS / Android**: 完全支持。
247
- - **Web**: 需要配置 `webpack` 以支持 `react-native-localize`。请参考 [react-native-localize 文档](https://github.com/zoontek/react-native-localize)。
265
+ ISC
package/dist/index.d.mts CHANGED
@@ -35,6 +35,8 @@ interface I18nEngine {
35
35
  formatDate(date: Date | number, options?: Intl.DateTimeFormatOptions): string;
36
36
  subscribe(listener: Listener): () => void;
37
37
  isRTL(): boolean;
38
+ /** 重置为跟随系统语言,撤销 setLocale 的用户锁定 */
39
+ resetToSystem(): void;
38
40
  ready(): Promise<void>;
39
41
  isReady(): boolean;
40
42
  }
@@ -55,12 +57,18 @@ declare class DefaultI18nEngine implements I18nEngine {
55
57
  private version;
56
58
  private readyPromise;
57
59
  private resolveReady?;
60
+ /** Intl 格式化器缓存,key = locale + JSON(options) */
61
+ private numberFormatCache;
62
+ private dateFormatCache;
63
+ /** locale chain 缓存,locale 或 fallbackLocales 变化时失效 */
64
+ private localeChainCache;
58
65
  constructor();
59
66
  init(translations: Translations, options?: I18nOptions): void;
60
67
  loadTranslations(translations: Translations): void;
61
68
  updateLocale(): void;
62
69
  setLocale(locale: string): void;
63
70
  getLocale(): string;
71
+ resetToSystem(): void;
64
72
  t(scope: Scope, options?: TranslateOptions): string;
65
73
  formatNumber(n: number, options?: Intl.NumberFormatOptions): string;
66
74
  formatCurrency(n: number, currency: string, options?: Intl.NumberFormatOptions): string;
@@ -69,11 +77,16 @@ declare class DefaultI18nEngine implements I18nEngine {
69
77
  isRTL(): boolean;
70
78
  ready(): Promise<void>;
71
79
  isReady(): boolean;
80
+ private invalidateCaches;
81
+ private getCachedNumberFormat;
82
+ private getCachedDateFormat;
72
83
  private handleRTL;
73
84
  private notifyListeners;
74
85
  private setLocaleFromSystem;
75
86
  private applyLocale;
76
87
  private translateAtLocale;
88
+ /** 将 locale 及其自动降级(如 en-US → en)追加到 chain 中 */
89
+ private pushWithDegradation;
77
90
  private getLocaleChain;
78
91
  private hasTranslation;
79
92
  private normalizeTranslateResult;
@@ -126,7 +139,14 @@ declare const loadTranslations: (translations: Translations) => void;
126
139
  declare const setLocale: (locale: string) => void;
127
140
  declare const getLocale: () => string;
128
141
  declare const t: (scope: Scope, options?: TranslateOptions) => string;
142
+ declare const formatNumber: (n: number, options?: Intl.NumberFormatOptions) => string;
143
+ declare const formatCurrency: (n: number, currency: string, options?: Intl.NumberFormatOptions) => string;
144
+ declare const formatDate: (date: Date | number, options?: Intl.DateTimeFormatOptions) => string;
145
+ declare const subscribe: (listener: Listener) => (() => void);
146
+ declare const isRTL: () => boolean;
147
+ /** 重置为跟随系统语言,撤销 setLocale 的用户锁定 */
148
+ declare const resetToSystem: () => void;
129
149
  declare const readyI18n: () => Promise<void>;
130
150
  declare const isI18nReady: () => boolean;
131
151
 
132
- export { I18nContext, type I18nContextType, type I18nOptions, I18nProvider, type I18nProviderProps, type Path, Trans, type Translations, i18nService as default, getLocale, initI18n, isI18nReady, loadTranslations, readyI18n, setLocale, t, useI18n, withI18n };
152
+ export { I18nContext, type I18nContextType, type I18nOptions, I18nProvider, type I18nProviderProps, type Listener, type Path, Trans, type Translations, i18nService as default, formatCurrency, formatDate, formatNumber, getLocale, initI18n, isI18nReady, isRTL, loadTranslations, readyI18n, resetToSystem, setLocale, subscribe, t, useI18n, withI18n };
package/dist/index.d.ts CHANGED
@@ -35,6 +35,8 @@ interface I18nEngine {
35
35
  formatDate(date: Date | number, options?: Intl.DateTimeFormatOptions): string;
36
36
  subscribe(listener: Listener): () => void;
37
37
  isRTL(): boolean;
38
+ /** 重置为跟随系统语言,撤销 setLocale 的用户锁定 */
39
+ resetToSystem(): void;
38
40
  ready(): Promise<void>;
39
41
  isReady(): boolean;
40
42
  }
@@ -55,12 +57,18 @@ declare class DefaultI18nEngine implements I18nEngine {
55
57
  private version;
56
58
  private readyPromise;
57
59
  private resolveReady?;
60
+ /** Intl 格式化器缓存,key = locale + JSON(options) */
61
+ private numberFormatCache;
62
+ private dateFormatCache;
63
+ /** locale chain 缓存,locale 或 fallbackLocales 变化时失效 */
64
+ private localeChainCache;
58
65
  constructor();
59
66
  init(translations: Translations, options?: I18nOptions): void;
60
67
  loadTranslations(translations: Translations): void;
61
68
  updateLocale(): void;
62
69
  setLocale(locale: string): void;
63
70
  getLocale(): string;
71
+ resetToSystem(): void;
64
72
  t(scope: Scope, options?: TranslateOptions): string;
65
73
  formatNumber(n: number, options?: Intl.NumberFormatOptions): string;
66
74
  formatCurrency(n: number, currency: string, options?: Intl.NumberFormatOptions): string;
@@ -69,11 +77,16 @@ declare class DefaultI18nEngine implements I18nEngine {
69
77
  isRTL(): boolean;
70
78
  ready(): Promise<void>;
71
79
  isReady(): boolean;
80
+ private invalidateCaches;
81
+ private getCachedNumberFormat;
82
+ private getCachedDateFormat;
72
83
  private handleRTL;
73
84
  private notifyListeners;
74
85
  private setLocaleFromSystem;
75
86
  private applyLocale;
76
87
  private translateAtLocale;
88
+ /** 将 locale 及其自动降级(如 en-US → en)追加到 chain 中 */
89
+ private pushWithDegradation;
77
90
  private getLocaleChain;
78
91
  private hasTranslation;
79
92
  private normalizeTranslateResult;
@@ -126,7 +139,14 @@ declare const loadTranslations: (translations: Translations) => void;
126
139
  declare const setLocale: (locale: string) => void;
127
140
  declare const getLocale: () => string;
128
141
  declare const t: (scope: Scope, options?: TranslateOptions) => string;
142
+ declare const formatNumber: (n: number, options?: Intl.NumberFormatOptions) => string;
143
+ declare const formatCurrency: (n: number, currency: string, options?: Intl.NumberFormatOptions) => string;
144
+ declare const formatDate: (date: Date | number, options?: Intl.DateTimeFormatOptions) => string;
145
+ declare const subscribe: (listener: Listener) => (() => void);
146
+ declare const isRTL: () => boolean;
147
+ /** 重置为跟随系统语言,撤销 setLocale 的用户锁定 */
148
+ declare const resetToSystem: () => void;
129
149
  declare const readyI18n: () => Promise<void>;
130
150
  declare const isI18nReady: () => boolean;
131
151
 
132
- export { I18nContext, type I18nContextType, type I18nOptions, I18nProvider, type I18nProviderProps, type Path, Trans, type Translations, i18nService as default, getLocale, initI18n, isI18nReady, loadTranslations, readyI18n, setLocale, t, useI18n, withI18n };
152
+ export { I18nContext, type I18nContextType, type I18nOptions, I18nProvider, type I18nProviderProps, type Listener, type Path, Trans, type Translations, i18nService as default, formatCurrency, formatDate, formatNumber, getLocale, initI18n, isI18nReady, isRTL, loadTranslations, readyI18n, resetToSystem, setLocale, subscribe, t, useI18n, withI18n };
package/dist/index.js CHANGED
@@ -73,6 +73,12 @@ var DefaultI18nEngine = class {
73
73
  __publicField(this, "version");
74
74
  __publicField(this, "readyPromise");
75
75
  __publicField(this, "resolveReady");
76
+ // --- 性能缓存 ---
77
+ /** Intl 格式化器缓存,key = locale + JSON(options) */
78
+ __publicField(this, "numberFormatCache", /* @__PURE__ */ new Map());
79
+ __publicField(this, "dateFormatCache", /* @__PURE__ */ new Map());
80
+ /** locale chain 缓存,locale 或 fallbackLocales 变化时失效 */
81
+ __publicField(this, "localeChainCache", /* @__PURE__ */ new Map());
76
82
  this.i18n = new i18nJs.I18n();
77
83
  this.listeners = /* @__PURE__ */ new Set();
78
84
  this.i18n.enableFallback = false;
@@ -107,6 +113,7 @@ var DefaultI18nEngine = class {
107
113
  this.localeSource = "system";
108
114
  this.missingBehavior = missingBehavior;
109
115
  this.onMissingKey = onMissingKey;
116
+ this.invalidateCaches();
110
117
  if (this.followSystem) this.updateLocale();
111
118
  this.version += 1;
112
119
  this.notifyListeners({ type: "translations", version: this.version });
@@ -142,6 +149,10 @@ var DefaultI18nEngine = class {
142
149
  getLocale() {
143
150
  return this.i18n.locale;
144
151
  }
152
+ resetToSystem() {
153
+ this.localeSource = "system";
154
+ this.updateLocale();
155
+ }
145
156
  t(scope, options) {
146
157
  var _a, _b, _c;
147
158
  if (typeof scope !== "string") {
@@ -175,7 +186,7 @@ var DefaultI18nEngine = class {
175
186
  return String(n);
176
187
  }
177
188
  try {
178
- return new Intl.NumberFormat(this.i18n.locale, options).format(n);
189
+ return this.getCachedNumberFormat(this.i18n.locale, options).format(n);
179
190
  } catch {
180
191
  return String(n);
181
192
  }
@@ -185,7 +196,7 @@ var DefaultI18nEngine = class {
185
196
  return `${n} ${currency}`;
186
197
  }
187
198
  try {
188
- return new Intl.NumberFormat(this.i18n.locale, {
199
+ return this.getCachedNumberFormat(this.i18n.locale, {
189
200
  ...options,
190
201
  style: "currency",
191
202
  currency
@@ -201,7 +212,7 @@ var DefaultI18nEngine = class {
201
212
  return d.toISOString();
202
213
  }
203
214
  try {
204
- return new Intl.DateTimeFormat(this.i18n.locale, options).format(d);
215
+ return this.getCachedDateFormat(this.i18n.locale, options).format(d);
205
216
  } catch {
206
217
  return d.toISOString();
207
218
  }
@@ -222,15 +233,40 @@ var DefaultI18nEngine = class {
222
233
  isReady() {
223
234
  return this.initialized;
224
235
  }
236
+ // ─── 缓存管理 ────────────────────────────────────────────
237
+ invalidateCaches() {
238
+ this.numberFormatCache.clear();
239
+ this.dateFormatCache.clear();
240
+ this.localeChainCache.clear();
241
+ }
242
+ getCachedNumberFormat(locale, options) {
243
+ const cacheKey = locale + (options ? JSON.stringify(options) : "");
244
+ let fmt = this.numberFormatCache.get(cacheKey);
245
+ if (!fmt) {
246
+ fmt = new Intl.NumberFormat(locale, options);
247
+ this.numberFormatCache.set(cacheKey, fmt);
248
+ }
249
+ return fmt;
250
+ }
251
+ getCachedDateFormat(locale, options) {
252
+ const cacheKey = locale + (options ? JSON.stringify(options) : "");
253
+ let fmt = this.dateFormatCache.get(cacheKey);
254
+ if (!fmt) {
255
+ fmt = new Intl.DateTimeFormat(locale, options);
256
+ this.dateFormatCache.set(cacheKey, fmt);
257
+ }
258
+ return fmt;
259
+ }
260
+ // ─── 私有方法 ────────────────────────────────────────────
225
261
  handleRTL(locale) {
226
262
  var _a, _b;
227
- const isRTL = this.isRTLLocale(locale);
263
+ const isRTL2 = this.isRTLLocale(locale);
228
264
  if (typeof ((_a = reactNative.I18nManager) == null ? void 0 : _a.allowRTL) !== "function" || typeof ((_b = reactNative.I18nManager) == null ? void 0 : _b.forceRTL) !== "function") {
229
265
  return;
230
266
  }
231
- if (reactNative.I18nManager.isRTL !== isRTL) {
232
- reactNative.I18nManager.allowRTL(isRTL);
233
- reactNative.I18nManager.forceRTL(isRTL);
267
+ if (reactNative.I18nManager.isRTL !== isRTL2) {
268
+ reactNative.I18nManager.allowRTL(isRTL2);
269
+ reactNative.I18nManager.forceRTL(isRTL2);
234
270
  }
235
271
  }
236
272
  notifyListeners(change) {
@@ -245,6 +281,7 @@ var DefaultI18nEngine = class {
245
281
  const normalizedLocale = this.normalizeLocaleTag(locale);
246
282
  if (this.i18n.locale !== normalizedLocale) {
247
283
  this.i18n.locale = normalizedLocale;
284
+ this.invalidateCaches();
248
285
  this.handleRTL(normalizedLocale);
249
286
  this.version += 1;
250
287
  this.notifyListeners({ type: "locale", version: this.version });
@@ -260,39 +297,36 @@ var DefaultI18nEngine = class {
260
297
  this.i18n.locale = prevLocale;
261
298
  }
262
299
  }
263
- getLocaleChain(locale) {
264
- const chain = [];
265
- const normalizedLocale = this.normalizeLocaleTag(locale);
266
- chain.push(normalizedLocale);
267
- const parts = normalizedLocale.split("-").filter(Boolean);
300
+ /** 将 locale 及其自动降级(如 en-US → en)追加到 chain 中 */
301
+ pushWithDegradation(chain, locale) {
302
+ const normalized = this.normalizeLocaleTag(locale);
303
+ chain.push(normalized);
304
+ const parts = normalized.split("-").filter(Boolean);
268
305
  for (let i = parts.length - 1; i >= 1; i -= 1) {
269
306
  chain.push(parts.slice(0, i).join("-"));
270
307
  }
308
+ }
309
+ getLocaleChain(locale) {
310
+ const cached = this.localeChainCache.get(locale);
311
+ if (cached) return cached;
312
+ const chain = [];
313
+ this.pushWithDegradation(chain, locale);
314
+ const normalizedLocale = this.normalizeLocaleTag(locale);
271
315
  const extra = this.fallbackLocales ? typeof this.fallbackLocales === "function" ? this.fallbackLocales(normalizedLocale) : this.fallbackLocales : [];
272
316
  for (const l of extra) {
273
- const normalizedFallback = this.normalizeLocaleTag(l);
274
- chain.push(normalizedFallback);
275
- const fallbackParts = normalizedFallback.split("-").filter(Boolean);
276
- for (let i = fallbackParts.length - 1; i >= 1; i -= 1) {
277
- chain.push(fallbackParts.slice(0, i).join("-"));
278
- }
317
+ this.pushWithDegradation(chain, l);
279
318
  }
280
319
  if (this.i18n.defaultLocale) {
281
- const normalizedDefault = this.normalizeLocaleTag(this.i18n.defaultLocale);
282
- chain.push(normalizedDefault);
283
- const defaultParts = normalizedDefault.split("-").filter(Boolean);
284
- for (let i = defaultParts.length - 1; i >= 1; i -= 1) {
285
- chain.push(defaultParts.slice(0, i).join("-"));
286
- }
320
+ this.pushWithDegradation(chain, this.i18n.defaultLocale);
287
321
  }
288
322
  const seen = /* @__PURE__ */ new Set();
289
- return chain.filter((l) => {
290
- if (!l) return false;
291
- const normalized = l;
292
- if (seen.has(normalized)) return false;
293
- seen.add(normalized);
323
+ const result = chain.filter((l) => {
324
+ if (!l || seen.has(l)) return false;
325
+ seen.add(l);
294
326
  return true;
295
327
  });
328
+ this.localeChainCache.set(locale, result);
329
+ return result;
296
330
  }
297
331
  hasTranslation(locale, key) {
298
332
  var _a;
@@ -327,7 +361,7 @@ var DefaultI18nEngine = class {
327
361
  }
328
362
  isRTLLocale(locale) {
329
363
  var _a;
330
- const normalized = locale.replace(/_/g, "-");
364
+ const normalized = this.normalizeLocaleTag(locale);
331
365
  const parts = normalized.split("-").filter(Boolean);
332
366
  const languageCode = (_a = parts[0]) == null ? void 0 : _a.toLowerCase();
333
367
  if (!languageCode) return false;
@@ -427,13 +461,13 @@ function useI18n() {
427
461
  }, [context]);
428
462
  }
429
463
  var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
464
+ var TAG_REGEX = /<(\/?)([A-Za-z][\w-]*)(?:\s[^>]*?)?(\/?)>/g;
430
465
  var parseString = (input) => {
431
466
  var _a;
432
467
  const root = { type: "tag", name: "root", children: [] };
433
468
  const stack = [root];
434
- const tagRegex = /<(\/?)([A-Za-z][\w-]*)(?:\s[^>]*?)?(\/?)>/g;
435
469
  let lastIndex = 0;
436
- for (const match of input.matchAll(tagRegex)) {
470
+ for (const match of input.matchAll(TAG_REGEX)) {
437
471
  const fullMatch = match[0];
438
472
  const isClosing = match[1] === "/";
439
473
  const tagName = match[2];
@@ -463,51 +497,57 @@ var parseString = (input) => {
463
497
  }
464
498
  return root.children;
465
499
  };
500
+ var renderAST = (nodes, keyPrefix, components, placeholderToElement) => {
501
+ return nodes.map((node, index) => {
502
+ const key = `${keyPrefix}-${index}`;
503
+ if (node.type === "text") {
504
+ const placeholders = Object.keys(placeholderToElement);
505
+ if (placeholders.length === 0) return node.content;
506
+ const pattern = new RegExp(`(${placeholders.map(escapeRegExp).join("|")})`, "g");
507
+ const parts = node.content.split(pattern);
508
+ if (parts.length === 1) return node.content;
509
+ return /* @__PURE__ */ React__default.default.createElement(React.Fragment, { key }, parts.map((part, i) => {
510
+ if (placeholderToElement[part]) {
511
+ return React.cloneElement(placeholderToElement[part], { key: `${key}-${i}` });
512
+ }
513
+ return part;
514
+ }));
515
+ } else if (node.type === "tag") {
516
+ const Component = components == null ? void 0 : components[node.name];
517
+ const children = renderAST(node.children, key, components, placeholderToElement);
518
+ if (React.isValidElement(Component)) {
519
+ return React.cloneElement(Component, { key }, children.length > 0 ? children : void 0);
520
+ }
521
+ return /* @__PURE__ */ React__default.default.createElement(React.Fragment, { key }, children);
522
+ }
523
+ return null;
524
+ });
525
+ };
466
526
  var Trans = ({ i18nKey, values, components, ...props }) => {
467
527
  const { t: t2 } = useI18n();
468
- const stringValues = {};
469
- const placeholderToElement = {};
470
- if (values) {
471
- Object.keys(values).forEach((key) => {
472
- const value = values[key];
473
- if (React.isValidElement(value)) {
474
- const placeholder = `__ELEMENT_${key}__`;
475
- placeholderToElement[placeholder] = value;
476
- stringValues[key] = placeholder;
477
- } else {
478
- stringValues[key] = value == null ? "" : String(value);
479
- }
480
- });
481
- }
482
- const translatedText = t2(i18nKey, stringValues);
483
- const ast = parseString(translatedText);
484
- const renderAST = (nodes, keyPrefix) => {
485
- return nodes.map((node, index) => {
486
- const key = `${keyPrefix}-${index}`;
487
- if (node.type === "text") {
488
- const placeholders = Object.keys(placeholderToElement);
489
- if (placeholders.length === 0) return node.content;
490
- const pattern = new RegExp(`(${placeholders.map(escapeRegExp).join("|")})`, "g");
491
- const parts = node.content.split(pattern);
492
- if (parts.length === 1) return node.content;
493
- return /* @__PURE__ */ React__default.default.createElement(React.Fragment, { key }, parts.map((part, i) => {
494
- if (placeholderToElement[part]) {
495
- return React.cloneElement(placeholderToElement[part], { key: `${key}-${i}` });
496
- }
497
- return part;
498
- }));
499
- } else if (node.type === "tag") {
500
- const Component = components == null ? void 0 : components[node.name];
501
- const children2 = renderAST(node.children, key);
502
- if (React.isValidElement(Component)) {
503
- return React.cloneElement(Component, { key }, children2.length > 0 ? children2 : void 0);
528
+ const { stringValues, placeholderToElement } = React.useMemo(() => {
529
+ const sv = {};
530
+ const pte = {};
531
+ if (values) {
532
+ for (const key of Object.keys(values)) {
533
+ const value = values[key];
534
+ if (React.isValidElement(value)) {
535
+ const placeholder = `__ELEMENT_${key}__`;
536
+ pte[placeholder] = value;
537
+ sv[key] = placeholder;
538
+ } else {
539
+ sv[key] = value == null ? "" : String(value);
504
540
  }
505
- return /* @__PURE__ */ React__default.default.createElement(React.Fragment, { key }, children2);
506
541
  }
507
- return null;
508
- });
509
- };
510
- const children = renderAST(ast, "trans");
542
+ }
543
+ return { stringValues: sv, placeholderToElement: pte };
544
+ }, [values]);
545
+ const translatedText = t2(i18nKey, stringValues);
546
+ const ast = React.useMemo(() => parseString(translatedText), [translatedText]);
547
+ const children = React.useMemo(
548
+ () => renderAST(ast, "trans", components, placeholderToElement),
549
+ [ast, components, placeholderToElement]
550
+ );
511
551
  return /* @__PURE__ */ React__default.default.createElement(reactNative.Text, { ...props }, children);
512
552
  };
513
553
 
@@ -517,6 +557,12 @@ var loadTranslations = (translations) => i18nService.loadTranslations(translatio
517
557
  var setLocale = (locale) => i18nService.setLocale(locale);
518
558
  var getLocale = () => i18nService.getLocale();
519
559
  var t = (scope, options) => i18nService.t(scope, options);
560
+ var formatNumber = (n, options) => i18nService.formatNumber(n, options);
561
+ var formatCurrency = (n, currency, options) => i18nService.formatCurrency(n, currency, options);
562
+ var formatDate = (date, options) => i18nService.formatDate(date, options);
563
+ var subscribe = (listener) => i18nService.subscribe(listener);
564
+ var isRTL = () => i18nService.isRTL();
565
+ var resetToSystem = () => i18nService.resetToSystem();
520
566
  var readyI18n = () => i18nService.ready();
521
567
  var isI18nReady = () => i18nService.isReady();
522
568
  var index_default = i18nService;
@@ -525,12 +571,18 @@ exports.I18nContext = I18nContext;
525
571
  exports.I18nProvider = I18nProvider;
526
572
  exports.Trans = Trans;
527
573
  exports.default = index_default;
574
+ exports.formatCurrency = formatCurrency;
575
+ exports.formatDate = formatDate;
576
+ exports.formatNumber = formatNumber;
528
577
  exports.getLocale = getLocale;
529
578
  exports.initI18n = initI18n;
530
579
  exports.isI18nReady = isI18nReady;
580
+ exports.isRTL = isRTL;
531
581
  exports.loadTranslations = loadTranslations;
532
582
  exports.readyI18n = readyI18n;
583
+ exports.resetToSystem = resetToSystem;
533
584
  exports.setLocale = setLocale;
585
+ exports.subscribe = subscribe;
534
586
  exports.t = t;
535
587
  exports.useI18n = useI18n;
536
588
  exports.withI18n = withI18n;
package/dist/index.mjs CHANGED
@@ -46,6 +46,12 @@ var DefaultI18nEngine = class {
46
46
  __publicField(this, "version");
47
47
  __publicField(this, "readyPromise");
48
48
  __publicField(this, "resolveReady");
49
+ // --- 性能缓存 ---
50
+ /** Intl 格式化器缓存,key = locale + JSON(options) */
51
+ __publicField(this, "numberFormatCache", /* @__PURE__ */ new Map());
52
+ __publicField(this, "dateFormatCache", /* @__PURE__ */ new Map());
53
+ /** locale chain 缓存,locale 或 fallbackLocales 变化时失效 */
54
+ __publicField(this, "localeChainCache", /* @__PURE__ */ new Map());
49
55
  this.i18n = new I18n();
50
56
  this.listeners = /* @__PURE__ */ new Set();
51
57
  this.i18n.enableFallback = false;
@@ -80,6 +86,7 @@ var DefaultI18nEngine = class {
80
86
  this.localeSource = "system";
81
87
  this.missingBehavior = missingBehavior;
82
88
  this.onMissingKey = onMissingKey;
89
+ this.invalidateCaches();
83
90
  if (this.followSystem) this.updateLocale();
84
91
  this.version += 1;
85
92
  this.notifyListeners({ type: "translations", version: this.version });
@@ -115,6 +122,10 @@ var DefaultI18nEngine = class {
115
122
  getLocale() {
116
123
  return this.i18n.locale;
117
124
  }
125
+ resetToSystem() {
126
+ this.localeSource = "system";
127
+ this.updateLocale();
128
+ }
118
129
  t(scope, options) {
119
130
  var _a, _b, _c;
120
131
  if (typeof scope !== "string") {
@@ -148,7 +159,7 @@ var DefaultI18nEngine = class {
148
159
  return String(n);
149
160
  }
150
161
  try {
151
- return new Intl.NumberFormat(this.i18n.locale, options).format(n);
162
+ return this.getCachedNumberFormat(this.i18n.locale, options).format(n);
152
163
  } catch {
153
164
  return String(n);
154
165
  }
@@ -158,7 +169,7 @@ var DefaultI18nEngine = class {
158
169
  return `${n} ${currency}`;
159
170
  }
160
171
  try {
161
- return new Intl.NumberFormat(this.i18n.locale, {
172
+ return this.getCachedNumberFormat(this.i18n.locale, {
162
173
  ...options,
163
174
  style: "currency",
164
175
  currency
@@ -174,7 +185,7 @@ var DefaultI18nEngine = class {
174
185
  return d.toISOString();
175
186
  }
176
187
  try {
177
- return new Intl.DateTimeFormat(this.i18n.locale, options).format(d);
188
+ return this.getCachedDateFormat(this.i18n.locale, options).format(d);
178
189
  } catch {
179
190
  return d.toISOString();
180
191
  }
@@ -195,15 +206,40 @@ var DefaultI18nEngine = class {
195
206
  isReady() {
196
207
  return this.initialized;
197
208
  }
209
+ // ─── 缓存管理 ────────────────────────────────────────────
210
+ invalidateCaches() {
211
+ this.numberFormatCache.clear();
212
+ this.dateFormatCache.clear();
213
+ this.localeChainCache.clear();
214
+ }
215
+ getCachedNumberFormat(locale, options) {
216
+ const cacheKey = locale + (options ? JSON.stringify(options) : "");
217
+ let fmt = this.numberFormatCache.get(cacheKey);
218
+ if (!fmt) {
219
+ fmt = new Intl.NumberFormat(locale, options);
220
+ this.numberFormatCache.set(cacheKey, fmt);
221
+ }
222
+ return fmt;
223
+ }
224
+ getCachedDateFormat(locale, options) {
225
+ const cacheKey = locale + (options ? JSON.stringify(options) : "");
226
+ let fmt = this.dateFormatCache.get(cacheKey);
227
+ if (!fmt) {
228
+ fmt = new Intl.DateTimeFormat(locale, options);
229
+ this.dateFormatCache.set(cacheKey, fmt);
230
+ }
231
+ return fmt;
232
+ }
233
+ // ─── 私有方法 ────────────────────────────────────────────
198
234
  handleRTL(locale) {
199
235
  var _a, _b;
200
- const isRTL = this.isRTLLocale(locale);
236
+ const isRTL2 = this.isRTLLocale(locale);
201
237
  if (typeof ((_a = I18nManager) == null ? void 0 : _a.allowRTL) !== "function" || typeof ((_b = I18nManager) == null ? void 0 : _b.forceRTL) !== "function") {
202
238
  return;
203
239
  }
204
- if (I18nManager.isRTL !== isRTL) {
205
- I18nManager.allowRTL(isRTL);
206
- I18nManager.forceRTL(isRTL);
240
+ if (I18nManager.isRTL !== isRTL2) {
241
+ I18nManager.allowRTL(isRTL2);
242
+ I18nManager.forceRTL(isRTL2);
207
243
  }
208
244
  }
209
245
  notifyListeners(change) {
@@ -218,6 +254,7 @@ var DefaultI18nEngine = class {
218
254
  const normalizedLocale = this.normalizeLocaleTag(locale);
219
255
  if (this.i18n.locale !== normalizedLocale) {
220
256
  this.i18n.locale = normalizedLocale;
257
+ this.invalidateCaches();
221
258
  this.handleRTL(normalizedLocale);
222
259
  this.version += 1;
223
260
  this.notifyListeners({ type: "locale", version: this.version });
@@ -233,39 +270,36 @@ var DefaultI18nEngine = class {
233
270
  this.i18n.locale = prevLocale;
234
271
  }
235
272
  }
236
- getLocaleChain(locale) {
237
- const chain = [];
238
- const normalizedLocale = this.normalizeLocaleTag(locale);
239
- chain.push(normalizedLocale);
240
- const parts = normalizedLocale.split("-").filter(Boolean);
273
+ /** 将 locale 及其自动降级(如 en-US → en)追加到 chain 中 */
274
+ pushWithDegradation(chain, locale) {
275
+ const normalized = this.normalizeLocaleTag(locale);
276
+ chain.push(normalized);
277
+ const parts = normalized.split("-").filter(Boolean);
241
278
  for (let i = parts.length - 1; i >= 1; i -= 1) {
242
279
  chain.push(parts.slice(0, i).join("-"));
243
280
  }
281
+ }
282
+ getLocaleChain(locale) {
283
+ const cached = this.localeChainCache.get(locale);
284
+ if (cached) return cached;
285
+ const chain = [];
286
+ this.pushWithDegradation(chain, locale);
287
+ const normalizedLocale = this.normalizeLocaleTag(locale);
244
288
  const extra = this.fallbackLocales ? typeof this.fallbackLocales === "function" ? this.fallbackLocales(normalizedLocale) : this.fallbackLocales : [];
245
289
  for (const l of extra) {
246
- const normalizedFallback = this.normalizeLocaleTag(l);
247
- chain.push(normalizedFallback);
248
- const fallbackParts = normalizedFallback.split("-").filter(Boolean);
249
- for (let i = fallbackParts.length - 1; i >= 1; i -= 1) {
250
- chain.push(fallbackParts.slice(0, i).join("-"));
251
- }
290
+ this.pushWithDegradation(chain, l);
252
291
  }
253
292
  if (this.i18n.defaultLocale) {
254
- const normalizedDefault = this.normalizeLocaleTag(this.i18n.defaultLocale);
255
- chain.push(normalizedDefault);
256
- const defaultParts = normalizedDefault.split("-").filter(Boolean);
257
- for (let i = defaultParts.length - 1; i >= 1; i -= 1) {
258
- chain.push(defaultParts.slice(0, i).join("-"));
259
- }
293
+ this.pushWithDegradation(chain, this.i18n.defaultLocale);
260
294
  }
261
295
  const seen = /* @__PURE__ */ new Set();
262
- return chain.filter((l) => {
263
- if (!l) return false;
264
- const normalized = l;
265
- if (seen.has(normalized)) return false;
266
- seen.add(normalized);
296
+ const result = chain.filter((l) => {
297
+ if (!l || seen.has(l)) return false;
298
+ seen.add(l);
267
299
  return true;
268
300
  });
301
+ this.localeChainCache.set(locale, result);
302
+ return result;
269
303
  }
270
304
  hasTranslation(locale, key) {
271
305
  var _a;
@@ -300,7 +334,7 @@ var DefaultI18nEngine = class {
300
334
  }
301
335
  isRTLLocale(locale) {
302
336
  var _a;
303
- const normalized = locale.replace(/_/g, "-");
337
+ const normalized = this.normalizeLocaleTag(locale);
304
338
  const parts = normalized.split("-").filter(Boolean);
305
339
  const languageCode = (_a = parts[0]) == null ? void 0 : _a.toLowerCase();
306
340
  if (!languageCode) return false;
@@ -400,13 +434,13 @@ function useI18n() {
400
434
  }, [context]);
401
435
  }
402
436
  var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
437
+ var TAG_REGEX = /<(\/?)([A-Za-z][\w-]*)(?:\s[^>]*?)?(\/?)>/g;
403
438
  var parseString = (input) => {
404
439
  var _a;
405
440
  const root = { type: "tag", name: "root", children: [] };
406
441
  const stack = [root];
407
- const tagRegex = /<(\/?)([A-Za-z][\w-]*)(?:\s[^>]*?)?(\/?)>/g;
408
442
  let lastIndex = 0;
409
- for (const match of input.matchAll(tagRegex)) {
443
+ for (const match of input.matchAll(TAG_REGEX)) {
410
444
  const fullMatch = match[0];
411
445
  const isClosing = match[1] === "/";
412
446
  const tagName = match[2];
@@ -436,51 +470,57 @@ var parseString = (input) => {
436
470
  }
437
471
  return root.children;
438
472
  };
473
+ var renderAST = (nodes, keyPrefix, components, placeholderToElement) => {
474
+ return nodes.map((node, index) => {
475
+ const key = `${keyPrefix}-${index}`;
476
+ if (node.type === "text") {
477
+ const placeholders = Object.keys(placeholderToElement);
478
+ if (placeholders.length === 0) return node.content;
479
+ const pattern = new RegExp(`(${placeholders.map(escapeRegExp).join("|")})`, "g");
480
+ const parts = node.content.split(pattern);
481
+ if (parts.length === 1) return node.content;
482
+ return /* @__PURE__ */ React.createElement(Fragment, { key }, parts.map((part, i) => {
483
+ if (placeholderToElement[part]) {
484
+ return cloneElement(placeholderToElement[part], { key: `${key}-${i}` });
485
+ }
486
+ return part;
487
+ }));
488
+ } else if (node.type === "tag") {
489
+ const Component = components == null ? void 0 : components[node.name];
490
+ const children = renderAST(node.children, key, components, placeholderToElement);
491
+ if (isValidElement(Component)) {
492
+ return cloneElement(Component, { key }, children.length > 0 ? children : void 0);
493
+ }
494
+ return /* @__PURE__ */ React.createElement(Fragment, { key }, children);
495
+ }
496
+ return null;
497
+ });
498
+ };
439
499
  var Trans = ({ i18nKey, values, components, ...props }) => {
440
500
  const { t: t2 } = useI18n();
441
- const stringValues = {};
442
- const placeholderToElement = {};
443
- if (values) {
444
- Object.keys(values).forEach((key) => {
445
- const value = values[key];
446
- if (isValidElement(value)) {
447
- const placeholder = `__ELEMENT_${key}__`;
448
- placeholderToElement[placeholder] = value;
449
- stringValues[key] = placeholder;
450
- } else {
451
- stringValues[key] = value == null ? "" : String(value);
452
- }
453
- });
454
- }
455
- const translatedText = t2(i18nKey, stringValues);
456
- const ast = parseString(translatedText);
457
- const renderAST = (nodes, keyPrefix) => {
458
- return nodes.map((node, index) => {
459
- const key = `${keyPrefix}-${index}`;
460
- if (node.type === "text") {
461
- const placeholders = Object.keys(placeholderToElement);
462
- if (placeholders.length === 0) return node.content;
463
- const pattern = new RegExp(`(${placeholders.map(escapeRegExp).join("|")})`, "g");
464
- const parts = node.content.split(pattern);
465
- if (parts.length === 1) return node.content;
466
- return /* @__PURE__ */ React.createElement(Fragment, { key }, parts.map((part, i) => {
467
- if (placeholderToElement[part]) {
468
- return cloneElement(placeholderToElement[part], { key: `${key}-${i}` });
469
- }
470
- return part;
471
- }));
472
- } else if (node.type === "tag") {
473
- const Component = components == null ? void 0 : components[node.name];
474
- const children2 = renderAST(node.children, key);
475
- if (isValidElement(Component)) {
476
- return cloneElement(Component, { key }, children2.length > 0 ? children2 : void 0);
501
+ const { stringValues, placeholderToElement } = useMemo(() => {
502
+ const sv = {};
503
+ const pte = {};
504
+ if (values) {
505
+ for (const key of Object.keys(values)) {
506
+ const value = values[key];
507
+ if (isValidElement(value)) {
508
+ const placeholder = `__ELEMENT_${key}__`;
509
+ pte[placeholder] = value;
510
+ sv[key] = placeholder;
511
+ } else {
512
+ sv[key] = value == null ? "" : String(value);
477
513
  }
478
- return /* @__PURE__ */ React.createElement(Fragment, { key }, children2);
479
514
  }
480
- return null;
481
- });
482
- };
483
- const children = renderAST(ast, "trans");
515
+ }
516
+ return { stringValues: sv, placeholderToElement: pte };
517
+ }, [values]);
518
+ const translatedText = t2(i18nKey, stringValues);
519
+ const ast = useMemo(() => parseString(translatedText), [translatedText]);
520
+ const children = useMemo(
521
+ () => renderAST(ast, "trans", components, placeholderToElement),
522
+ [ast, components, placeholderToElement]
523
+ );
484
524
  return /* @__PURE__ */ React.createElement(Text, { ...props }, children);
485
525
  };
486
526
 
@@ -490,8 +530,14 @@ var loadTranslations = (translations) => i18nService.loadTranslations(translatio
490
530
  var setLocale = (locale) => i18nService.setLocale(locale);
491
531
  var getLocale = () => i18nService.getLocale();
492
532
  var t = (scope, options) => i18nService.t(scope, options);
533
+ var formatNumber = (n, options) => i18nService.formatNumber(n, options);
534
+ var formatCurrency = (n, currency, options) => i18nService.formatCurrency(n, currency, options);
535
+ var formatDate = (date, options) => i18nService.formatDate(date, options);
536
+ var subscribe = (listener) => i18nService.subscribe(listener);
537
+ var isRTL = () => i18nService.isRTL();
538
+ var resetToSystem = () => i18nService.resetToSystem();
493
539
  var readyI18n = () => i18nService.ready();
494
540
  var isI18nReady = () => i18nService.isReady();
495
541
  var index_default = i18nService;
496
542
 
497
- export { I18nContext, I18nProvider, Trans, index_default as default, getLocale, initI18n, isI18nReady, loadTranslations, readyI18n, setLocale, t, useI18n, withI18n };
543
+ export { I18nContext, I18nProvider, Trans, index_default as default, formatCurrency, formatDate, formatNumber, getLocale, initI18n, isI18nReady, isRTL, loadTranslations, readyI18n, resetToSystem, setLocale, subscribe, t, useI18n, withI18n };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-i18njs",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "一个轻量级、类型安全、零心智负担的 React Native 国际化解决方案。",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,6 +8,17 @@
8
8
  },
9
9
  "license": "ISC",
10
10
  "author": "wangws",
11
+ "keywords": [
12
+ "react-native",
13
+ "i18n",
14
+ "internationalization",
15
+ "localization",
16
+ "l10n",
17
+ "translation",
18
+ "locale",
19
+ "react",
20
+ "typescript"
21
+ ],
11
22
  "sideEffects": false,
12
23
  "main": "dist/index.js",
13
24
  "module": "dist/index.mjs",
@@ -32,11 +43,13 @@
32
43
  "build": "tsup",
33
44
  "prepack": "npm run build"
34
45
  },
46
+ "dependencies": {
47
+ "i18n-js": "^4.5.1",
48
+ "react-native-localize": "^3.6.1"
49
+ },
35
50
  "peerDependencies": {
36
- "i18n-js": ">=4 <5",
37
51
  "react": ">=16.8",
38
- "react-native": ">=0.64",
39
- "react-native-localize": ">=3 <4"
52
+ "react-native": ">=0.64"
40
53
  },
41
54
  "devDependencies": {
42
55
  "@types/jest": "^30.0.0",
@@ -44,11 +57,9 @@
44
57
  "@types/react-native": "^0.72.8",
45
58
  "@types/react-test-renderer": "^19.1.0",
46
59
  "esbuild": "^0.25.0",
47
- "i18n-js": "^4.5.1",
48
60
  "jest": "^30.2.0",
49
61
  "react": "^19.2.3",
50
62
  "react-native": "^0.83.1",
51
- "react-native-localize": "^3.6.1",
52
63
  "react-native-web": "^0.20.0",
53
64
  "react-test-renderer": "^19.2.3",
54
65
  "ts-jest": "^29.4.6",