🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@complex-suite/utils

Package Overview
Dependencies
Maintainers
1
Versions
16
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@complex-suite/utils - npm Package Compare versions

Comparing version
3.0.5
to
3.0.6
+23
src/locale/en.ts
import type { LocalePack } from './types'
import type { UtilsMessages } from './zh'
/** en 消息对象(铺平格式,key 必须与 zhMessages 完全一致) */
export const enMessages: UtilsMessages = {
'color.invalidFormat': 'Invalid color format',
'env.resetChange': (params) => `[resetEnvData: Target real env is ${params.env} data changed!][${params.info ?? '-'}]`,
'reactive.objFormatError': 'defineReactive error: obj must be an object',
'reactive.optionFormatError': 'defineReactive error: option must be an object',
'reactive.descriptorError': 'defineReactive error: original descriptor does not have both getter and setter, cannot implement reactivity',
'promise.triggerError': 'triggerPromise error: Promise not passed correctly',
'string.paramNotExist': 'str/target parameter does not exist',
'life.notExist': (params) => `Current value does not exist (${params.id})`,
'life.existCallback': (params) => `Current callback already exists: ${params.id}`,
'life.prefix': 'LifeCycle'
}
const en: LocalePack<UtilsMessages> = {
lang: 'en-US',
messages: enMessages
}
export default en
import Locale from './Locale'
import type {
MessageValue,
FlatMessages,
LocalePack,
LocaleOptions,
ChangeFrom
} from './types'
import zh from './zh'
import type { UtilsMessages } from './zh'
/**
* utils 包的 utilsLocale 单例(以 UtilsMessages 为类型基准,zh 包直接传入构造函数完成注册+设置)。
* utils 包内部各模块可直接使用此单例。
*
* 【架构规划】suite 各子包(data / component 等)应采用独立实例 + 静态总线同步方案:
* 1. 每个子包定义自己的 XxxMessages 类型,new Locale<XxxMessages>({ pack: ..., name: '...' }) 创建独立实例
* 2. 各实例在 constructor 中自动注册到静态总线 Locale.$bus
* 3. 任一实例调用 setLocale() 时,通过 $bus 自动同步到其他已注册该语言的实例
* 4. 此方案保证类型安全、key 隔离、语言切换联动三者兼备
* 其他子包应导入 Locale 类创建独立实例,而非直接共用此单例。
*
* 【语言包按需加载/注册】构造时默认仅注册中文包。
* 英文包 en 作为独立子路径导出(./en),主入口不加载它,避免无谓的模块求值开销。
* 需要英文时显式导入并注册:
* import en from '@complex-suite/utils/locale/en'
* utilsLocale.registerLocale(en)
*/
const utilsLocale = new Locale<UtilsMessages>({ pack: zh, name: 'utils' })
export {
utilsLocale,
Locale,
zh
}
export type {
MessageValue,
FlatMessages,
LocalePack,
LocaleOptions,
ChangeFrom,
UtilsMessages
}
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'
import Locale from './Locale'
import { zhMessages } from './zh'
import { enMessages } from './en'
/** 测试用消息 schema(无索引签名,验证 t() 的类型安全)
* 使用 type 而非 interface:interface 无隐式索引签名,不满足 T extends FlatMessages 约束;
* type 字面量在结构类型系统下可正确满足 Record<string, MessageValue>。 */
type TestMessages = {
greeting: string
farewell: string
'user.name': (params: { name: string }) => string
'user.age': (params: { age: number }) => string
'nested.deep.key': string
}
/**
* 测试 fixtures:抽取高频重复的 5-key 消息集,避免字面量散落各处。
* Locale 构造函数与 registerLocale 内部均做浅拷贝,直接传入 fixture 不会污染原对象。
*/
const zhFixture: TestMessages = {
greeting: '你好',
farewell: '再见',
'user.name': (params) => `用户${params.name}`,
'user.age': (params) => `${params.age}岁`,
'nested.deep.key': '深层值'
}
const enFixture: TestMessages = {
greeting: 'Hello',
farewell: 'Goodbye',
'user.name': (params) => `User ${params.name}`,
'user.age': (params) => `${params.age} years old`,
'nested.deep.key': 'Deep value'
}
const jaFixture: TestMessages = {
greeting: 'こんにちは',
farewell: 'さようなら',
'user.name': (params) => `ユーザー${params.name}`,
'user.age': (params) => `${params.age}歳`,
'nested.deep.key': '深い値'
}
/** 替换用变体(greeting 不同于标准 fixture) */
const zhNewFixture: TestMessages = {
greeting: '新问候',
farewell: '新再见',
'user.name': (params) => `新${params.name}`,
'user.age': (params) => `新${params.age}岁`,
'nested.deep.key': '新深层值'
}
/**
* fixture 工厂函数:生成带后缀的 A/B 组变体,用于跨实例同步测试。
* 避免手动重复定义多组几乎相同的消息集。
*/
function makeFixture(suffix: string): TestMessages {
return {
greeting: `你好-${suffix}`,
farewell: `再见-${suffix}`,
'user.name': (params) => `用户${suffix}${params.name}`,
'user.age': (params) => `${params.age}岁`,
'nested.deep.key': `深层${suffix}`
}
}
function makeEnFixture(suffix: string): TestMessages {
return {
greeting: `Hello-${suffix}`,
farewell: `Bye-${suffix}`,
'user.name': (params) => `User${suffix}${params.name}`,
'user.age': (params) => `${params.age}yr`,
'nested.deep.key': `Deep${suffix}`
}
}
describe('Locale', () => {
let locale: Locale<TestMessages>
/** 全局强制开启 dev 模式,确保所有 warn/error 日志在测试中生效 */
const _savedIsDev = Locale.isDev
beforeAll(() => { Locale.isDev = () => true })
afterAll(() => { Locale.isDev = _savedIsDev })
beforeEach(() => {
locale = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: zhFixture }, name: 'test' })
locale.registerLocale({ lang: 'en-US', messages: enFixture })
})
afterEach(() => {
locale.detachFromBus() // 从静态总线中清理,避免跨测试实例污染
})
// ─── onChange ──────────────────────────────────────────────────────────────
describe('onChange', () => {
it('语言切换时多个监听都触发,from 为 set', () => {
const cb1 = vi.fn()
const cb2 = vi.fn()
locale.onChange(cb1)
locale.onChange(cb2)
locale.setLocale('en-US')
expect(cb1).toHaveBeenCalledWith('en-US', 'set')
expect(cb2).toHaveBeenCalledWith('en-US', 'set')
})
it('取消其中一个不影响另一个', () => {
const cb1 = vi.fn()
const cb2 = vi.fn()
const un1 = locale.onChange(cb1)
locale.onChange(cb2)
un1()
locale.setLocale('en-US')
expect(cb1).not.toHaveBeenCalled()
expect(cb2).toHaveBeenCalledWith('en-US', 'set')
})
it('无效语言不触发监听', () => {
const cb = vi.fn()
locale.onChange(cb)
const result = locale.setLocale('fr')
expect(result).toBe(false)
expect(cb).not.toHaveBeenCalled()
})
it('某个监听抛错不阻断后续监听,setLocale 仍返回 true 且打印 error', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const errorCb = vi.fn(() => { throw new Error('listener error') })
const normalCb = vi.fn()
locale.onChange(errorCb)
locale.onChange(normalCb)
const result = locale.setLocale('en-US')
expect(result).toBe(true)
expect(errorCb).toHaveBeenCalledWith('en-US', 'set')
expect(normalCb).toHaveBeenCalledWith('en-US', 'set')
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('[Locale:test] onChange 监听执行出错:Error: listener error'))
errorSpy.mockRestore()
})
it('消息修改时触发监听,from 为 update', () => {
const cb = vi.fn()
locale.onChange(cb)
locale.updateLocaleMessage('zh-CN', 'greeting', '您好')
expect(cb).toHaveBeenCalledWith('zh-CN', 'update')
})
it('修改非当前语言不触发监听', () => {
const cb = vi.fn()
locale.onChange(cb)
locale.updateLocaleMessage('en-US', 'greeting', 'Hi')
expect(cb).not.toHaveBeenCalled()
})
})
// ─── t ────────────────────────────────────────────────────────────────────
describe('t', () => {
it('返回字符串消息', () => {
expect(locale.t('greeting')).toBe('你好')
})
it('未知 key 返回 key 本身并打印 warning', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect((locale.t as any)('unknown.key')).toBe('unknown.key')
expect(warnSpy).toHaveBeenCalledWith('[Locale:test] key "unknown.key" 在语言 "zh-CN" 中不存在。')
warnSpy.mockRestore()
})
it('函数消息插值正确', () => {
expect(locale.t('user.name', { name: 'Tom' })).toBe('用户Tom')
expect(locale.t('user.age', { age: 18 })).toBe('18岁')
})
it('函数消息抛错时兜底返回 key 本身并打印 error', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// 注册一个函数消息会抛错的语言包
const brokenMessages = { ...zhFixture, 'user.name': (() => { throw new TypeError('params is null') }) as any }
locale.registerLocale({ lang: 'ja', messages: brokenMessages }, false)
locale.setLocale('ja')
expect((locale.t as any)('user.name', null)).toBe('user.name')
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('[Locale:test] key "user.name" 函数消息执行出错')
)
errorSpy.mockRestore()
})
})
// ─── setLocale ─────────────────────────────────────────────────────────────
describe('setLocale', () => {
it('成功切换返回 true', () => {
expect(locale.setLocale('en-US')).toBe(true)
expect(locale.getLocale()).toBe('en-US')
})
it('未注册语言返回 false 且不切换', () => {
expect(locale.setLocale('fr')).toBe(false)
expect(locale.getLocale()).toBe('zh-CN')
})
it('相同语言返回 true 且不触发 onChange', () => {
const cb = vi.fn()
locale.onChange(cb)
expect(locale.setLocale('zh-CN')).toBe(true)
expect(cb).not.toHaveBeenCalled()
})
it('zh -> en -> zh 切换后文案正确变化', () => {
expect(locale.t('greeting')).toBe('你好')
locale.setLocale('en-US')
expect(locale.t('greeting')).toBe('Hello')
locale.setLocale('zh-CN')
expect(locale.t('greeting')).toBe('你好')
})
})
// ─── registerLocale ────────────────────────────────────────────────────────
describe('registerLocale', () => {
it('注册新语言返回 true', () => {
const result = locale.registerLocale({
lang: 'ja',
messages: jaFixture
})
expect(result).toBe(true)
locale.setLocale('ja')
expect(locale.t('greeting')).toBe('こんにちは')
})
it('已注册语言默认拒绝并返回 false', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = locale.registerLocale({
lang: 'zh-CN',
messages: zhNewFixture
})
expect(result).toBe(false)
expect(warnSpy).toHaveBeenCalledWith('[Locale:test] 语言包 "zh-CN" 已注册,如需替换请设置 replace=true。')
expect(locale.t('greeting')).toBe('你好')
warnSpy.mockRestore()
})
it('已注册语言 replace=true 时替换并返回 true', () => {
const cb = vi.fn()
locale.onChange(cb)
const result = locale.registerLocale({
lang: 'zh-CN',
messages: zhNewFixture
}, true)
expect(result).toBe(true)
expect(locale.t('greeting')).toBe('新问候')
expect(cb).toHaveBeenCalledWith('zh-CN', 'replace')
})
it('replace=true 替换非当前语言不触发 onChange', () => {
const cb = vi.fn()
locale.onChange(cb)
const result = locale.registerLocale({
lang: 'en-US',
messages: enFixture
}, true)
expect(result).toBe(true)
expect(cb).not.toHaveBeenCalled()
})
})
// ─── updateLocaleMessage ───────────────────────────────────────────────────
describe('updateLocaleMessage', () => {
it('更新单个字符串值', () => {
locale.updateLocaleMessage('zh-CN', 'greeting', '您好')
expect(locale.t('greeting')).toBe('您好')
})
it('向未注册语言写入被拦截并打印 warning', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
locale.updateLocaleMessage('ja', 'greeting', 'こんにちは')
expect(warnSpy).toHaveBeenCalledWith('[Locale:test] 语言包 "ja" 未注册,写入已阻止。请先调用 registerLocale。')
expect(locale.setLocale('ja')).toBe(false)
warnSpy.mockRestore()
})
it('更新函数类型消息后调用正确', () => {
locale.updateLocaleMessage('zh-CN', 'user.name', (params) => `你好${params.name}`)
expect(locale.t('user.name', { name: 'Tom' })).toBe('你好Tom')
})
it('修改当前语言触发 onChange', () => {
const cb = vi.fn()
locale.onChange(cb)
locale.updateLocaleMessage('zh-CN', 'greeting', '您好')
expect(cb).toHaveBeenCalledWith('zh-CN', 'update')
})
it('修改非当前语言不触发 onChange', () => {
const cb = vi.fn()
locale.onChange(cb)
locale.updateLocaleMessage('en-US', 'greeting', 'Hi')
expect(cb).not.toHaveBeenCalled()
})
})
// ─── replaceLocale ─────────────────────────────────────────────────────────
describe('replaceLocale', () => {
it('替换整个语言包', () => {
locale.replaceLocale('zh-CN', { ...zhFixture, hello: '你好', bye: '再见' } as TestMessages)
expect((locale.t as any)('hello')).toBe('你好')
expect((locale.t as any)('bye')).toBe('再见')
expect(locale.t('greeting')).toBe('你好')
})
it('替换不影响其他语言', () => {
locale.replaceLocale('zh-CN', { ...zhFixture, hello: '你好' } as TestMessages)
locale.setLocale('en-US')
expect(locale.t('greeting')).toBe('Hello')
})
it('替换当前语言触发 onChange', () => {
const cb = vi.fn()
locale.onChange(cb)
locale.replaceLocale('zh-CN', { ...zhFixture, hello: '你好' } as TestMessages)
expect(cb).toHaveBeenCalledWith('zh-CN', 'replace')
})
it('替换非当前语言不触发 onChange', () => {
const cb = vi.fn()
locale.onChange(cb)
locale.replaceLocale('en-US', { ...enFixture, hello: 'Hello' } as TestMessages)
expect(cb).not.toHaveBeenCalled()
})
})
// ─── batchUpdateLocale ────────────────────────────────────────────────────
describe('batchUpdateLocale', () => {
it('同时更新多语言指定字段', () => {
locale.batchUpdateLocale({
"zh-CN": { greeting: '您好' },
"en-US": { greeting: 'Hi' }
})
expect(locale.t('greeting')).toBe('您好')
locale.setLocale('en-US')
expect(locale.t('greeting')).toBe('Hi')
})
it('当前语言被修改时触发 onChange', () => {
const cb = vi.fn()
locale.onChange(cb)
locale.batchUpdateLocale({ "zh-CN": { greeting: '您好' } })
expect(cb).toHaveBeenCalledWith('zh-CN', 'update')
})
it('非当前语言被修改时不触发 onChange', () => {
const cb = vi.fn()
locale.onChange(cb)
locale.batchUpdateLocale({ "en-US": { greeting: 'Hi' } })
expect(cb).not.toHaveBeenCalled()
})
it('更新已有字段不影响其他字段', () => {
locale.batchUpdateLocale({ "zh-CN": { greeting: '您好' } })
expect(locale.t('farewell')).toBe('再见')
})
it('同时更新当前语言和非当前语言后 $currentMessages 缓存正确', () => {
// 同时更新 zh(当前)和 en(非当前),验证当前语言缓存可见性(浅引用语义)、非当前语言写入生效
locale.batchUpdateLocale({
"zh-CN": { greeting: '您好' },
"en-US": { greeting: 'Hi' }
})
// 当前语言缓存通过浅引用自动可见(无需显式刷新)
expect(locale.t('greeting')).toBe('您好')
// 切换到 en 后,非当前语言的写入应生效
locale.setLocale('en-US')
expect(locale.t('greeting')).toBe('Hi')
})
})
// ─── getMessages ──────────────────────────────────────────────────────────
describe('getMessages', () => {
it('获取当前语言的消息包', () => {
const msgs = locale.getMessages()!
expect(msgs.greeting).toBe('你好')
expect(msgs.farewell).toBe('再见')
})
it('获取指定语言的消息包', () => {
const msgs = locale.getMessages('en-US')!
expect(msgs.greeting).toBe('Hello')
})
it('返回浅拷贝,修改不影响内部状态', () => {
const msgs = locale.getMessages()!
msgs.greeting = '被修改了'
expect(locale.t('greeting')).toBe('你好')
})
it('未注册语言返回 undefined', () => {
const msgs = locale.getMessages('fr')
expect(msgs).toBeUndefined()
})
})
// ─── hasMessage ──────────────────────────────────────────────────────────
describe('hasMessage', () => {
it('存在的 key 返回 true', () => {
expect(locale.hasMessage('greeting')).toBe(true)
})
it('不存在的 key 返回 false', () => {
expect((locale.hasMessage as any)('unknown.key')).toBe(false)
})
it('可以指定语言检查', () => {
expect(locale.hasMessage('greeting', 'en-US')).toBe(true)
expect(locale.hasMessage('greeting', 'fr')).toBe(false)
})
})
// ─── constructor ──────────────────────────────────────────────────────────
describe('constructor', () => {
it('必须传入语言包和名称', () => {
const l = new Locale({
pack: { lang: 'zh-CN', messages: { hello: '你好' } },
name: 'test'
})
expect(l.getLocale()).toBe('zh-CN')
expect((l.t as any)('hello')).toBe('你好')
})
})
// ─── removeLocale ─────────────────────────────────────────────────────────
describe('removeLocale', () => {
it('成功移除语言包返回 true', () => {
expect(locale.removeLocale('en-US')).toBe(true)
expect(locale.setLocale('en-US')).toBe(false)
})
it('禁止移除当前正在使用的语言包并打印 warning', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const cb = vi.fn()
locale.onChange(cb)
expect(locale.removeLocale('zh-CN')).toBe(false)
expect(locale.getLocale()).toBe('zh-CN')
expect(cb).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('[Locale:test] 语言包 "zh-CN" 正在使用中,禁止移除。请先切换到其他语言。')
warnSpy.mockRestore()
})
it('切换语言后可移除之前的当前语言', () => {
locale.setLocale('en-US')
expect(locale.removeLocale('zh-CN')).toBe(true)
expect(locale.hasMessage('greeting', 'zh-CN')).toBe(false)
})
it('移除非当前语言不触发 onChange', () => {
const cb = vi.fn()
locale.onChange(cb)
expect(locale.removeLocale('en-US')).toBe(true)
expect(cb).not.toHaveBeenCalled()
})
it('移除不存在的语言返回 false 并打印 warning', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(locale.removeLocale('fr')).toBe(false)
expect(warnSpy).toHaveBeenCalledWith('[Locale:test] 语言包 "fr" 未注册,移除失败。')
warnSpy.mockRestore()
})
it('移除基准语言后 $baseLang 降级为当前语言,后续注册以新基准校验', () => {
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// 先切到 en-US,然后移除基准 zh-CN
locale.setLocale('en-US')
expect(locale.removeLocale('zh-CN')).toBe(true)
// 基准应降级为当前语言 en-US
expect(infoSpy).toHaveBeenCalledWith('[Locale:test] 基准语言 "zh-CN" 已移除,校验基准降级为当前语言 "en-US"。')
// 注册一个不完整的 ja 包(缺少 farewell),应相对于新基准 en-US 检测
const jaIncomplete = { ...jaFixture }
delete (jaIncomplete as Record<string, unknown>).farewell
locale.registerLocale({ lang: 'ja', messages: jaIncomplete })
// 应与新基准 en-US 对比(en-US 有 farewell)
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('registerLocale 检测到 key 缺失(语言="ja",对比基准="en-US"):'))
infoSpy.mockRestore()
warnSpy.mockRestore()
})
it('移除基准语言后 $baseLang 降级,replaceLocale 非基准语言时以新基准做交叉校验', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// 先切到 en-US,移除基准 zh-CN(降级为 en-US)
locale.setLocale('en-US')
locale.removeLocale('zh-CN')
// 注册完整 ja 包,再 replace 缺 key 的 ja 包
locale.registerLocale({ lang: 'ja', messages: jaFixture })
const jaIncomplete = { ...jaFixture }
delete (jaIncomplete as Record<string, unknown>).farewell
locale.replaceLocale('ja', jaIncomplete as TestMessages)
// 回归检测:ja 自身丢失 farewell
const regressCall = warnSpy.mock.calls.find(c => typeof c[0] === 'string' && c[0].includes('replaceLocale 检测到 key 丢失(语言="ja"):'))
expect(regressCall).toBeTruthy()
// 交叉校验应相对于新基准 en-US(而非已移除的 zh-CN)
const crossCall = warnSpy.mock.calls.find(c => typeof c[0] === 'string' && c[0].includes('相对于基准语言 "en-US" 的 key 缺失(语言="ja")'))
expect(crossCall).toBeTruthy()
warnSpy.mockRestore()
})
})
// ─── Locale.isDev 静态方法 ────────────────────────────────────────────────
describe('Locale.isDev 静态方法', () => {
it('复写 Locale.isDev 可自定义开发环境判断', () => {
const originalIsDev = Locale.isDev
Locale.isDev = () => false
const l = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: { ...zhFixture } }, name: 'test' })
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
l.setLocale('fr')
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
Locale.isDev = originalIsDev
})
})
// ─── key 完整性 ────────────────────────────────────────────────────────────
describe('key 完整性', () => {
it('真实内置 zh 和 en 语言包 key 集合完全一致', () => {
const zhKeys = Object.keys(zhMessages).sort()
const enKeys = Object.keys(enMessages).sort()
expect(zhKeys).toEqual(enKeys)
})
})
// ─── 边界用例 ────────────────────────────────────────────────────────────
describe('边界用例', () => {
it('updateLocaleMessage 拦截未注册语言,不影响已有语言包', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
locale.updateLocaleMessage('fr', 'greeting', 'Bonjour')
expect(warnSpy).toHaveBeenCalledWith('[Locale:test] 语言包 "fr" 未注册,写入已阻止。请先调用 registerLocale。')
expect(locale.hasMessage('greeting', 'fr')).toBe(false)
warnSpy.mockRestore()
})
it('batchUpdateLocale 跳过未注册语言,不影响已注册语言', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
locale.batchUpdateLocale({
"zh-CN": { greeting: '您好' },
fr: { greeting: 'Bonjour' }
})
expect(locale.t('greeting')).toBe('您好')
expect(warnSpy).toHaveBeenCalledWith('[Locale:test] 语言包 "fr" 未注册,跳过批量更新。请先调用 registerLocale。')
expect(locale.hasMessage('greeting', 'fr')).toBe(false)
warnSpy.mockRestore()
})
it('getMessages 返回的函数值仍可正常调用', () => {
const msgs = locale.getMessages()!
expect(typeof msgs['user.name']).toBe('function')
expect(msgs['user.name']({ name: 'Test' })).toBe('用户Test')
})
it('registerLocale replace=true 替换当前语言只触发一次 onChange', () => {
const cb = vi.fn()
locale.onChange(cb)
locale.registerLocale({
lang: 'zh-CN',
messages: zhNewFixture
}, true)
expect(cb).toHaveBeenCalledTimes(1)
expect(cb).toHaveBeenCalledWith('zh-CN', 'replace')
})
})
// ─── hasLocale / getRegisteredLocales ─────────────────────────────────────
describe('hasLocale / getRegisteredLocales', () => {
it('已注册语言返回 true', () => {
expect(locale.hasLocale('zh-CN')).toBe(true)
expect(locale.hasLocale('en-US')).toBe(true)
})
it('未注册语言返回 false', () => {
expect(locale.hasLocale('fr')).toBe(false)
})
it('获取所有已注册语言列表', () => {
const locales = locale.getRegisteredLocales().sort()
expect(locales).toEqual(['en-US', 'zh-CN'])
})
it('注册新语言后列表更新', () => {
locale.registerLocale({ lang: 'ja', messages: jaFixture })
const locales = locale.getRegisteredLocales().sort()
expect(locales).toEqual(['en-US', 'ja', 'zh-CN'])
})
})
// ─── replaceLocale 校验 ───────────────────────────────────────────────────
describe('replaceLocale 校验', () => {
it('未注册语言 replace 被拦截并打印 warning', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
locale.replaceLocale('fr', { greeting: 'Bonjour' } as TestMessages)
expect(warnSpy).toHaveBeenCalledWith('[Locale:test] 语言包 "fr" 未注册,replace 已阻止。请先调用 registerLocale。')
expect(locale.hasLocale('fr')).toBe(false)
warnSpy.mockRestore()
})
})
// ─── t() 警告 ─────────────────────────────────────────────────────────────
describe('t() 警告', () => {
it('当前语言 key 缺失时返回 key 本身并打印警告', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const l = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: { ...zhFixture } }, name: 'test' })
// 注册一个不含 greeting 的 ja 语言包(删除 greeting 使 key 缺失)
const jaWithoutGreeting = { ...jaFixture }
delete (jaWithoutGreeting as Record<string, unknown>).greeting
l.registerLocale({ lang: 'ja', messages: jaWithoutGreeting })
l.setLocale('ja')
// ja 没有 greeting,返回 key 本身
expect(l.t('greeting')).toBe('greeting')
expect(warnSpy).toHaveBeenCalledWith(
'[Locale:test] key "greeting" 在语言 "ja" 中不存在。'
)
warnSpy.mockRestore()
l.detachFromBus()
})
it('未知 key 返回 key 本身并打印警告', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// 默认 currentLang=zh,unknown 在 zh 中不存在
expect((locale.t as any)('unknown.key')).toBe('unknown.key')
expect(warnSpy).toHaveBeenCalledWith(
'[Locale:test] key "unknown.key" 在语言 "zh-CN" 中不存在。'
)
warnSpy.mockRestore()
})
})
// ─── onChange 迭代安全(问题1)────────────────────────────────────────────
describe('onChange 迭代安全', () => {
it('直接迭代下,回调中新增的监听器会被当前轮次调用(Set 迭代语义)', () => {
const newer = vi.fn()
const adder = vi.fn(() => {
locale.onChange(newer)
})
const existing = vi.fn()
locale.onChange(existing)
locale.onChange(adder)
locale.setLocale('en-US')
// 直接迭代 Set:newer 在 adder 之后插入,迭代器会访问到
expect(existing).toHaveBeenCalledTimes(1)
expect(adder).toHaveBeenCalledTimes(1)
expect(newer).toHaveBeenCalledTimes(1)
// 再次切换时三者均正常触发
locale.setLocale('zh-CN')
expect(newer).toHaveBeenCalledTimes(2)
})
it('直接迭代下,回调中删除的未访问监听器不会被调用(Set 迭代语义)', () => {
const toBeRemoved = vi.fn()
const remover = vi.fn(() => {
unsub()
})
locale.onChange(remover)
const unsub = locale.onChange(toBeRemoved)
locale.setLocale('en-US')
// 直接迭代 Set:remover 先执行并删除 toBeRemoved,迭代器跳过已删除项
expect(remover).toHaveBeenCalledTimes(1)
expect(toBeRemoved).not.toHaveBeenCalled()
// toBeRemoved 已从 Set 中移除,再次切换不会再调用
locale.setLocale('zh-CN')
expect(toBeRemoved).not.toHaveBeenCalled()
expect(remover).toHaveBeenCalledTimes(2)
})
})
// ─── 跨实例同步(问题2)───────────────────────────────────────────────────
describe('跨实例同步', () => {
/** 创建一对已注册 zh+en 的同步测试实例 */
function makePair(): [Locale<TestMessages>, Locale<TestMessages>] {
const a = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: zhFixture }, name: 'test-A' })
const b = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: zhFixture }, name: 'test-B' })
a.registerLocale({ lang: 'en-US', messages: enFixture })
b.registerLocale({ lang: 'en-US', messages: enFixture })
return [a, b]
}
it('A 实例 setLocale,B 实例自动同步切换', () => {
// 使用不同文案验证 A/B 实例数据隔离
const lA = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: makeFixture('A') }, name: 'test-A' })
const lB = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: makeFixture('B') }, name: 'test-B' })
lA.registerLocale({ lang: 'en-US', messages: makeEnFixture('A') })
lB.registerLocale({ lang: 'en-US', messages: makeEnFixture('B') })
try {
// A 切换到 en → B 自动同步
lA.setLocale('en-US')
expect(lA.getLocale()).toBe('en-US')
expect(lB.getLocale()).toBe('en-US')
expect(lA.t('greeting')).toBe('Hello-A')
expect(lB.t('greeting')).toBe('Hello-B')
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
})
it('A 切换的语言 B 未注册时,B 不切换', () => {
const [lA, lB] = makePair()
// 移除 B 的 en 包,使其无法跟随切换
lB.removeLocale('en-US')
try {
lA.setLocale('en-US')
expect(lA.getLocale()).toBe('en-US')
expect(lB.getLocale()).toBe('zh-CN') // B 无 en 包,不切换
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
})
it('已脱离总线的实例不被同步', () => {
const [lA, lB] = makePair()
lB.detachFromBus()
try {
lA.setLocale('en-US')
expect(lA.getLocale()).toBe('en-US')
// B 已脱离总线,不联动
expect(lB.getLocale()).toBe('zh-CN')
} finally {
lA.detachFromBus()
}
})
it('跨实例同步触发各自的 onChange', () => {
const [lA, lB] = makePair()
const cbA = vi.fn()
const cbB = vi.fn()
lA.onChange(cbA)
lB.onChange(cbB)
try {
lA.setLocale('en-US')
expect(cbA).toHaveBeenCalledWith('en-US', 'set')
// B 是被总线同步的,from 为 'sync' 以区分主动切换
expect(cbB).toHaveBeenCalledWith('en-US', 'sync')
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
})
it('递归同步被 $syncing 锁阻止', () => {
const [lA, lB] = makePair()
try {
lA.setLocale('en-US')
// 两者都切换到 en,不会无限递归
expect(lA.getLocale()).toBe('en-US')
expect(lB.getLocale()).toBe('en-US')
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
})
it('sync=false 时跳过总线同步,其他实例不跟随切换', () => {
const [lA, lB] = makePair()
try {
// A 独立切换,不同步 B
lA.setLocale('en-US', { sync: false })
expect(lA.getLocale()).toBe('en-US')
expect(lB.getLocale()).toBe('zh-CN') // B 未受影响
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
})
it('sync=false 时仍正常触发自身的 onChange', () => {
const lA = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: zhFixture }, name: 'test' })
lA.registerLocale({ lang: 'en-US', messages: enFixture })
const cbA = vi.fn()
lA.onChange(cbA)
try {
lA.setLocale('en-US', { sync: false })
expect(cbA).toHaveBeenCalledWith('en-US', 'set')
} finally {
lA.detachFromBus()
}
})
// ─── 边界用例(问题1/4)──────────────────────────────────────────────────
it('同步时目标实例已在目标语言则不触发该实例 onChange(问题1修复)', () => {
const [lA, lB] = makePair()
const cbB = vi.fn()
lB.onChange(cbB)
// 先把 B 切到 en(不通过总线,避免 A 联动)
lB.setLocale('en-US', { sync: false })
cbB.mockClear()
try {
// A 切到 en,B 已在 en,按相同语言契约不应再触发 B 的 onChange
lA.setLocale('en-US')
expect(lA.getLocale()).toBe('en-US')
expect(lB.getLocale()).toBe('en-US')
expect(cbB).not.toHaveBeenCalled()
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
})
it('同步过程中 setLocale 被 $syncing 拦截并打印 warn', () => {
const [lA, lB] = makePair()
lB.registerLocale({ lang: 'ja', messages: zhFixture })
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// B 的 sync 回调中尝试调用 setLocale,应被 $syncing 锁拦截
lB.onChange((_lang, from) => {
if (from === 'sync') {
const result = lB.setLocale('ja', { sync: false })
expect(result).toBe(false)
}
})
try {
lA.setLocale('en-US')
// A 切到 en
expect(lA.getLocale()).toBe('en-US')
// B 被同步到 en,回调中的 setLocale 被拦截,B 保持在 en
expect(lB.getLocale()).toBe('en-US')
expect(warnSpy).toHaveBeenCalledWith('[Locale:test-B] 同步过程中不允许调用 setLocale,已阻止。')
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
warnSpy.mockRestore()
})
it('detachFromBus 后实例不再被总线联动,attachToBus 后恢复联动', () => {
const [lA, lB] = makePair()
try {
// B 脱离总线
expect(lB.detachFromBus()).toBe(true)
lA.setLocale('en-US')
expect(lA.getLocale()).toBe('en-US')
expect(lB.getLocale()).toBe('zh-CN') // B 已脱离,不联动
// B 重新加入总线
expect(lB.attachToBus()).toBe(true)
// 当前语言已是 en,B 仍是 zh,需主动切换或等待下次同步
lA.setLocale('zh-CN', { sync: false })
lA.setLocale('en-US')
expect(lB.getLocale()).toBe('en-US') // B 恢复联动
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
})
})
// ─── replaceLocale 开发环境 key 完整性校验(问题6)───────────────────────
describe('replaceLocale 开发环境 key 完整性校验', () => {
it('开发模式下,replaceLocale 缺失 key 时打印警告', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const l = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: { ...zhFixture } }, name: 'test' })
// 替换时缺失 farewell 和 user.age
l.replaceLocale('zh-CN', {
greeting: '你好',
'user.name': (params) => `用户${params.name}`,
'nested.deep.key': '深层值'
} as TestMessages)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[Locale:test] replaceLocale 检测到 key 丢失(语言="zh-CN"):')
)
const callArg = warnSpy.mock.calls.find(c => typeof c[0] === 'string' && c[0].includes('replaceLocale 检测到 key 丢失'))
expect(callArg).toBeTruthy()
expect(callArg![0]).toContain('farewell')
expect(callArg![0]).toContain('user.age')
warnSpy.mockRestore()
})
it('开发模式下,replaceLocale 无缺失 key 时不打印警告', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const l = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: { ...zhFixture } }, name: 'test' })
// 完整替换,无缺失
l.replaceLocale('zh-CN', zhNewFixture)
const keyLossCall = warnSpy.mock.calls.find(c => typeof c[0] === 'string' && c[0].includes('replaceLocale 检测到 key 丢失'))
expect(keyLossCall).toBeUndefined()
warnSpy.mockRestore()
})
it('生产模式下,replaceLocale 不执行 key 完整性校验', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const _savedIsDev = Locale.isDev
Locale.isDev = () => false
const l = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: { ...zhFixture } }, name: 'test' })
l.replaceLocale('zh-CN', {
greeting: '你好',
'user.name': (params) => `用户${params.name}`,
'nested.deep.key': '深层值'
} as TestMessages)
const keyLossCall = warnSpy.mock.calls.find(c => typeof c[0] === 'string' && c[0].includes('replaceLocale 检测到 key 丢失'))
expect(keyLossCall).toBeUndefined()
warnSpy.mockRestore()
Locale.isDev = _savedIsDev
})
it('开发模式下,replaceLocale 非基准语言时同时执行回归检测和 $baseLang 交叉校验', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const l = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: { ...zhFixture } }, name: 'test' })
// 注册完整的 en 包作为基准
l.registerLocale({ lang: 'en-US', messages: { ...enFixture } })
// 替换 en-US 时缺失 greeting(回归检测),且缺失 farewell(相对于 $baseLang=zh-CN 的交叉校验)
l.replaceLocale('en-US', {
'user.name': (params) => `User ${params.name}`,
'user.age': (params) => `${params.age} years old`,
'nested.deep.key': 'Deep value'
} as TestMessages)
// 回归检测:en-US 自身丢失了 greeting 和 farewell
const regressCall = warnSpy.mock.calls.find(c => typeof c[0] === 'string' && c[0].includes('replaceLocale 检测到 key 丢失(语言="en-US"):'))
expect(regressCall).toBeTruthy()
expect(regressCall![0]).toContain('greeting')
expect(regressCall![0]).toContain('farewell')
// 基准交叉校验:相对于 $baseLang=zh-CN 也缺失 greeting 和 farewell
const baseCrossCall = warnSpy.mock.calls.find(c => typeof c[0] === 'string' && c[0].includes('相对于基准语言 "zh-CN" 的 key 缺失(语言="en-US")'))
expect(baseCrossCall).toBeTruthy()
expect(baseCrossCall![0]).toContain('greeting')
expect(baseCrossCall![0]).toContain('farewell')
warnSpy.mockRestore()
})
})
// ─── 实例名称 ──────────────────────────────────────────────────────────────
describe('实例名称', () => {
it('name 必填,日志前缀为 [Locale:name]', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const l = new Locale<TestMessages>({
pack: { lang: 'zh-CN', messages: zhFixture },
name: 'utils'
})
l.setLocale('fr')
expect(warnSpy).toHaveBeenCalledWith('[Locale:utils] 语言包 "fr" 未注册,切换已阻止。')
warnSpy.mockRestore()
l.detachFromBus()
})
})
// ─── attachToBus / detachFromBus 边界 ─────────────────────────────────────
describe('attachToBus / detachFromBus 边界', () => {
it('已在总线中时 attachToBus 返回 false', () => {
// beforeEach 中的 locale 默认已加入总线
expect(locale.attachToBus()).toBe(false)
})
it('不在总线中时 detachFromBus 返回 false', () => {
const l = new Locale<TestMessages>({
pack: { lang: 'zh-CN', messages: zhFixture },
attachToBus: false,
name: 'test'
})
expect(l.detachFromBus()).toBe(false)
l.detachFromBus()
})
it('detach 后 attach 恢复总线成员资格', () => {
expect(locale.detachFromBus()).toBe(true)
expect(locale.attachToBus()).toBe(true)
})
it('同步过程中 detachFromBus 被拦截并返回 false', () => {
const lA = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: zhFixture }, name: 'test-A' })
const lB = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: zhFixture }, name: 'test-B' })
lA.registerLocale({ lang: 'en-US', messages: enFixture })
lB.registerLocale({ lang: 'en-US', messages: enFixture })
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// B 的 sync 回调中尝试 detachFromBus,应被 $syncing 锁拦截
lB.onChange((_lang, from) => {
if (from === 'sync') {
const result = lB.detachFromBus()
expect(result).toBe(false)
}
})
try {
lA.setLocale('en-US')
expect(lA.getLocale()).toBe('en-US')
expect(lB.getLocale()).toBe('en-US') // B 正常同步,detach 被拦截
expect(warnSpy).toHaveBeenCalledWith('[Locale:test-B] 同步过程中不允许调用 detachFromBus,已阻止。')
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
warnSpy.mockRestore()
})
it('同步过程中 attachToBus 被拦截并返回 false', () => {
const lA = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: zhFixture }, name: 'test-A' })
const lB = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: zhFixture }, name: 'test-B' })
lA.registerLocale({ lang: 'en-US', messages: enFixture })
lB.registerLocale({ lang: 'en-US', messages: enFixture })
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// 先让 B 脱离总线
lB.detachFromBus()
// A 的 onChange 回调中尝试让 B 重新加入,应被 $syncing 锁拦截
lA.onChange(() => {
const result = lB.attachToBus()
expect(result).toBe(false)
})
try {
lA.setLocale('en-US')
expect(lA.getLocale()).toBe('en-US')
expect(lB.getLocale()).toBe('zh-CN') // B 已脱离且 attachToBus 被拦截
expect(warnSpy).toHaveBeenCalledWith('[Locale:test-B] 同步过程中不允许调用 attachToBus,已阻止。')
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
warnSpy.mockRestore()
})
})
// ─── 同步跳过警告 ─────────────────────────────────────────────────────────
describe('同步跳过警告', () => {
it('同步时目标实例未注册目标语言,打印警告', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const lA = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: makeFixture('A') }, name: 'test-A' })
const lB = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: makeFixture('B') }, name: 'test-B' })
lA.registerLocale({ lang: 'en-US', messages: makeEnFixture('A') })
// lB 不注册 en
try {
lA.setLocale('en-US')
expect(lA.getLocale()).toBe('en-US')
expect(lB.getLocale()).toBe('zh-CN') // B 未注册 en,不切换
expect(warnSpy).toHaveBeenCalledWith('[Locale:test-B] 未注册语言 "en-US",未跟随同步。')
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
warnSpy.mockRestore()
})
})
// ─── constructor attachToBus=false ────────────────────────────────────────
describe('constructor attachToBus=false', () => {
it('attachToBus=false 时实例不加入总线,不被同步', () => {
const lA = new Locale<TestMessages>({
pack: { lang: 'zh-CN', messages: makeFixture('A') },
name: 'test-A'
})
const lB = new Locale<TestMessages>({
pack: { lang: 'zh-CN', messages: makeFixture('B') },
attachToBus: false,
name: 'test-B'
})
lA.registerLocale({ lang: 'en-US', messages: makeEnFixture('A') })
lB.registerLocale({ lang: 'en-US', messages: makeEnFixture('B') })
try {
lA.setLocale('en-US')
expect(lA.getLocale()).toBe('en-US')
expect(lB.getLocale()).toBe('zh-CN') // B 不在总线,不跟随
} finally {
lA.detachFromBus()
lB.detachFromBus()
}
})
})
// ─── registerLocale 开发环境 key 校验 ────────────────────────────────
describe('registerLocale 开发环境 key 校验', () => {
it('新注册语言包缺失 key 时打印警告', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// 注册一个缺少 farewell 和 user.age 的 ja 包
const jaIncomplete: TestMessages = {
greeting: 'こんにちは',
'user.name': (p) => `ユーザー${p.name}`,
'nested.deep.key': '深い値',
farewell: '',
'user.age': () => ''
}
// 先删除两个 key 使其缺失
delete (jaIncomplete as Record<string, unknown>).farewell
delete (jaIncomplete as Record<string, unknown>)['user.age']
locale.registerLocale({ lang: 'ja', messages: jaIncomplete })
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('registerLocale 检测到 key 缺失(语言="ja",对比基准="zh-CN"):')
)
const callArg = warnSpy.mock.calls.find(c => typeof c[0] === 'string' && c[0].includes('registerLocale 检测到 key 缺失'))
expect(callArg).toBeTruthy()
expect(callArg![0]).toContain('farewell')
expect(callArg![0]).toContain('user.age')
warnSpy.mockRestore()
})
it('新注册语言包 key 完整时不打印警告', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
locale.registerLocale({ lang: 'ja', messages: jaFixture })
const keyLossCall = warnSpy.mock.calls.find(c => typeof c[0] === 'string' && c[0].includes('registerLocale 检测到 key 缺失'))
expect(keyLossCall).toBeUndefined()
warnSpy.mockRestore()
})
})
// ─── removeAllListeners ───────────────────────────────────────────────
describe('removeAllListeners', () => {
it('removeAllListeners 清除所有监听器', () => {
const cb1 = vi.fn()
const cb2 = vi.fn()
locale.onChange(cb1)
locale.onChange(cb2)
locale.removeAllListeners()
locale.setLocale('en-US')
expect(cb1).not.toHaveBeenCalled()
expect(cb2).not.toHaveBeenCalled()
})
})
// ─── isDev 公开属性 ──────────────────────────────────────────────────────
describe('isDev 公开属性', () => {
it('默认委托 Locale.isDev', () => {
// beforeAll 中 Locale.isDev = () => true
expect(locale.isDev()).toBe(true)
})
it('直接覆写 isDev 字段可自定义', () => {
const originalIsDev = locale.isDev
locale.isDev = () => false
expect(locale.isDev()).toBe(false)
locale.isDev = originalIsDev
})
})
// ─── 跨实例链式同步 A->B->C ─────────────────────────────────────────
describe('跨实例链式同步', () => {
it('A 切换后 B、C 都被同步($syncing 不误拦截)', () => {
const lA = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: zhFixture }, name: 'test-A' })
const lB = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: zhFixture }, name: 'test-B' })
const lC = new Locale<TestMessages>({ pack: { lang: 'zh-CN', messages: zhFixture }, name: 'test-C' })
lA.registerLocale({ lang: 'en-US', messages: enFixture })
lB.registerLocale({ lang: 'en-US', messages: enFixture })
lC.registerLocale({ lang: 'en-US', messages: enFixture })
const cbB = vi.fn()
const cbC = vi.fn()
lB.onChange(cbB)
lC.onChange(cbC)
try {
lA.setLocale('en-US')
expect(lB.getLocale()).toBe('en-US')
expect(lC.getLocale()).toBe('en-US')
expect(cbB).toHaveBeenCalledWith('en-US', 'sync')
expect(cbC).toHaveBeenCalledWith('en-US', 'sync')
} finally {
lA.detachFromBus()
lB.detachFromBus()
lC.detachFromBus()
}
})
})
// ─── replaceLocale 空对象 ─────────────────────────────────────────────
describe('replaceLocale 空对象', () => {
it('替换为空对象后 t() 返回 key 本身', () => {
locale.replaceLocale('zh-CN', {} as TestMessages)
expect(locale.t('greeting')).toBe('greeting')
})
})
// ─── 原型链防御 ───────────────────────────────────────────────────────────
describe('原型链防御', () => {
it('hasLocale 对原型链属性返回 false', () => {
expect(locale.hasLocale('toString')).toBe(false)
expect(locale.hasLocale('constructor')).toBe(false)
expect(locale.hasLocale('hasOwnProperty')).toBe(false)
})
it('hasMessage 对原型链属性返回 false', () => {
expect((locale.hasMessage as any)('toString')).toBe(false)
expect((locale.hasMessage as any)('constructor')).toBe(false)
})
it('t() 对原型链属性返回 key 本身并打印警告', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect((locale.t as any)('toString')).toBe('toString')
expect(warnSpy).toHaveBeenCalledWith('[Locale:test] key "toString" 在语言 "zh-CN" 中不存在。')
warnSpy.mockRestore()
})
})
// ─── batchUpdateLocale 空对象 ────────────────────────────────────────────
describe('batchUpdateLocale 空对象', () => {
it('传入空对象不触发 onChange', () => {
const cb = vi.fn()
locale.onChange(cb)
locale.batchUpdateLocale({})
expect(cb).not.toHaveBeenCalled()
})
})
// ─── 函数消息返回非 string 值(D10)──────────────────────────────────────
describe('函数消息返回非 string 值', () => {
it('函数返回非 string 时直接返回该值,不做运行时校验', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// 通过 as any 绕过类型检查,注册一个返回 number 的函数(违反 MessageValue 类型契约)
locale.updateLocaleMessage('zh-CN', 'greeting', (() => 42) as any)
// D10 设计决策:不校验返回值类型,直接返回
expect(locale.t('greeting') as any).toBe(42)
// 不应打印 key 不存在的 warn
const keyNotExistCall = warnSpy.mock.calls.find(c => typeof c[0] === 'string' && c[0].includes('不存在'))
expect(keyNotExistCall).toBeUndefined()
warnSpy.mockRestore()
})
})
// ─── 无 process 环境下 isDev ──────────────────────────────────────────────
describe('无 process 环境下 isDev', () => {
it('无 process 时 isDev 默认返回 false', () => {
// beforeAll 已将 Locale.isDev 覆写为 () => true,此处临时恢复默认实现
// 以测试 process 缺失分支(合并 LocaleBase 后无独立静态入口可绕过覆写)
const saved = Locale.isDev
Locale.isDev = () => {
if (typeof process !== 'undefined' && process.env) {
return process.env.NODE_ENV !== 'production'
}
return false
}
vi.stubGlobal('process', undefined)
try {
expect(Locale.isDev()).toBe(false)
} finally {
vi.unstubAllGlobals()
Locale.isDev = saved
}
})
})
})
import type { FlatMessages, MessageValue, LocalePack, LocaleOptions, ChangeFrom } from './types'
import hasOwnKey from '../object/hasOwnKey'
/**
* Locale —— 轻量多语言管理器
* @typeParam T 消息 schema 类型,用于 t() / updateLocaleMessage 等方法的类型推导
*
* 类结构说明:跨实例同步总线($bus / $syncing)与静态 isDev 等不依赖泛型 T 的公共逻辑
* 直接以静态成员形式存放于本类,无需独立基类。总线存储 Locale<any> 引用,解除对泛型 T 的耦合。
*/
class Locale<T extends FlatMessages = FlatMessages> {
/**
* 开发环境判断(静态全局共享)。
* 设计说明:suite 各子包(utils/data/component 等)创建的 Locale 实例共享同一套环境变量体系,
* 因此 isDev 设计为静态全局属性,由 env 模块统一覆盖,所有实例自动生效。
* 子包创建新实例时无需重复配置,只需确保 env 模块已被加载。
* 需自定义时直接复写:Locale.isDev = () => yourChecker()
*/
static isDev: () => boolean = () => {
if (typeof process !== 'undefined' && process.env) {
return process.env.NODE_ENV !== 'production'
}
return false
}
/**
* 跨实例语言同步:静态总线 + 同步锁。
* suite 各子包独立创建 Locale 实例,当一个实例调用 setLocale 时,
* 自动同步到总线中其他已注册了目标语言的实例,实现"一键切换"。
*
* 【$syncing 设计意图】
* $syncing 为全局同步锁,语义是"当前正在执行跨实例同步,期间禁止任何 setLocale 调用"。
* onChange 监听器的职责是【接收】语言变更通知并做出响应(如刷新 UI 文案),
* 而非在回调中【发起】新的语言切换。
* 若允许在同步过程中调用 setLocale,会导致:
* - 总线中各实例语言状态分裂(A→en 同步到 B,B 的回调又切到 ja,A/C 未感知)
* - 潜在的递归同步死循环
* 因此 $syncing 采用全局布尔锁而非实例级锁,这是有意设计,不是粒度不够。
*
* 总线成员存储为强引用 Set<Locale<any>>,实例为应用级单例(D7),无需 WeakRef。
* ⚠️ $bus / $syncing 为内部静态属性,外部代码请勿直接操作。
*/
protected static $bus: Set<Locale<any>> = new Set()
protected static $syncing: boolean = false
private $currentLang: string
private $messages: Record<string, T> = {}
/** 当前语言消息包引用缓存,t() 高频调用时避免重复属性查找 */
private $currentMessages: T | null = null
/** 首个注册语言(构造函数传入),作为 key 完整性校验的权威基准 */
private $baseLang: string
private $listeners: Set<(lang: string, from: ChangeFrom) => void> = new Set()
/**
* 实例级开发环境判断函数,默认委托给静态 Locale.isDev()。
* 需自定义时直接覆写本字段:locale.isDev = () => yourChecker()
*/
isDev: () => boolean = () => Locale.isDev()
/** 实例名称,用于日志标识,便于多实例调试时定位来源 */
private $name: string
/** 统一日志输出(仅开发环境),自动添加 [Locale:name] 前缀 */
private $log(level: 'info' | 'warn' | 'error', message: string): void {
if (!this.isDev()) return
console[level](`[Locale:${this.$name}] ${message}`)
}
/** 刷新当前语言消息包引用缓存(语言切换 / 语言包替换后必须调用) */
private $refreshMessageRefs(): void {
this.$currentMessages = this.$messages[this.$currentLang] ?? null
}
/**
* 内部方法:应用语言变更(更新状态 + 刷新缓存 + 通知监听器)。
* 总线同步复用此方法,保证目标实例状态变更路径统一,避免直接操作私有字段。
*/
private $applyLocaleChange(lang: string, from: ChangeFrom): void {
this.$currentLang = lang
this.$refreshMessageRefs()
this.$notifyChange(from)
}
/**
* 将本实例从跨实例同步总线中移除,移除后其他实例 setLocale 不再联动本实例。
* 已脱离总线时调用无副作用。返回是否成功移除(true 表示此前在总线中)。
* 同步过程中调用会被拦截并返回 false,防止 Set 迭代期间修改总线成员。
*/
detachFromBus(): boolean {
if (Locale.$syncing) {
this.$log('warn', '同步过程中不允许调用 detachFromBus,已阻止。')
return false
}
return Locale.$bus.delete(this)
}
/**
* 将本实例重新加入跨实例同步总线。
* 已在总线中时调用无副作用,返回 false。
* 同步过程中调用会被拦截并返回 false,防止 Set 迭代期间修改总线成员。
*/
attachToBus(): boolean {
if (Locale.$syncing) {
this.$log('warn', '同步过程中不允许调用 attachToBus,已阻止。')
return false
}
if (Locale.$bus.has(this)) return false
Locale.$bus.add(this)
return true
}
/**
* @param options.pack 默认语言包,自动注册并设为当前语言
* @param options.name 实例名称,用于日志标识,便于多实例调试
* @param options.attachToBus 是否加入跨实例同步总线,默认 true。
* 传 false 时创建完全独立的实例,其他实例 setLocale 不会联动本实例。
*/
constructor(options: LocaleOptions<T>) {
this.$name = options.name
this.$messages[options.pack.lang] = { ...options.pack.messages }
this.$currentLang = options.pack.lang
this.$baseLang = options.pack.lang
this.$refreshMessageRefs()
if (options.attachToBus !== false) {
Locale.$bus.add(this)
}
}
/** 监听语言切换或消息变更,返回取消函数 */
onChange(cb: (lang: string, from: ChangeFrom) => void): () => void {
this.$listeners.add(cb)
return () => { this.$listeners.delete(cb) }
}
/** 移除所有监听器,适用于组件卸载等批量清理场景 */
removeAllListeners(): void {
this.$listeners.clear()
}
/**
* 注册语言包
* - 未注册语言:直接注册,返回 true
* - 已注册语言:replace=false 拒绝并返回 false;replace=true 替换并返回 true
*
* 注意:replace=true 时内部委托 replaceLocale,若替换的是当前语言会触发 onChange(from = 'replace')。
*
* 【dev 校验基准】以首个注册语言($baseLang)为权威基准检测缺失 key,
* 保证所有语言包与统一的 schema 基准一致。$baseLang 在被移除时自动降级为 $currentLang。
*/
registerLocale(pack: LocalePack<T>, replace: boolean = false): boolean {
const lang = pack.lang
const { messages } = pack
if (this.hasLocale(lang)) {
if (!replace) {
this.$log('warn', `语言包 "${lang}" 已注册,如需替换请设置 replace=true。`)
return false
}
this.replaceLocale(lang, messages)
return true
}
this.$messages[lang] = { ...messages }
// 开发环境校验 key 完整性:以首个注册语言($baseLang)为权威基准检测缺失 key
// $baseLang 在被移除时自动降级为 $currentLang,保证始终指向有效的基准语言包
if (this.isDev() && lang !== this.$baseLang) {
const baseKeys = Object.keys(this.$messages[this.$baseLang])
const newKeySet = new Set(Object.keys(messages))
const missing = baseKeys.filter(k => !newKeySet.has(k))
if (missing.length > 0) {
this.$log('warn', `registerLocale 检测到 key 缺失(语言="${lang}",对比基准="${this.$baseLang}"):${missing.join(', ')}`)
}
}
return true
}
/**
* 切换语言
* - 未注册语言:返回 false,阻止切换
* - 相同语言:返回 true 但不触发 onChange、不触发跨实例同步(避免无意义的 UI 刷新)
* ⚠️ 注意:当 sync=true(默认)时,发起者自身的 onChange(from='set') 及总线同步均在 $syncing 锁期间执行。
* 回调中调用 setLocale()(即使 sync: false)会被拦截返回 false。
* 如需在回调中触发其他实例的语言切换,请使用异步方式绕过:
* setTimeout(() => otherLocale.setLocale('en'), 0)
* // 或 Promise.resolve().then(() => ...)
*
* @param lang 目标语言标识
* @param options.sync 是否触发跨实例总线同步,默认 true。
* 传 false 时仅当前实例独立切换语言,不联动其他实例。
*/
setLocale(lang: string, options?: { sync?: boolean }): boolean {
// 同步过程中阻止 setLocale,防止回调中改变状态导致跨实例状态分裂
if (Locale.$syncing) {
this.$log('warn', '同步过程中不允许调用 setLocale,已阻止。')
return false
}
if (!this.hasLocale(lang)) {
this.$log('warn', `语言包 "${lang}" 未注册,切换已阻止。`)
return false
}
// 相同语言不触发切换:避免无意义的 onChange 通知和跨实例同步
if (lang === this.$currentLang) {
return true
}
// 自身状态更新 + onChange 统一在 $syncing 锁期间执行,
// 防止监听器回调中调用 setLocale 导致跨实例状态分裂(D11)
const sync = options?.sync !== false
Locale.$syncing = true
try {
this.$applyLocaleChange(lang, 'set')
if (sync) {
// 遍历总线中其他实例(from = 'sync'),跳过自身、已在目标语言的实例
for (const instance of Locale.$bus) {
if (instance === this || instance.$currentLang === lang) {
continue
}
if (!instance.hasLocale(lang)) {
instance.$log('warn', `未注册语言 "${lang}",未跟随同步。`)
continue
}
// 通过统一方法更新目标实例状态,避免直接操作私有字段
instance.$applyLocaleChange(lang, 'sync')
}
}
} finally {
Locale.$syncing = false
}
return true
}
/** 获取当前语言 */
getLocale(): string {
return this.$currentLang
}
/**
* 获取指定语言的消息包(浅拷贝),不传则取当前语言。
* 设计说明:MessageValue 仅支持 string | function 两种值类型,
* 浅拷贝足以防止外部修改内部状态。若未来扩展 MessageValue 支持对象类型,需改为深拷贝。
*/
getMessages(lang?: string): T | undefined {
const target = lang ?? this.$currentLang
if (!this.hasLocale(target)) {
return undefined
}
return { ...this.$messages[target] }
}
/**
* 判断指定 key 是否存在于语言包中(仅检查目标语言自身)。
* 设计意图:调用方通过此方法判断当前语言包是否缺少某个 key,
* 若缺少则可通过 updateLocaleMessage / batchUpdateLocale 补全。
* 这与 t() 的行为刻意不同 —— hasMessage 是"检查是否存在",t() 是"翻译"。
*/
hasMessage<K extends keyof T & string>(key: K, lang?: string): boolean {
const target = lang ?? this.$currentLang
if (!this.hasLocale(target)) return false
return hasOwnKey(this.$messages[target], key)
}
/**
* 翻译函数
* - 字符串消息:无需传 params
* - 函数消息:params 类型与消息函数签名一致
* - key 缺失时返回 key 本身并打印警告
*
* 设计决策:不回退到默认语言。
* key 缺失时直接返回 key 字符串,保证调用方能及时发现翻译遗漏,
* 而非静默使用其他语言文案。需要回退行为的场景请在调用层自行处理。
*/
t<K extends keyof T & string>(
key: K,
...params: T[K] extends (params: infer P) => string
? [P]
: []
): string {
const messages = this.$currentMessages!
if (!hasOwnKey(messages, key)) {
this.$log('warn', `key "${key}" 在语言 "${this.$currentLang}" 中不存在。`)
return key
}
const value: MessageValue = messages[key]
// 解析:函数则调用并传参,字符串则直接返回
if (typeof value === 'function') {
try {
return value(params[0])
} catch (err) {
this.$log('error', `key "${key}" 函数消息执行出错:${String(err)}`)
return key
}
}
return value
}
/** 更新某个 key 的值,若影响当前语言则触发 onChange(from = 'update') */
updateLocaleMessage<K extends keyof T & string>(
lang: string,
key: K,
value: T[K]
): void {
if (!this.hasLocale(lang)) {
this.$log('warn', `语言包 "${lang}" 未注册,写入已阻止。请先调用 registerLocale。`)
return
}
this.$messages[lang][key] = value
if (lang === this.$currentLang) {
this.$notifyChange('update')
}
}
/**
* 替换指定语言的整个语言包,若影响当前语言则触发 onChange(from = 'replace')
*
* 【dev 校验基准】双重校验:
* 1. 以被替换语言自身的旧包为基准检测缺失 key,防止替换时丢失已有 key(回归检测)。
* 2. 以 $baseLang 为基准检测新包是否缺少全局 schema 中的 key,防止替换后与基准偏离累积。
* 这与 registerLocale 以 $baseLang 为基准的校验目的不同:replaceLocale 先做自回归,再做全局基准校验。
*/
replaceLocale(lang: string, msgs: T): void {
if (!this.hasLocale(lang)) {
this.$log('warn', `语言包 "${lang}" 未注册,replace 已阻止。请先调用 registerLocale。`)
return
}
// 开发环境校验 key 完整性
if (this.isDev()) {
const newKeySet = new Set(Object.keys(msgs))
// 1. 回归检测:以旧语言包自身为基准检测新包是否丢失 key
const oldKeys = Object.keys(this.$messages[lang])
const missingRegress = oldKeys.filter(k => !newKeySet.has(k))
if (missingRegress.length > 0) {
this.$log('warn', `replaceLocale 检测到 key 丢失(语言="${lang}"):${missingRegress.join(', ')}`)
}
// 2. 基准交叉校验:以 $baseLang 为基准检测新包是否缺少全局 schema 中的 key(防止与基准偏离累积)
if (lang !== this.$baseLang) {
const baseKeys = Object.keys(this.$messages[this.$baseLang])
const missingFromBase = baseKeys.filter(k => !newKeySet.has(k))
if (missingFromBase.length > 0) {
this.$log('warn', `replaceLocale 检测到相对于基准语言 "${this.$baseLang}" 的 key 缺失(语言="${lang}"):${missingFromBase.join(', ')}`)
}
}
}
this.$messages[lang] = { ...msgs }
if (lang === this.$currentLang) {
this.$refreshMessageRefs()
this.$notifyChange('replace')
}
}
/**
* 批量更新多个语言包的指定字段,若影响当前语言则触发 onChange(from = 'update')
*
* 注:不调用 $refreshMessageRefs(),遵循 D4 浅引用语义——
* $currentMessages 与 $messages[currentLang] 指向同一对象,修改属性后引用自动可见。
* 这与 updateLocaleMessage 的行为保持一致。
*/
batchUpdateLocale(data: Record<string, Partial<T>>): void {
let currentAffected = false
for (const rawLang of Object.keys(data)) {
if (!this.hasLocale(rawLang)) {
this.$log('warn', `语言包 "${rawLang}" 未注册,跳过批量更新。请先调用 registerLocale。`)
continue
}
const patch = data[rawLang]
for (const key of Object.keys(patch) as (keyof T & string)[]) {
const v = patch[key]
if (v !== undefined) {
// TS 限制:Object.keys 遍历泛型 Partial<T> 时无法将 v 从 Partial<T>[K] 收窄为 T[K],
// 运行时 v 已通过上方 !== undefined 守卫排除 undefined,断言安全
this.$messages[rawLang][key] = v as T[keyof T & string]
}
}
if (rawLang === this.$currentLang) {
currentAffected = true
}
}
if (currentAffected) {
this.$notifyChange('update')
}
}
/**
* 移除指定语言包
* - 移除当前语言或语言不存在:返回 false
* - 成功移除返回 true(不触发 onChange)
*/
removeLocale(lang: string): boolean {
if (!this.hasLocale(lang)) {
this.$log('warn', `语言包 "${lang}" 未注册,移除失败。`)
return false
}
if (this.$currentLang === lang) {
this.$log('warn', `语言包 "${lang}" 正在使用中,禁止移除。请先切换到其他语言。`)
return false
}
delete this.$messages[lang]
// 若移除的是基准语言,降级为当前语言以保持基准语义一致
if (lang === this.$baseLang) {
this.$baseLang = this.$currentLang
this.$log('info', `基准语言 "${lang}" 已移除,校验基准降级为当前语言 "${this.$currentLang}"。`)
}
return true
}
/** 判断指定语言是否已注册 */
hasLocale(lang: string): boolean {
return Object.prototype.hasOwnProperty.call(this.$messages, lang)
}
/** 获取所有已注册语言列表 */
getRegisteredLocales(): string[] {
return Object.keys(this.$messages)
}
/**
* 通知所有监听者,单个监听抛错不阻断后续。
* 直接迭代 Set:回调中新增的监听器会被当前轮次调用,删除的未访问监听器会被跳过(Set 迭代语义)。
* 不保证未来版本继续使用此迭代语义,调用方不应依赖此行为。
*/
private $notifyChange(from: ChangeFrom): void {
for (const cb of this.$listeners) {
try {
cb(this.$currentLang, from)
} catch (err) {
this.$log('error', `onChange 监听执行出错:${String(err)}`)
}
}
}
}
export default Locale
# Locale 模块
`@complex-suite/utils` 的轻量多语言管理器,供 suite 所有子包共用。
**设计哲学**:内部项目,信任调用方,精简防御代码,聚焦运行时性能。不对外部使用者的类型违规和运行时误操作做兜底,将防御责任交还给调用方,换取更少的运行时分支和更紧凑的代码。
**核心能力**:
- **类型安全** — `t()` 的 key 和函数参数在编译期完全类型安全,各语言包 key 集合由类型系统强制一致
- **跨实例同步** — 各子包独立创建实例,任一实例切换语言时自动联动总线中其他实例
- **实例隔离** — 消息包互不污染,类型互不耦合
- **dev 校验** — key 完整性校验仅在开发环境执行,生产零开销
---
## 目录
- [架构概览](#架构概览)
- [快速上手](#快速上手)
- [API 参考](#api-参考)
- [跨子包协作注意事项](#跨子包协作注意事项)
- [设计决策](#设计决策请勿提出相关优化建议)
- [待优化项与已决定项](#待优化项与已决定项)
---
## 架构概览
### 核心设计
1. **类型安全**:`T extends FlatMessages` 泛型 + 基准语言包(`zh.ts`)推导 `UtilsMessages` 类型,`t()` 的 key 和函数参数编译期完全类型安全,各语言包 key 集合由类型系统强制一致。
2. **跨实例同步**:静态 `$bus`(`Set<Locale<any>>`)+ `$syncing` 全局锁。各子包独立创建实例,任一实例 `setLocale` 时自动同步到总线中其他已注册目标语言的实例。
3. **实例隔离**:每个子包拥有独立实例,消息包互不污染,类型互不耦合。
4. **dev 校验**:key 完整性校验仅在 dev 环境执行,生产零开销。
### 类结构
- **`Locale<T>`**(泛型类):轻量多语言管理器,`T` 为消息 schema 类型。跨实例同步总线(`$bus` / `$syncing`)与静态 `isDev` 等不依赖泛型 `T` 的公共逻辑以静态成员形式直接存放于本类,无需独立基类。
> 总线存储 `Locale<any>` 引用以解除对泛型 `T` 的耦合,总线上只会加入 `Locale<T>` 实例。
### 文件职责
| 文件 | 职责 |
|---|---|
| `Locale.ts` | `Locale<T>` 实现(含跨实例同步总线、静态 `isDev`) |
| `types.ts` | `MessageValue` / `FlatMessages` / `LocalePack` / `LocaleOptions` / `ChangeFrom` 类型定义 |
| `index.ts` | 导出公共 API + 创建 `utilsLocale` 单例 |
| `zh.ts` | 中文基准消息包,`UtilsMessages` 类型的唯一来源 |
| `en.ts` | 英文消息包,实现 `UtilsMessages` 类型 |
---
## 快速上手
### utils 包内部使用单例
```ts
import { utilsLocale } from '@complex-suite/utils'
// 英文包作为独立子路径导出,按需导入(默认仅注册中文)
import en from '@complex-suite/utils/locale/en'
// 注册英文包
utilsLocale.registerLocale(en)
// 翻译
console.log(utilsLocale.t('color.invalidFormat'))
// 切换语言(自动同步到总线中其他实例)
utilsLocale.setLocale('en-US')
```
### 其他子包创建独立实例
```ts
import { Locale } from '@complex-suite/utils'
// 1. 定义基准消息包和类型
const zhMessages = {
'data.loadSuccess': '加载成功',
'data.loadFailed': (params: { reason: string }) => `加载失败:${params.reason}`
}
export type DataMessages = typeof zhMessages
// 2. 创建实例(自动加入跨实例同步总线)
const dataLocale = new Locale<DataMessages>({
pack: { lang: 'zh-CN', messages: zhMessages },
name: 'data'
})
// 3. 翻译(类型安全)
dataLocale.t('data.loadSuccess') // → '加载成功'
dataLocale.t('data.loadFailed', { reason: '超时' }) // → '加载失败:超时'
// 4. 监听语言切换
const unsub = dataLocale.onChange((lang, from) => {
console.log(`data 语言切换为 ${lang},来源 ${from}`)
})
// 组件卸载时取消
unsub()
// 或批量清理
dataLocale.removeAllListeners()
```
### 创建独立实例(不参与总线同步)
```ts
const standalone = new Locale<MyMessages>({
pack: { lang: 'zh-CN', messages: zhMessages },
attachToBus: false, // 不加入跨实例同步总线
name: 'standalone'
})
// 其他实例 setLocale 不会联动 standalone
```
---
## 项目约定
本模块为**内部项目**,有且仅有项目维护者本人使用。所有子包调用方由同一开发者维护,可保证严格遵循 API 契约,不出现以下异常场景:
- 通过 `as any` 等绕过类型检查
- 销毁应用级单例实例
- 使用非标准语言标识格式
- 在 onChange 回调中发起新的 setLocale 调用
- 其他违反类型契约或运行时约定的操作
**优化方向**:保证调用方不进行异常操作 → 精简异常兜底代码 → 聚焦运行时性能。后续迭代中,仅对异常路径才能触发的防御性代码进行删除,减少不必要的运行时分支。
### 子包语言包结构约定
- **zh 包作为类型基准**:中文包同时承担类型 schema 定义和翻译数据两个职责,`en` 包通过 `typeof zhMessages` 推导类型约束,编译期保证 key 完全一致。
- **`MessageValue` 函数参数使用 `any`**:TypeScript 函数参数逆变约束,`unknown` 会导致具体参数类型无法赋值给 `MessageValue`。
---
## API 参考
### 构造函数
```ts
new Locale<T>(options: LocaleOptions<T>)
```
| 参数 | 类型 | 说明 |
|---|---|---|
| `options.pack` | `LocalePack<T>` | 默认语言包,自动注册并设为当前语言 |
| `options.name` | `string` | 实例名称,用于日志标识(`[Locale:name]`) |
| `options.attachToBus` | `boolean` | 是否加入跨实例同步总线,默认 `true`。传 `false` 创建完全独立的实例 |
### 语言管理
| 方法 | 签名 | 说明 |
|---|---|---|
| `setLocale` | `(lang: string, options?: { sync?: boolean }): boolean` | 切换语言。未注册返回 `false`;相同语言返回 `true` 但不触发 onChange;`sync: false` 仅切换自身不联动其他实例 |
| `getLocale` | `(): string` | 获取当前语言 |
| `hasLocale` | `(lang: string): boolean` | 判断语言是否已注册 |
| `getRegisteredLocales` | `(): string[]` | 获取所有已注册语言列表。返回顺序不保证顺序(实现依赖 `Object.keys`,当前为准插入顺序,但此行为不作为 API 契约) |
| `registerLocale` | `(pack: LocalePack<T>, replace?: boolean): boolean` | 注册语言包。已存在时 `replace=false` 拒绝返回 `false`,`replace=true` 替换 |
| `replaceLocale` | `(lang: string, msgs: T): void` | 替换整个语言包,影响当前语言时触发 onChange(`from='replace'`) |
| `removeLocale` | `(lang: string): boolean` | 移除语言包。禁止移除当前语言 |
### 消息操作
| 方法 | 签名 | 说明 |
|---|---|---|
| `t` | `<K extends keyof T & string>(key: K, ...params): string` | 翻译。key 缺失返回 key 本身并打印警告;函数消息出错时兜底返回 key 本身 |
| `getMessages` | `(lang?: string): T \| undefined` | 获取消息包(浅拷贝),不传取当前语言;未注册语言返回 `undefined` |
| `hasMessage` | `<K extends keyof T & string>(key: K, lang?: string): boolean` | 判断 key 是否存在于指定语言包 |
| `updateLocaleMessage` | `<K extends keyof T & string>(lang: string, key: K, value: T[K]): void` | 更新单个 key,影响当前语言触发 onChange(`from='update'`) |
| `batchUpdateLocale` | `(data: Record<string, Partial<T>>): void` | 批量更新多语言字段,影响当前语言触发一次 onChange |
### 监听器
| 方法 | 签名 | 说明 |
|---|---|---|
| `onChange` | `(cb: (lang: string, from: ChangeFrom) => void): () => void` | 监听语言切换/消息变更,返回取消函数 |
| `removeAllListeners` | `(): void` | 移除所有监听器,适用于组件卸载场景 |
> **onChange 重复注册去重行为**:监听器内部使用 `Set` 存储,同一回调函数重复注册只生效一次(Set 去重)。但每次调用 `onChange` 都会返回一个独立的取消函数,调用其中任一取消函数都会移除该监听器。此后其他取消函数再调用时为空操作(`Set.delete` 返回 `false`)。使用时避免对同一回调重复注册。
>
> **onChange 迭代中的增删行为**:`$notifyChange` 直接迭代 Set 通知监听器。在迭代过程中通过 `onChange` 新增的监听器会被当前通知轮次调用(Set 迭代器会访问已插入的新元素);通过取消函数删除的尚未被调用的监听器则不会被调用(Set 迭代器跳过已删除元素)。此行为由 Set 的标准迭代语义决定,不保证未来版本不变。
### 总线管理
| 方法 | 签名 | 说明 |
|---|---|---|
| `attachToBus` | `(): boolean` | 重新加入总线。已在总线中或同步过程中返回 `false` |
| `detachFromBus` | `(): boolean` | 脱离总线。已脱离或同步过程中返回 `false` |
### 环境判断
| 成员 | 签名 | 说明 |
|---|---|---|
| `Locale.isDev` | `static () => boolean` | 静态全局 dev 判断,由 env 模块统一覆盖 |
| `locale.isDev` | `() => boolean` | 实例级 dev 判断,默认委托 `Locale.isDev`。需自定义时直接覆写:`locale.isDev = () => yourChecker()` |
### ChangeFrom 类型
```ts
type ChangeFrom = 'set' | 'update' | 'replace' | 'sync'
```
| 值 | 含义 |
|---|---|
| `'set'` | 主动语言切换(`setLocale`) |
| `'update'` | 消息修改(`updateLocaleMessage` / `batchUpdateLocale`) |
| `'replace'` | 语言包替换(`replaceLocale`) |
| `'sync'` | 跨实例总线同步 |
> `removeLocale` 不触发 onChange(移除当前语言被禁止,移除其他语言对当前语言无影响)。若移除的是基准语言(`$baseLang`),校验基准会自动降级为当前语言(`$currentLang`),并输出 info 日志。后续注册新语言包时,key 完整性校验将以降级后的基准为准。
---
## 跨子包协作注意事项
### C1. 各子包构建时需配置 `sideEffects`
**背景**:各子包在模块顶层 `new Locale()` 创建实例(副作用代码)。当子包从源码发布迁移到构建产物发布时(参见 complex-suite/Todo.md §2),tsup/rollup 可能 tree-shake 掉纯副作用代码,导致 `new Locale()` 被移除、该子包不参与总线同步。
**当前状态**:各子包 `package.json` 尚未配置 `sideEffects`,但当前以 `.ts` 源码形式发布,暂无风险。
**构建发布时需执行**:
- 在每个子包 `package.json` 中标记 `"sideEffects": true` 或精确指定有副作用的文件(如 `"sideEffects": ["./src/locale/index.ts"]`)
- 构建时确认 tsup/rollup 不会移除顶层 `new Locale()` 调用
### C2. `@complex-suite/*` 包版本一致性要求
**要求**:所有 `@complex-suite/*` 包必须版本一致。若消费者安装了不同版本的 `@complex-suite/utils`,可能出现两个 `Locale` 类,各自的静态 `$bus` 独立,导致 A 实例的 `setLocale` 不会同步到 B 实例(总线分裂)。
**当前保证方式**:`workspace:*` 在 publish 时自动替换为具体版本号,版本一致时 pnpm/npm 自动去重(参见 complex-suite/Todo.md §2.4)。
**待补充**:后期考虑在 dev 模式下添加多版本检测机制(如 `globalThis.__complexSuiteLocaleBus` 已存在则复用),在多包生态扩展时尤为重要。
### C3. Locale 实例创建时机约束
**约束**:Locale 实例必须在所有使用 `t()` 的模块之前创建。子包通常在模块顶层 `const xxxLocale = new Locale(...)` 创建实例,如果某个模块在实例创建前就调用了 `t()`,会报错(实例未初始化)。
**保证方式**:通过 ES Module 的 import 顺序保证(`import` 语句按依赖图解析,副作用代码在模块首次加载时执行)。子包开发者需确保 locale 文件的 import 位于其他业务模块之前。
### C4. 跨实例同步直接遍历总线
**行为**:`setLocale` 触发总线同步时,直接遍历 `Locale.$bus`(Set 迭代)。由 `$syncing` 全局锁保证遍历期间总线成员不被修改,因此无需数组快照。
- `$syncing` 锁期间调用 `setLocale` / `detachFromBus` / `attachToBus` 均会被拦截(返回 `false`),因此回调中无法修改总线成员或发起新一轮同步。
**使用建议**:如需在回调中动态调整总线成员,应在异步上下文中执行(`setTimeout` / `Promise.resolve().then`),以避开当前同步轮次。
---
## 设计决策(请勿提出相关优化建议)
### D3. `t()` 不提供 fallback 机制 — 故意设计
key 缺失时直接返回 key 字符串,保证调用方能及时发现翻译遗漏,而非静默使用其他语言文案。需要回退行为的场景由调用层自行处理。
### D4. 不在 `updateLocaleMessage` / `batchUpdateLocale` 中显式调用 `$refreshMessageRefs` — 故意设计
`$currentMessages` 是对 `$messages[currentLang]` 的浅引用,修改原对象属性后引用自动可见,无需刷新。`updateLocaleMessage` 和 `batchUpdateLocale` 均通过 `this.$messages[lang][key] = v` 修改原对象属性,`$currentMessages` 作为同一对象的引用变化自动可见,因此两者都不调用 `$refreshMessageRefs()`,行为统一。
### D5. key 完整性校验仅检测缺失 key,不检测多余 key — 故意设计
多余 key 不影响运行(`t()` 只访问 schema 中定义的 key),且允许各语言包保留扩展条目以备未来使用。缺失 key 会导致 `t()` 返回 key 字符串,才是需要及时发现的问题。
### D6. 不提供语言标识归一化 — 故意设计
Locale 不对语言标识做任何格式转换,调用方需自行保证传入标准格式(如 `zh-CN`、`en-US`)。归一化掩盖了调用方格式不一致的问题,删除后不一致会在跨实例同步时立即暴露,强制各子包从源头统一语言标识规范。
### D7. 不提供 `destroy()` 销毁方法 — 故意设计
Locale 实例为应用级单例,各子包实例从创建到页面关闭始终存活,没有"用完销毁"的场景。一旦销毁,多语言功能永久失效,跨实例总线同步也被割裂。总线清理由 `detachFromBus()` 承担。监听器与实例同生命周期,无需提前释放。
### D8. `t()` 中 `$currentMessages!` 非空断言不做运行时防御 — 故意设计
`$currentMessages` 在构造函数中初始化,之后仅在 `setLocale`(已校验语言存在)、`replaceLocale`(替换当前语言后刷新)中更新。`removeLocale` 禁止移除当前语言,因此正常使用下不可能为 null。若为 null 则说明使用者破坏了内部不变量,不属于库层应防御的范畴。添加 null 检查会引入额外分支和性能开销,且掩盖调用方的错误用法。
### D9. `t()` 函数消息出错在生产环境完全静默 — 故意设计
i18n 函数的契约是"返回字符串",调用方通常不对 `t()` 包 try/catch。生产环境抛错会导致组件渲染中断、白屏等严重后果,因此必须兜底返回 key。日志静默是设计要求——生产环境不应因翻译函数的内部错误产生控制台噪音,问题应在开发阶段通过 dev 日志发现并修复。
### D10. `t()` 不校验函数消息返回值类型 — 故意设计
`MessageValue` 类型约束已规定函数签名为 `(params: any) => string`,遵循类型契约时返回值恒为 string。校验返回值类型属于为使用者的类型违规做防御,引入额外运行时开销。遵循类型约束是使用者的责任,库层不应为此买单。
### D11. `$syncing` 全局锁对 `sync: false` 同样拦截 — 故意设计
`$syncing` 为全局同步锁,语义是"当前正在执行跨实例同步,期间禁止任何 `setLocale` 调用"。即使 `sync: false`(仅切换自身),调用者此时处于某个 `onChange` 回调中(正在响应同步),在回调中改变自身语言状态会导致该回调后续逻辑基于不一致的状态运行,属于错误用法。本模块为内部项目,调用方可保证不在 `onChange` 回调中调用 `setLocale`,因此不存在 `sync: false` 被误伤的真实场景。不对此做特殊放宽,保持 `$syncing` 语义简洁统一。
### D12. `registerLocale(replace=true)` 不执行 `$baseLang` 基准校验 — 故意设计
`registerLocale` 以 `$baseLang` 为基准校验新增语言包的 key 完整性,保证新语言包与全局 schema 一致。但当 `replace=true` 替换已有语言包时,内部委托 `replaceLocale` 执行,遵循替换语义——`replaceLocale` 以被替换语言自身的旧包为基准做回归检测,并以 `$baseLang` 为基准做交叉校验(防止替换后与全局基准偏离累积)。`registerLocale` 自身的 `$baseLang` 校验仅适用于新增语言包场景,替换场景的校验由 `replaceLocale` 统一负责,职责分离更清晰。
---
## 待优化项与已决定项
### 1. ~~`$busRemove` / `$busHas` 线性扫描优化~~(已取消)
**取消原因**:D7 明确实例为应用级单例,从创建到页面关闭始终存活。总线上实例数 N = 子包数(≤ 10),O(n) 线性扫描实际开销可忽略。引入 WeakMap 反查索引反而增加了代码复杂度,收益为零。
### 2. ~~跨实例同步泛型强转~~(已通过类合并消除)
**结论**:原 `entry as Locale<T>` 强转已消除。
**分析**:合并 `LocaleBase` 入 `Locale` 后,`$bus` 类型改为 `Set<Locale<any>>`,遍历时直接以 `Locale<any>` 访问 `$currentLang` / `hasLocale` / `$applyLocaleChange` 等成员,同类实例间私有成员可正常访问,无需强转。
### 3. ~~`registerLocales` 批量注册方法~~(已删除)
**已删除**:该方法为 `registerLocale` 的简单循环包装,增加了"部分成功语义"的认知负担。调用方可自行循环调用 `registerLocale` 获取更精确的控制。删除后减少 API 表面和测试维护量。
### 4. `$log` 调用点模板字符串提前求值(不实施)
**现状**:所有 `$log` 调用点使用模板字符串构建消息(如 `` this.$log('warn', `key "${key}" 在语言 "${this.$currentLang}" 中不存在。`) ``),模板字符串在函数调用前求值,即使 `$log` 内部 `isDev()` 返回 `false` 也会执行字符串拼接。
**分析**:生产环境 key 缺失路径(不应发生但可能)存在不必要的字符串分配。但实测函数包装(`() => string`)反而比直接构建字符串更消耗性能,前置 `if (this.isDev())` 判断虽零开销但需每个调用点重复。该路径仅在生产环境 key 缺失时触发(属于异常路径),性能影响微乎其微,不值得为此增加代码复杂度。
/**
* 消息值类型:字符串或函数
* 函数参数必须使用 any,因为 TypeScript 函数参数逆变约束:
* - unknown 会导致具体参数类型(如 { id: string })无法赋值给 MessageValue
* - never 同理也不可行(never 是所有类型的子类型,逆变后具体函数无法赋值给 never 参数函数)
* 已通过 tsc --strict 验证:unknown 方案会产生 TS2344 类型报错
*/
export type MessageValue = string | ((params: any) => string)
/**
* 展平后的消息映射:dot-key -> MessageValue
*/
export type FlatMessages = Record<string, MessageValue>
/**
* 语言包结构
* @typeParam T 消息 schema 类型,约束 messages 的类型
*/
export interface LocalePack<T extends FlatMessages = FlatMessages> {
lang: string
messages: T
}
/**
* Locale 构造函数选项
* @typeParam T 消息 schema 类型
*/
export interface LocaleOptions<T extends FlatMessages = FlatMessages> {
/** 默认语言包,自动注册并设为当前语言 */
pack: LocalePack<T>
/** 是否加入跨实例同步总线,默认 true。传 false 时创建完全独立的实例 */
attachToBus?: boolean
/** 实例名称,用于日志标识,便于多实例调试 */
name: string
}
/**
* 变更来源:'set' 主动语言切换,'update' 消息修改,'replace' 语言包替换,'sync' 跨实例总线同步
* 注:removeLocale 不触发 onChange(移除当前语言被禁止,移除其他语言对当前语言无影响)
*/
export type ChangeFrom = 'set' | 'update' | 'replace' | 'sync'
import type { LocalePack } from './types'
/**
* zh 基准消息对象(铺平格式,key 使用 dot-notation)。
*
* ⚠️ 架构约定:本文件是 utils 包消息 schema 的【唯一类型来源】。
* UtilsMessages 类型由 zhMessages 结构推导而来,en 等其他语言包必须实现
* UtilsMessages 类型以保证 key 集合一致。新增/修改 key 请先在此处变更,
* 其他语言包会因类型不匹配在编译期报错,从而强制同步。此为有意设计,请勿重构为独立 schema 类型文件。
*/
export const zhMessages = {
'color.invalidFormat': '颜色格式无效',
'env.resetChange': (params: { env: string | number, info?: string | number }) => `[resetEnvData:触发目标真实环境为${params.env}的数据变更!][${params.info ?? '-'}]`,
'reactive.objFormatError': 'defineReactive函数错误,obj需要对象格式',
'reactive.optionFormatError': 'defineReactive函数错误,option需要对象格式',
'reactive.descriptorError': 'defineReactive函数运行错误,obj的原descriptor配置中getter和setter未能同时配置,无法实现响应式',
'promise.triggerError': 'triggerPromise函数运行错误,未正确传递Promise',
'string.paramNotExist': 'str/target参数不存在',
'life.notExist': (params: { id: string | number }) => `不存在当前值(${params.id})`,
'life.existCallback': (params: { id: string | number }) => `存在当前回调:${params.id}`,
'life.prefix': '生命周期'
}
/** utils 包消息 schema 类型,基于 zhMessages 推导 */
export type UtilsMessages = typeof zhMessages
const zh: LocalePack<UtilsMessages> = {
lang: 'zh-CN',
messages: zhMessages
}
export default zh
/**
* 判断对象是否存在指定的自身属性(不检查原型链)
* @param data 目标对象
* @param key 属性键
* @returns data 是否存在自身属性 key
*/
function hasOwnKey(data: object, key: PropertyKey): boolean {
return Object.prototype.hasOwnProperty.call(data, key)
}
export default hasOwnKey
+22
-0

@@ -43,2 +43,3 @@ // class加载

import hasProp from './src/object/hasProp'
import hasOwnKey from './src/object/hasOwnKey'
import jsonToForm from './src/object/jsonToForm'

@@ -117,2 +118,12 @@ import mergeData from './src/object/mergeData'

// locale加载
// 注意:实例 isDev 默认委托 Locale.isDev(),采用晚期绑定(调用时才解析),
// 因此 env 模块对 Locale.isDev 的覆盖无论发生在 utilsLocale 创建前后,实例均能命中覆盖后的版本,
// 不依赖 import 顺序。env 与 locale 之间为循环依赖(env 顶层 import utilsLocale),
// 由 ES Module 解析机制处理,此处保持 env 在 locale 之前的 import 顺序仅为可读性。
import Locale from './src/locale/Locale'
import { utilsLocale } from './src/locale/index'
import type { LocalePack, LocaleOptions, MessageValue, FlatMessages, ChangeFrom } from './src/locale/types'
import type { UtilsMessages } from './src/locale/zh'
// worker加载

@@ -182,2 +193,3 @@ import startWorker, { type funcType as StartWorkerFuncType, type optionType as StartWorkerOptionType } from './src/worker/startWorker'

hasProp,
hasOwnKey,
jsonToForm,

@@ -267,2 +279,12 @@ mergeData,

// locale
Locale,
utilsLocale,
type LocalePack,
type LocaleOptions,
type MessageValue,
type FlatMessages,
type ChangeFrom,
type UtilsMessages,
// worker

@@ -269,0 +291,0 @@ startWorker,

+5
-1
{
"name": "@complex-suite/utils",
"version": "3.0.5",
"version": "3.0.6",
"description": "a complex utils",
"type": "module",
"main": "index.ts",
"exports": {
".": "./index.ts",
"./locale/en": "./src/locale/en.ts"
},
"repository": {

@@ -8,0 +12,0 @@ "type": "git",

+6
-5
import _Data from "./_Data"
import exportMsg, { consoleType } from "../utils/exportMsg"
import exportMsg, { type consoleType } from "../utils/exportMsg"
import { utilsLocale } from '../locale'

@@ -75,3 +76,3 @@ let lifeId = 0

} else {
this.$exportMsg(`不存在当前值(${id})`)
this.$exportMsg(utilsLocale.t('life.notExist', { id }))
}

@@ -92,3 +93,3 @@ }

$exportMsg(content: string, type: consoleType = 'error') {
return exportMsg(`生命周期:${this.prop}:${content}`, type)
return exportMsg(`${utilsLocale.t('life.prefix')}:${this.prop}:${content}`, type)
}

@@ -112,3 +113,3 @@ }

if (lifeValueInitOption.id && this.get(lifeValueInitOption.id) && !lifeValueInitOption.replace) {
this.$exportMsg(`存在当前回调:${lifeValueInitOption.id}`)
this.$exportMsg(utilsLocale.t('life.existCallback', { id: lifeValueInitOption.id }))
} else {

@@ -178,3 +179,3 @@ const lifeValue = new LifeValue(lifeValueInitOption, this)

if (lifeValueInitOption.id && this.get(lifeValueInitOption.id) && !lifeValueInitOption.replace) {
this.$exportMsg(`存在当前回调:${lifeValueInitOption.id}`)
this.$exportMsg(utilsLocale.t('life.existCallback', { id: lifeValueInitOption.id }))
} else {

@@ -181,0 +182,0 @@ const lifeValue = new LifeValue(lifeValueInitOption, this)

import { LifeList } from "../class/LifeData"
import $exportMsg from "../utils/$exportMsg"
import { type consoleType } from "../utils/exportMsg"
import { utilsLocale } from '../locale'
import Locale from '../locale/Locale'

@@ -39,3 +41,3 @@ const option = {

if (getEnv('real') === env) {
$exportMsg(`[resetEnvData:触发目标真实环境为${env}的数据变更!][${info || '-'}]`, type)
$exportMsg(utilsLocale.t('env.resetChange', { env, info: info ?? '' }), type)
return fn()

@@ -48,1 +50,14 @@ }

}
// 自动设置 locale 的开发环境判断逻辑(引用 env 模块时自动生效)
// real 未设置时回退到 process.env.NODE_ENV 判断,避免初始状态下 dev 日志被静默
Locale.isDev = () => {
const real = getEnv('real')
if (real) {
return real === 'development'
}
if (typeof process !== 'undefined' && process.env) {
return process.env.NODE_ENV !== 'production'
}
return false
}
import $exportMsg from '../utils/$exportMsg'
import isPromise from './../type/isPromise'
import triggerFunction from './triggerFunction'
import { utilsLocale } from '../locale'

@@ -45,3 +46,3 @@ type UnpackPromise<T> = T extends Promise<infer R> ? R : T

triggerFunction(error, 'notPromise')
$exportMsg(`triggerPromise函数运行错误,未正确传递Promise`)
$exportMsg(utilsLocale.t('promise.triggerError'))
}

@@ -48,0 +49,0 @@ }

import getComplexType from '../type/getComplexType'
import $exportMsg from '../utils/$exportMsg'
import { utilsLocale } from '../locale'

@@ -25,7 +26,7 @@ export type defineReactiveOptionType = {

if (getComplexType(obj) != 'object') {
$exportMsg('defineReactive函数错误,obj需要对象格式')
$exportMsg(utilsLocale.t('reactive.objFormatError'))
return false
}
if (typeof option != 'object') {
$exportMsg('defineReactive函数错误,option需要对象格式')
$exportMsg(utilsLocale.t('reactive.optionFormatError'))
return false

@@ -78,3 +79,3 @@ }

} else {
$exportMsg('defineReactive函数运行错误,obj的原descriptor配置中getter和setter未能同时配置,无法实现响应式')
$exportMsg(utilsLocale.t('reactive.descriptorError'))
return false

@@ -81,0 +82,0 @@ }

import $exportMsg from '../utils/$exportMsg'
import { utilsLocale } from '../locale'

@@ -48,3 +49,3 @@ /**

} else {
$exportMsg('str/target参数不存在')
$exportMsg(utilsLocale.t('string.paramNotExist'))
return []

@@ -51,0 +52,0 @@ }

import exportMsg from "./exportMsg"
import { utilsLocale } from '../locale'

@@ -43,3 +44,3 @@ const rgbRegex = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/

}
exportMsg('颜色格式无效')
exportMsg(utilsLocale.t('color.invalidFormat'))
}

@@ -46,0 +47,0 @@ }