首页/文章/electron

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

2025-03-05
3442 分钟
...

正文

直接贴出代码:

主进程实现

首先需要在 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}`);
    
    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;
}


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');


  let localeName = normalizeLocaleName(appLocale);
  let messages;

  try {
    messages = getLocaleMessages(localeName);

    
    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');
  }

  
  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';


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

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
electron2025-03-05
应用多开,限制只启动一个应用,防止多个实例
通过app.requestSingleInstanceLock来控制应用的多开,返回值为boolean。 此方法的返回值表示你的应用程序实例是否成功取得了锁。如果它取得锁失败,你可以假设另一个应用实例已经取得了锁并且仍旧在运行,并立即退出。 即:如果当前进程是应用程序的主要实例,则此方法返回true,同时你的应用会继续运行。如果当它返回false,如果你的程序没有取得锁,它应该立刻退出,并且将参数发送给那个已经取到锁的进程。 在macO...
学习
electron2024-09-23
Electron实现文件缓存背景
背景 基于Electron研发的一款IM企业通信桌面端应用,会存在非常多文件、图片、视频类型回话消息。在Electron应用(桌面客户端软件)中,快速加载并显示图片是提升用户体验的关键。然而,传统的图片加载方式往往存在加载速度慢、资源占用高等问题,影响了用户的使用体验。 解决的问题 1. 支持自定义配置存储的磁盘位置 2. 支持长期存储 3. 支持自定义存储大小 4. 支持自定义存储类型(如图片、视频、文件,或者更细致化到MIME) 5...
学习
electron2024-09-23
我的 Electron 客户端也可以全量和增量更新了
前言 本文主要介绍 客户端应用的自动更新,包括全量和增量这两种方式。 全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。 增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。 本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。 如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。 前置说明: 1. 由于业务场景的限制,本文介绍的更新仅支持 操作系统,其余操作系统未作兼容处理。 2. 更新流...
学习
electron2024-09-20
我的 Electron 客户端被第三方页面入侵了
问题描述 公司有个内部项目是用 来开发的,有个功能需要像浏览器一样加载第三方站点。 本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面 。 这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码。 翻译一下就是:如果当前窗口不是顶级窗口的话,将当前窗口设置为顶级窗口。 奇怪的是两者不是 跨域 了吗,为什么 还可以影响顶级窗口。 先说一下我当时的一些解决办法: 1. 用 替换 2. 给 添...
学习
electron2024-09-13
Electron+WebRTC实现局域网内的远程控制软件
我的工位上有一台笔记本(windows),一个显示器,一台服务器(linux)。显示器同时连接笔记本和服务器,当需要用到服务器的时候:1.使用todesk等软件进行连接;2.直接把显示器切换到服务器桌面,然后给服务器插上鼠标键盘。在这种场景下,屏幕画面的传输对我来说并不是必要的,这种场景下能不能用一套键鼠控制两个电脑呢?边做边学便有了这个小工具。记录下来,其中遇到的问题和解决方案。 通过electron + Vue + primevue...
学习
electron2024-09-11
如何将 Electron 项目上架
前言 是一个开源框架,它允许开发者使用 技术( 、 和 )来构建跨平台的桌面应用程序。 应用程序可以运行在 、 和 上,为用户提供了一种统一的方式来开发和维护软件。 本文将探讨如何将 Electron 构建的桌面应用程序上架到 中。 创建证书 进入苹果开发者平台 进入证书列表: 创建证书(Certificates) 用于对需要上架应用进行签名 创建 和 两个证书。 证书用于在 网站上注册的计算机上签署用于开发和测试的应用程序。 注册方法...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录