首页 > 其他分享 >自建国际化方案-语言包拆分/按需加载/缓存

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

时间:2024-11-26 16:58:20浏览次数:7  
标签:翻译 缓存 const 语言包 i18n return zh 加载

背景

国际化多语言(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'
}


这里 t 的内容:

```javascript
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插件实现了这个功能

相关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}`
          }
        }
      }
    }
  },
})

标签:翻译,缓存,const,语言包,i18n,return,zh,加载
From: https://blog.csdn.net/qq_34435913/article/details/144063183

相关文章

  • HTTP 缓存技术
    HTTP缓存技术1.缓存概述HTTP缓存技术通过存储已请求资源的副本,减少重复请求、提升响应速度,并节省带宽。缓存可以在客户端、代理服务器、CDN(内容分发网络)等位置进行,能够有效提升Web应用的性能、降低服务器压力和加速用户访问体验。2.缓存的位置和类型2.1浏览器......
  • 如何在CodeIgniter中加载视图
    在CodeIgniter中加载视图(view)是开发Web应用程序中的一个基本步骤。CodeIgniter的视图机制允许你将HTML与PHP代码分离,从而提高代码的可维护性和可读性。以下是如何在CodeIgniter中加载视图的基本步骤:1.创建一个控制器首先,你需要创建一个控制器,用于处理请求并加载视图。假设你......
  • 隐匿加载恶意程序
    本文将从NTFS交换数据流(ADS)介绍入手,分析当前APT组织对其进行利用的手法,来介绍ADS在持久化和加载恶意程序中起到的作用。前言本文将从NTFS交换数据流(ADS)介绍入手,分析当前APT组织对其进行利用的手法,来介绍ADS在持久化和加载恶意程序中起到的作用。ADS简介NTFS交换数据流(al......
  • 缓存与数据库一致性——延迟双删
    一、延时双删是什么?        延迟双删(DelayDoubleDelete)是一种在数据更新或删除时为了保证数据一致性而采取的策略。这种策略通常用于解决数据在缓存和数据库中不一致的问题。        数据一致性:缓存和数据库一致性问题是指在应用系统中,缓存层(通常是内存中......
  • Caffeine本地缓存和缓存雪崩 缓存击穿 缓存穿透
    初次发布于我的个人文档参考:缓存雪崩,缓存击穿,缓存穿透Caffeine本地缓存在一些场景下可以引入缓存加速,利用redis实现缓存通常是一个不错的选择,但有时为了避免系统变得复杂可以使用本地缓存。Caffeine就是一个高效的本地缓存组件。使用方式如下:1.安装依赖<dependency>......
  • java操作邮箱 - 邮箱发送验证码 -redis分布式缓存 -redisson分布式缓存
    初次发布于我的个人文档参考:java操作163邮箱本文以163邮箱为例,介绍如何用java发送邮箱。1.获取邮箱授权码进入163邮箱-设置-POP3/SMTP/IMAP-开启POP3/SMTP服务记录得到的授权码2.安装依赖//https://mvnrepository.com/artifact/jakarta.activation/jakarta.activation-ap......
  • Linux之CPU缓存
    CPU缓存系统中最快的存储是处理器中的寄存器。但寄存器由于造价比较昂贵,提供的空间也非常有限。因此系统中必须使用存储量大但速度慢的主存(内存)。高速缓存的速度都比主存快。主存的访问时间是8纳秒以上,而缓存的访问时间只有几个CPU时钟周期。在标准的X86平台下,一般有L1L2L3......
  • 支持无限加载的js图片画廊插件
    在线演示  下载  natural-gallery-js是一款支持无限加载的js图片画廊插件。该js图片画廊支持图片的懒加载,可以对图片进行搜索,分类,还可以以轮播图的方式来展示和切换图片。  使用方法在页面中引入下面的CSS和js文件。<linkrel="stylesheet"href="../dist/t......
  • 如何在CodeIgniter中添加或加载模型
    在CodeIgniter框架中,模型(Model)是用于与数据库进行交互的重要组件。模型通常包含数据库查询、业务逻辑以及与数据库表相关的函数。以下是如何在CodeIgniter中添加或加载模型的步骤:1.创建模型文件首先,你需要在application/models目录下创建一个PHP文件来定义你的模型。文件名......
  • 内存与缓存区别
    缓存用途:缓存的主要目的是为了减少对内存或更慢的存储设备的访问次数,将频繁访问的数据暂存起来,以便下次使用时能够更快地获取,从而提升系统的整体性能。内存用途:内存主要用于存储正在运行的程序和数据,为CPU提供快速的数据访问,以保证程序的正常运行。区别解释一内存是计算机......