用 React 和 Electron 開發多國語系的應用程式(i18n)
因為 javascript 跨平台的特性,有越來越多的應用程式選擇以 javascript 最為開發語言,而其中不外乎桌面端的應用程式,而要快速將網頁程式包裹成桌面端應用程式,又不想維護差異性太大的兩份專案時,Electron 成為了開發網頁桌面程式的一個選擇
使用目前流行的三大前端框架之一的 React 最為網頁的基底,並且透過 Electron 完成桌面端的應用程式的開發,已經是多數人的選擇
而其中不外乎會遇到 i18n 多語言切換的需求,這時候如何有效地串接語系切換在 Electron 和 React 變成了工程師需解決的課題
本範例以最簡單的 simple code 來示範如何透過 React 和 Electron 編譯出一個桌面端應用程式的範本,並且串切兩者之間 i18n 語系切換的動作
所有的程式碼範例可以參考在
首先,完成 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