Sugar-Electron 基於Electron的輕量級開發框架

前言

今天給你們帶來一款基於Electron桌面開發平臺的自研應用框架Sugar-Electron,指望能改善Electron應用穩定性和幫助開發團隊下降開發和維護成本。html

筆者使用Electron作桌面應用,已經有3年的時間,期間也遇到不少大大小小的坑。但總結起來,最大的問題仍是應用穩定性和開發效率問題。咱們指望經過這個框架,能讓應用程序在這兩個方面有所優化。webpack

項目源碼地址:
https://github.com/SugarTurboS/Sugar-Electronios

若有任何疑問,能夠掃碼加入微信羣聊討論git

在這裏插入圖片描述

關於應用穩定性

咱們知道Electron應用程序有三大基礎模塊。github

  • 主進程
  • 渲染進程
  • 進程間通訊

因爲咱們屬於多窗口(多渲染進程)的應用,因此咱們會把窗口公共的服務模塊都寫到主進程模塊,這爲整個程序的穩定性埋下了隱患。web

在Electron中,主進程控制了整個程序的生命週期,同時也負責管理它建立出來的各個渲染進程。一旦主進程的代碼出現問題,那麼會致使如下狀況發生。ajax

  • 主進程出現未捕獲的異常崩潰,直接致使應用退出。
  • 主進程出現阻塞,直接致使所有渲染進程阻塞,UI處於阻塞無響應狀態。

因此,在Sugar-Electron中,咱們引入了Service進程的概念,指望將業務原來寫在主進程的代碼,遷移到Service進程中(本質上是渲染進程),使得這些代碼致使的崩潰不會使得整個程序退出。而主進程的進程管理器能夠在Service崩潰時,重啓該進程並恢復崩潰前的狀態,從而提升整個程序的穩定性和可用性。npm

關於開發效率低

Electron屬於桌面開發平臺提供桌面應用開發的能力框架,上手簡單。但框架自己缺乏約定,所以使用Electron作應用開發,系統模塊會出現各類千奇百怪的劃分,代碼會出現多種多樣的寫法,這會顯著的增長學習成本,下降開發人員的效率。sugar-electron按照約定進行開發,下降團隊協做成本,以提高效率。json

特性

  • 內置進程間通訊模塊,支持請求響應、發佈訂閱的方式
  • 內置進程間狀態共享模塊,支持狀態同步變動、狀態變動監聽
  • 內置進程管理模塊,支持進程模塊集中式管理
  • 內置配置管理模塊,支持開發、測試、生產環境配置切換
  • 內置插件模塊,支持高度可擴展的插件機制
  • 框架侵入性低,項目接入改形成本低
  • 漸進式開發

設計原則

1、sugar-electron一切圍繞渲染進程爲核心設計,主進程只是充當進程管理(建立、刪除、異常監控)和調度(進程通訊、狀態功能橋樑)的守護進程的角色。axios

主進程不處理業務邏輯,這麼設計的好處:

  1. 能夠避免主進程出現未捕獲異常崩潰,致使應用退出
  2. 避免主進程出現阻塞,引發所有渲染進程阻塞,致使UI阻塞無響應

2、sugar-electron全部的業務模塊都是渲染進程。咱們知道進程之間是不能直接訪問的,爲了讓進程之間的調用就像同線程模塊之間直接調用同樣方便,sugar-electron提供瞭如下三個模塊:

  1. 進程間通訊模塊
  2. 進程間狀態共享模塊
  3. 進程管理模塊

3、爲了保證框架核心的足夠精簡、穩定、高效,所以框架的擴展能力相當重要,爲此sugar-electron提供自定義插件機制擴展框架能力,還能夠促進業務邏輯複用,甚至於生態圈的造成。

以下是框架邏輯視圖:
image

sugar-electron基於類微內核架構設計,以下圖所示:

image

其框架核心有七大模塊:

  • 基礎進程類BaseWindow
  • 服務進程類Service
  • 進程管理windowCenter
  • 進程間通訊ipc
  • 進程間狀態共享store
  • 配置中心config
  • 插件管理plugins

安裝

npm i sugar-electron

腳手架

npm i sugar-electron-cli -g

sugar-electron-cli init

核心功能

基礎進程類——BaseWindow

基礎進程類BaseWindow基於BrowserWindow二次封裝,sugar-electron以BaseWindow爲載體,聚合了框架全部核心模塊。

舉個例子

使用BrowserWindow建立渲染進程

// 在主進程中.
const { BrowserWindow } = require('electron')
let win = new BrowserWindow({ width: 800, height: 600, show: false });
win.on('ready-to-show', () => {})
win.loadURL('https://github.com');

使用BaseWindow建立渲染進程

// 在主進程中.
const { BaseWindow } = require('sugar-electron');
let win = new BaseWindow('winA', {
  url: 'https://github.com' // BaseWindow 特有屬性,默認打開的頁面
  width: 800, ght: 600, show: false
});
win.on('ready-to-show', () => {})
const browserWindowInstance = winA.open();

服務進程類——Service

在實際業務開發中,咱們須要有一個進程去承載業務進程通用模塊的功能,Service爲此而生。Service進程實例實際上也是渲染進程,只是開發者只須要傳入啓動入口js文件,便可建立一個渲染進程,且BaseWindow同樣,聚合框架全部核心模塊。

舉個例子

// -----------------------主進程-----------------------
const service = new Service('service', path.join(__dirname, 'app.js'), true);
service.on('success', function () {
    console.log('service進程啓動成功');
});
service.on('fail', function () {
    console.log('service進程啓動異常');
});
service.on('crashed', function () {
    console.log('service進程崩潰'); // 對應webContents.on('crashed')
});
service.on('closed', function () {
    console.log('service進程關閉'); // 對應browserWindow.on('closed')
});

進程通訊——ipc

ipc做爲進程間通訊核心模塊,支持三種通訊方式:

  1. 請求響應(渲染進程間)
  2. 發佈訂閱(渲染進程間)
  3. 主進程與渲染進程通訊

請求響應

邏輯視圖:

image

舉個例子

// 服務進程service
const { ipc } = require('sugar-electron');  
// 註冊響應服務A1
ipc.response('service-1', (json, cb) => {
    console.log(json); // { name: 'winA' }
    cb('service-1響應');
});

// 渲染進程winA
const { ipc, windowCenter } = require('sugar-electron');  

const r1 = await windowCenter.service.request('service-1', { name: 'winA' });
console.log(r1); // service-1響應
// 等同
const r2 = await ipc.request('service', 'service-1', { name: 'winA' });
console.log(r2); // service-1響應

異常

狀態碼 1 說明 2
1 找不到進程
2 找不到進程註冊服務
3 超時

發佈訂閱

邏輯視圖:

image

舉個例子

// 服務進程service
const { ipc } = require('sugar-electron');
setInterval(() => {
    ipc.publisher('service-publisher', { name: '發佈消息' });
}, 1000);

// winA
const { ipc, windowCenter } = require('sugar-electron');  

// 訂閱
const unsubscribe = windowCenter.service.subscribe('service-publisher', (json) => {
    console.log(json); // { name: '發佈消息' }
});
// 等同
const unsubscribe = ipc.subscribe('service', service-publisher', (json) => {
    console.log(json); // { name: '發佈消息' }
});


// 取消訂閱 
unsubscribe();
// 等同
windowCenter.service.unsubscribe('service-publisher', cb);

主進程與渲染進程間通訊(進程名"main",爲主進程預留)

sugar-electron框架設計理念全部業務模塊都有各個渲染進程完成,因此基本上不存在與主進程通訊的功能,但不排除有主進程與渲染進程通訊的場景。因此sugar-electron進程通訊模塊支持與主進程通訊接口。

舉個例子

// 主進程
const { ipc } = require('sugar-electron');
ipc.response('test', (data, cb) => {
    console.log(data); // 我是渲染進程
    cb('我是主進程')
});
 
// winA
const { windowCenter } = require('sugar-electron');
const res = windowCenter.main.request('test', '我是渲染進程');
console.log(res); // 我是主進程

// 或者
const res = ipc.request('main', 'test', '我是渲染進程');
console.log(res); // 我是主進程

進程管理——windowCenter

sugar-electron全部的業務模塊都是渲染進程。咱們知道進程之間是不能直接訪問的,全部有了進程管理模塊。

全部的渲染進程都能在windowCenter中根據進程名對應的惟一key找到對應的渲染進程,讓進程之間的調用就像同線程模塊之間直接調用同樣方便。

舉個例子

需求:winA內打開winB,並在winB webContents初始化完成後,設置窗口B setSize(400, 400)

// 主進程
const { BaseWindow, Service, windowCenter } = require('sugar-electron');
// 設置窗口默認設置,詳情請參考Electron BrowserWindow文檔
BaseWindow.setDefaultOption({
  show: false
});
 
// winA
const winA = new BaseWindow('winA', {
   url: `file://${__dirname}/indexA.html`
});
 
// winB
const winB = new BaseWindow('winB', {
   url: `file://${__dirname}/indexB.html`
});
 
// 建立winA窗口實例
windowCenter.winA.open(); // 等同於winA.open();
// winA
const { windowCenter } = require('sugar-electron');
const winB = windowCenter.winB;
// 建立winB窗口實例
await winB.open();
// 訂閱窗口建立完成「ready-to-show」
const unsubscribe = winB.subscribe('ready-to-show', () => {
   // 解綁訂閱
   unsubscribe();
  // 設置winB size[400, 400]
   const r1 = await winB.setSize(400, 400);
   // 獲取winB size[400, 400]
   const r2 = await winB.getSize();
   console.log(r1, r2);
});

==備註:服務進程句柄經過windowCenter也能夠獲取==

進程間狀態共享——store

sugar-electron是多進程架構設計,在業務系統中,避免不了多個業務進程共享狀態。因爲進程間內存相互獨立,不互通,爲此sugar-electron框架集成了進程狀態共享模塊。

進程狀態共享模塊分紅兩個部分:

  • 主進程申明共享狀態數據
  • 渲染進程設置、獲取共享狀態數據,訂閱狀態變化

舉個例子

// 主進程——初始化申明state
const { store } = require('sugar-electron');
store.createStore({
    state: {
        name: '我是store'
    },
    modules: {
        moduleA: {
            state: {
                name: '我是moduleA'
            }
        },
        moduleB: {
            state: {
                name: '我是moduleB'
            },
            modules: {
                moduleC: {
                    state: {
                        name: '我是moduleC'
                    }
                }
            }
        }
    }
});
// 渲染進程A,訂閱state變化
const { store } = require('sugar-electron');
console.log(store.state.name); // 我是store
// 訂閱更新消息
const unsubscribe = store.subscribe((data) => {
    console.log(store.state.name); // 改變state
    unsubscribe(); // 取消訂閱
});
 
// moduleA
const moduleA = store.getModule('moduleA');
console.log(moduleA.state.name); // 我是moduleA
const unsubscribeA = moduleA.subscribe((data) => {
    console.log(moduleA.state.name); // 改變moduleA
    unsubscribeA(); // 取消訂閱
});
// 渲染進程B,設置state
const { store } = require('sugar-electron');
await store.setState({
    'name': '改變state'
});
 
// moduleA
const moduleA = store.getModule('moduleA');
await moduleA.setState({
    'name': '改變moduleA'
});

配置——config

sugar-electron提供了多環境配置,可根據環境變量切換配置,默認加載生成環境配置。

config
|- config.base.js     // 基礎配置
|- config.js          // 生產配置
|- config.test.js     // 測試配置——環境變量env=test
|- config.dev.js      // 開發配置——環境變量env=dev

流程圖:

image

舉個例子

// 主進程
const { config } = require('sugar-electron');
global.config = config.setOption({ appName: 'sugar-electron', configPath: path.join(__dirname, 'config') });

// 渲染進程
const { config } = require('sugar-electron');
console.log(config);

==備註:==

  • AppData/appName 配置文件config.json { "env": "環境變量", "config": "配置" }
  • sugar-electron默認根據根目錄config自動初始化

插件——plugins

一個好用的框架離不開框架的可擴展性和業務複用。開發者經過plugins模塊自定義插件和配置安裝插件。

==使用一款插件,須要三個步驟:==

  1. 自定義封裝
  2. config目錄配置問題plugins.js配置插件安裝
  3. 使用插件

插件封裝

// 一、自定義封裝ajax插件adpter
const axios = require('axios');
const apis = {
    FETCH_DATA_1: {
        url: '/XXXXXXX1',
        method: 'POST'
    }
}
 
module.exports = {
    /**
     * 安裝插件,自定義插件必備
     * @ctx [object] 框架上下文對象{ config, ipc, store, windowCenter, plugins }
     * @params [object] 配置參數
    */
    install(ctx, params = {}) {
        // 經過配置文件讀取基礎服務配置
        const baseServer = ctx.config.baseServer;
        return {
            async callAPI(action, option) {
                const { method, url } = apis[action];
                try {
                    // 經過進程狀態共享SDK獲取用戶ID
                    const token = ctx.store.state.token;
                    const res = await axios({
                        method,
                        url: `${baseServer}${url}`,
                        data: option,
                        timeout: params.timeout // 經過插件配置超時時間
                    });
                    if (action === 'LOGOUT') {
                        // 經過進程間通訊模塊,告知主進程退出登陸
                        ctx.ipc.sendToMain('LOGOUT');
                    }
                    return res;
                } catch (error) {
                    throw error;
                }
            }
        }
    }
}

插件安裝

在配置中心目錄plugins.js配置插件安裝

config
|- config.base.js     // 基礎配置
|- config.js          // 生產配置
|- config.test.js     // 測試配置——環境變量env=test
|- config.dev.js      // 開發配置——環境變量env=dev
|- plugins.js         // 插件配置文件
// 二、配置插件安裝
const path = require('path');
exports.adpter = {
    // 若是根路徑plugins目錄有對應的插件名,則不須要配置path或package
    path: path.join(__dirname, '../plugins/adpter'),  // 插件絕對路徑
    package: 'adpter',  // 插件包名,若是package與path同時存在,則package優先級更高
    enable: true, // 是否啓動插件
    include: ['winA'], // 插件使用範圍,若是爲空,則全部渲染進程安裝
    params: { timeout: 20000 } // 傳入插件參數
};

插件使用

// 三、使用插件——winA
const { plugins } = require('sugar-electron');
const res = await plugins.adpter.callAPI('FETCH_DATA_1', {});

自動初始化核心模塊

使用過egg開發者應該知道,egg基礎功能模塊會根據對應的目錄自動初始化。sugar-electron也提供根據目錄自動初始化的能力。只須要使用框架啓動接口start傳入配置參數便可完成核心模塊自動初始化

舉個例子

const { start } = require('sugar-electron');
start({
    appName: '應用名',
    basePath: '啓動目錄',
    configPath: '配置中心目錄', // 可選,默認basePath + './config'
    storePath: '進程狀態共享目錄', // 可選,默認basePath + './store'
    windowCenterPath: '配置中心目錄', // 可選,默認basePath + './windowCenter'
    pluginsPath: '插件目錄', // 可選,默認basePath + './plugins'
})

注意事項

一、因爲sugar-electron核心模塊會自動判斷主進程或者渲染進程環境,自動選擇加載不一樣環境的模塊,若是使用webpack打包會致使把兩個環境的代碼都打包進去,可能還會出現異常。

所以,若是使用webpack打包,引入sugar-electron採用以下方式:

// 主進程
const { ipc, store, ... } = require('sugar-electron/main')


// 渲染進程
const { ipc, store, ... } = require('sugar-electron/render')

API

start

框架啓動接口,自動掛載config、store、windowCenter、plugins模塊

主進程API

/**
 * 啓動sugar
 * @param {object} options 啓動參數
 * @param {string} options.appName 應用名
 * @param {string} options.basePath 啓動目錄
 * @param {string} options.configPath 配置目錄,默認basePath + './config'
 * @param {string} options.storePath 進程狀態共享目錄,默認basePath + './store'
 * @param {string} options.windowCenterPath 窗口中心目錄,默認basePath + './windowCenter'
 * @param {string} options.pluginsPath 插件目錄,默認basePath + './plugins'
*/
start(opions)

使用舉例

// -----------------------主進程-----------------------
start({
    appName: string, 應用名,%appData%目錄
    basePath: string, 啓動目錄
    configPath: string, 配置目錄
    storePath: string, 進程狀態共享目錄
    windowCenterPath: string, 窗口中心目錄
    pluginsPath: string 插件目錄
});

BaseWindow

/**
 * 主進程調用
 * @param {string} name
 * @param {object} option
 */
new BaseWindow(name, option);

主進程API

setDefaultOptions [類方法]設置窗口默認配置

/**
 * @param {object} option 參考electron BrowserWindow
 */
setDefaultOptions(option)

open [實例方法]建立一個BrowserWindow實例

/**
 * @param {object} option 參考electron BrowserWindow
 * @return {browserWindow}
 */
open(option)

getInstance [實例方法]

/**
 * @return {browserWindow}
 */
getInstance(option)

publisher [實例方法]向當前窗口發佈通知,可參考ipc模塊

/**
 * @param {string} eventName 通知事件名
 * @param {object} param 參數
 * @return {browserWindow}
 */
publisher(eventName, param)

使用舉例

// -----------------------主進程-----------------------
const { BaseWindow } = require('sugar-electron');
BaseWindow.setDefaultOptions({
    width: 600,
    height: 800,
    show: false,
    ...
});
const winA = new BaseWindow('winA', { url: 'https://github.com' });
const instance = winA.open({...}); // 建立窗口
instance === winA.getInstance(); // true

Service

主進程API

/*
 * 建立服務進程
 * @param {string} name 服務進程名
 * @param {string} path 啓動入口文件路徑(絕對路徑)
 * @param {boolean} devTool  是否打開調試工具,默認false
 */
new Service(name = '', path = '', openDevTool = false);

使用舉例

// -----------------------主進程-----------------------
const service = new Service('service', path.join(__dirname, 'app.js'), true);
service.on('success', function () {
    console.log('service進程啓動成功');
});
service.on('fail', function () {
    console.log('service進程啓動異常');
});
service.on('crashed', function () {
    console.log('service進程崩潰'); // 對應webContents.on('crashed')
});
service.on('closed', function () {
    console.log('service進程關閉'); // 對應browserWindow.on('closed')
});

windowCenter

主進程、渲染進程API

windowCenter: object 進程集合key=進程名 value=進程實例,默認{}

使用舉例

// -----------------------主進程-----------------------
const service = new Service('service', path.join(__dirname, 'app.js'), true);
const winA = new BaseWindow('winA', {});
const winB = new BaseWindow('winB', {});
windowCenter['service'] === service; // true
windowCenter['winA'] === winA; // true
windowCenter['winB'] === winB; // true
windowCenter['winA'].open(); // 建立winA窗口實例,同步調用
windowCenter['winA'].on('ready-to-show', () => {
    windowCenter['winA'].setFullscreen(true);
});
// -----------------------渲染進程-----------------------
// 渲染進程接口調用其實是經過ipc通道通知主進程進程接口調用,因此接口異步而非同步
(async () => {
   await windowCenter['winA'].open(); // 建立winA窗口實例,異步Promise調用
   windowCenter['winA'].subscribe('ready-to-show', async () => {
       await windowCenter['winA'].setFullscreen(true);
   });
})()

ipc

主進程API
response 響應

/**
 * 註冊響應服務
 * @param {string} eventName 事件名  
 * @param {function} callback 回調
 * 使用方式,渲染進程ipc.request('main', eventName, param)
 */
response(eventName, callback)

渲染進程API

setDefaultRequestTimeout 設置響應超時時間

setDefaultRequestTimeout(timeout = 0);

request 請求

/**
 * @param {string} toId 進程ID(註冊通訊進程模塊名) 
 * @param {string} eventName 事件名 
 * @param {any} data 請求參數 
 * @param {number} timeout 超時時間,默認20s * 
 * @return 返回Promise對象
 */
request(toId, eventName, data, timeout)

response 響應

/**
 * 註冊響應服務
 * @param {string} eventName 事件名  
 * @param {function} callback 回調
 */
response(eventName, callback)

unresponse 註銷響應服務

/**
 * @param {string} eventName 事件名  
 * @param {function} callback 回調
 */
unresponse(eventName, callback)

publisher 發佈

/**
 * @param {string} eventName 事件名  
 * @param {any} param 參數
 */
publisher(eventName, param)

subscribe 訂閱

/**
 * @param {string} toId 進程ID(註冊通訊進程模塊名) 
 * @param {string} eventName 事件名 
 * @param {function} callback 回調
 */
subscribe(toId, eventName, callback)

unsubscribe 取消訂閱

/**
 * @param {string} toId 進程ID(註冊通訊進程模塊名) 
 * @param {string} eventName 事件名 
 * @param {function} callback 回調
 */
unsubscribe(toId, eventName, callback)

使用舉例

// ---------------------winA---------------------
const { ipc } = require('sugar-electron');  
// 註冊響應服務A1
ipc.response('get-data', (json, cb) => {
    console.log(json); // { name: 'winB' }
    cb('winA響應');
});


// ---------------------winB---------------------
const { ipc, windowCenter } = require('sugar-electron');  
const btn1 = document.querySelector('#btn1');
const { winA } = windowCenter;
btn1.onclick = () => {
    const r1 = await winA.request('get-data', { name: 'winB' });
    console.log(r1); // winA響應
    // 等同
    const r2 = await ipc.request('get-data', 'get-data', { name: 'winB' });
    console.log(r2); // winA響應
}

store

主進程API

createStore 初始化state

/**
 * @param {object} store
 */
createStore(store)

渲染進程API

setState 設置state

/**
 * 單個值設置
 * @param {string} key
 * @param {any} value
 * @return 返回Promise對象
 */
 setState(key, value)
 
 /**
 * 批量設置
 * @param {object} state
 * @return 返回Promise對象
 */
 setState(state)

subscribe 訂閱當前module的值變化通知

/**
 * @param {function} cb 訂閱回調
 * @return {function} 返回註銷訂閱function
 */
subscribe(cb)

unsubscribe 註銷訂閱

/**
 * @param {funtion} cb 訂閱回調
 */
unsubscribe(cb)

getModule 獲取module

/**
 * 獲取module
 * @param {string} moduleName 模塊名
 * @return {object} module
 * 返回:setState: 設置當前模塊state;subscribe: 訂閱;unsubscribe: 註銷訂閱;getModule: 獲取當前模塊的子模塊;getModules
 */
getModule(moduleName)

getModules 獲取全部modules

/**
 * @return {array} [module, module, module]
 */
getModules()

使用舉例

// 主進程——初始化申明state
const { store } = require('sugar-electron');
store.createStore({
    state: {
        name: '我是store'
    },
    modules: {
        moduleA: {
            state: {
                name: '我是moduleA'
            }
        },
        moduleB: {
            state: {
                name: '我是moduleB'
            },
            modules: {
                moduleC: {
                    state: {
                        name: '我是moduleC'
                    }
                }
            }
        }
    }
});
 
// 渲染進程
const { store } = require('sugar-electron');
store.state.name; // 我是store

// 訂閱更新消息
const unsubscribe = store.subscribe((data) => {
    console.log('更新:', data); // 更新:{ name: '我是store1' }
});
await store.setState({
    'name': '我是store1'
});
unsubscribe(); // 取消訂閱
 
// moduleA
const moduleA = store.getModule('moduleA');
moduleA.state.name; // 我是moduleA
const unsubscribeA = moduleA.subscribe((data) => {
    console.log('更新:', data); // 更新:{ name: '我是moduleA1' }
});
await moduleA.setState({
    'name': '我是moduleA1'
});
moduleA.unsubscribe(cb); // 取消訂閱
相關文章
相關標籤/搜索