1. 为什么需要手写i18n方案
在Vue3项目中实现国际化(i18n)时,很多开发者第一反应是直接使用vue-i18n这类成熟库。但当我们面对简单场景时,手写一个极简i18n方案反而更有优势。我最近在一个轻量级后台管理系统中就遇到了这种情况——项目只需要支持中英文切换,引入完整的vue-i18n显得过于臃肿。
手写方案的核心价值在于:
- 零依赖,打包体积减少约30KB(vue-i18n的最小gzip后体积)
- 避免学习复杂API,自定义的API更贴合项目需求
- 可以按需实现功能,比如我们只需要文本替换不需要日期/数字格式化
- 对TypeScript支持更灵活,类型定义完全可控
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础架构设计
2.1 语言包结构设计
我们采用JSON作为语言包格式,这是最通用的i18n数据格式。在src/locales目录下创建:
code复制locales/
├── en.json
└── zh-CN.json
示例en.json内容:
json复制{
"buttons": {
"confirm": "Confirm",
"cancel": "Cancel"
},
"messages": {
"welcome": "Welcome back, {name}!"
}
}
这种嵌套结构比扁平化设计更易维护,特别是当文案数量超过50条时。通过命名空间(如buttons、messages)可以避免命名冲突。
2.2 核心类实现
创建一个I18n类作为核心,使用Composition API风格编写:
typescript复制// src/utils/i18n.ts
type LocaleMessages = Record<string, any>
class I18n {
private locale: string
private messages: LocaleMessages
constructor(options: { locale: string; messages: LocaleMessages }) {
this.locale = options.locale
this.messages = options.messages
}
t(key: string, params?: Record<string, any>): string {
const keys = key.split('.')
let value = this.messages
for (const k of keys) {
if (!value[k]) return key // 回退到key本身
value = value[k]
}
// 处理插值
if (typeof value === 'string' && params) {
return value.replace(/\{(\w+)\}/g, (_, p1) => params[p1] || '')
}
return value
}
setLocale(locale: string): void {
this.locale = locale
}
getLocale(): string {
return this.locale
}
}
这个基础实现已经包含了:
- 嵌套key解析(如"buttons.confirm")
- 模板插值(如"Welcome {name}")
- 语言切换能力
- TypeScript类型安全
3. Vue3集成方案
3.1 提供全局i18n实例
在main.ts中初始化并注入:
typescript复制// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import en from './locales/en.json'
import zh from './locales/zh-CN.json'
const i18n = new I18n({
locale: 'en',
messages: { en, zh }
})
const app = createApp(App)
app.provide('i18n', i18n)
app.mount('#app')
3.2 在组件中使用
选项式API用法
vue复制<script>
export default {
inject: ['i18n'],
methods: {
getText(key) {
return this.i18n.t(key)
}
}
}
</script>
<template>
<button>{{ getText('buttons.confirm') }}</button>
</template>
组合式API用法(推荐)
创建useI18n组合函数:
typescript复制// src/composables/useI18n.ts
import { inject } from 'vue'
export function useI18n() {
const i18n = inject<I18n>('i18n')
if (!i18n) throw new Error('i18n not provided')
return {
t: i18n.t.bind(i18n),
setLocale: i18n.setLocale.bind(i18n),
getLocale: i18n.getLocale.bind(i18n)
}
}
组件中使用:
vue复制<script setup>
import { useI18n } from '@/composables/useI18n'
const { t } = useI18n()
</script>
<template>
<p>{{ t('messages.welcome', { name: 'John' }) }}</p>
</template>
4. 高级功能实现
4.1 语言热切换
在语言切换按钮组件中:
vue复制<script setup>
import { useI18n } from '@/composables/useI18n'
const { setLocale, getLocale } = useI18n()
const toggleLocale = () => {
const newLocale = getLocale() === 'en' ? 'zh-CN' : 'en'
setLocale(newLocale)
localStorage.setItem('user-locale', newLocale) // 持久化
}
</script>
4.2 异步加载语言包
对于大型项目,可以按需加载语言包:
typescript复制class I18n {
// ...原有代码...
async switchLocale(locale: string) {
const messages = await import(`@/locales/${locale}.json`)
this.messages = messages.default
this.locale = locale
}
}
4.3 类型安全增强
为语言包创建类型定义:
typescript复制// src/locales/types.ts
type MessageSchema = {
buttons: {
confirm: string
cancel: string
}
messages: {
welcome: string
}
}
declare module '@/utils/i18n' {
interface I18n {
t<K extends keyof MessageSchema>(key: K): string
}
}
这样在调用t()方法时会有自动补全和类型检查。
5. 性能优化技巧
5.1 生产环境移除未使用语言
使用unplugin-auto-import自动按需引入:
javascript复制// vite.config.js
import AutoImport from 'unplugin-auto-import/vite'
export default {
plugins: [
AutoImport({
imports: [
{
'@/locales': [
['*', 'locales'] // 按需引入语言包
]
}
]
})
]
}
5.2 编译时处理
通过vite插件在构建时静态分析模板中的i18n key:
javascript复制// vite.config.js
export default {
plugins: [
{
name: 'i18n-transform',
transform(code, id) {
if (!id.endsWith('.vue')) return
// 匹配模板中的i18n调用
const keys = code.match(/\$t\(['"](.+?)['"]\)/g)
if (keys) {
// 生成预加载代码...
}
}
}
]
}
5.3 响应式优化
避免在模板中频繁调用t()方法:
vue复制<script setup>
import { computed } from 'vue'
import { useI18n } from '@/composables/useI18n'
const { t } = useI18n()
const buttonText = computed(() => t('buttons.confirm'))
</script>
<template>
<button>{{ buttonText }}</button>
</template>
6. 常见问题解决方案
6.1 动态key处理
当key需要动态生成时:
typescript复制const dynamicKey = `buttons.${isSubmit ? 'submit' : 'cancel'}`
t(dynamicKey) // 需要类型断言
更安全的做法是提前定义所有可能的key。
6.2 缺失key处理
增强t()方法,添加开发环境警告:
typescript复制t(key: string, params?: Record<string, any>): string {
if (process.env.NODE_ENV === 'development') {
if (!this._keyExists(key)) {
console.warn(`Missing i18n key: ${key}`)
}
}
// ...原有实现...
}
private _keyExists(key: string): boolean {
const keys = key.split('.')
let value = this.messages
for (const k of keys) {
if (!value[k]) return false
value = value[k]
}
return typeof value === 'string'
}
6.3 复数处理
简单实现复数形式:
typescript复制t(key: string, count?: number): string {
if (count !== undefined) {
const pluralKey = `${key}.${count === 1 ? 'singular' : 'plural'}`
const pluralMessage = this._getMessage(pluralKey)
if (pluralMessage) {
return pluralMessage.replace('{count}', count.toString())
}
}
return this._getMessage(key)
}
语言包配置:
json复制{
"messages": {
"apple": {
"singular": "1 apple",
"plural": "{count} apples"
}
}
}
7. 测试策略
7.1 单元测试
使用vitest测试核心功能:
typescript复制import { describe, it, expect } from 'vitest'
import { I18n } from '@/utils/i18n'
describe('i18n', () => {
const messages = {
test: {
hello: 'Hello {name}'
}
}
const i18n = new I18n({
locale: 'en',
messages: { en: messages }
})
it('should translate simple key', () => {
expect(i18n.t('test.hello', { name: 'World' })).toBe('Hello World')
})
it('should fallback to key when missing', () => {
expect(i18n.t('test.missing')).toBe('test.missing')
})
})
7.2 组件测试
测试组件中的i18n集成:
typescript复制import { render } from '@testing-library/vue'
import { createI18n } from '@/utils/i18n'
import MyComponent from '@/components/MyComponent.vue'
describe('MyComponent', () => {
it('renders translated text', () => {
const { getByText } = render(MyComponent, {
global: {
provide: {
i18n: createI18n({
locale: 'en',
messages: {
en: { greeting: 'Hello' }
}
})
}
}
})
expect(getByText('Hello')).toBeTruthy()
})
})
7.3 E2E测试
使用Cypress测试语言切换:
javascript复制describe('i18n switch', () => {
it('changes language', () => {
cy.visit('/')
cy.contains('button', 'English').click()
cy.contains('Welcome').should('exist')
cy.contains('button', '中文').click()
cy.contains('欢迎').should('exist')
})
})
8. 与vue-i18n的对比
8.1 功能对比
| 功能 | 手写方案 | vue-i18n |
|---|---|---|
| 基本翻译 | ✅ | ✅ |
| 插值 | ✅ | ✅ |
| 复数 | 基本 | 完善 |
| 日期/数字格式化 | ❌ | ✅ |
| 类型安全 | 完全可控 | 需要配置 |
| 打包体积 | <1KB | ~30KB |
| 动态加载 | 手动实现 | 内置 |
8.2 适用场景建议
使用手写方案当:
- 项目简单,只需要文本翻译
- 对包体积敏感
- 需要完全控制实现细节
- 项目使用TypeScript且需要精确类型
使用vue-i18n当:
- 需要完整i18n功能(日期、数字等)
- 项目复杂,需要内置的规模化方案
- 需要与Vue生态其他工具集成
- 团队已经熟悉vue-i18n API
9. 扩展思路
9.1 服务端渲染(SSR)支持
在server端创建i18n实例:
typescript复制// server-entry.ts
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import App from './App.vue'
import { I18n } from './utils/i18n'
export async function render(url: string) {
const i18n = new I18n({
locale: detectLocaleFromRequest(url),
messages: loadLocaleMessages()
})
const app = createSSRApp(App)
app.provide('i18n', i18n)
const html = await renderToString(app)
return { html, initialState: { locale: i18n.getLocale() } }
}
9.2 构建时提取key
编写脚本自动提取模板中的i18n key:
javascript复制// scripts/extract-i18n-keys.js
const fs = require('fs')
const path = require('path')
const keys = new Set()
// 遍历所有Vue文件
function walk(dir) {
fs.readdirSync(dir).forEach(file => {
const fullPath = path.join(dir, file)
if (fs.statSync(fullPath).isDirectory()) {
walk(fullPath)
} else if (fullPath.endsWith('.vue')) {
const content = fs.readFileSync(fullPath, 'utf-8')
const matches = content.match(/\$t\(['"](.+?)['"]\)/g)
if (matches) {
matches.forEach(match => {
const key = match.slice(3, -1)
keys.add(key)
})
}
}
})
}
walk('src')
fs.writeFileSync('i18n-keys.json', JSON.stringify([...keys], null, 2))
9.3 IDE插件开发
为VS Code开发插件提供:
- 语言包key自动补全
- 未定义key警告
- 快速跳转到定义
- 一键提取模板中的文本到语言包
json复制// package.json片段
{
"contributes": {
"languages": [{
"id": "i18n-json",
"filenames": ["*.i18n.json"]
}],
"commands": [{
"command": "extension.extractI18n",
"title": "Extract text to i18n"
}]
}
}
10. 实际项目经验
在最近的后台项目中,我遇到了几个值得分享的案例:
案例1:动态模块加载
某个功能模块需要独立维护自己的语言包。我们通过约定目录结构实现了自动加载:
code复制modules/
user/
locales/
en.json
zh-CN.json
product/
locales/
en.json
zh-CN.json
在模块注册时自动合并语言包:
typescript复制function registerModule(module) {
const locales = import.meta.glob(`../modules/${module}/locales/*.json`)
for (const [path, loader] of Object.entries(locales)) {
const lang = path.match(/\/([a-z-]+)\.json$/i)[1]
const messages = await loader()
i18n.mergeMessages(lang, messages)
}
}
案例2:文案热更新
运营人员需要不经过发版就能修改文案。我们实现了:
- 开发环境读取本地语言包
- 生产环境优先从API获取最新文案
- 通过WebSocket监听文案变更
typescript复制if (import.meta.env.PROD) {
fetch('/api/i18n-messages')
.then(res => res.json())
.then(messages => {
i18n.updateMessages(messages)
})
const ws = new WebSocket('/i18n-updates')
ws.onmessage = (event) => {
const { lang, key, value } = JSON.parse(event.data)
i18n.updateMessage(lang, key, value)
}
}
案例3:多团队协作
当多个团队共同开发时,我们通过以下方式避免冲突:
- 使用命名前缀:
teamA.login.title - 自动化key冲突检测
- 定期合并重复文案
- 建立文案变更审批流程
bash复制# 冲突检测脚本示例
find src -name '*.vue' -exec grep -E '\$t\(['\'"][^'\'"]+['\'"]\)' {} \; |
awk -F'"' '{print $2}' |
sort |
uniq -c |
awk '$1>1 {print "冲突key: "$2}'
