用 React 和 Electron 開發多國語系的應用程式(i18n)

許聖泉 Michael Hsu
24 min readApr 23, 2020

--

因為 javascript 跨平台的特性,有越來越多的應用程式選擇以 javascript 最為開發語言,而其中不外乎桌面端的應用程式,而要快速將網頁程式包裹成桌面端應用程式,又不想維護差異性太大的兩份專案時,Electron 成為了開發網頁桌面程式的一個選擇

使用目前流行的三大前端框架之一的 React 最為網頁的基底,並且透過 Electron 完成桌面端的應用程式的開發,已經是多數人的選擇

而其中不外乎會遇到 i18n 多語言切換的需求,這時候如何有效地串接語系切換在 Electron 和 React 變成了工程師需解決的課題

本範例以最簡單的 simple code 來示範如何透過 React 和 Electron 編譯出一個桌面端應用程式的範本,並且串切兩者之間 i18n 語系切換的動作

所有的程式碼範例可以參考在

https://github.com/tpps88206/react-electron-i18n-example

首先,完成 React & Redux 的部分

使用 create-react-app 來初始化 React 專案

npx create-react-app react-electron-i18n-example
cd react-electron-i18n-example

安裝 Material UI

npm install --save-dev @material-ui/core

Redux

安裝 Redux

npm install --save-dev redux react-redux redux-observable redux-devtools-extension electron-devtools-installer

建立 Redex Store

src/redux/stores/index.js

import { createStore, applyMiddleware } from 'redux';
import { createEpicMiddleware } from 'redux-observable';
import { composeWithDevTools } from 'redux-devtools-extension';

import rootReducer from '../reducers';
import rootEpic from '../epics';

const epicMiddleware = createEpicMiddleware();

const configureStore = () => {
const store = createStore(
rootReducer,
composeWithDevTools({ trace: true })(applyMiddleware(epicMiddleware)),
);

epicMiddleware.run(rootEpic);

return store;
};

export default configureStore;

建立 Redux reducer,並且先建立一個 root 的 reducer 作為範本

src/redux/reducers/index.js

import { combineReducers } from 'redux';

import root from './root';

const reducers = { root };

export default combineReducers(reducers);

src/redux/reducers/root.js

const initState = {};

const root = (state = initState, action) => {
const {type} = action;
switch (type) {
default:
return state;
}
};

export default root;

建立 Redux epic

src/redux/epics/index.js

import { combineEpics } from 'redux-observable';export default combineEpics(...Object.values({ }));

Container & Component

安裝 React Router

npm install --save-dev react-router react-router-dom react-loadable

建立主要頁面的 container,用來設定 router、props 和 translation

src/containers/AppFrame/index.js

import { compose } from 'redux';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { withTranslation } from 'react-i18next';
import AppFrame from '../../components/AppFrame';const mapStateToProps = state => ({ });const mapDispatchToProps = dispatch => ({ });export default compose(
withRouter,
connect(
mapStateToProps,
mapDispatchToProps,
),
withTranslation(['common']),
)(AppFrame);

src/containers/index.js

import React from 'react';
import Loadable from 'react-loadable';
import { Provider } from 'react-redux';
import { Route, Switch } from 'react-router-dom';
import { startIPCEventListener } from '../event/ipc-event-listener';
import configureStore from '../redux/stores';
const store = configureStore();
// Import IPC event listener
startIPCEventListener(store);
const Index = (props) => {
const match = props.match;
const AsyncAppFrame = new Loadable({
loader: () => import('./AppFrame'),
loading: () => [],
});
return (
<Provider store={store}>
<React.Fragment>
<Switch>
<Route exact path={match.url} render={() => <AsyncAppFrame />} />
</Switch>
</React.Fragment>
</Provider>
);
};
export default Index;

主要頁面的 component,這邊將頁面分為上方、左邊和右邊,總共三個區塊,並且在上方和右邊區塊中留有內文變數(t(‘TopBar’) 和 t(‘RightPanel’)),來做為後面切換語系的 Demo

src/components/AppFrame/index.js

import React from 'react';
import { withStyles } from '@material-ui/core/styles';

import styles from './styles';

const AppFrame = (props) => {
const classes = props.classes;
const t = props.t;

return (
<div className={classes.container}>
<div className={classes.topBar}>{ t('TopBar') }</div>
<div className={classes.main}>
<div className={classes.leftPanel}>
</div>
<div className={classes.rightPanel}>{ t('RightPanel') }</div>
</div>
</div>
);
};

export default withStyles(styles, { withTheme: true })(AppFrame);

src/components/AppFrame/styles.js

const styles = theme => ({
container: {
height: '100vh',
width: '100vw',
display: 'flex',
flexDirection: 'column',
},
main: {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'row',
},
topBar: {
minHeight: 56,
width: '100vw',
},
leftPanel: {
minWidth: 234,
},
rightPanel: {
minWidth: 234,
}
});

export default styles;

最後完成 App.js 和 index.js 就完成 React 頁面的部分了,接下來就是 Electron 和 i18n 的工作

src/App.js

import React from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import { Router } from 'react-router';
import { createBrowserHistory } from 'history'
import Root from './containers';

const history = createBrowserHistory();

export default () => (
<Router history={history}>
<Switch>
<Route path="/" component={Root} />
<Redirect to="/" />
</Switch>
</Router>
);

src/index.js

import React from 'react';
import { render } from 'react-dom';

import App from './App';
import * as serviceWorker from './serviceWorker';

import './index.css';
import './i18n';

const root = document.getElementById('root');

!!root && render(<App />, root);

serviceWorker.unregister();

Electron

安裝 Electron

npm install --save-dev electron electron-is-dev

新增 electron script 進 package.json

...  "scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"electron": "electron ."
...

完成核心的檔案

在 start.js 中處理各種視窗上的行為

src/electron/start.js

const { app, BrowserWindow } = require('electron');
const isDev = require('electron-is-dev');
const path = require('path');
const { default: installExtension, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer');

const LanguageController = require('./controller/language-controller');
const { setupMenu } = require('./menu');
const i18n = require('./i18n');

let mainWindow;

function createWindow() {
mainWindow = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true,
nodeIntegrationInWorker: true,
preload: __dirname + '/preload.js'
}
});
mainWindow.maximize();
mainWindow.show();
mainWindow.webContents.openDevTools();

LanguageController.setup(mainWindow);

mainWindow.loadURL(
isDev
? 'http://localhost:3000'
: `file://${path.join(__dirname, '../build/index.html')}`
);

mainWindow.on('closed', () => {
mainWindow = null;
});
};

function installDevTool() {
installExtension(REACT_DEVELOPER_TOOLS)
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err));
};

app.on('ready', () => {
createWindow();
installDevTool();
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});

i18n.on('loaded', (loaded) => {
i18n.changeLanguage('en-US', (err, t) => {
if (err) {
console.error(err);
}
});
i18n.off('loaded');
});

i18n.on('languageChanged', (lng) => {
setupMenu(i18n);
});

i18n.on('failedLoading', (lng, ns, msg) => {
console.error(msg);
});

src/electron/preload.js

// All of the Node.js APIs are available in the preload process.
// It has the same sandbox as a Chrome extension.
window.addEventListener('DOMContentLoaded', () => {
const replaceText = (selector, text) => {
const element = document.getElementById(selector);
if (element) element.innerText = text
};

for (const type of ['chrome', 'node', 'electron']) {
replaceText(`${type}-version`, process.versions[type])
}
});

目錄

這邊會先使用 i18n 的部分在目錄的行為和變數上,後面才會補齊 i18n 的相關設定

src/electron/menu.js

const { Menu, app } = require('electron');
const ActionController = require('./controller/action-controller');

const isMac = process.platform === 'darwin';

module.exports = {
setupMenu: (i18n) => {
const menuTemplate = [
...(isMac ? [{
label: app.name,
submenu: [
{ role: 'about' },
{ role: 'quit' }
]
}] : []),
{
label: i18n.t('File'),
submenu: [
{
label: i18n.t('Open'),
},
]
},
{
label: i18n.t('Edit'),
submenu: [
{ type: 'separator' },
]
},
{
label: i18n.t('Insert'),
submenu: [
{ type: 'separator' },
]
},
{
label: i18n.t('Preview'),
submenu: [
{ type: 'separator' },
]
},
{
label: i18n.t('Help'),
submenu: [
{
label: i18n.t('Language'),
submenu: [
{
label: '繁體中文',
click() {
ActionController.mapAction('language.change', { language: 'zh-TW' });
}
},
{
label: '简体中文',
click() {
ActionController.mapAction('language.change', { language: 'zh-CN' });
}
},
{
label: 'English',
click() {
ActionController.mapAction('language.change', { language: 'en-US' });
}
}
]
},
{ type: 'separator' },
]
}
];
const menu = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menu);
}
};

語系切換動作

因為 Electron中可能會有多個動作事件需要處理,所以這邊使用 action-controller 最為統籌所有動作的主幹,接下來每一種動作事件都會開一隻 js 檔來專門處理,所以我們另外開一隻關於語系切換的動作 language-controller.js

src/electron/controller/action-controller.js

const LanguageController = require('./language-controller');

class ActionController {
constructor() {
this.actionHistory = [];
}
mapAction(action, ...args) {
switch (action) {
case 'language.change':
if (args && args.length > 0) {
LanguageController.changeLanguage(args[0]);
} else {
console.error('The argument of language changed is empty.');
}
break;
default:
return;
}
this.actionHistory.push({ action, args });
};

}

module.exports = new ActionController();

呼叫事件則會同時切換 Electron 視窗上的語系,並且同時透過 IPC 呼叫 React 進行網頁上的語系切換

src/electron/controller/language-controller.js

const i18n = require('../i18n');

class LanguageController {
constructor() {
this.window = null;
}

setup(window) {
this.window = window;
};

changeLanguage(ipcObject) {
// change language at react side
this.window.webContents.send('language-changed', ipcObject);
// change language at electron side
i18n.changeLanguage(ipcObject.language, (error, t) => {
if (error) {
console.error(error);
}
});
}
}

module.exports = new LanguageController();

重頭戲 i18n

安裝 i18n

npm install --save-dev i18next react-i18next i18next-node-fs-backend i18next-xhr-backend moment

準備 string table 檔案

這邊會將 Electron 和 React 要使用的檔案分開放,然後再根據不同語系與類型作檔案的分類

public/locales/electron/en-US/menu.json

{
"File": "File",
"Open": "Open",
"Edit": "Edit",
"Insert": "Insert",
"Preview": "Preview",
"Help": "Help",
"Language": "Language"
}

public/locales/electron/zh-CN/menu.json

{
"File": "档案",
"Open": "开启",
"Edit": "编辑",
"Insert": "插入",
"Preview": "预览",
"Help": "说明",
"Language": "语言"
}

public/locales/electron/zh-TW/menu.json

{
"File": "檔案",
"Open": "開啟",
"Edit": "編輯",
"Insert": "插入",
"Preview": "預覽",
"Help": "說明",
"Language": "語言"
}

public/locales/react/en-US/common.json

{
"TopBar": "Top Bar",
"RightPanel": "Right Panel"
}

public/locales/react/zh-CN/common.json

{
"TopBar": "上方列",
"RightPanel": "右面板"
}

public/locales/react/zh-TW/common.json

{
"TopBar": "上方列",
"RightPanel": "右面板"
}

i18n 設定檔

要特別注意 React 和 Eletron 是需要不同的 i18n 設定檔,而且寫法上與變數也都有些許差異

src/i18n.js

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import backend from 'i18next-xhr-backend';
import moment from 'moment';

i18n
.use(backend)
.use(initReactI18next)
.init({
lng: 'en-US',
defaultNS: 'common',
fallbackLng: 'en-US',
load: 'currentOnly',
debug: false,
whitelist: [
'en-US',
'zh-TW',
'zh-CN',
],
interpolation: {
escapeValue: false,
format: (value, format) => {
if (value instanceof moment) { return value.format(format); }
return value;
},
},
backend: {
loadPath: '/locales/react/{{lng}}/{{ns}}.json',
},
react: {
useSuspense: false,
},
initImmediate: false,
});

export default i18n;

src/electron/i18n.js

const i18n = require('i18next');
const backend = require('i18next-node-fs-backend');

i18n
.use(backend)
.init({
// String or array of namespaces to load
// Please set all of namespaces which electron will use to translate
// It will load them at the initialization stage
ns: ['menu'],
defaultNS: 'menu',
fallbackLng: 'en-US',
debug: false,
whitelist: [
'en-US',
'zh-TW',
'zh-CN',
],
interpolation: {
escapeValue: false
},
backend:{
// path where resources get loaded from
loadPath: './public/locales/electron/{{lng}}/{{ns}}.json',
// jsonIndent to use when storing json files
jsonIndent: 2,
},
});

module.exports = i18n;

啟動程式

都完成後就可以啟動我們的應用程式了,要注意的是 Electron 是把網站包裹在視窗中,所以啟動時必須先啟動好 React 網站的部分,再來啟動 Electron,並且兩個 command 要在不同的 Terminal 視窗執行

啟動 React

npm start

啟動 Electron

npm run electron

Resources

--

--

許聖泉 Michael Hsu
許聖泉 Michael Hsu

No responses yet