Electron+Mobx+React 開發記錄

> Contents

  1. 前言
  2. 開發環境搭建
  3. 引入Webpack4.0前端打包工具
  4. Electron代碼結構和代碼熱更新
  5. 前端界面React + Mobx 代碼結構和熱更新
  6. Linux桌面客戶端開發遇到的問題

前言


最近桌面系統從Ubuntu18.04切換到了Manjaro Linux 17,之前聽說Manjaro的軟件豐富,倉庫更新及時,很多常用軟件都能一鍵安裝(比如QQ,微信),同時也支持主流的Linux桌面環境:Gnome、KDE、Cinnamon、Mate、Deepin等等,安裝了Gnome版本的Manjaro之後發現果然還不錯。系統安裝好後配置比較繁瑣,就想給Manjaro寫一個GUI客戶端工具用於安裝常用軟件和作爲簡單的系統管理工具 - electronux
作爲一名正直的前端開發人員,理所應當地就準備使用Electron + Node.js + React + Mobx + Webpack + Shell 來進行開發啦 ~ 目前仍然在開發中,這篇文章用於記錄自己的環境搭建過程、一些對Electron+React開發的理解以及談談自己遇到的一些Linux桌面軟件開發時遇到的問題和解決辦法。

開發環境搭建


代碼目錄結構
electronux  
|---- [dir ] app ( 主代碼目錄 )
|----------- [dir ] app/configure ( 應用配置更新 )
|----------- [dir ] app/runtime ( 運行數據文件 )
|
|----------- [dir ] app/services ( 後臺服務存放目錄 )
|------------------------ [dir ] app/services/middleware ( 一些中間處理件 )
|------------------------ [dir ] app/services/shell ( shell腳本存放目錄 )
|------------------------ [dir ] app/services/main-serv ( 主進程服務 )
|------------------------ [dir ] app/services/render-serv ( 渲染進程服務 )
|
|----------- [dir ] app/stores ( 前端狀態管理文件目錄 )
|----------- [dir ] app/styles  ( 公用樣式表文件 )
|----------- [dir ] app/utils  ( 公用工具函數 )
|
|----------- [dir ] app/views  ( UI界面代碼 )
|------------------------ [dir ] app/views/module1  ( 界面模塊1 )
|------------------------ [dir ] app/views/module2  ( 界面模塊2)
|------------------------ [dir ] app/views/module3  ( 界面模塊3 )
|
|----------- [file] app/App.js  ( 前端應用入口文件 )
|----------- [file] app/index.js ( 前端應用熱加載文件 )
|
|---- [dir ] dist ( 前端代碼編譯打包文件存放目錄 )
|---- [dir ] resources ( 前端靜態資源存放目錄 )
|
|---- [file] .babelrc ( babel配置文件 )
|---- [file] .editorconfig (編輯器編碼規範文件)
|---- [file] .eslintrc ( 代碼格式檢查配置文件 )
|---- [file] .gitignore ( git忽略追蹤配置文件 )
|---- [file] electron-builder.json ( electron-builder打包配置文件 )
|---- [file] index.html  ( 應用渲染入口頁面 )
|---- [file] index.js ( 應用主進程入口文件 )
|---- [file] package.json (前端模塊和框架配置文件)
|---- [file] webpack.config.js (webpack開發環境配置文件)
|---- [file] webpack.prod.config.js  ( webpack生產環境配置文件 )

項目環境依賴配置文件
{
  "name": "electronux",
  "description": "linux manager-software powered by electron & react & Mobx ",
  "version": "1.0.0",
  "author": {
    "name": "NoJsJa",
    "email": "[email protected]"
  },
  "scripts": {
    "start": "concurrently \"npm run start-dev\" \"npm run start-electron\"",
    "start-dev": "cross-env NODE_ENV=development webpack-dev-server",
    "start-electron": "nodemon --exec 'cross-env NODE_ENV=development electron --inspect=5858 index'",
    "start-production": "cross-env NODE_ENV=production electron --inspect=5858 index",
    "build-all": "npm run dist && npm run build",
    "dist": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js",
    "build": "electron-builder -l"
  },
  "keywords": [
    "electron",
    "react",
    "mobx",
    "react-router",
    "webpack4"
  ],
  "license": "",
  "nodemonConfig": {
    "ignore": [
      "resources/*",
      "node_modules/*",
      "dist/*",
      "build/*",
      "app/stores/*",
      "app/styles/*",
      "app/services/shell/*",
      "app/configure/view.conf",
      "app/views/*",
      "app/App.js",
      "app/main.js",
      "app/index.js",
      "electron-builder.yml"
    ],
    "delay": "1000"
  },
  "dependencies": {
    "semantic-ui-css": "^2.4.0",
    "semantic-ui-react": "^0.82.5",
    "mobx": "^4.4.1",
    "mobx-react": "^5.2.8",
    "prop-types": "^15.6.2",
    "react": "^16.5.1",
    "react-dom": "^16.5.1",
    "react-hot-loader": "^4.3.8",
    "react-router": "^4.3.1",
    "react-router-dom": "^4.3.1",
    "history": "^4.7.2"
  },
  "devDependencies": {
    "babel-core": "^6.26.3",
    "babel-eslint": "^10.0.1",
    "babel-loader": "^7.1.5",
    "babel-plugin-transform-decorators-legacy": "^1.3.5",
    "babel-preset-env": "^1.7.0",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "babel-preset-stage-0": "^6.24.1",
    "clean-webpack-plugin": "^0.1.19",
    "concurrently": "^3.6.1",
    "cross-env": "^5.2.0",
    "css-loader": "^0.28.11",
    "electron": "^2.0.9",
    "electron-builder": "^20.28.4",
    "eslint": "^5.6.1",
    "eslint-config-airbnb": "^17.1.0",
    "eslint-plugin-import": "^2.14.0",
    "eslint-plugin-jsx-a11y": "^6.1.2",
    "eslint-plugin-react": "^7.11.1",
    "extract-text-webpack-plugin": "^4.0.0-beta.0",
    "file-loader": "^2.0.0",
    "html-loader": "^0.5.5",
    "html-webpack-plugin": "^3.2.0",
    "node-sass": "^4.9.4",
    "nodemon": "^1.18.4",
    "sass-loader": "^7.1.0",
    "source-map-support": "^0.5.9",
    "style-loader": "^0.21.0",
    "url-loader": "^1.1.2",
    "webpack": "^4.19.0",
    "webpack-cli": "^2.1.5",
    "webpack-dev-server": "^3.1.8"
  }
}

引入Webpack4.0前端打包工具


webpack開發環境配置文件
const path = require('path');
const webpack = require('webpack');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

// 拆分樣式文件
const extractSass = new ExtractTextPlugin({
  filename: 'style.scss.css',
});

const extractCss = new ExtractTextPlugin({
  filename: 'style.css',
});

module.exports = {
  devtool: 'source-map',
  entry: [
    'react-hot-loader/patch',
    'webpack-dev-server/client?http://localhost:3000',
    'webpack/hot/only-dev-server',
    './app/index',
  ],
  mode: 'development',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  resolve: {
    alias: {
      resources: path.resolve(__dirname, 'resources'),
      app: path.resolve(__dirname, 'app'),
    },
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['babel-loader'],
      },
      {
        test: /\.css$/,
        use: extractCss.extract({
          fallback: 'style-loader',
          use: 'css-loader',
          publicPath: '/',
        }),
      },
      {
        test: /\.scss$/,
        use: extractSass.extract({
          use: [{
            loader: 'css-loader',
          }, {
            loader: 'sass-loader',
          }],
          fallback: 'style-loader', // 在開發環境使用 style-loader
          publicPath: '/',
        }),
      },
      {
        test: /\.html$/,
        use: {
          loader: 'html-loader',
        },
      },
      {
        test: /\.(png|jpg|gif|svg|ico|woff|eot|ttf|woff2)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: '[path][name].[ext]',
            },
          },
        ],
      },
    ],
  },

  plugins: [
    extractSass,
    extractCss,
    new webpack.HotModuleReplacementPlugin(),
    new CleanWebpackPlugin(['dist']),
    new webpack.NamedModulesPlugin(),
    new webpack.NoEmitOnErrorsPlugin(),
  ],

  devServer: {
    host: 'localhost',
    port: 3000,
    historyApiFallback: true,
    hot: true,
  },
  target: 'electron-renderer',
};

Electron基本原理和代碼熱更新


Electron 運行 package.json 的 main 腳本的進程被稱爲主進程。 在主進程中運行的腳本通過創建web頁面來展示用戶界面。 一個 Electron 應用總是有且只有一個主進程。
由於 Electron 使用了 Chromium 來展示 web 頁面,所以 Chromium 的多進程架構也被使用到。 每個 Electron 中的 web 頁面運行在它自己的渲染進程中。
在普通的瀏覽器中,web頁面通常在一個沙盒環境中運行,不被允許去接觸原生的資源。 然而 Electron 的用戶在 Node.js 的 API 支持下可以在頁面中和操作系統進行一些底層交互。
進程使用 BrowserWindow 實例創建頁面。 每個 BrowserWindow 實例都在自己的渲染進程裏運行頁面。 當一個 BrowserWindow 實例被銷燬後,相應的渲染進程也會被終止。
主進程管理所有的web頁面和它們對應的渲染進程。 每個渲染進程都是獨立的,它只關心它所運行的 web 頁面。
在頁面中調用與 GUI 相關的原生 API 是不被允許的,因爲在 web 頁面裏操作原生的 GUI 資源是非常危險的,而且容易造成資源泄露。 如果你想在 web 頁面裏使用 GUI 操作,其對應的渲染進程必須與主進程進行通訊,請求主進程進行相關的 GUI 操作。

創建主進程

在index.js文件中我們引入electron和所有的自定義模塊文件,並根據開發環境或是生產環境來進行主進程窗口加載,開發環境下使用http協議加載由webpack-dev-server啓動的http服務,生產環境下使用file協議加載本地由webpack打包好的前端bundle.js文件,所以開發環境下npm start指令其實主要是執行了兩步操作,一是啓動webpack-dev-server,此時已經可以通過外部瀏覽器訪問到localhost:3000的http服務,只不過我們實際是用electron之中的chromium瀏覽器來加載的,它與node.js主進程共享同一個chrome v8引擎,所以理論上,在頁面加載後,你同樣可以在渲染進程中使用node.js API,比如用使用fs模塊訪問文件系統。

主進程代碼熱更新

我用了nodemon工具實現了主進程代碼熱更新,如果不用nodemon工具那麼 npm start-electron命令實際是執行cross-env NODE_ENV=development electron index,就是簡單的用electron啓動主進程文件,使用nodemon之後npm start-electron實際上是執行nodemon --exec 'cross-env NODE_ENV=development electron index',最後在package.json文件中增加一個nodemonConfig字段用於指定哪些文件需要納入nodemon監聽即可。

=> package.json中定義的啓動腳本:

  "scripts": {
    "start": "concurrently \"npm run start-dev\" \"npm run start-electron\"",
    "start-dev": "cross-env NODE_ENV=development webpack-dev-server",
    "start-electron": "nodemon --exec 'cross-env NODE_ENV=development electron index'",
    "build": "npm run dist && npm run build-all",
    "dist": "cross-env NODE_ENV=production webpack  --config webpack.production.config.js",
    "build-all": "build -lmw"
  },

=> package.json中nodemonConfig字段

"nodemonConfig": {
    "ignore": [
      "resources/*",
      "node_modules/*",
      "dist/*",
      "app/stores/*",
      "app/styles/*",
      "app/services/shell/*",
      "app/configure/view.conf",
      "app/views/*",
      "app/App.js",
      "app/main.js",
      "app/index.js"
    ],
    "delay": "1000"
  },

=> 項目啓動文件index.js:

...
// 根據運行環境加載窗口 //
function loadWindow(window, env) {
  if (env === 'development') {
    // wait for webpack-dev-server start
    setTimeout(() => {
      window.loadURL(url.format({
        pathname: 'localhost:3000',
        protocol: 'http:',
        slashes: true,
      }));
      // window.webContents.openDevTools();
    }, 1e3);
  } else {
    window.loadURL(url.format({
      pathname: path.join(path.resolve(__dirname, './dist'), 'index.html'),
      protocol: 'file:',
      slashes: true,
    }));
  }
}

/* ------------------- main window ------------------- */

function createWindow() {
  const { width, height } = getAppConf();
  win = new BrowserWindow({
    width,
    height,
    title: 'electronux',
    autoHideMenuBar: true,
  });

  win.on('resize', () => {
    const [_width, _height] = win.getContentSize();
    viewConf.set({
      width: _width,
      height: _height,
    });
  });

  loadWindow(win, nodeEnv);
}

/* ------------------- electron event ------------------- */

app.on('ready', () => {
  if (nodeEnv === 'development') {
    sourceMapSupport.install();
  }
  createWindow();
});

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

app.on('will-quit', () => {
  viewConf.write().then(() => 0, (err) => {
    console.error(err);
    throw new Error('App quit: view-conf write error !');
  });
});

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

前端界面React + Mobx 代碼結構和熱更新


代碼結構
  1. App.js前端入口文件
    入口文件基本是整個前端應用的關鍵點,我們使用mobx-react包提供的Provider組件加載整個應用,並把各個應用模塊(按功能劃分)的mobx store示例作爲props屬性傳入Provider,在各個組建中使用修飾器@inject就能直接使用store實例了,頁面層次比較多的話最好使用React Router進行路由管理,值得注意的是React Router V4版本跟之前版本的理念和使用方式有很大區別,可以去官網查閱相關文檔react-router4
/* ------------------- export global history ------------------- */
export const history = createHistory();

const stores = {
  install: new InstallState(),
  startup: new StartupState(),
  info: new InfoState(),
  clean: new CleanState(),
  pub: new PublicState(),
};

function App() {
  return (
    <Provider {...stores}>
      <Router history={history}>
        <Route path="/" component={HomePage} />
      </Router>
    </Provider>
  );
}

/* ------------------- export provider ------------------- */
export default App;

  1. mobx store 存儲
    這是項目其中一個系統清理模塊的mobx store,在store中被mobx監聽的屬性最好結構層次簡單、只有單一的功能劃分,不要把一個屬性對象的嵌套寫得太深。開發時我們把UI界面的數據抽象成store中的數據時可能會下意識地根據頁面顯示狀態而把單個屬性對象寫得過於複雜,但其實頁面顯示狀態只是邏輯的數據結構,我們在store中存儲的時候應該儘量將這種邏輯數據結構翻譯成扁平化的數據結構,然後再在各個屬性對象之間建立映射關係。
    並且使用了mobx之後請儘量依賴mobx的數據引用監聽自動更新特性,多寫computedautorun來自動生成數據,使用action修飾一些需要更改store屬性的方法。
class Clean {
  constructor() { }
  /* ------------------- observable ------------------- */

  // 所有檢查項目 //
  @observable items = {
    appCache: false,
    appLog: false,
    trash: false,
    packageCache: false,
  };

  // 主界面加載 //
  @observable loadingMain = false;

  // 清理路徑 //
  cleanPaths = {
    appCache: [`/home/${this.userinfo.username}/.cache`],
    appLog: ['/var/log/'],
    trash: [`/home/${this.userinfo.username}/.local/share/Trash/files`],
    packageCache: ['/var/cache/pacman/pkg'],
  }

  // 路徑模塊映射 //
  @observable cleanPathMap = {
    appCache: [], // '/var/log/pacman.log'
    appLog: [],
    trash: [],
    packageCache: [],
  }

  // 清理內容 //
  @observable cleanContents = observable.map({})

  // 清理大小 //
  cleanSizes = {
    // '/var/log//pacman.log': '10kb',
  }

  // ---- 清理選項細節-數據對象邏輯樹結構 ---- //
  // @observable cleanDetails = {
  //   appCache: {
  //     url: [`/home/${this.userinfo.username}/.cache`], // 指定掃描路徑多個
  //     contents: { // 絕對路徑
  //       // '/var/cache/pacman/pkg/zsh-5.6.2-1-x86_64.pkg.tar.xz': false,
  //     },
  //     size: {
  //       // '/var/cache/pacman/pkg/zsh-5.6.2-1-x86_64.pkg.tar.xz': '10kb',
  //     },
  //   },
  //   appLog: {
  //     url: ['/var/log/'],
  //     contents: {
  //       // '/var/log//pacman.log': false,
  //     },
  //     size: {
  //       // '/var/log//pacman.log': '10kb',
  //     },
  //   }
  // }

  /* ------------------- static ------------------- */


  /* ------------------- computed ------------------- */

  // 獲取所有被選中的detail item //
  @computed get allCheckedDetail() {
    const a = [];
    this.cleanContents.forEach((v, k) => {
      if (v) a.push(k);
    });
    return a;
  }

  // 清理路徑詳細信息 //
  @computed get cleanDetail() {
    const result = [];
    Object.keys(this.cleanPathMap).forEach((item) => {
      if (this.items[item]) {
        const oneResult = {
          label: item,
          contents: [],
        };
        this.cleanPathMap[item].forEach((it) => {
          oneResult.contents.push({
            content: it,
            size: this.cleanSizes[it] || 0,
          });
        });

        result.push(oneResult);
      }
    });

    return result;
  }
}

export default Clean;

  1. 頁面組件劃分
    在views目錄下創建的各個目錄都是一個單獨的組件目錄,組件目錄下有一個組件入口文件和css樣式表文件以及其它子組件,入口文件載入css文件和子組件,使用@inject修飾器後各個組件都可以獨立訪問mobx store實例,不必在父和子組件之間通過props進行逐級參數傳遞,但是如果一個子組件依賴父組件來加工原始數據的話也可以使用props傳遞參數。
    使用了mobx之後,並不是說每個頁面需要使用的數據都有必要納入mobx store的管理,在我的代碼中只是把關鍵性數據以及關鍵性數據加工方法存入了store中,每個組件拿到store傳遞下來的數據後一些頁面狀態可能需要依賴組件各自的數據處理函數進行數據二次加工,我覺得這樣應該會減輕store實例的負載壓力,非絕對中心化。比如在一個列表菜單組件中,這個組件的列表數據可以切換顯示和隱藏,但是控制這個列表顯示/隱藏的參數狀態visible沒有必要納入store實例管理,相對的管理這個列表組件的store實例只是存儲了列表數據的數組,以及一些必要的數據加工方法。

  2. 渲染進程和主進程ipc通信的問題
    頁面的每個渲染進程(ipcRender),雖然說可以直接使用node.js原生模塊和api,但是不建議在渲染進程中過度使用原生模塊,一是因爲一些node.js原生模塊並沒有考慮到進程安全的問題,第二個原因是渲染進程應該專注處理頁面交互和數據處理問題,劃清代碼的功能區域,把和系統交互的問題交由主進程(ipcMain)處理,把網絡數據請求也交由各自的service服務,減少不必要的模塊和數據耦合。渲染進程通過ipc通信向主進程發送處理請求,主進程和service負責原始數據的獲取和網絡數據的傳輸,最後主進程通過ipc通信向對應的渲染進程返回處理結果,service拿到的網絡數據也通過回調事件發送給渲染進程。項目中我把mobx store作爲和主進程通信的橋樑,mobx store向主進程發送信號,同時也在接收到主進程的ipc通信事件後再把主進程發回來的數據更新到各個observer。總之主進程和service服務負責系統交互、原始數據獲取和傳輸,渲染進程mobx store負責響應信號和事件進行業務數據更新,各個view子組件只負責頁面渲染和用戶交互。

前端代碼熱更新
  1. webpack.config.js中啓動webpack-dev-server的熱更新功能
devServer: {
    host: 'localhost',
    port: 3000,
    historyApiFallback: true,
    hot: true,
  },
  1. 使用react-hot-loader的AppContainer組件
import { AppContainer } from 'react-hot-loader';

import 'semantic-ui-css/semantic.min.css';
import './styles/public.css';

import App from './App';

render(
  <AppContainer>
    <App />
  </AppContainer>,
  document.getElementById('root')
);

Linux桌面客戶端開發遇到的問題


使用node.js子進程child_process執行shell腳本時無法取得系統root權限

項目中有的腳本需要使用root權限,比如安裝和卸載軟件、掃描系統關鍵路徑,node.js裏執行shell腳本可以使用child_process模塊(node.js子進程),child_process有幾個方法,spawnexecexecFilefork,它們都能創建子進程以執行指定文件或命令,具體的使用方法見Node API,如果我們的腳本或指令需要使用root權限那可就麻煩了,桌面應用又不是終端,不可能用着用着讓用戶去終端輸入密碼吧,況且只是在開發環境下能看到終端輸出,應用打包安裝運行起來後就是一個獨立的應用程序了,根本沒法輸入終端密碼,仔細查閱了Electron官網API發現electron官方並沒有集成一個什麼系統權限調用窗口之類的組件。沒辦法了,這種情況下手動寫出了兩種方法:

  1. 調用獲取系統權限的系統自帶組件來執行自定義命令和腳本
  2. 封裝一個彈窗組件來獲取用戶首次輸入的密碼,然後手動把密碼記錄到文件中,應用啓動的時候從文件中讀出密碼,在使用child_process創建子進程的時候再監聽子進程的輸出事件和錯誤事件,然後把讀取到的保存在內存中的密碼以輸入流(input stream)的形式發送給child_process創建的子進程,子進程讀取到輸入流傳入的密碼後就能繼續執行了。

具體代碼見github/nojsja/electronux/app/utils/sudo-prompt.js

感謝閱讀,文章中出現的錯誤之處還請多原諒~

未完待續.....
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章