feWorkflow - 使用 electron, react, redux, immutable 構建桌面 App

這篇文章主要是項目中用到的開發框架功能點上的一個總結,包括基本的操做流程和一些心得體會。javascript

前言

15年初建立了適用於目前團隊的gulp工做流,旨在以一個gulpfile來操做和執行全部文件結構。隨着項目依賴滾雪球式的增加,拉取npm包成了配置中最麻煩而極容易出錯的一項。爲了解決配置過程當中遇到的種種問題,15年末草草實現了一個方案,用nw.js(基於Chromium和node.js的app執行工具)框架來編寫了一個簡單的桌面應用gulp-ui, 所作的操做是打包gulpfile和所依賴的全部node_modules在一塊兒,而後簡單粗暴的在app內部執行gulpfile。css

gulp-ui 作出來後再團隊中使用了一段時間,以單個項目來執行的方式確實在常常多項目開發的使用環境中多有不便。因而在這個基礎上,重寫了整個代碼結構,開發瞭如今的版本feWorkflow.html

feWorkflow 改用了electron作爲底層,使用react, redux, immutable框架作ui開發,仍然基於運行gulpfile的方案,這樣可使每一個使用本身團隊的gulp工做流快速接入和自由調整。前端

圖片描述

功能:一鍵式開發/壓縮java

  • less實時監聽編譯cssnode

  • css前綴自動補全react

  • 格式化html,並自動替換src源碼路徑爲tc_idc發佈路徑linux

  • 壓縮圖片(png|jpg|gif|svg)webpack

  • 壓縮或格式化js,並自動替換src源碼路徑爲tc_idc發佈路徑git

  • 同步刷新瀏覽器browserSync

框架選型

electron

與 NW.js 類似,Electron 提供了一個能經過 JavaScript 和 HTML 建立桌面應用的平臺,同時集成 Node 來授予網頁訪問底層系統的權限。

使用nw.js時遇到了不少問題,設置和api比較繁瑣,因而改版過程用再開發便利性上的考慮轉用了electron。

electron應用佈署很是簡單,存放應用程序的文件夾須要叫作 app 而且須要放在 Electron 的 資源文件夾下(在 macOS 中是指 Electron.app/Contents/Resources/,在 Linux 和 Windows 中是指 resources/) 就像這樣:

macOS

electron/Electron.app/Contents/Resources/app/
├── package.json
├── main.js
└── index.html

在 Windows 和 Linux 中

electron/resources/app
├── package.json
├── main.js
└── index.html

而後運行 Electron.app (或者 Linux 中的 electron,Windows 中的 electron.exe), 接着 Electron 就會以你的應用程序的方式啓動。

目錄釋義

package.json主要用來指定app的名稱,版本,入口文件,依賴文件等。

{
  "name"    : "your-app",
  "version" : "0.1.0",
  "main"    : "main.js"
}

main.js 應該用於建立窗口和處理系統事件,官方也是推薦使用es6來開發,典型的例子以下:

const electron = require('electron');
//引入app模塊
const {app} = electron;
// 引入窗口視圖
const {BrowserWindow} = electron;
//設置一個變量
let mainWindow;

function createWindow() {
  //實例化一個新的窗口
  mainWindow = new BrowserWindow({width: 800, height: 600});

  //加載electron主頁面
  mainWindow.loadURL(`file://${__dirname}/index.html`);

  //打開chrome開發者工具
  mainWindow.webContents.openDevTools();

  //監聽窗口關閉狀態
  mainWindow.on('closed', () => {
    mainWindow = null;
  });
}
//當app初始化完畢,開始建立一個新窗口
app.on('ready', createWindow);

//監聽app窗口關閉狀態
app.on('window-all-closed', () => {
  //mac osx中只有執行command+Q纔會退出app,不然保持活動狀態
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  //mac osx中再dock圖標點擊時從新建立一個窗口
  if (mainWindow === null) {
    createWindow();
  }
});

index.html則用來輸出你的html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
  </head>
  <body>
    <h1>Hello World!</h1>
    We are using node <script>document.write(process.versions.node)</script>,
    Chrome <script>document.write(process.versions.chrome)</script>,
    and Electron <script>document.write(process.versions.electron)</script>.
  </body>
</html>

electron官方提供了一個快速開始的模板:

# Clone the Quick Start repository
$ git clone https://github.com/electron/electron-quick-start

# Go into the repository
$ cd electron-quick-start

# Install the dependencies and run
$ npm install && npm start

更多入門介紹能夠查看這裏Electron快速入門.

添加開發者工具

由於項目中用到了react以及redux,爲了方便開發,將chrome的這兩項插件引入到項目中。以redux爲例:

  1. 安裝redux-devtools-extensionchrome擴展

  2. 地址欄輸入chrome://extensions/打開擴展程序面板,找到redux-devtools-extension的ID, 這串ID是相似於lmhkpmbekcpmknklioeibfkpmmfibljd的字符串;

  3. 找到系統chrome存儲擴展的目錄:

    • windows地址: %LOCALAPPDATA%GoogleChromeUser DataDefaultExtensions;

    • linux可能在:

    ~/.config/google-chrome/Default/Extensions/
    ~/.config/google-chrome-beta/Default/Extensions/
    ~/.config/google-chrome-canary/Default/Extensions/
    ~/.config/chromium/Default/Extensions/
    • macOS目錄地址:~/Library/Application Support/Google/Chrome/Default/Extensions;

  4. 調用BrowserWindow.addDevToolsExtension的API, 把這串地址傳遞進去,對於redux-devtools-extensions,大概會是~/Library/Application Support/Google/Chrome/Default/Extensions/lmhkpmbekcpmknklioeibfkpmmfibljd/2.2.1.1_0/'。在electron表現爲:

BrowserWindow.addDevToolsExtension('~/Library/Application Support/Google/Chrome/Default/Extensions/lmhkpmbekcpmknklioeibfkpmmfibljd/2.2.1.1_0/');

這樣,在electron的控制檯就能夠看到對應的devtools標籤了。
若是用的是react-devtools在這一步已經可使用,須要注意的是對於redux-devtools-extensions還有一個步驟,storecreateStore時須要增長一行判斷window.devToolsExtension && window.devToolsExtension()

const store = createStore(reducer, window.devToolsExtension && window.devToolsExtension());

redux-devtools

React + ES6

React作爲一個用來構建UI的JS庫開闢了一個至關另類的途徑,實現了前端界面的高效率高性能開發。React的虛擬DOM不只帶來了簡單的UI開發邏輯,同時也帶來了組件化開發的思想。

ES6作爲js的新規範帶來了許多新的變化,從代碼的編寫上也帶來了許多的便利性。

一個簡單的react模塊示例:

//jsx
var HelloMessage = React.createClass({
  render: function() {
    return <div>Hello {this.props.name}</div>;
  }
});

ReactDOM.render(<HelloMessage name="John" />, document.getElementById('root')));
//html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>
//實際輸出
<div id="root">Hello John</div>

經過React.createClass建立一個react模塊,使用render函數返回這個模塊中的實際html模板,而後引用ReactDOMrender函數生成到指定的html模塊中。調用HelloMessage的方法,則是寫成一個xhtml的形式<HelloMessage name="John" />,將name裏面的"John"作爲一個屬性值傳到HelloMessage中,經過this.props.name來調用。

固然,這個是未經編譯的jsx文件,不能實際輸出到html中,若是想要未經編譯使用jsx文件,能夠在html中引用babel的組件,例如:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Hello React!</title>
    <script src="build/react.js"></script>
    <script src="build/react-dom.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.34/browser.min.js"></script>
  </head>
  <body>
    <div id="example"></div>
    <script type="text/babel">
      ReactDOM.render(
        <h1>Hello, world!</h1>,
        document.getElementById('example')
      );
    </script>
  </body>
</html>

自從es6正式發佈後,react也改用了babel作爲編譯工具,也所以許多開發者開始將代碼開發風格項es6轉變。

因而React.createClass的方法被取代爲es6中的擴展類寫法:

class HelloWorld extends React.Component {
  render() {
    return <div>Hello {this.props.name}</div>;
  }
}

咱們能夠看到這些語法有了細微的不一樣:

//ES5的寫法
var HelloWorld = React.createClass({
  handleClick: function(e) {...},
  render: function() {...},
});

//ES6及以上寫法
class HelloWorld extends React.Component {
  handleClick(e) {...}
  render() {...}
}

在feWorkflow中基本都是使用ES6的寫法作爲開發, 例如最終輸出的container模塊:

import ListFolder from './list/list';
import Dropzone from './layout/dropzone';
import ContainerEmpty from './container-empty';
import ContainerFt from './layout/container-ft';
import Aside from './layout/aside';
import { connect } from 'react-redux';

const Container = ({ lists }) => (
  <div className="container">
    <div className="container-bd">
      {lists.size ? <ListFolder /> : <ContainerEmpty />}
      <Dropzone />
    </div>
    <ContainerFt />
    <Aside />
  </div>
);

const mapStateToProps = (states) => ({
  lists: states.lists
});

export default connect(mapStateToProps)(Container);

import作爲ES6的引入方式,來取代commonJS的require模式,等同於

var ListFoder = require('./list/list');

輸出從module.export = Container; 替換成export default Container;,這種寫法其實等同於:

// ES5寫法
var Container = React.createClass({
  render: function() { 
      ...
      {this.props.lists.size ? <ListFolder /> : <ContainerEmpty />}
    ...
  },
});

{ lists }的寫法編譯成ES5的寫法等同於:

var Container = function Container(_ref) {
  var lists = _ref.lists;
  ...
}

至關於減小了很是多的賦值操做,極大了減小了開發的工做量。

Webpack

ES6中介紹了一下編譯以後的代碼,而每一個文件裏其實也並無import必須的react模塊,其實都是經過Webpack這個工具來執行了編譯和打包。在webpack中引入了babel-loader來編譯reactes6的代碼,並將css經過less-loader, css-loader, style-loader自動編譯到html的style標籤中,再經過

new webpack.ProvidePlugin({
  React: 'react'
}),

的形式,將react組件註冊到每一個js文件中,不需再重複引用,最後把全部的js模塊編譯打包輸出到 dist/bundle.js,再html中引入便可。

流程圖:

圖片描述

webpack部分設置:

var path = require('path');
var webpack = require('webpack');

module.exports = {
  devtool: 'source-map',
  entry: [
    './src/index'
  ],
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'bundle.js',
    publicPath: '/dist/'
  },
  target: 'atom',
  module: {
    loaders: [
      {
        test: /\.js$/,
        include: path.join(__dirname, 'src'),
        loader: require.resolve('babel-loader'),
        ...
      },
     ...
    ]
  },
  ...
};

webpack須要設置入口文件entry,在此是引入了源碼文件夾src中的index.js,和一個或多個出口文件output,輸出devtoolsource-map使得源代碼可見,而非編譯後的代碼,而後制定所須要的loader來作模塊的編譯。

electron相關的一個比較重要的點是,必須指定target: atom,不然會出現沒法resolve electron modules的報錯提示。

更多介紹能夠參考Webpack 入門指迷

feWorkflow項目中選用了react-transform-hmr作爲模板,已經寫好了基礎的webpack文件,支持react熱加載,再也不須要常常去刷新electron,不過該做者已經中止維護這個項目,而是恢復維護react-hot-reload,如今從新開發React Hot Loader 3, 有興趣能夠去了解一下。

Redux

Redux是針對JavaScript apps的一個可預見的state容器。它能夠幫助咱們寫一個行爲保持一致性的應用,能夠運行再不一樣的環境中(client,server,和原生),並不是常容易測試。

Redux 能夠用這三個基本原則來描述:

1. 單一數據源

整個應用的 state 被儲存在一個 object tree 中,而且這個 object tree 只存在於惟一一個 store 中。

let store = createStore(counter) //建立一個redux store來保存你的app中全部state

//當state更新時,可使用 subscribe()來綁定監聽更新UI,一般狀況下不會直接使用這個方法,而是會用view層綁定庫(相似react-redux等)。
store.subscribe(() =>
  console.log(store.getState()) //拋出全部數據
)

2. State是隻讀的

唯一改變 state 的方法就是觸發 action,action 是一個用於描述已發生事件的普通對象。

全部的修改都被集中化處理,且嚴格按照一個接一個的順序執行. 而執行的方法是調用dispatch

store.dispatch({
  type: 'COMPLETE_TODO',
  index: 1
});

3. 使用純函數來執行修改

爲了描述 action 如何改變 state tree ,你須要編寫 reducers

Reducer 只是一些純函數,它接收先前的 stateaction,並返回新的 state

function counter(state = 0, action) {
  switch (action.type) {
  case 'INCREMENT':
    return state + 1
  case 'DECREMENT':
    return state - 1
  default:
    return state
  }
}

redux流程圖:

圖片描述

React-Redux

redux在react中應用還須要加載react-redux模塊,由於store爲單一state結構頭,咱們僅須要在入口處調用react-redux的Provider方法拋出store

render(
  <Provider store={store}>
    <Container />
  </Provider>,
  document.getElementById('root')
);

這樣,在container的內部都能接收到store

咱們須要一個操做store的reducer. 當咱們的reducer拆分好對應給不一樣的子組件以後,redux提供了一個combineReducers的方法,把全部的reducers合併起來:

import { combineReducers } from 'redux';
import lists from './list';
import snackbar from './snackbar';
import dropzone from './dropzone';

export default combineReducers({
  lists,
  snackbar,
  dropzone,
});

而後經過createStore的方式連接storereducer

import { createStore } from 'redux';
import reducer from '../reducer/reducer';

export default createStore(reducer);

上文介紹redux的時候也說過,state是隻讀的,只能經過action來操做,一樣咱們也能夠把dispatch映射成爲一個props傳入Container中。

在子模塊中, 則把這個store映射成react的props,再用connect方法,把store和component連接起來:

import { connect } from 'react-redux'; //引入connect方法
import { addList } from '../../action/addList'; //從action中引入addList方法

const AddListBtn = ({ lists, addList }) => (
  <FloatingActionButton
    onClick={(event) => {
        addList('do something here');
          return false;
      });
    }}
  >;
);
const mapStateToProps = (states) => ({
  //從state.lists獲取數據存儲到lists中,作爲屬性傳遞給AddListBtn
  lists: states.lists
});

const mapDispatchToProps = (dispatch) => ({
  //將addList函數作爲屬性傳遞給AddListBtn
  addList: (name, location) => dispatch(addList(name, location));
});

//lists, addList作爲屬性連接到Conta
export default connect(mapStateToProps, mapDispatchToProps)(AddListBtn);

這樣,就完成了redux與react的交互,很便捷的從上而下操做數據。

immutable.js

Immutable Data是指一旦被創造後,就不能夠被改變的數據。

經過使用Immutable Data,可讓咱們更容易的去處理緩存、回退、數據變化檢測等問題,簡化咱們的開發。

因此當對象的內容沒有發生變化時,或者有一個新的對象進來時,咱們傾向於保持對象引用的不變。這個工做正是咱們須要藉助Facebook的 Immutable.js來完成的。

不變性意味着數據一旦建立就不能被改變,這使得應用開發更爲簡單,避免保護性拷貝(defensive copy),而且使得在簡單的應用 邏輯中實現變化檢查機制等。

var stateV1 = Immutable.fromJS({  
users: [
    { name: 'Foo' },
    { name: 'Bar' }
]
});
 
var stateV2 = stateV1.updateIn(['users', 1], function () {  
    return Immutable.fromJS({
        name: 'Barbar'
    });
});
 
stateV1 === stateV2; // false  
stateV1.getIn(['users', 0]) === stateV2.getIn(['users', 0]); // true  
stateV1.getIn(['users', 1]) === stateV2.getIn(['users', 1]); // false

feWorkflow項目中使用最多的是List來建立一個數組,Map()來建立一個對象,再經過set的方法來更新數組,例如:

import { List, Map } from 'immutable';

export const syncFolder = List([
  Map({
    name: 'syncFromFolder',
    label: '從目錄複製',
    location: ''
  }),
  Map({
    name: 'syncToFolder',
    label: '複製到目錄',
    location: ''
  })
]);

更新的時候使用setIn方法,傳遞Map對象的序號,選中location這個屬性,經過action傳遞過來的新值action.location更新值,並返回一個全新的數組。

case 'SET_SYNC_FOLDER':
      return state.setIn(['syncFolder', action.index, 'location'], action.location);

數據存儲

存:immutable的數據已經不是單純的json數據格式,當咱們要作json格式的數據存儲的時候,可使用toJS()方法拋出js對象,並經過JSON.stringnify()將js數據轉換成json字符串,存入localstorage中。

export const saveState = (name = 'state', state = 'state') => {
  try {
    const data = JSON.stringify(state.toJS());
    localStorage.setItem(name, data);
  } catch(err) {
    console.log('err', err);
  }
}

取:讀取本地的json格式數據後,當須要加載進頁面,首先須要把這段json數據轉換會immutable.js數據格式,immutable提供了fromJS()方法,將js對象和數組轉換成immtable的MapsLists格式。

import { fromJS, Iterable } from 'immutable';

export const loadState = (name = 'setting') => {
  try {
    const data = localStorage.getItem(name);

    if (data === null) {
      return undefined;
    }

    return fromJS(JSON.parse(data), (key, value) => {
      const isIndexed = Iterable.isIndexed(value);
      return isIndexed ? value.toList() : value.toMap();
    });

  } catch(err) {
    return undefined;
  }
};

應用示例

上文介紹了整個feWorkflow的UI技術實現方案,如今來介紹下實際上gulp在這裏是如何工做的。

思路

咱們知道node中調用child_processexec能夠執行系統命令,gulpfile.js自己會調用離自身最近的node_modules,而gulp提供了API能夠經過flag的形式(—cwd)來執行不一樣的路徑。以此爲思路,以最簡單的方式,在按鈕上綁定執行狀態(dev或者build,包括flag等),經過exec直接運行gulp file.js.

實現

當按鈕點擊的時候,判斷是否在執行中,若是在執行中則殺掉進程,若是不在執行中則經過exec執行當前按鈕狀態的命令。而後扭轉按鈕的狀態,等待下一次按鈕點擊。

命令模式以下:

const ListBtns = ({btns, listId, listLocation, onProcess, cancelBuild, setSnackbar}) => (
  <div className="btn-group btn-group__right">
    {
      btns.map((btn, i) => (
        <RaisedButton
          key={i}
          className="btn"
          style={style}
          label={btn.get('name')}
          labelPosition="after"
          primary={btn.get('process')}
          secondary={btn.get('fail')}
          pid={btn.get('pid')}
          onClick={() => {
            if (btn.get('process')) {
              kill(btn.get('pid'));
            } else {
              let child = exec(`gulp ${btn.get('cmd')} --cwd ${listLocation} ${btn.get('flag')} --gulpfile ${cwd}/gulpfile.js`,  {
                cwd
              });

              child.stderr.on('data', function (data) {
                let str = data.toString();

                console.error('exec error: ' + str);
                kill(btn.get('pid'));
                cancelBuild(listId, i, btn.get('name'), child.pid, str, true);
                dialog.showErrorBox('Oops, 出錯了', str);
              });

              child.stdout.on('data', function (data) {
                console.log(data.toString())
                onProcess(listId, i, btn.get('text'), child.pid, data.toString())
              });

              //關閉
              child.stdout.on('close', function () {
                cancelBuild(listId, i, btn.get('name'), child.pid, '編譯結束', false);
                setSnackbar('編譯結束');

                console.info('編譯結束');
              });
            }
          }}
        />
      ))
    }
  </div>
);

—cwd把gulp的操做路徑指向了咱們定義的src路徑,—gulpfile則強行使用feWorkflow中封裝的gulp file.js。我在js中對路徑作了處理,以src作爲截斷點,拼接命令行,假設拖放了一個位於D:Codeworkvdlotteryv3src下的路徑,那麼輸出的命令格式爲:

//執行命令
let child = exec(`gulp ${btn.get('cmd')} --cwd ${listLocation} ${btn.get('flag')} --gulpfile ${cwd}/gulpfile.js`)

//編譯輸出命令:
gulp dev --cwd D:\Code\work\vd\lottery\v3\src --development

同時,經過action扭轉了按鈕狀態:

export function processing(id, index, name, pid, data) {
  return {
    id,
    type: 'PROCESSING',
    btns: {
      index,
      name,
      pid,
      data,
      process: true,
      cmd: name
    }
  };
}

調用dispatch發送給reducer

const initState = List([]);

export default (state = initState, action) => {
  switch (action.type) {
      ...
        case 'PROCESSING':
      return state.map(item => {
        if (item.get('id') == action.id) {
          return item.withMutations(i => {
            i
              .set('status', action.btns.cmd)
              .set('snackbar', action.snackbar)
              .setIn(['btns', action.btns.index, 'text'], action.btns.name)
              .setIn(['btns', action.btns.index, 'name'], '編譯中...')
              .setIn(['btns', action.btns.index, 'process'], action.btns.process)
              .setIn(['btns', action.btns.index, 'pid'], action.btns.pid);
          });

        } else {
          return item;
        }
      });
     ...

這樣,就是整個文件執行的過程。

寫在最後

此次的改版作了不少新的嘗試,斷斷續續的花了很多時間,尚未達到最初的設想,也還缺失了一些重要的功能。後續還須要補充很多東西。成品確實還比較簡單,代碼也許也比較雜亂,全部代碼開源在github上,歡迎斧正。

參考資料:

  1. electron docs

  2. babel react-on-es6-plus

  3. webpack

  4. redux

相關文章
相關標籤/搜索