国际化探索:颗粒化方案

最近公司平台需国际化改造,以适配不同语言环境。基于特定需求,展开了一系列技术探索与实践。

但是平台的国际化需满足以下前提:

  • 不需要支持站内实时切换语言,需刷新页面完成切换。
  • 平台包含大量业务页面,导致语言包体积庞大且各页面语言包独立性高;故尽可能的切割语言包,如按路由拆分,按需获取。
  • 考虑到部分页面内容需要提前就需确定,无法采用异步加载语言包的方式。

调研

github.com/fnando/i18n
I18nextProvider | react-i18next documentation

在调研过程中,我们发现大多数 i18n 实现库都侧重于站内切换语言,这会导致同时加载多个语言包,随着页面增多,语言包体积膨胀,不符合我们的需求。

I18nextProvider | react-i18next documentation

不过,我们注意到了 I18nextProvider,它通过提供不同的 i18n 实例来加载不同的语言包,能在一定程度上实现路由间的分片加载,这启发了我们在第二版方案中实现类似的功能。

github.com/i18next/i18...

此外,对于异步加载语言包的方案,如将语言包放在 public 目录下,根据语言动态加载,虽然可行,但因一些限制,我们无法采用这种方式。

颗粒化方案

方案核心

方案的核心是将国际化颗粒度细化到极致,通过以下步骤实现:

  1. 极致颗粒度国际化:将每个文本片段都独立处理。
  2. 利用 Tree Shaking:通过 TypeScript 的特性,移除未使用的 i18n 变量,减少体积。
  3. vite 插件提取 i18n 内容:将语言包按需拆分。
  4. 打包压缩:通过变量压缩等手段进一步减小语言包体积。

i18n 实现

以取浏览器语言为例

typescript 复制代码
const userLanguage = navigator.language || navigator.userLanguage

const supportLan = ['zh', 'en']

export const lan = supportLan.find(e => userLanguage.startsWith(e)) ?? 'zh'

export const t = (x: { [key: string]: any }) => {
  return x[lan]
}

使用

语言包定义

typescript 复制代码
import { t } from './getI18nText

export const Button_Add_I18n = t({
  zh: '新增',
  en: 'Add',
})

export const Button_DeleteText_I18n = t({
  zh: '删除',
  en: 'Delete',
})

export const Button_EditText_I18n = t({
  zh: '编辑',
  en: 'Edit',
})

export const Button_ViewText_I18n = t({
  zh: '查看',
  en: 'View',
})

// ...

组件中使用

typescript 复制代码
const Xxx = () => {
  return (
    <Button type='primary'>
      {Button_Add_I18n}
    </Button>
  )
}

打包优化

  1. Tree Shaking:利用 TypeScript 的特性,过滤掉未使用的 i18n 变量,减少打包体积。

  2. 变量压缩:通过压缩工具,将变量名进行压缩,进一步减小体积。例如:

typescript 复制代码
export const Button_Add_I18n = t({
  zh: '新增',
  en: 'Add',
})

// 压缩后 -->
export const a = t({
  zh: '新增',
  en: 'Add',
})

语言拆分

利用 vite 配置和插件,将 i18n 语言包内容进行提炼和拆分。首先,通过 rollup 的 manualChunks 提取所有 i18n.ts 文件,并为其生成唯一的 chunk 名称。

vite.config.js

typescript 复制代码
module.exports = options => {
  return {
    // ...
    build: {
      rollupOptions: {
        output: {
          manualChunks: (id, { getModuleInfo }) => {
	          // 找到 i18n.ts 文件
            const match = /.*i18n\.ts*/.test(id)
            if (match) {
              // 存储所有依赖此i18n文件的入口点
              const dependentEntryPoints = []
              // 使用Set避免重复处理模块,初始为当前模块的直接引用者
              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)
              // 返回格式化的chunk名称,例如:i18n-3a7f5c2d
              return `i18n-${key}`
            }
          },
        },
      },
    },
  }
}

打包之后,我们就能看到生成的 i18n 的文件:

然后,利用 vite 插件对这些 chunk 进行处理,将其中的语言包内容拆解成单独的语言文件,如 i18n-zh-*.jsi18n-en-*.js,实现单语言加载。

i18nPlugin.js

typescript 复制代码
const { traverse } = require('@babel/core')
const generate = require('@babel/generator')
const { parse } = require('@babel/parser')

function RandomString(length) {
  const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
  const digits = '0123456789'
  const allCharacters = letters + digits

  let result = letters.charAt(Math.floor(Math.random() * letters.length))
  for (let i = 1; i < length; i++) {
    result += allCharacters.charAt(Math.floor(Math.random() * allCharacters.length))
  }

  return result
}

const lans = ['zh', 'en']

function I18nPlugin() {
  return {
    name: 'i18nPlugin',
    enforce: 'pre',
    generateBundle(outputOptions, bundle) {
      // 遍历生成的所有 chunks
      for (const [fileName, chunkInfo] of Object.entries(bundle)) {
        // 配合 manualChunks 中的设置,manualChunks 中将语言包的chunk命名为 i18n-xxx
        if (chunkInfo.name.startsWith('i18n-')) {
          // 对所有语言进行提炼
          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: RandomString(),
              source: code,
            })
          })
        }
      }
    },
  }
}

module.exports = {
  I18nPlugin,
}

利用之前 manualChunks 打包好的 i18n 文件标识,取找到所有的文件并通过 AST 进行语言提炼,生成单独语言的新文件。

单语言映射

index.html 中加入一个标识,然后在 vite 插件中,通过 transformIndexHtml 方法,将提炼后的语言包文件进行载入并替换该标识,从而实现单语言映射。

index.html

html 复制代码
<!DOCTYPE html>
<html>
  <!-- .... -->
  <script createImportMap></script>
  <!-- .... -->
</html>

i18nPlugin.js

javascript 复制代码
function I18nPlugin() {
  return {
    name: 'i18nPlugin',
    enforce: 'pre',
    generateBundle(outputOptions, bundle) {
      // ...
    },
    transformIndexHtml(html, option) {
	    // 忽略 dev
      if (option.server?.config?.mode === 'development') {
        return html
      }

      const { bundle } = option
      const fileList = []
      // 找到所有的 i18n 文件
      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']
        const lan = supportLan.find(e => userLanguage.startsWith(e)) ?? 'zh'
        const map = {}
        fileList.forEach(fileName => {
          map[\`/\${fileName}\`] = \`/\${fileName.replace(/(.*i18n-)([a-z0-9]+-)/, \`$1\${lan}-$2\`)}\`;
        })

        // 更新 importmap 的函数
        function updateImportMap(newMapping) {
          const importMap = document.querySelector('script[type="importmap"]');
          if(importMap) {
            const importMapJson = JSON.parse(importMap.textContent);
            importMapJson.imports = { importMapJson.imports, ...newMapping };
            importMap.textContent = JSON.stringify(importMapJson);
          } else {
            const importMapObj = {
              imports: map,
            };
            const imp = document.createElement('script');
            imp.type = 'importmap';
            imp.textContent = JSON.stringify(importMapObj);
            const head = document.head;
            header.insertBefore(imp, head.firstChild)
          }
        }

        // 使用时调用 updateImportMap 并传入新的映射
        updateImportMap(map)
      </script>`,
      )

      return newHtml
    },
  }
}

拆除了单独看这个替换的函数部分:

javascript 复制代码
// 语言包列表
const fileList = []
const userLanguage = navigator.language || navigator.userLanguage

const supportLan = ['zh', 'en']
const lan = supportLan.find(e => userLanguage.startsWith(e)) ?? 'zh'
const map = {}

// 根据当前语言,找到对应的语言包,生成对应的映射
fileList.forEach(fileName => {
  map[`/${fileName}`] = `/${fileName.replace(/(.*i18n-)([a-z0-9]+-)/, `$1${lan}-$2`)}`;
})

// 更新 importmap 的函数
function updateImportMap(newMapping) {
  const importMap = document.querySelector('script[type="importmap"]');
  if(importMap) {
    const importMapJson = JSON.parse(importMap.textContent);
    importMapJson.imports = { importMapJson.imports, ...newMapping };
    importMap.textContent = JSON.stringify(importMapJson);
  } else {
    const importMapObj = {
      imports: map,
    };
    const imp = document.createElement('script');
    imp.type = 'importmap';
    imp.textContent = JSON.stringify(importMapObj);
    const head = document.head;
    header.insertBefore(imp, head.firstChild)
  }
}

// 使用时调用 updateImportMap 并传入新的映射
updateImportMap(map)

这里的 map 是关系,建立映射关系,将原本的语言包映射到单一语言包上。

比如原本引入的是 i18n-1abc4c9b-d12e7f4b.js 文件,通过映射之后,假设现在加载的是中文,那么就会变成 i18n-1abc4c9b-d12e7f4b.jsi18n-zh-1abc4c9b-d12e7f4b.js,从而节省了一半的体积。

该方案的主要优化逻辑是在打包阶段实现的,通过颗粒化处理和打包优化,显著减少了语言包的体积。然而,这种方式对开发者来说并不友好,开发体验较差。

在后续的方案中,我们将重点优化开发体验,通过单语言拆分和路由拆分等方向来改进。

相关推荐
花生侠15 分钟前
记录:前端项目使用pnpm+husky(v9)+commitlint,提交代码格式化校验
前端
猿榜16 分钟前
魔改编译-永久解决selenium痕迹(二)
javascript·python
阿幸软件杂货间20 分钟前
阿幸课堂随机点名
android·开发语言·javascript
一涯22 分钟前
Cursor操作面板改为垂直
前端
我要让全世界知道我很低调29 分钟前
记一次 Vite 下的白屏优化
前端·css
threelab29 分钟前
three案例 Three.js波纹效果演示
开发语言·javascript·ecmascript
1undefined231 分钟前
element中的Table改造成虚拟列表,并封装成hooks
前端·javascript·vue.js
蓝倾1 小时前
淘宝批量获取商品SKU实战案例
前端·后端·api
comelong1 小时前
Docker容器启动postgres端口映射失败问题
前端