likes
comments
collection
share

不依赖第三方库自己实现 Electron 的主/渲染进程的国际化功能

作者站长头像
站长
· 阅读数 12

前言

首先看一下效果(只是为了演示,其中分别为主和渲染进程的文案国际化)。 中文:

不依赖第三方库自己实现 Electron 的主/渲染进程的国际化功能

不依赖第三方库自己实现 Electron 的主/渲染进程的国际化功能

英文:

不依赖第三方库自己实现 Electron 的主/渲染进程的国际化功能

不依赖第三方库自己实现 Electron 的主/渲染进程的国际化功能

正文

直接贴出代码:

主进程实现

首先需要在 main.ts 文件中获取当前系统的语言,然后初始化国际化相关:

import loadLocale from './locale';

// ......

let locale: I18n.Locale;

// ......

app.on('ready', async () => {
  logger.info('app ready');

  if (!locale) {
    const appLocale = process.env.NODE_ENV === 'test' ? 'en' : app.getLocale();
    logger.info(`locale: ${appLocale}`);
    // 根据系统语言 调用 loadLocale 方法
    locale = loadLocale({ appLocale });
  }

  // ......

  createLogin();
});

locale.ts 文件:

import { join } from 'path';
import { readFileSync } from 'fs-extra';
import { app } from 'electron';
import { merge } from 'lodash';
import { setup } from './i18n';

function normalizeLocaleName(locale: string) {
  if (/^en-/.test(locale)) {
    return 'en';
  }

  return locale;
}

// 根据系统语言加载对应的 message.json 文件
function getLocaleMessages(locale: string): I18n.Message {
  const onDiskLocale = locale.replace('-', '_');

  const targetFile = app.isPackaged
    ? join(process.resourcesPath, '_locales', onDiskLocale, 'messages.json')
    : join(__dirname, '../..', '_locales', onDiskLocale, 'messages.json');

  return JSON.parse(readFileSync(targetFile, 'utf-8'));
}

export default function loadLocale({
  appLocale,
}: { appLocale?: string } = {}): I18n.Locale {
  if (!appLocale) {
    throw new TypeError('`appLocale` is required');
  }

  const english = getLocaleMessages('en');

  // Load locale - if we can't load messages for the current locale, we
  // default to 'en'
  //
  // possible locales:
  // https://github.com/electron/electron/blob/master/docs/api/locales.md
  let localeName = normalizeLocaleName(appLocale);
  let messages;

  try {
    messages = getLocaleMessages(localeName);

    // We start with english, then overwrite that with anything present in locale
    messages = merge(english, messages);
  } catch (err) {
    console.log(
      `Problem loading messages for locale ${localeName} ${err.stack}`
    );
    console.log('Falling back to en locale');

    localeName = 'en';
    messages = english;
  }

  const i18n = setup(appLocale, messages);

  return {
    i18n,
    name: localeName,
    messages,
  };
}

i18n.ts 文件

const log = typeof window !== 'undefined' ? console : console;

export const setup = (locale: string, messages: I18n.Message) => {
  if (!locale) {
    throw new Error('i18n: locale parameter is required');
  }
  if (!messages) {
    throw new Error('i18n: messages parameter is required');
  }

  // 核心方法 用来匹配对应的文案 通过 key, 提供 substitutions 来替换匹配到的占位符
  // 其实和渲染进程用到的 getMessage 方案逻辑一致
  // getMessage('About ElectronReact'))
  // getMessage('EmojiPicker--skin-tone', [`${tone}`])
  const getMessage: I18n.I18nFn = (key, substitutions) => {
    const entry = messages[key];
    if (!entry) {
      log.error(
        `i18n: Attempted to get translation for nonexistent key '${key}'`
      );
      return '';
    }
    if (Array.isArray(substitutions) && substitutions.length > 1) {
      throw new Error(
        'Array syntax is not supported with more than one placeholder'
      );
    }
    if (
      typeof substitutions === 'string' ||
      typeof substitutions === 'number'
    ) {
      throw new Error('You must provide either a map or an array');
    }

    const { message } = entry;
    if (!substitutions) {
      return message;
    }
    if (Array.isArray(substitutions)) {
      return substitutions.reduce(
        (result, substitution) =>
          result.toString().replace(/\$.+?\$/, substitution.toString()),
        message
      );
    }

    const FIND_REPLACEMENTS = /\$([^$]+)\$/g;

    let match = FIND_REPLACEMENTS.exec(message);
    let builder = '';
    let lastTextIndex = 0;

    while (match) {
      if (lastTextIndex < match.index) {
        builder += message.slice(lastTextIndex, match.index);
      }

      const placeholderName = match[1];
      const value = substitutions[placeholderName];
      if (!value) {
        log.error(
          `i18n: Value not provided for placeholder ${placeholderName} in key '${key}'`
        );
      }
      builder += value || '';

      lastTextIndex = FIND_REPLACEMENTS.lastIndex;
      match = FIND_REPLACEMENTS.exec(message);
    }

    if (lastTextIndex < message.length) {
      builder += message.slice(lastTextIndex);
    }

    return builder;
  };

  getMessage.getLocale = () => locale;

  return getMessage;
};

然后在主进程中就可以通过 locale.i18n("About ElectronReact") 来实现国际化了。

渲染进程的实现

App 在启动的时候渲染进程已经执行了国际化的初始化,所以在主进程已经保存了一份当前语言的 message.json 信息,所以渲染进程就不需要进程这一步,直接从主进程获取即可。

主进程进程添加一个 locale-data 事件,供渲染进程获取国际化相关数据:

ipcMain.on('locale-data', (event) => {
  event.returnValue = locale.messages;
});

首先我们要在 preload 中提前将国际化相关的信息写入到渲染进程的js环境中:

preload.js

const localeMessages = ipcRenderer.sendSync('locale-data');

contextBridge.exposeInMainWorld('Context', {
    platform: process.platform,
    NODE_ENV: process.env.NODE_ENV,

    localeMessages,
    
    // ......
  });

在渲染进程的代码中(web端项目基于 React,所以使用 Context 来实现):

首先在根组件:

import Login from './login';
import { I18n } from 'Renderer/utils/i18n';

// 获取国际化 json 数据
const { localeMessages } = window.Context;

const Root: React.ComponentType = () => {
  return (
    <I18n messages={localeMessages}>
      <Login />
    </I18n>
  );
};

export default Root;

utils/i18n.ts 文件:

import { createContext, useCallback, useContext } from 'react';

export type I18nFn = (
  key: string,
  substitutions?: Array<string | number> | ReplacementValuesType
) => string;

export type ReplacementValuesType = {
  [key: string]: string | number;
};

const I18nContext = createContext<I18nFn>(() => 'NO LOCALE LOADED');

export type I18nProps = {
  children: React.ReactNode;
  messages: { [key: string]: { message: string } };
};

export const I18n: React.ComponentType<I18nProps> = ({
  children,
  messages,
}): JSX.Element => {
  const getMessage = useCallback<I18nFn>(
    (key, substitutions) => {
      if (Array.isArray(substitutions) && substitutions.length > 1) {
        throw new Error(
          'Array syntax is not supported with more than one placeholder'
        );
      }

      const { message } = messages[key];
      if (!substitutions) {
        return message;
      }

      if (Array.isArray(substitutions)) {
        return substitutions.reduce(
          (result, substitution) =>
            result.toString().replace(/\$.+?\$/, substitution.toString()),
          message
        ) as string;
      }

      const FIND_REPLACEMENTS = /\$([^$]+)\$/g;

      let match = FIND_REPLACEMENTS.exec(message);
      let builder = '';
      let lastTextIndex = 0;

      while (match) {
        if (lastTextIndex < match.index) {
          builder += message.slice(lastTextIndex, match.index);
        }

        const placeholderName = match[1];
        const value = substitutions[placeholderName];
        if (!value) {
          // eslint-disable-next-line no-console
          console.error(
            `i18n: Value not provided for placeholder ${placeholderName} in key '${key}'`
          );
        }
        builder += value || '';

        lastTextIndex = FIND_REPLACEMENTS.lastIndex;
        match = FIND_REPLACEMENTS.exec(message);
      }

      if (lastTextIndex < message.length) {
        builder += message.slice(lastTextIndex);
      }

      return builder;
    },
    [messages]
  );

  return (
    <I18nContext.Provider value={getMessage}>{children}</I18nContext.Provider>
  );
};

export const useI18n = (): I18nFn => useContext(I18nContext);

然后在实际的组件中可以这么调用:

import { useI18n } from 'Renderer/utils/i18n';

export const DemoComponent:React.FC = () => {
    const i18n = useI18n();

    return (
        <div>
            <p>{i18n("About ElectronReact")}</p>
            /** 需要替换的值 最终展示为 肤色 tone */
            <p>{i18n("EmojiPicker--skin-tone", [`${tone}`])}</p>
        </div>
    )
}

最后准备好各个语言文案的 json 文件:

zh_CN//message.json

{
  "About ElectronReact":{
    "message": "关于 ElectronReact",
    "description": "The text of the login button"
  },
  "signIn": {
    "message": "登录",
    "description": "The text of the login button"
  },
  "signUp": {
    "message": "注册",
    "description": "The text of the sign up button"
  },
  "EmojiPicker--empty": {
    "message": "没有找到符合条件的表情",
    "description": "Shown in the emoji picker when a search yields 0 results."
  },
  "EmojiPicker--search-placeholder": {
    "message": "搜索表情",
    "description": "Shown as a placeholder inside the emoji picker search field."
  },
  "EmojiPicker--skin-tone": {
    "message": "肤色 $tone$",
    "description": "Shown as a tooltip over the emoji tone buttons.",
    "placeholders": {
      "status": {
        "content": "$1",
        "example": "2"
      }
    }
  },
  "EmojiPicker__button--recents": {
    "message": "最近通话",
    "description": "Label for recents emoji picker button"
  },
  "EmojiPicker__button--emoji": {
    "message": "表情符号",
    "description": "Label for emoji emoji picker button"
  },
  "EmojiPicker__button--animal": {
    "message": "动物",
    "description": "Label for animal emoji picker button"
  },
  "EmojiPicker__button--food": {
    "message": "食物",
    "description": "Label for food emoji picker button"
  },
  "EmojiPicker__button--activity": {
    "message": "活动",
    "description": "Label for activity emoji picker button"
  },
  "EmojiPicker__button--travel": {
    "message": "旅行",
    "description": "Label for travel emoji picker button"
  },
  "EmojiPicker__button--object": {
    "message": "物品",
    "description": "Label for object emoji picker button"
  },
  "EmojiPicker__button--symbol": {
    "message": "符号",
    "description": "Label for symbol emoji picker button"
  },
  "EmojiPicker__button--flag": {
    "message": "旗帜",
    "description": "Label for flag emoji picker button"
  },
  "sendMessageToContact": {
    "message": "发送消息",
    "description": "Shown when you are sent a contact and that contact has a signal account"
  }
}

en/message.json

{
  "About ElectronReact":{
    "message": "About ElectronReact",
    "description": "The text of the login button"
  },
  "signIn": {
    "message": "Sign in",
    "description": "The text of the login button"
  },
  "signUp": {
    "message": "Sign up",
    "description": "The text of the sign up button"
  },
  "EmojiPicker--empty": {
    "message": "No emoji found",
    "description": "Shown in the emoji picker when a search yields 0 results."
  },
  "EmojiPicker--search-placeholder": {
    "message": "Search Emoji",
    "description": "Shown as a placeholder inside the emoji picker search field."
  },
  "EmojiPicker--skin-tone": {
    "message": "Skin tone $tone$",
    "placeholders": {
      "status": {
        "content": "$1",
        "example": "2"
      }
    },
    "description": "Shown as a tooltip over the emoji tone buttons."
  },
  "EmojiPicker__button--recents": {
    "message": "Recents",
    "description": "Label for recents emoji picker button"
  },
  "EmojiPicker__button--emoji": {
    "message": "Emoji",
    "description": "Label for emoji emoji picker button"
  },
  "EmojiPicker__button--animal": {
    "message": "Animal",
    "description": "Label for animal emoji picker button"
  },
  "EmojiPicker__button--food": {
    "message": "Food",
    "description": "Label for food emoji picker button"
  },
  "EmojiPicker__button--activity": {
    "message": "Activity",
    "description": "Label for activity emoji picker button"
  },
  "EmojiPicker__button--travel": {
    "message": "Travel",
    "description": "Label for travel emoji picker button"
  },
  "EmojiPicker__button--object": {
    "message": "Object",
    "description": "Label for object emoji picker button"
  },
  "EmojiPicker__button--symbol": {
    "message": "Symbol",
    "description": "Label for symbol emoji picker button"
  },
  "EmojiPicker__button--flag": {
    "message": "Flag",
    "description": "Label for flag emoji picker button"
  },
  "sendMessageToContact": {
    "message": "Send Message",
    "description": "Shown when you are sent a contact and that contact has a signal account"
  }
}

最后

代码都在这里:electron_client