Electron + React + Node.js + ES6 開發桌面軟件

@(Technical)[Electron|React|Node.js|ES6|Material Design]


1、概述

近來工作上需要做一款 PC 上的軟件,這款軟件大體來講是類似 PPT 的一款課件制作軟件。由于我最近幾年專注于移動 App 的開發,對 PC 端開發的了解有些滯后。所以我首先需要看看,在 PC 上采用什么框架能夠順利完成我的工作。

我的目標是,在完成這款軟件的同時能夠順便學習一下比較流行的技術。在經過前期技術調研后,我明確了實現這款軟件所需要的技術條件:

  • 不采用 C++ 方面的類庫,比如 MFC、Qt、DuiLib 等等;
  • 本來想試試 C# 來開發,但 C# 對我來說,需要從頭學習,如果學 C# 只為了開發這一款軟件,后續再無用武之地,那么對我來說,學習的驅動力不大;
  • 之前學習了移動端的開發庫 React Native,所以對 React 組件化的開發方式頗有好感,所以想嘗試用 React 來開發。

2、技術路線

基于以上幾點考慮,我通過搜索了解了 Electron 這個框架,果斷采用了下面的技術路線:

Technical 用途 文檔官網
Electron 包裝 HTML 頁面,為網頁提供一個本地運行環境 http://electron.atom.io/docs/
React 用 React 組件來寫頁面 https://facebook.github.io/react/
Node.js 為 Electron 提供運行環境 https://nodejs.org/en/docs/
ES6 Javascript 最新的 ES6 標準來完成代碼 http://es6-features.org/#Constants

Electron 是基于 Node.js 的上層框架,為 HTML 頁面提供一個 Native 的殼,并提供本地化的功能——如創建 Native 的 Window、文件選擇對話框、MessageBox、Window 菜單、Context 菜單、系統剪切板的訪問等等。Electron 支持 Windows/Linux/Mac 系統,所以我們只需要寫一份 Javascript 代碼和 HTML 頁面,就可以打包到不同的系統上面運行。

React 主要用來以組件化的方式寫頁面,另外 React 已經有非常非常多的開源組件可以用了,我們只需要通過 npm 引入進來即可使用。

因為 Electron 運行在 Node.js 環境里,所以我們的 App 已經可以訪問所有的 Node.js 的模塊了——比如文件系統、文件訪問等等,可以很方便的實現 Javascript 操作本地文件。另外安裝其他 npm 包也不費吹灰之力。

3、環境配置

將上述技術結合起來,需要的環境配置如下:

Library & Technical 用途
electron-prebuilt Electron 基礎庫
electron-reload 自動檢測本地文件修改,并重新加載頁面
electron-packager 將最終的代碼打包,提供給用戶
react React 基礎庫
react-dom 將 React 組件渲染到 HTML 頁面中(ReactDOM.render)
react-tap-event-plugin 使 material-ui 庫支持按鈕點擊事件(http://www.material-ui.com/#/get-started/installation
babel + babelify 將 ES6 代碼轉換成低版本的 Javascript 代碼
babel-preset-es2015 轉換 ES6 代碼
babel-preset-react 轉換 React JSX 語法的代碼
babel-plugin-transform-es2015-spread babel 插件,轉換 ES6 中的 spread 語法
babel-plugin-transform-object-rest-spread babel 插件,轉換 ES6 中的 Object spread 語法
browserify + watchify 自動檢測本地文件修改,結合 babel 重新轉換 ES6 代碼
Material-UI Google Material-Design 風格的 React UI 組件(http://www.material-ui.com/#/components/app-bar

4、各個庫交互流程

交互流程

5、開始開發

5.1、新建項目

首先我們要安裝 Node.js。然后通過下面的命令新建一個項目:

npm init

新建項目

5.2、添加依賴

這樣,就在我們的項目目錄下新建了一個 package.json 文件,然后我們安裝其他 npm 依賴。用下面的命令:

npm install --save-dev module_name

npm install --save module_name

--save-dev--save 參數的區別是:--save-dev 參數會把添加的 npm 包添加到 devDependencies 下,devDependencies 依賴只在開發環境下使用,而 --save 會添加到 dependencies 依賴下面。

這樣,我們就知道了,將 babelwatchify 等等生成代碼的依賴庫,需要安裝在 devDependencies 依賴下面,而像 React 等等,需要安裝在 dependencies 下面,以供打包發布后還能使用。

我們依次將前面第 3 小節所述的依賴全部通過 npm 安裝進來,其中 electron 相關組件安裝起來非常慢,有很大幾率失敗——如果安裝失敗了,多試幾次或者翻墻安裝。

npm install --save-dev electron-prebuilt electron-reload electron-packager

npm install --save-dev babel babelify babel-preset-es2015 babel-preset-react babel-plugin-transform-es2015-spread

npm install --save-dev browserify watchify

npm install --save react react-dom react-tap-event-plugin

npm install --save material-ui

5.3、babel 配置

安裝好 babel 后,還需要進行配置。我們通過 babel 官網了解到,需要在項目目錄下放置一個 .babelrc 文件,讓 babel 知道轉換哪些代碼。我們的 .babelrc 文件內容如下:

{
    "presets": [
        "es2015",
        "react"
    ],

    "plugins": [
        "transform-object-rest-spread"
    ]
}

通過 presetsplugins 兩個子項,我們告知 babel 轉換 ES6 和 React JSX 風格的代碼,另外還需轉換 ES6 中的 spread 語法。

5.4、watchify & electron-packager & electron 配置

另外,我們需要在 package.json 文件中配置 watchify,讓其可以自動檢測本地代碼變化,并且自動轉換代碼。package.json 如下:

// package.json

{
  "name": "demoapps",
  "version": "1.0.0",
  "description": "",
  "author": "arnozhang",
  "main": "index.js",
  "scripts": {
    "start": "electron .",
    "watch": "watchify app/appEntry.js -t babelify -o public/js/bundle.js --debug --verbose",
    "package": "electron-packager ./ DemoApps --overwrite --app-version=1.0.0 --platform=win32 --arch=all --out=../DemoApps --version=1.2.1 --icon=./public/img/app-icon.icns"
  },
  "devDependencies": {
    "babel": "^6.5.2",
    "babel-plugin-transform-es2015-spread": "^6.8.0",
    "babel-plugin-transform-object-rest-spread": "^6.8.0",
    "babel-preset-es2015": "^6.9.0",
    "babel-preset-react": "^6.5.0",
    "babelify": "^7.3.0",
    "browserify": "^13.0.1",
    "electron-packager": "^7.0.3",
    "electron-prebuilt": "^1.2.1",
    "electron-reload": "^1.0.0",
    "watchify": "^3.7.0"
  },
  "dependencies": {
    "material-ui": "^0.15.0",
    "react": "^15.1.0",
    "react-color": "2.1.0",
    "react-dom": "^15.1.0",
    "react-tap-event-plugin": "^1.0.0"
  }
}

通過 package.json 文件,我們在 scripts 下面配置了三個命令:startwatchpackage,分別用于啟動 App、檢測并轉換代碼、打包 App。

start: electron .
watch:watchify app/appEntry.js -t babelify -o public/js/bundle.js --debug --verbose
package:electron-packager ./ DemoApps --overwrite --app-version=1.0.0 --platform=win32 --arch=all --out=../DemoApps --version=1.2.1 --icon=./public/img/app-icon.icns

然后我們在命令行下通過 npm run xxx ,可以運行上面定義好的命令。我們看到,通過 babelify 將代碼轉換輸出到 public/js/bundle.js 目錄下,所以我們發布時只需要發布這一個轉換好的 js 文件即可。

5.5、目錄結構一覽

在我們正式開發之前,先來看一下整個項目的目錄結構:

目錄結構一覽

6、正式開發

6.1、Electron 開發模式

這里就不得不提到 Electron 的開發模式了,Electron 只是為 HTML 頁面提供了一個 Native 的殼,業務邏輯還需要通過 HTML + js 代碼去實現,Electron 提供兩個進程來完成這個任務:一個主進程,負責創建 Native 窗口,與操作系統進行 Native 的交互;一個渲染進程,負責渲染 HTML 頁面,執行 js 代碼。兩個進程之間的交互通過 Electron 提供的 IPC API 來完成。

由于我們在 package.json 文件中,指定了 mainindex.js,Electron 啟動后會首先在主進程加載執行這個 js 文件——我們需要在這個進程里面創建窗口,調用方法以加載頁面(index.html)。

6.2、index.js 開發

index.js 文件如下:

/*
 * Copyright (C) 2016. All Rights Reserved.
 *
 * @author  Arno Zhang
 * @date    2016/06/22
 */

'use strict';

const electron = require('electron');
const {app, BrowserWindow, Menu, ipcMain, ipcRenderer} = electron;


let isDevelopment = true;

if (isDevelopment) {
    require('electron-reload')(__dirname, {
        ignored: /node_modules|[\/\\]\./
    });
}


var mainWnd = null;

function createMainWnd() {
    mainWnd = new BrowserWindow({
        width: 800,
        height: 600,
        icon: 'public/img/app-icon.png'
    });

    if (isDevelopment) {
        mainWnd.webContents.openDevTools();
    }

    mainWnd.loadURL(`file://${__dirname}/index.html`);

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


app.on('ready', createMainWnd);

app.on('window-all-closed', () => {
    app.quit();
});

這段代碼很簡單,在 app ready 事件中,創建了主窗口,并通過 BrowserWindowloadURL 方法加載了本地目錄下的 index.html 頁面。在 app 的 window-all-closed 事件中,調用 app.quit 方法退出整個 App。

另外我們看到通過引入 electron-reload 模塊,讓本地文件更新后,自動重新加載頁面:

require('electron-reload')(__dirname, {ignored: /node_modules|[\/\\]\./});

6.3、index.html 開發

接下來就是 index.html 頁面的開發了,這里也比較簡單:

<!DOCTYPE html>

<html>
    <head>
        <meta charset="utf-8">
        <title>Electron Demo Apps</title>

        <link rel="stylesheet" type="text/css" href="public/css/main.css">
    </head>

    <body>
        <div id="content">
        </div>

        <script src="public/js/bundle.js"></script>
    </body>
</html>

看的出來,我們定義了一個 id 為 contentdiv,這個 div 是我們的容器,React 組件將會渲染到這個 div 上面。然后引入了 public/js/bundle.js 這個 Javascript 文件——前面講過,這個文件是通過 babelify 轉換生成的。

6.4、app/appEntry.js 開發

我們知道,public/js/bundle.js 是通過 app/appEntry.js 生成而來,所以,appEntry.js 文件主要負責 HTML 頁面渲染——我們通過 React 來實現它。

/*
 * Copyright (C) 2016. All Rights Reserved.
 *
 * @author  Arno Zhang
 * @date    2016/06/22
 */

'use strict';

import React from 'react';
import ReactDOM from 'react-dom';
import injectTapEventPlugin from 'react-tap-event-plugin';

const events = window.require('events');
const path = window.require('path');
const fs = window.require('fs');

const electron = window.require('electron');
const {ipcRenderer, shell} = electron;
const {dialog} = electron.remote;

import getMuiTheme from 'material-ui/styles/getMuiTheme';
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
import TextField from 'material-ui/TextField';
import RaisedButton from 'material-ui/RaisedButton';


let muiTheme = getMuiTheme({
    fontFamily: 'Microsoft YaHei'
});


class MainWindow extends React.Component {

    constructor(props) {
        super(props);
        injectTapEventPlugin();

        this.state = {
            userName: null,
            password: null
        };
    }

    render() {
        return (
            <MuiThemeProvider muiTheme={muiTheme}>
                <div style={styles.root}>
                    <img style={styles.icon} src='public/img/app-icon.png'/>

                    <TextField
                        hintText='請輸入用戶名'
                        value={this.state.userName}
                        onChange={(event) => {this.setState({userName: event.target.value})}}/>
                    <TextField
                        hintText='請輸入密碼'
                        type='password'
                        value={this.state.password}
                        onChange={(event) => {this.setState({password: event.target.value})}}/>

                    <div style={styles.buttons_container}>
                        <RaisedButton
                            label="登錄" primary={true}
                            onClick={this._handleLogin.bind(this)}/>
                        <RaisedButton
                            label="注冊" primary={false} style={{marginLeft: 60}}
                            onClick={this._handleRegistry.bind(this)}/>
                    </div>
                </div>
            </MuiThemeProvider>
        );
    }

    _handleLogin() {
        let options = {
            type: 'info',
            buttons: ['確定'],
            title: '登錄',
            message: this.state.userName,
            defaultId: 0,
            cancelId: 0
        };

        dialog.showMessageBox(options, (response) => {
            if (response == 0) {
                console.log('OK pressed!');
            }
        });
    }

    _handleRegistry() {
    }
}

const styles = {
    root: {
        position: 'absolute',
        left: 0,
        top: 0,
        right: 0,
        bottom: 0,
        display: 'flex',
        flex: 1,
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center'
    },
    icon: {
        width: 100,
        height: 100,
        marginBottom: 40
    },
    buttons_container: {
        paddingTop: 30,
        width: '100%',
        display: 'flex',
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center'
    }
};


let mainWndComponent = ReactDOM.render(
    <MainWindow />,
    document.getElementById('content'));

現在我們已經到了渲染進程了,這里引入 electron 必須使用 window.require 來引入,否則會出錯。這里刨除 material-ui 的部分,主要的代碼是:

let mainWndComponent = ReactDOM.render(
    <MainWindow />, 
    document.getElementById('content'));

我們通過 ReactDOM.render 方法將一個 React 組件渲染到了一個 div 上面。

7、運行起來

現在我們已經可以運行這個程序了,首先我們啟動 Watchify,主要是讓其監控本地文件修改,實時轉換生成 public/js/bundle.js 文件:

npm run watch

npm run watch

接下來調用 start 命令就可以啟動 App 了:

npm run start

npm run start

運行之后,我們馬上看到我們的窗口了:

運行結果
彈出對話框

8、打包

打包命令非常簡單,但比較耗時:

npm run package

執行完之后可以在對應的目錄看到打包好的文件夾了,將這個文件夾壓縮,可以提供給用戶運行。為了防止代碼泄露,你還可以通過 asar 這個 npm 模塊,將你的代碼打包為 asar 文件。

9、后續

這里只是一個介紹,你可以根據你自己的情況去開發對應的 App。我在開發過程中發現了一些好用的庫,這里順便記錄一下:

library 用途
font-detective 檢測系統中安裝的字體列表
extend 拷貝一個對象
draft-js facebook 出品的 React 富文本編輯器,高度可定制化
draft-js-export-html 將 draft-js 的數據轉換成 HTML 代碼,定制化不高,有高定制化需求時,需要改代碼
material-design-icons 一系列 Material 風格的 Icon,可以結合 material-ui 中的 FontIcon 使用
md5-file 計算文件 MD5,提供同步和異步兩種計算方式
node-native-zip 文件的壓縮和解壓縮,非常好用
xmlbuilder XML 生成器,簡單好用!極其推薦
react-color React 組件化的顏色選擇器,支持各種方式的選擇!極其推薦
react-resizeable-and-movable React 組件化的拖動 & 改變大小的模塊
qrcode.react React 組件化的二維碼生成器,極其推薦
request 用來下載 & 上傳
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容