正文

直接贴出代码:

主进程实现

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

 1import loadLocale from './locale';
 2
 3
 4let locale: I18n.Locale;
 5
 6
 7app.on('ready', async () => {
 8  logger.info('app ready');
 9
10  if (!locale) {
11    const appLocale = process.env.NODE_ENV === 'test' ? 'en' : app.getLocale();
12    logger.info(`locale: ${appLocale}`);
13    
14    locale = loadLocale({ appLocale });
15  }
16  
17
18  createLogin();
19});

locale.ts 文件:

 1import { join } from 'path';
 2import { readFileSync } from 'fs-extra';
 3import { app } from 'electron';
 4import { merge } from 'lodash';
 5import { setup } from './i18n';
 6
 7function normalizeLocaleName(locale: string) {
 8  if (/^en-/.test(locale)) {
 9    return 'en';
10  }
11
12  return locale;
13}
14
15
16function getLocaleMessages(locale: string): I18n.Message {
17  const onDiskLocale = locale.replace('-', '_');
18
19  const targetFile = app.isPackaged
20    ? join(process.resourcesPath, '_locales', onDiskLocale, 'messages.json')
21    : join(__dirname, '../..', '_locales', onDiskLocale, 'messages.json');
22
23  return JSON.parse(readFileSync(targetFile, 'utf-8'));
24}
25
26export default function loadLocale({
27  appLocale,
28}: { appLocale?: string } = {}): I18n.Locale {
29  if (!appLocale) {
30    throw new TypeError('`appLocale` is required');
31  }
32
33  const english = getLocaleMessages('en');
34
35
36  let localeName = normalizeLocaleName(appLocale);
37  let messages;
38
39  try {
40    messages = getLocaleMessages(localeName);
41
42    
43    messages = merge(english, messages);
44  } catch (err) {
45    console.log(
46      `Problem loading messages for locale ${localeName} ${err.stack}`
47    );
48    console.log('Falling back to en locale');
49
50    localeName = 'en';
51    messages = english;
52  }
53
54  const i18n = setup(appLocale, messages);
55
56  return {
57    i18n,
58    name: localeName,
59    messages,
60  };
61}

i18n.ts 文件

 1const log = typeof window !== 'undefined' ? console : console;
 2
 3export const setup = (locale: string, messages: I18n.Message) => {
 4  if (!locale) {
 5    throw new Error('i18n: locale parameter is required');
 6  }
 7  if (!messages) {
 8    throw new Error('i18n: messages parameter is required');
 9  }
10
11  
12  const getMessage: I18n.I18nFn = (key, substitutions) => {
13    const entry = messages[key];
14    if (!entry) {
15      log.error(
16        `i18n: Attempted to get translation for nonexistent key '${key}'`
17      );
18      return '';
19    }
20    if (Array.isArray(substitutions) && substitutions.length > 1) {
21      throw new Error(
22        'Array syntax is not supported with more than one placeholder'
23      );
24    }
25    if (
26      typeof substitutions === 'string' ||
27      typeof substitutions === 'number'
28    ) {
29      throw new Error('You must provide either a map or an array');
30    }
31
32    const { message } = entry;
33    if (!substitutions) {
34      return message;
35    }
36    if (Array.isArray(substitutions)) {
37      return substitutions.reduce(
38        (result, substitution) =>
39          result.toString().replace(/\$.+?\$/, substitution.toString()),
40        message
41      );
42    }
43
44    const FIND_REPLACEMENTS = /\$([^$]+)\$/g;
45
46    let match = FIND_REPLACEMENTS.exec(message);
47    let builder = '';
48    let lastTextIndex = 0;
49
50    while (match) {
51      if (lastTextIndex < match.index) {
52        builder += message.slice(lastTextIndex, match.index);
53      }
54
55      const placeholderName = match[1];
56      const value = substitutions[placeholderName];
57      if (!value) {
58        log.error(
59          `i18n: Value not provided for placeholder ${placeholderName} in key '${key}'`
60        );
61      }
62      builder += value || '';
63
64      lastTextIndex = FIND_REPLACEMENTS.lastIndex;
65      match = FIND_REPLACEMENTS.exec(message);
66    }
67
68    if (lastTextIndex < message.length) {
69      builder += message.slice(lastTextIndex);
70    }
71
72    return builder;
73  };
74
75  getMessage.getLocale = () => locale;
76
77  return getMessage;
78};
79

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

渲染进程的实现

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

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

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

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

preload.js

 1const localeMessages = ipcRenderer.sendSync('locale-data');
 2
 3contextBridge.exposeInMainWorld('Context', {
 4    platform: process.platform,
 5    NODE_ENV: process.env.NODE_ENV,
 6
 7    localeMessages,
 8    
 9    
10  });
11

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

首先在根组件:

 1import Login from './login';
 2import { I18n } from 'Renderer/utils/i18n';
 3
 4
 5const { localeMessages } = window.Context;
 6
 7const Root: React.ComponentType = () => {
 8  return (
 9    <I18n messages={localeMessages}>
10      <Login />
11    </I18n>
12  );
13};
14
15export default Root;
16

utils/i18n.ts 文件:

 1import { createContext, useCallback, useContext } from 'react'
 2
 3export type I18nFn = (
 4  key: string,
 5  substitutions?: Array<string | number> | ReplacementValuesType
 6) => string
 7
 8export type ReplacementValuesType = {
 9  [key: string]: string | number
10}
11
12const I18nContext = createContext<I18nFn>(() => 'NO LOCALE LOADED')
13
14export type I18nProps = {
15  children: React.ReactNode
16  messages: { [key: string]: { message: string } }
17}
18
19export const I18n: React.ComponentType<I18nProps> = ({
20  children,
21  messages,
22}): JSX.Element => {
23  const getMessage = useCallback<I18nFn>(
24    (key, substitutions) => {
25      if (Array.isArray(substitutions) && substitutions.length > 1) {
26        throw new Error(
27          'Array syntax is not supported with more than one placeholder'
28        )
29      }
30
31      const { message } = messages[key]
32      if (!substitutions) {
33        return message
34      }
35
36      if (Array.isArray(substitutions)) {
37        return substitutions.reduce(
38          (result, substitution) =>
39            result.toString().replace(/\$.+?\$/, substitution.toString()),
40          message
41        ) as string
42      }
43
44      const FIND_REPLACEMENTS = /\$([^$]+)\$/g
45
46      let match = FIND_REPLACEMENTS.exec(message)
47      let builder = ''
48      let lastTextIndex = 0
49
50      while (match) {
51        if (lastTextIndex < match.index) {
52          builder += message.slice(lastTextIndex, match.index)
53        }
54
55        const placeholderName = match[1]
56        const value = substitutions[placeholderName]
57        if (!value) {
58          // eslint-disable-next-line no-console
59          console.error(
60            `i18n: Value not provided for placeholder ${placeholderName} in key '${key}'`
61          )
62        }
63        builder += value || ''
64
65        lastTextIndex = FIND_REPLACEMENTS.lastIndex
66        match = FIND_REPLACEMENTS.exec(message)
67      }
68
69      if (lastTextIndex < message.length) {
70        builder += message.slice(lastTextIndex)
71      }
72
73      return builder
74    },
75    [messages]
76  )
77
78  return (
79    <I18nContext.Provider value={getMessage}>{children}</I18nContext.Provider>
80  )
81}
82
83export const useI18n = (): I18nFn => useContext(I18nContext)
84

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

 1import { useI18n } from 'Renderer/utils/i18n';
 2
 3export const DemoComponent:React.FC = () => {
 4    const i18n = useI18n();
 5
 6    return (
 7        <div>
 8            <p>{i18n("About ElectronReact")}</p>
 9            /** 需要替换的值 最终展示为 肤色 tone */
10            <p>{i18n("EmojiPicker--skin-tone", [`${tone}`])}</p>
11        </div>
12    )
13}

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

zh_CN//message.json

 1{
 2  "About ElectronReact":{
 3    "message": "关于 ElectronReact",
 4    "description": "The text of the login button"
 5  },
 6  "signIn": {
 7    "message": "登录",
 8    "description": "The text of the login button"
 9  },
10  "signUp": {
11    "message": "注册",
12    "description": "The text of the sign up button"
13  },
14  "EmojiPicker--empty": {
15    "message": "没有找到符合条件的表情",
16    "description": "Shown in the emoji picker when a search yields 0 results."
17  },
18  "EmojiPicker--search-placeholder": {
19    "message": "搜索表情",
20    "description": "Shown as a placeholder inside the emoji picker search field."
21  },
22  "EmojiPicker--skin-tone": {
23    "message": "肤色 $tone$",
24    "description": "Shown as a tooltip over the emoji tone buttons.",
25    "placeholders": {
26      "status": {
27        "content": "$1",
28        "example": "2"
29      }
30    }
31  },
32  "EmojiPicker__button--recents": {
33    "message": "最近通话",
34    "description": "Label for recents emoji picker button"
35  },
36  "EmojiPicker__button--emoji": {
37    "message": "表情符号",
38    "description": "Label for emoji emoji picker button"
39  },
40  "EmojiPicker__button--animal": {
41    "message": "动物",
42    "description": "Label for animal emoji picker button"
43  },
44  "EmojiPicker__button--food": {
45    "message": "食物",
46    "description": "Label for food emoji picker button"
47  },
48  "EmojiPicker__button--activity": {
49    "message": "活动",
50    "description": "Label for activity emoji picker button"
51  },
52  "EmojiPicker__button--travel": {
53    "message": "旅行",
54    "description": "Label for travel emoji picker button"
55  },
56  "EmojiPicker__button--object": {
57    "message": "物品",
58    "description": "Label for object emoji picker button"
59  },
60  "EmojiPicker__button--symbol": {
61    "message": "符号",
62    "description": "Label for symbol emoji picker button"
63  },
64  "EmojiPicker__button--flag": {
65    "message": "旗帜",
66    "description": "Label for flag emoji picker button"
67  },
68  "sendMessageToContact": {
69    "message": "发送消息",
70    "description": "Shown when you are sent a contact and that contact has a signal account"
71  }
72}

en/message.json

 1{
 2  "About ElectronReact":{
 3    "message": "About ElectronReact",
 4    "description": "The text of the login button"
 5  },
 6  "signIn": {
 7    "message": "Sign in",
 8    "description": "The text of the login button"
 9  },
10  "signUp": {
11    "message": "Sign up",
12    "description": "The text of the sign up button"
13  },
14  "EmojiPicker--empty": {
15    "message": "No emoji found",
16    "description": "Shown in the emoji picker when a search yields 0 results."
17  },
18  "EmojiPicker--search-placeholder": {
19    "message": "Search Emoji",
20    "description": "Shown as a placeholder inside the emoji picker search field."
21  },
22  "EmojiPicker--skin-tone": {
23    "message": "Skin tone $tone$",
24    "placeholders": {
25      "status": {
26        "content": "$1",
27        "example": "2"
28      }
29    },
30    "description": "Shown as a tooltip over the emoji tone buttons."
31  },
32  "EmojiPicker__button--recents": {
33    "message": "Recents",
34    "description": "Label for recents emoji picker button"
35  },
36  "EmojiPicker__button--emoji": {
37    "message": "Emoji",
38    "description": "Label for emoji emoji picker button"
39  },
40  "EmojiPicker__button--animal": {
41    "message": "Animal",
42    "description": "Label for animal emoji picker button"
43  },
44  "EmojiPicker__button--food": {
45    "message": "Food",
46    "description": "Label for food emoji picker button"
47  },
48  "EmojiPicker__button--activity": {
49    "message": "Activity",
50    "description": "Label for activity emoji picker button"
51  },
52  "EmojiPicker__button--travel": {
53    "message": "Travel",
54    "description": "Label for travel emoji picker button"
55  },
56  "EmojiPicker__button--object": {
57    "message": "Object",
58    "description": "Label for object emoji picker button"
59  },
60  "EmojiPicker__button--symbol": {
61    "message": "Symbol",
62    "description": "Label for symbol emoji picker button"
63  },
64  "EmojiPicker__button--flag": {
65    "message": "Flag",
66    "description": "Label for flag emoji picker button"
67  },
68  "sendMessageToContact": {
69    "message": "Send Message",
70    "description": "Shown when you are sent a contact and that contact has a signal account"
71  }
72}

最后

代码都在这里:electron_client

个人笔记记录 2021 ~ 2025