自建国际化方案-语言包拆分/按需加载/缓存

951 阅读7分钟

背景

国际化多语言(i18n)支持是一个古老的需求,当下企业出海潮的大背景下是一项重要的基础功能。本文将结合自己产品的特点,探讨传统国际化方案的优缺点,并给出适合自己的方案。

原理

国际化的基本原理是通过映射,将同一个文案,映射成不同国家语言的过程,大部分是直接映射,当然少数情况会伴随着格式或顺序的变化

我们可以从最简单的解决方案讨论起,即我们设计一个 json 存储这个映射信息,然后在html里加载这个json,根据浏览器语言去动态映射

讨论

需要响应式吗?

响应式就是指,在页面上切换语言,不刷新页面就可以看到效果
大部分spa应用(react/vue等等)如果要做到响应式,在组件内部的文本,比较好实现,因为我们可以控制组件/应用的更新,但是还有少部分不在组件内的文本,例如一些枚举值, 一些函数内的文本,则比较麻烦,需要把这些文本改成可重复执行的响应式的

import { useTranslation } from 'react-i18next'

function TestComponent() {
  // 利用现成的库可以比较容易的实现响应式
  const { t } = useTranslation(['home'])
  return (
     <h2>{t('home')}</h2>
  )
}

// 某些函数内部的文本,需要特殊改造
function transform() {
 // something
 return 'error msg'
}

// 这种静态的映射也需要特殊改造
const customEnum = {
    code1: '编码1',
    code2: '编码2'
}

问题是,我们真的需要响应式吗?
切换语言这个行为本身还是比较低频,一般应用初始化的时候,根据浏览器设置或者应用设置,直接渲染对应语言即可,即使切换了语言,页面reload一下也无大碍,平衡工作量和体验来看,不做响应式也许对我们是更好的选择

谁来翻译?

大型的应用或者企业,都有专门的翻译团队来做文本的翻译。不过我们是初创公司,而且需要支持的语言非常有限,考虑到成本,还是选择让开发同学自己来做翻译,而且现在AI大行其道,针对一些场景化的文案也能翻译的很好。

谁来翻译文本其实决定了翻译的架构。

试想,如果是专业的翻译团队,肯定搭建一个中心化的翻译平台会更好,将各个业务,移动端,h5,pc都统一到一个平台来翻译会更舒服。

那么如果是开发同学自己来翻译呢?肯定更希望直接在代码仓库中填写翻译文件。
普通的json文件用作翻译存在几个体验问题:

  1. 没有自动提示
  2. 不能从组件内直接ctrl跳转到翻译
  3. 不能从翻译ctrl看到有哪些地方引用了该翻译

当然可以通用插件提升翻译体验,i18n-ally是我看到的非常好的vscode插件。

前端存储or服务端存储映射?

将 “语言包” 放在服务端有几个好处

  1. 修改方便,数据库里面修改完直接可以生效

坏处是

  1. 文件名带hash值的语言包是直接可以强缓存的,或者当文件名固定的时候使用协商缓存,而在服务端(数据库里面)存储,缓存做起来不太容易
  2. 一般静态资源(json映射文件)放到nginx上是有gzip压缩的,服务端返回翻译信息则不太好压缩

语言包拆分/按需加载

一个语言包可能需要几百k甚至上M,一次性加载可能会影响首屏性能。如果要拆分,可能有几种办法

  1. 手动拆分,约定好每个页面创建一个语言包文件,然后通过路由与语言包组件的绑定,动态加载
  2. 通过打包工具的某些插件,分析每个页面里用到哪些语言包的哪些块,将其打成不同的子语言包,然后动态加载

打包时需要把语言包独立出来吗?

两种方案

  1. 将语言包打成单独的文件,而且如果做了拆分/按需加载,那么每种语言可能会出现多个(子)语言包
  2. 直接将不同的语言打进js文件中,即有多少语言,就有多少js的不同版本

我觉得两种差别不大吧,我还是倾向于将语言包打成单独的文件,多了一些请求,但是看起来清楚一些

持续维护问题

语言包中的内容可以预见的会逐步增长,持续维护面临两个问题:

  1. 修改,例如产品经理指出某个组件内的文本有误,开发同学顺利找到组件,并找到了这个文本在语言包中的key,修改的时候一定要小心,因为这个key可能在其他地方也用到了,需要全局搜索一下
  2. 移除,组件可能会删除或者废弃,但遗留在语言包中的翻译一般不会跟着删除,除非我们定期清理,每个key都全局搜索一下,有没有引用,当然我们也可能上一些技术手段,例如treeshakeing

国际化方案

权衡之下,我们

  1. 放弃了响应式
  2. 选择了将 “语言包” 放到前端
  3. 希望实现语言包自动拆分/按需加载
  4. 通过语言包文件带hash值实现语言包http强缓存
  5. 语言包自动treeshaking,即使写到语言包里,如果没有地方引用,也不会打包进最终的产物中
  6. 希望有良好的开发体验,按着ctrl可以来回跳转

具体实现

先来看一下用法:

import { t } from '../../utils/getI18nText' // t 是工具函数,自动根据当前语言替换
import {
    text1,
    text2
} from './i18n' 
// i18n.ts 文件是约定的语言包文件,只要是以 i18n结尾的ts文件都会被视作语言包

// 场景1:固定值,直接使用,无需往里传
export const dictMap = {
    '1': t(text1),
    '2': t(text2)
}
export const text = t(text1)

// 场景2:函数内,直接使用,无需hook
export const pureFunction = () => {
    const flag = true
    if(flag) {
        return t(text1)
    } else {
        return t(text2)
    }
}

// 场景3:组件内,直接使用,无需hook
export default function Page1 () {
    return <div>page1 {t(text1)}</div>
}

///// i18n.ts 文件内容,可以看出就是普通的对象导出
export const text2 = {
    zh: '测试文本2',
    en: 'testText2'
}

export const text3 = {
    zh: '测试文本3',
    en: 'testText3'
}

export const text1 = {
    zh: '测试文本1',
    en: 'testText1'
}

image.png

image.png

这里 t 的内容:

const userLanguage = navigator.language || navigator.userLanguage;

const supportLan = ['zh', 'en']
// 初始化的时候确定当前浏览器语言,反正切换语言的时候会重新reload一下,不怕
let lan = supportLan.find(e => userLanguage.startsWith(e)) ?? 'zh'

// 运行时直接简单映射一下就行
export const t = (x: { [key: string]: string }) => {
    return x[lan]
}

可以想象,如果不加处理,那么语言都将打进产物包中,无法根据当前浏览器语言去加载不同的语言包
我们使用的是vite,项目是react,因此我们通过vite插件实现了这个功能

1725003243001.png

image.png

image.png

image.png

image.png 相关vite插件实现

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { nanoid } from 'nanoid'
import crypto from 'crypto'
import { parse } from '@babel/parser';
import generate from '@babel/generator';
import { traverse } from '@babel/core';

function I18nPlugin() {
  return {
    name: 'i18nPlugin',
    enforce: 'pre',
    generateBundle(outputOptions, bundle) {
      const lans = ['zh', 'en']
      // 遍历生成的所有 chunks
      for (const [fileName, chunkInfo] of Object.entries(bundle)) {
        if (chunkInfo.name.startsWith('i18n-')) {
          // 配合 manualChunks 中的设置,manualChunks 中将语言包的chunk命名为 i18n-xxx

          // 对 AST 进行操作
          lans.forEach(lan => {
            // 对文件内容进行 AST 解析
            const ast = parse(chunkInfo.code, {
              sourceType: 'module',
              plugins: ['jsx'] // 根据代码类型选择插件
            });
            traverse(ast, {
              ObjectExpression(path) {
                // 找到对象表达式(即 { zh: '测试文本1', en: 'testText1' } 形式的部分)
                path.node.properties = path.node.properties.filter(prop => {
                  // 保留 key 为 zh 的属性
                  return prop.key.name === lan;
                });
              }
            });

            // 生成修改后的代码
            const { code } = generate.default(ast);

            // 使用 emitFile 发出新的文件
            const newFileName = fileName.replace(/(i18n-)([a-z0-9]+-)/, `$1${lan}-$2`);

            this.emitFile({
              type: 'asset',
              fileName: newFileName,
              name: nanoid(5),
              source: code
            });
          })
        }
      }
    },
    transformIndexHtml(html, option) {
      const {
        bundle,
      } = option

      const fileList = []

      for (const [fileName, chunkInfo] of Object.entries(bundle)) {
        if (chunkInfo.name.startsWith('i18n-')) {
          fileList.push(fileName)
        }
      }
      const newHtml = html.replace(`<script createImportMap></script>`, `<script>
        const fileList = ${JSON.stringify(fileList)}
        const userLanguage = navigator.language || navigator.userLanguage;

        const supportLan = ['zh', 'en']
        let lan = supportLan.find(e => userLanguage.startsWith(e)) ?? 'zh'
        const map = {}
        fileList.forEach(fileName => {
          map[\`/\${fileName}\`] = \`/\${fileName.replace(/(.*i18n-)([a-z0-9]+-)/, \`$1\${lan}-$2\`)}\`;
        })
        const importMap = {
          imports: map,
        };
        const imp = document.createElement('script');
        imp.type = 'importmap';
        imp.textContent = JSON.stringify(importMap);
        document.currentScript.after(imp);
      </script>`)

      return newHtml
    },
  }
}


// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), I18nPlugin()],
  build: {
    modulePreload: false,
    rollupOptions: {
      // external: (id, importer: string | undefined, isResolved: boolean) => {
      //   const match = /.*i18n\.ts*/.test(id)
      //   return !!match 
      // },
      output: {
        manualChunks: (id: string, { getModuleInfo }: any) => {
          const match = /.*i18n\.ts*/.test(id)
          if (match) {
            const dependentEntryPoints = []
            const idsToHandle = new Set(getModuleInfo(id).importers)

            for (const moduleId of idsToHandle) {
              const { isEntry, dynamicImporters, importers } =
                getModuleInfo(moduleId)
              if (isEntry || dynamicImporters.length > 0) { dependentEntryPoints.push(moduleId) }
              for (const importerId of importers) idsToHandle.add(importerId)
            }

            const depStr = dependentEntryPoints.sort().join()
            const key = crypto.createHash('sha256').update(depStr).digest('hex').slice(0, 8);
            return `i18n-${key}`
          }
        }
      }
    }
  },
})