国际化探索:颗粒化方案

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

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

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

调研

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,从而节省了一半的体积。

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

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

相关推荐
apcipot_rain2 小时前
【应用密码学】实验五 公钥密码2——ECC
前端·数据库·python
油丶酸萝卜别吃2 小时前
OpenLayers 精确经过三个点的曲线绘制
javascript
ShallowLin2 小时前
vue3学习——组合式 API:生命周期钩子
前端·javascript·vue.js
Nejosi_念旧3 小时前
Vue API 、element-plus自动导入插件
前端·javascript·vue.js
互联网搬砖老肖3 小时前
Web 架构之攻击应急方案
前端·架构
pixle03 小时前
Vue3 Echarts 3D饼图(3D环形图)实现讲解附带源码
前端·3d·echarts
麻芝汤圆4 小时前
MapReduce 入门实战:WordCount 程序
大数据·前端·javascript·ajax·spark·mapreduce
juruiyuan1115 小时前
FFmpeg3.4 libavcodec协议框架增加新的decode协议
前端
Peter 谭6 小时前
React Hooks 实现原理深度解析:从基础到源码级理解
前端·javascript·react.js·前端框架·ecmascript
周胡杰6 小时前
鸿蒙接入flutter环境变量配置windows-命令行或者手动配置-到项目的创建-运行demo项目
javascript·windows·flutter·华为·harmonyos·鸿蒙·鸿蒙系统