Loading

lingui.js 多语言自动提取翻译键

背景介绍

目前有个项目,页面已上百,突然说要做国际化,懵了,页面这么多,那不得累滩,之前接触过的国际化是 react-intl,原理:每个国家的语言维护一份js字典,字典里有很多key,都是唯一,使用时通过读取这个key就能拿到对应国家的语言文本内容

例如

import { IntlProvider, FormattedMessage } from 'react-intl';

const messages = {
  en: {
    greeting: 'Hello, {name}!',
  },
  fr: {
    greeting: 'Bonjour, {name}!',
  },
};

const App = () => {
  return (
    <IntlProvider locale="en" messages={messages['en']}>
      <h1><FormattedMessage id="greeting" values={{ name: 'John' }} /></h1>
    </IntlProvider>
  );
};

如果是前期一开始就考虑了做国际化,这么这个方案没什么问题,顶多在写固定文本时把数据维护进字典里面就好了,但是,现在上百个页面已经有了中文,用这种方式,成本太大了,要修改每个文件

并且它有个比较大的弊端,对于英文不懂的人来说,写 <FormattedMessage id="greeting" values={{ name: 'John' }} />都不懂啥意思,起码没中文看起来那么直观,于是乎,开启调研模式

前期调研

目前比较流行的国际化方案如下

1. react-intl

react-intl 是一个由 formatjs 提供的库,它是 React 中最流行的国际化解决方案之一。它提供了格式化日期、数字和消息的功能,以及支持在应用中切换语言。

特点:

支持日期、时间、数字格式化。
支持消息格式化,带有插值。
支持多语言切换。
支持本地化规则(如日期、货币、数字)。

使用方法

npm install react-intl

使用

import { IntlProvider, FormattedMessage } from 'react-intl';

const messages = {
  en: {
    greeting: 'Hello, {name}!',
  },
  fr: {
    greeting: 'Bonjour, {name}!',
  },
};

const App = () => {
  return (
    <IntlProvider locale="en" messages={messages['en']}>
      <h1><FormattedMessage id="greeting" values={{ name: 'John' }} /></h1>
    </IntlProvider>
  );
};

2. react-i18next

react-i18next 是一个基于 i18next 的 React 国际化库,具有灵活的配置和插件系统,支持语言切换、动态加载语言包等功能。

特点:
支持语言切换,自动切换 UI 语言。
支持动态加载语言包。
支持插值和后端加载。
插件丰富,支持多种扩展。

安装

npm install react-i18next i18next

使用

import { useTranslation } from 'react-i18next';

const App = () => {
  const { t, i18n } = useTranslation();

  return (
    <div>
      <h1>{t('greeting', { name: 'John' })}</h1>
      <button onClick={() => i18n.changeLanguage('fr')}>Switch to French</button>
    </div>
  );
};

配置

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

i18n.use(initReactI18next).init({
  resources: {
    en: {
      translation: {
        greeting: 'Hello, {{name}}!',
      },
    },
    fr: {
      translation: {
        greeting: 'Bonjour, {{name}}!',
      },
    },
  },
  lng: 'en',
  fallbackLng: 'en',
});

3. lingui.js

lingui.js 是一个轻量级的国际化库,专注于简单易用和优化性能。它支持提取翻译文件、按需加载等功能。

支持自动提取翻译键。
按需加载翻译文件。
性能较好,支持静态优化。
提供高效的格式化功能。

安装

npm install @lingui/react @lingui/core

使用

import { I18nProvider, Trans } from '@lingui/react';
import { i18n } from '@lingui/core';
import enMessages from './locales/en/messages';
import frMessages from './locales/fr/messages';

i18n.load({
  en: enMessages,
  fr: frMessages,
});

i18n.activate('en');

const App = () => {
  return (
    <I18nProvider i18n={i18n}>
      <h1><Trans>greeting</Trans></h1>
    </I18nProvider>
  );
};

我的选择

在看到 lingui.js 时,有个支持自动提取翻译键,功能吸引到了我,什么叫自动提取翻译键,看下面代码

jsx源代码

<div title={`测试`} data-content={test(`测试`)}>
  测试 {test(`测试`)}
</div>

如果要使用 支持自动提取翻译键 功能,字符串用 t`测试` 方式包裹起来,节点内容则用 <Trans>测试</Trans> 包裹起来

<div title={t`测试`} data-content={test(t`测试`)}>
  <Trans>测试 {test(t`测试`)}</Trans>
</div>

执行命令

npx lingui extract

它会自动提取  t`测试`  <Trans>测试</Trans> 作为一个唯一key,然后对应生成两份对应的字典源代码,分别是中文和英文(我这里只配置了中文和英文)

生成的文件如下

中文文件 /locales/zh/messages.po

msgid ""
msgstr ""
"POT-Creation-Date: 2024-12-09 18:45+0800\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"

#: src/pages/Test/Test2/index.tsx
#: src/pages/Test/Test2/index.tsx
msgid "测试"
msgstr "测试"

英文文件 /locales/en/messages.po

msgid ""
msgstr ""
"POT-Creation-Date: 2024-12-09 18:45+0800\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"

#: src/pages/Test/Test2/index.tsx
#: src/pages/Test/Test2/index.tsx
msgid "测试"
msgstr "test"

字段解释

msgid:字典中的唯一标识,程序通过 t`测试` 来匹配msgid 值,然后提取对应的 msgstr 值

msgstr:字典翻译的内容

我们可以看到中文字典,msgstr 有对应的值,而英文字段里没有,因为我是以中文为默认语言,所以脚本自动补充了中文翻译,英文值空缺了,后续我们需要做的就是把英文翻译给补全

执行命令

npx lingui compile

它会将locales/*.po 文件转成对应的js文件(已压缩)

中文文件 /locales/zh/messages.ts

/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"0qIdia\":[\"测试\"]}")as Messages;

英文文件 /locales/en/messages.ts

/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"0qIdia\":[\"test\"]}")as Messages;

结论

最终其实我们只需要维护 locales/*.po 文件 ,然后通过 npx lingui compile 来编译输出我们所需已翻译好的js字典

抛出问题

如何让所有的中文都用 t`测试`  或者 <Trans>测试</Trans> 包裹呢?  

需求分析

源代码

const Test = () => {
  const test = (t: string) => {
    return t;
  };
  return (
    <div>
      <div title="测试" data-content={test('测试')}>
        测试 {test('测试')}
      </div>
      <QkProTable headerTitle="测试" />
    </div>
  );
};

转换完之后的代码

import { useLingui, Trans } from '@lingui/react/macro';
const Test = () => {
  const { t } = useLingui();
  const test = (t: string) => {
    return t;
  };
  return (
    <div>
      <div title={t`测试`} data-content={test(t`测试`)}>
        <Trans>测试 {test(t`测试`)}</Trans>
      </div>
      <QkProTable headerTitle={t`测试`} />
    </div>
  );
};

分析

1. 将 x="xx" 转换成 x={t`xx`}

2. 将 <div>测试</div> 转换成 <div><Trans>测试</Trans></div>

3. 如果没有引入 import { useLingui, Trans } from '@lingui/react/macro'; 则引入

4. 组件如果没用hooks const {  t } = useLingui(); 则需要使用hooks

5. 转换过的内容不能再继续转换

6. 引入过的插件不能再引入

7. hook使用了不能再使用,hook的使用针对的是组件不是文件

最终代码

autoWrapChinese.mjs

import fs from 'fs';
import { glob } from 'glob';

function addImportsAndHooks(content) {
  // 检查是否已经有相关导入
  const hasLinguiImport = /import.*from ['"]@lingui\/react\/macro['"]/g.test(content);
  const hasTransImport = /import.*\{.*Trans.*\}.*from/g.test(content);
  const hasUseLinguiImport = /import.*\{.*useLingui.*\}.*from/g.test(content);

  let newContent = content;

  // 需要添加的导入项
  const importsToAdd = [];
  if (!hasTransImport && !hasLinguiImport) {
    importsToAdd.push('Trans');
  }
  if (!hasUseLinguiImport && !hasLinguiImport) {
    importsToAdd.push('useLingui');
  }

  // 如果需要添加导入
  if (importsToAdd.length > 0) {
    if (hasLinguiImport) {
      // 在已有的 lingui 导入中添加新的导入项
      newContent = newContent.replace(
        /(import.*\{)(.*)(}.*from ['"]@lingui\/react\/macro['"])/,
        (match, start, imports, end) => {
          const currentImports = imports.split(',').map(i => i.trim());
          const newImports = [...new Set([...currentImports, ...importsToAdd])];
          return `${start} ${newImports.join(', ')} ${end}`;
        }
      );
    } else {
      // 添加新的导入语句
      const importStatement = `import { ${importsToAdd.join(', ')} } from '@lingui/react/macro';\n`;
      // 在其他导入语句后面添加
      newContent = newContent.replace(
        /(import.*from.*['"].*['"];?\n(?:import.*from.*['"].*['"];?\n)*)/,
        `$1${importStatement}`
      );
    }
  }

  // 查找所有组件函数
  const componentRegex = /(?:export\s+)?(?:const|function)\s+([A-Z]\w+)\s*=\s*(?:\([^)]*\))?\s*=>\s*{([^}]*)}/g;

  // 用于存储已处理过的组件
  const processedComponents = new Set();

  // 处理每个组件函数
  newContent = newContent.replace(componentRegex, (match, componentName, componentBody) => {
    // 避免重复处理同一个组件
    if (processedComponents.has(componentName)) {
      return match;
    }
    processedComponents.add(componentName);

    // 检查组件内是否已经使用了 useLingui hook
    const hasHook = /const\s*{\s*t\s*}\s*=\s*useLingui\(\)/.test(componentBody);

    // 检查组件是否需要国际化(包含中文或t`)
    const needsI18n = /[\u4e00-\u9fa5]|t`/.test(componentBody);

    if (!hasHook && needsI18n) {
      // 在组件函数体开始处添加 hook
      return match.replace(
        /^((export\s+)?(?:const|function)\s+[A-Z]\w+\s*=\s*(?:\([^)]*\))?\s*=>\s*{)/,
        '$1\n  const { t } = useLingui();'
      );
    }

    return match;
  });

  return newContent;
}

function wrapChineseText(content) {
  // 跳过已经被 t` ` 或 <Trans> 包裹的内容
  const skipPattern = /(?:t`[\u4e00-\u9fa5]+`|<Trans>[\u4e00-\u9fa5]+<\/Trans>)/g;
  let positions = [];
  let match;
  while ((match = skipPattern.exec(content)) !== null) {
    positions.push([match.index, match.index + match[0].length]);
  }

  function shouldProcess(index, length) {
    return !positions.some(([start, end]) =>
      (index >= start && index < end) ||
      (index + length > start && index + length <= end)
    );
  }

  // 处理属性中的中文(包括反引号字符串)
  content = content.replace(
    /(\w+)=(?:{)?["'`]?([\u4e00-\u9fa5]+)["'`]?}?/g,
    (match, attr, chinese, offset) => {
      if (shouldProcess(offset, match.length)) {
        return `${attr}={t\`${chinese}\`}`;
      }
      return match;
    }
  );

  // 处理函数参数中的中文字符串(包括反引号字符串)
  content = content.replace(
    /\(["'`]([\u4e00-\u9fa5]+)["'`]\)/g,
    (match, chinese, offset) => {
      if (shouldProcess(offset, match.length)) {
        return `(t\`${chinese}\`)`;
      }
      return match;
    }
  );

  // 处理普通字符串中的中文(包括反引号字符串)
  content = content.replace(
    /(?<!t)["'`]([\u4e00-\u9fa5]+)["'`]/g,
    (match, chinese, offset) => {
      if (shouldProcess(offset, match.length)) {
        return `t\`${chinese}\``;
      }
      return match;
    }
  );

  // 定义常见的 HTML/React 标签和组件
  const tags = [
    'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
    'button', 'a', 'li', 'td', 'th', 'label', 'small',
    'strong', 'em', 'i', 'b', 'article', 'section', 'main',
    'header', 'footer', 'nav', 'aside', 'title',
  ].join('|');

  // 处理标签中的纯中文文本
  const tagRegex = new RegExp(
    `(?<=<(${tags})(?:\\s+[^>]*)?>[\\s]*)([\u4e00-\u9fa5]+(?:[\\s}\\{][^<>]*[\u4e00-\u9fa5]+)*)[\\s]*(?=</(?:${tags})>)`,
    'g'
  );

  content = content.replace(tagRegex, (match, tag, text, offset) => {
    if (shouldProcess(offset, match.length)) {
      return `<Trans>${text}</Trans>`;
    }
    return match;
  });

  return content;
}

// 处理文件
async function processFiles() {
  try {
    const files = await glob('src/pages/LabelManagement/**/*.{ts,tsx}');
    for (const file of files) {
      const content = fs.readFileSync(file, 'utf8');
      let processed = wrapChineseText(content);

      // 如果内容有改变,添加必要的导入和hooks
      if (content !== processed) {
        processed = addImportsAndHooks(processed);
        fs.writeFileSync(file, processed);
        console.log(`已处理文件: ${file}`);
      }
    }
  } catch (error) {
    console.error('错误:', error);
  }
}

processFiles();

如何使用

 在package.json中添加命令行

"i18n:auto-wrap": "node scripts/autoWrapChinese.mjs"

执行 npm run i18n:auto-wrap 

posted @ 2024-12-10 16:29  冯叶青  阅读(24)  评论(0编辑  收藏  举报