Electron+Mobx+React 開發記錄(一)

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

install_list.png

install_permission.png

install_detail.png

clean_search.png

clean_detail.png
開發環境搭建
程式碼目錄結構
electronux |---- [dir ] app ( 主程式碼目錄 ) |----------- [dir ] app/configure ( 應用配置檔案 ) |----------- [dir ] app/runtime ( 執行資料檔案 ) | |----------- [dir ] app/services ( 後臺服務存放目錄 ) |------------------------ [dir ] app/services/middleware ( 主程序訊號監聽器目錄 ) |------------------------ [dir ] app/services/shell ( shell指令碼存放目錄 ) | |----------- [dir ] app/stores ( 前端狀態管理檔案目錄 ) |----------- [dir ] app/styles( 共用樣式表文件 ) |----------- [dir ] app/utils( 小工具函式 ) | |----------- [dir ] app/views( 介面程式碼 ) |------------------------ [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.yml ( 應用編譯打包配置檔案 ) |---- [file] index.html( index.html入口頁面 ) |---- [file] index.js ( electron應用入口檔案 ) |---- [file] package.json (前端模組和框架配置檔案) |---- [file] webpack.config.js (webpack開發環境配置檔案) |---- [file] webpack.production.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 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" }, "nodemon node.js熱更新配置檔案": "-------------------------", "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" }, "dependencies": { "@fortawesome/fontawesome-svg-core": "^1.2.4", "@fortawesome/free-brands-svg-icons": "^5.3.1", "@fortawesome/free-solid-svg-icons": "^5.3.1", "@fortawesome/react-fontawesome": "^0.1.3", "electron": "^2.0.9", "electron-builder": "^20.28.4", "semantic-ui-css": "^2.4.0", "semantic-ui-react": "^0.82.5" }, "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", "concurrently": "^3.6.1", "cross-env": "^5.2.0", "css-loader": "^0.28.11", "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", "file-loader": "^2.0.0", "history": "^4.7.2", "html-webpack-plugin": "^3.2.0", "mobx": "^4.4.1", "mobx-react": "^5.2.8", "nodemon": "^1.18.4", "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", "style-loader": "^0.21.0", "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'); 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: '/', }, module: { // webpack 常用module配置 rules: [ { // babel用於網頁相容 test: /\.js$/, use: ['babel-loader'], }, { // css載入器 test: /\.css$/, use: ['style-loader', 'css-loader'], }, { // 檔案載入器 test: /\.(png|jpg|gif|svg|woff|eot|ttf|woff2)$/, use: [ 'file-loader', ], }, ], }, plugins: [ // webpack外掛配置 new webpack.HotModuleReplacementPlugin(), // 熱替換 new webpack.NamedModulesPlugin(), new webpack.NoEmitOnErrorsPlugin(), ], devServer: { // webpack-dev-server熱更新配置檔案 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 程式碼結構和熱更新
程式碼結構
-
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;
-
mobx store 儲存
這是專案其中一個系統清理模組的mobx store,在store中被mobx監聽的屬性最好結構層次簡單、只有單一的功能劃分,不要把一個屬性物件的巢狀寫得太深。開發時我們把UI介面的資料抽象成store中的資料時可能會下意識地根據頁面顯示狀態而把單個屬性物件寫得過於複雜,但其實頁面顯示狀態只是邏輯的資料結構,我們在store中儲存的時候應該儘量將這種邏輯資料結構
翻譯
成扁平化的資料結構,然後再在各個屬性物件之間建立對映關係。並且使用了mobx之後請儘量依賴mobx的資料引用監聽自動更新特性,多寫
computed
、autorun
來自動生成資料,使用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;
-
頁面元件劃分
在views目錄下建立的各個目錄都是一個單獨的元件目錄,元件目錄下有一個元件入口檔案和css樣式表文件以及其它子元件,入口檔案載入css檔案和子元件,使用
@inject
修飾器後各個元件都可以獨立訪問mobx store例項,不必在父和子元件之間通過props進行逐級引數傳遞,但是如果一個子元件依賴父元件來加工原始資料的話也可以使用props傳遞引數。使用了mobx之後,並不是說每個頁面需要使用的資料都有必要納入mobx store的管理,在我的程式碼中只是把
關鍵性資料
以及關鍵性資料加工方法
存入了store中,每個元件拿到store傳遞下來的資料後一些頁面狀態可能需要依賴元件各自的資料處理函式進行資料二次加工,我覺得這樣應該會減輕store例項的負載壓力,非絕對中心化。比如在一個列表選單元件中,這個元件的列表資料可以切換顯示和隱藏,但是控制這個列表顯示/隱藏的引數狀態visible
沒有必要納入store例項管理,相對的管理這個列表元件的store例項只是儲存了列表資料的陣列,以及一些必要的資料加工方法。
前端程式碼熱更新
- webpack.config.js中啟動webpack-dev-server的熱更新功能
devServer: { host: 'localhost', port: 3000, historyApiFallback: true, hot: true, },
- 使用
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有幾個方法, spawn
、 exec
、 execFile
、 fork
,它們都能建立子程序以執行指定檔案或命令,具體的使用方法見 Node API ,如果我們的指令碼或指令需要使用root許可權那可就麻煩了,桌面應用又不是終端,不可能用著用著讓使用者去終端輸入密碼吧,況且只是在開發環境下能看到終端輸出,應用打包安裝執行起來後就是一個獨立的應用程式了,根本沒法輸入終端密碼,仔細查閱了Electron官網API發現electron官方並沒有整合一個什麼系統許可權呼叫視窗之類的元件。沒辦法了,這種情況下手動寫出了兩種方法:
- 呼叫獲取系統許可權的系統自帶元件來執行自定義命令和指令碼
- 封裝一個彈窗元件來獲取使用者首次輸入的密碼,然後手動把密碼記錄到檔案中,應用啟動的時候從檔案中讀出密碼,在使用child_process建立子程序的時候再監聽子程序的輸出事件和錯誤事件,然後把讀取到的儲存在記憶體中的密碼以輸入流(input stream)的形式傳送給child_process建立的子程序,子程序讀取到輸入流傳入的密碼後就能繼續執行了。

electron_pssword.png

install_permission.png
具體程式碼見: github/nojsja/electronux/app/utils/sudo-prompt.js
感謝閱讀,文章中出現的錯誤之處還請多原諒~