侧边栏

将vite项目进行国际化改造

发布于 | 分类于 前端/前端业务

最近需要将已有的vite项目改造为国际化版本(短期先支持中文和英文),之前对于i18n的实践不多,在一番折腾之后终于将多语言成功落地,本文用来回顾一下整个过程。

参考

什么是i18n

i18n 是 国际化(internationalization) 的缩写,表示在软件开发中为了使应用程序能够适应不同语言和文化而进行的一系列优化工作。

这个缩写中的 "i" 和 "n" 分别是单词 "internationalization" 的第一个字母和最后一个字母,中间的 "18" 代表这个单词中间有 18 个字母。

简单来说,i18n主要是为了让软件能够支持多种语言、日期格式、货币符号等,让软件可以在多个国家和地区之间推广。

i18n 通常与 l10n (本地化,localization)一同提及,l10n 指的是根据特定地区的文化和语言习惯进行的进一步优化,比如翻译、格式调整等。

i18n的原理很简单,其本质上就是做映射

js
const contentMap = {
    zh: '这是中文内容',
    en: 'This is in English'
    // ...其他语言
}
const locale = getLocale() // 根据运行环境判断当前语言
const content = contentMap[locale]

部分应用提供了选项,可以供用户选择自己偏好的语言;部分应用直接根据部署的站点,就提前预设了对应的语言,但本质上都是这一套映射的逻辑。

回到内容本身,又有两种策略,分别是按语言准备多套代码,或者一套代码,将其中多语言的部分进行局部映射

多套代码

这种场景适合整个应用比较偏纯文字的情况,比如各种文档提供的多语言。

比如vitepress官网提供的多语言选项

实际上对于每种语言,都准备了对应语言的文档内容,每种语言的文档独立维护

在切换语言时,只需要将路由重定向到该语言的路径下面即可

局部映射

在更常规的产品应用中,准备多套代码文件独立维护无疑是很困难的,代码文件往往包含业务逻辑,多套文件在逻辑维护上面无疑是灾难。

因此,通用的做法是将代码文件中需要进行多语言处理的部分,根据locale映射为期望地区的字符。

html
<p>{{ $t('message.hello') }}</p>

其中

js
const i18nMap = {
    zh: {
        'message.hello': '你好世界',
        // ... 其他需要映射的地方
    },
    en: {
        'message.hello': 'Hello world'
    }
}

这样,开发者只需要维护一套代码文件,以及一份多语言的映射文件即可,同时将源代码中使用单语言的部分,都通过$t(key)来进行替换

项目难点

对于我们的项目而言,显然使用局部映射是更好的做法,也是目前前端项目做i18n的主流方案。

由于项目使用的是vite+vue3的技术栈,因此可以使用vue-i18n这个库来实现国际化。但在实际落地的时候,却遇到了很多问题。

改动的地方多

整个项目使用的是monorepo架构,已经迭代了很长一段时间,有200+页面、近千个代码文件,跑了一下项目中需要进行处理的地方,包括

  • 文字
    • 静态文本
    • 有动态数据的文本
    • 考虑不同语法特性,比如单数复数等
  • 有文字内容的图片、图标等
  • 部分接口消息返回的错误消息,这个由后端去处理

深入到该项目的每个文件中,挨个替换文本为映射,工作量很大,而整个改造工作期限只有两周的时间,工期比较紧迫。

$t(key)形式不直观

其次,采用这种映射的方式,导致代码中存在大量的$t(key),需要为原本内容定义大量的key

  • 众所周知,软件开发中变量命名是一个很困难的问题;
  • 一个简写的key不如原始中文的可读性,从阅读源码的角度来看,后期维护也不太好。

性能问题

整个项目的翻译映射文件很大,而vue-i18n是在应用运行时来进行语言映射的。如果一开始就加载所有的翻译文件,就会造成资源的浪费

所幸其提供了延迟加载翻译的功能,可以按需加载所需的映射文件,不过需要我们按照一定格式组织翻译文件。

小结

如果项目从一开始就规划了国际化,在迭代过程中逐步添加语言映射,感受到的工作量可能还不是很明显,使用vue-i18n是可行的。

但对于目前要处理的项目,我需要考虑一种更有效的方案将整个单语言项目进行国际化升级。

方案设计

项目的第一个国际化版本分为中文站点和英文站点,使用不同域名,分别部署到对应站点,不会考虑在产品中向用户提供一个自由切换语言的选项。

基于这个特点,我开始思考是否可以在构建的时候就进行多语言的切换,而不是在程序运行时来处理国际化的问题。最终我采用了如下方案。

脚本标记

首先要解决的就是项目文件多,手动修改内容耗时非常长,这一步需要借助自动化来完成。

大致思路是:编写一个nodeJS脚本,该脚本会遍历整个项目的源文件,找到那些中文的内容,然后将其使用一个特殊的占位符i18n(xxx)进行标记

js
// 忽略注释中的中文
function checkComment(content, index) {
  let i = index
  while (i >= 0 && content[i] !== '\n') {
    if (content[i] === '/' && content[i - 1] === '/') {
      return true
    }
    i--
  }
  return false
}

// 忽略已经被手动替换的i18n(xxx)
function checkI18n(content, index) {
  let i = index
  while (i >= 0 && content[i] !== '\n') {
    if (content[i] === '(' && content.substring(i - 4, i) === 'i18n') {
      return true
    }
    i--
  }
  return false
}

const globalContent = {}
// 替换文件内容
function replaceContent(content) {
  const re = /[\u4e00-\u9fa5,]+/g

  const str = content.replace(re, function (match, index) {
    // 忽略注释
    if (checkComment(content, index)) {
      return match
    }
    if (checkI18n(content, index)) {
      return match
    }

    const key = `i18n(${match})`
    globalContent[key] = match
    return `i18n(${match})`
  })
  return str
}
// 遍历对应目录,最后将globalContent的内容保存到本地文件中

在这个过程中,比如代码文件中的

html
<button>开始</button>

会被替换为

html
<button>i18n(开始)</button>

在这一步,我们就得到了一个映射JSON

json
{
  "i18n(开始)": "开始"
}

这么做的好处是:避免了$t(key)带来的取名和不直观的问题,在源码中,还是可以很直观的看出原本占位出的中文含义,本质上就是将中文作为key。

这个脚本会替换代码中的所有中文内容,包括template中的模版、引号中的字符串等等,避免了手动逐个修改文件带来的工作量。

但在实际运行中,情况要复杂很多,比如中文内容是不连续的,中间还插入了英文、数字、其他HTML标签等情况。/[\u4e00-\u9fa5,]+/g这个简单的正则并无法完美处理要标记的内容,这种情况下可以手动修改源文件中要标记的地方。

由于整个项目很庞大,通过脚本一次性跑完所有文件,排查内容的时候也比较麻烦,我采用的策略是按模块进行替换,一次性的改动控制在10个文件以内,这样可以边运行脚本、边进行检测。

GPT自动翻译

拿到了需要替换的中文内容之后,就可以将这个映射拿去翻译,这里可以借助GPT之类的工具,也不用单独写额外的脚本了,我这里使用的claude

prompt: 接下来我会给你一份JSON,你需要维持其key不变,将其value翻译为英文,最后返回新的JSON

最终会得到一份英文映射

通过这一步,我们就可以得到多个语言的映射

js
{
  zh: {
    "i18n(开始)": "开始"
  },
  en: {
    "i18n(开始)": "Start"
  }
}

vite插件替换

在第一步中,通过脚本将需要替换的地方使用了占位符i18n(xxx)进行标记,由于要实现在构建时替换对应的国际化内容,可以通过vite插件的transform钩子来实现。

js
function unicodeToChinese(str) {
  return str.replace(/\\u[\dA-Fa-f]{4}/g, function (match) {
    return String.fromCharCode(parseInt(match.replace('\\u', ''), 16))
  })
}

export function i18nPlaceholderPlugin(locale = 'zh') {
  // 这里就是上面得到的映射JSON
  const filesMap = {
    zh,
    en,
  }
  const filename = filesMap[locale]
  if (!filename) {
    throw new Error(`找不到${locale}对应的文件内容`)
  }

  return {
    name: 'i18n-placeholder-plugin',
    transform(src, id) {
      if (/\.(vue|ts|tsx)$/.test(id)) {
        const words = filesMap[locale]
        // 约定对应的国际化语言占位符
        const code = src.replace(/i18n\(.*?\)/g, (match) => {
          const record = words[unicodeToChinese(match)]
          return record ?? match
        })

        return {
          code,
        }
      }
      return src
    },
  }
}

在启动的时候,通过环境变量读取当前的locale,vite本身提供了.env文件来配置环境变量

VITE_LOCALE = 'en'

vite.config.ts中,可以通过loadEnv读取到配置文件中的环境变量

ts
import { defineConfig, loadEnv } from 'vite'
import { i18nPlaceholderPlugin } from '@project/i18n/plugin'

export default defineConfig(({ mode }) => {
  const { VITE_LOCALE = 'zh'} = loadEnv(mode, process.cwd())

  return {
    plugins: [
      i18nPlaceholderPlugin(VITE_LOCALE),
      vue(),
    ],
  }
})

通过这个插件,原本在源码文件中的

<button>i18n(开始)</button>

就会被替换为

html
<button>Start</button>

这样,就完成了在构建阶段进行国际化的目标。

中文图片处理

对于那些包含中文的图片、图标等,也可以采用该方式进行处理,比如https://cdn.shymean.com/xxx_zh.png是一张包含中文的图片,就可以将源文件中改地址替换为

html
<img src="https://cdn.shymean.com/xxx_zh.png"/>

替换为

html
<img src="i18n(https://cdn.shymean.com/xxx_zh.png)"/>

在之后的映射文件中,将其映射为xxx_en.png

js
{
  zh: {
    "i18n(开始)": "开始",
    "i18n(https://cdn.shymean.com/xxx_zh.png)": 'https://cdn.shymean.com/xxx_zh.png'
  },
  en: {
    "i18n(开始)": "Start",
    "i18n(https://cdn.shymean.com/xxx_zh.png)": 'https://cdn.shymean.com/xxx_en.png'
  }
}

由于脚本无法识别具体要替换的图片,这一步只有通过手动来处理源文件中需要标记的地方。

动态文本

上面的自动化脚本,可以处理绝大部分静态文字的内容,但对于部分动态文字而言,比如

权益将于${expireDate}过期

这段文字,如果是通过脚本来编辑,则只能标记为i18n(权益将于)i18n(过期),跟具体的翻译是对不上的

The benefit will expire at ${expireDate}

这种情况下,还是需要借助vue-i18n

ts
import { createI18n } from 'vue-i18n'
export { useI18n } from 'vue-i18n'

export const i18n = createI18n({
  // something vue-i18n options here ...
  // @ts-ignore
  locale: getCurrentEnvLocale(),
  fallbackLocale: 'zh',
  messages: {
    zh,
    en,
  },
})

// 全局的翻译api,避免template和其他模块中调用不统一的情况
export function t(...args: any) {
  // @ts-ignore
  return i18n.global.t.call(i18n.global, ...args)
}

因此,整个项目中实际上存在两套国际化方案:存在i18n(xxx)静态标记的内容,和少量t(xxxKey, params)动态切换的内容。

如果不存在i18n(xxx)静态标记的内容,那整个项目相当于就回退到了vue-i18n的。

小结

借助这个方案,我花了三天时间就完成了项目中核心门户模块的国际化切换,感觉还是比较成功的,为我节省了国际化过程中大量适配工作,不需要手动挨个修改源文件的工作量(当然还是需要人工来检查一下标记的内容是否合理

国际化工作原理本身并不复杂,就是编写映射文件,在源码进行替换。

如果项目一开始就规划了国际化,在实际开发中并不会感受到明确的工作量,但对于已有的历史项目进行国际化改造,其成本就比较大了,如果你也存在类似的需求,希望上述的方案会给你一些参考。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。