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

这篇文章主要是项目中用到的开发框架功能点上的一个总结,包括基本的操作流程和一些心得体会。

前言

15年初创建了适用于目前团队的gulp工作流,旨在以一个gulpfile来操作和执行所有文件结构。随着项目依赖滚雪球式的增长,拉取npm包成了配置中最麻烦而极容易出错的一项。为了解决配置过程中遇到的种种问题,15年底草草实现了一个方案,用nw.js(基于Chromium和node.js的app执行工具)框架来编写了一个简单的桌面应用gulp-ui, 所做的操作是打包gulpfile和所依赖的所有node_modules在一起,然后简单粗暴的在app内部执行gulpfile。

gulp-ui 做出来后再团队中使用了一段时间,以单个项目来执行的方式确实在经常多项目开发的使用环境中多有不便。于是在这个基础上,重写了整个代码结构,开发了现在的版本feWorkflow.

feWorkflow 改用了electron做为底层,使用react, redux, immutable框架做ui开发,仍然基于运行gulpfile的方案,这样可以使每个使用自己团队的gulp工作流快速接入和自由调整。

《feWorkflow - 使用 electron, react, redux, immutable 构建桌面 App》

功能:一键式开发/压缩

  • less实时监听编译css

  • css前缀自动补全

  • 格式化html,并自动替换src源码路径为tc_idc发布路径

  • 压缩图片(png|jpg|gif|svg)

  • 压缩或格式化js,并自动替换src源码路径为tc_idc发布路径

  • 同步刷新浏览器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());

《feWorkflow - 使用 electron, react, redux, immutable 构建桌面 App》

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中引入即可。

流程图:

《feWorkflow - 使用 electron, react, redux, immutable 构建桌面 App》

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流程图:

《feWorkflow - 使用 electron, react, redux, immutable 构建桌面 App》

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

    原文作者:whatif
    原文地址: https://segmentfault.com/a/1190000005879164
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞