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工作流快速接入和自由调整。
功能 一键式开发/压缩 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:
1
2
3
4
electron/Electron.app/Contents/Resources/app/
├── package.json
├── main.js
└── index.html
在 Windows 和 Linux 中:
1
2
3
4
electron/resources/app
├── package.json
├── main.js
└── index.html
然后运行 Electron.app (或者 Linux 中的 electron,Windows 中的 electron.exe), 接着 Electron 就会以你的应用程序的方式启动。
目录释义 package.json主要用来指定app的名称,版本,入口文件,依赖文件等
1
2
3
4
5
{
  "name"     : "your-app" ,
  "version"  : "0.1.0" ,
  "main"     : "main.js" 
}
main.js 应该用于创建窗口和处理系统事件,官方也是推荐使用es6来开发,典型的例子如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const  electron = require ('electron' );
const  {app} = electron;
const  {BrowserWindow} = electron;
let  mainWindow;
function  createWindow (
  
  mainWindow = new  BrowserWindow({width : 800 , height : 600 });
  
  mainWindow.loadURL(`file://${__dirname} /index.html` );
  
  mainWindow.webContents.openDevTools();
  
  mainWindow.on('closed' , () => {
    mainWindow = null ;
  });
}
app.on('ready' , createWindow);
app.on('window-all-closed' , () => {
  
  if  (process.platform !== 'darwin' ) {
    app.quit();
  }
});
app.on('activate' , () => {
  
  if  (mainWindow === null ) {
    createWindow();
  }
});
index.html则用来输出你的html:
1
2
3
4
5
6
7
8
9
10
11
12
13
<!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官方提供了一个快速开始的模板:
1
2
3
4
5
6
7
8
# 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 + ES6 React做为一个用来构建UI的JS库开辟了一个相当另类的途径,实现了前端界面的高效率高性能开发。React的虚拟DOM不仅带来了简单的UI开发逻辑,同时也带来了组件化开发的思想。
ES6做为js的新规范带来了许多新的变化,从代码的编写上也带来了许多的便利性。
一个简单的react模块示例:
1
2
3
4
5
6
7
8
var  HelloMessage = React.createClass({
  render : function (
    return  <div > Hello {this.props.name}</div > 
  }
});
ReactDOM.render(<HelloMessage  name ="John"  /> , document.getElementById('root')));
1
2
3
4
5
6
7
8
9
10
11
//html
<!DOCTYPE html> 
<html  lang ="en" > 
<head > 
  <meta  charset ="UTF-8" > 
  <title > Document</title > 
</head > 
<body > 
  <div  id ="root" > </div > 
</body > 
</html > 
1
2
//实际输出
<div  id ="root" > Hello John</div > 
通过React.createClass创建一个react模块,使用render函数返回这个模块中的实际html模板,然后引用ReactDOM的render函数生成到指定的html模块中。调用HelloMessage的方法,则是写成一个xhtml的形式<HelloMessage name="John" />,将name里面的”John”做为一个属性值传到HelloMessage中,通过this.props.name来调用。
当然,这个是未经编译的jsx文件,不能实际输出到html中,如果想要未经编译使用jsx文件,可以在html中引用babel的组件,例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!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中的扩展类写法:
1
2
3
4
5
class  HelloWorld  extends  React .Component  
  render() {
    return  <div > Hello {this.props.name}</div > 
  }
}
我们可以看到这些语法有了细微的不同:
1
2
3
4
5
6
7
8
9
10
11
var  HelloWorld = React.createClass({
  handleClick : function (e ) 
  render : function (
});
class  HelloWorld  extends  React .Component  
  handleClick(e) {...}
  render() {...}
}
在feWorkflow中基本都是使用ES6的写法做为开发, 例如最终输出的container模块:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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模式,等同于
1
var  ListFoder = require ('./list/list' );
输出从module.export = Container; 替换成export default Container;
这种写法其实等同于:
1
2
3
4
5
6
7
8
var  Container = React.createClass({
  render : function (
    ...
    {this .props.lists.size ? <ListFolder  />  : <ContainerEmpty  /> }
    ...
  },
});
{ lists }的写法编译成ES5的写法等同于:
1
2
3
4
var  Container = function  Container (_ref ) 
  var  lists = _ref.lists;
  ...
}
相当于减少了非常多的赋值操作, 极大了减少了开发的工作量。
Webpack ES6中介绍了一下编译之后的代码,而每个文件里其实也并没有import必须的react模块,其实都是通过Webpack这个工具来执行了编译和打包。在webpack中引入了babel-loader来编译react和es6的代码,并将css通过less-loader,css-loader, style-loader自动编译到html的style标签中,再通过
1
2
3
new  webpack.ProvidePlugin({
  React : 'react' 
}),
的形式,将react组件注册到每个js文件中,不需再重复引用,最后把所有的js模块编译打包输出到dist/bundle.js,再html中引入即可。
流程图:
webpack部分设置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 可以用这三个基本原则来描述:
单一数据源 
整个应用的 state 被储存在一个 object tree 中,并且这个 object tree 只存在于唯一一个 store 中。 
1
2
3
4
5
6
let  store = createStore(counter) 
store.subscribe(()  =>
  console .log(store.getState()) 
)
State是只读的 
惟一改变 state 的方法就是触发 action,action 是一个用于描述已发生事件的普通对象。
所有的修改都被集中化处理,且严格按照一个接一个的顺序执行. 而执行的方法是调用dispatch。
1
2
3
4
store.dispatch({
  type : 'COMPLETE_TODO' ,
  index : 1 
});
使用纯函数来执行修改 
为了描述 action 如何改变 state tree ,你需要编写 reducers。
Reducer 只是一些纯函数,它接收先前的 state 和 action,并返回新的 state。
1
2
3
4
5
6
7
8
9
10
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
1
2
3
4
5
6
render(
  <Provider  store ={store} > 
    <Container  /> 
  </Provider > ,
  document .getElementById('root' )
);
这样,在container的内部都能接收到store。
我们需要一个操作store的reducer. 当我们的reducer拆分好对应给不同的子组件之后,redux提供了一个combineReducers的方法,把所有的reducers合并起来:
1
2
3
4
5
6
7
8
9
10
import  { combineReducers } from  'redux' ;
import  lists from  './list' ;
import  snackbar from  './snackbar' ;
import  dropzone from  './dropzone' ;
export  default  combineReducers({
  lists,
  snackbar,
  dropzone,
});
然后通过createStore的方式链接store与reducer:
1
2
3
4
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链接起来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import  { connect } from  'react-redux' ; 
import  { addList } from  '../../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),并且使得在简单的应用 逻辑中实现变化检查机制等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var  stateV1 = Immutable.fromJS({  
users : [
  { name : 'Foo'  },
  { name : 'Bar'  }
]
});
 
var  stateV2 = stateV1.updateIn(['users' , 1 ], function  (
  return  Immutable.fromJS({
    name : 'Barbar' 
  });
});
 
stateV1 === stateV2; 
stateV1.getIn(['users' , 0 ]) === stateV2.getIn(['users' , 0 ]); 
stateV1.getIn(['users' , 1 ]) === stateV2.getIn(['users' , 1 ]); 
feWorkflow项目中使用最多的是List来创建一个数组,Map()来创建一个对象,再通过set的方法来更新数组,例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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更新值,并返回一个全新的数组。
1
2
case  'SET_SYNC_FOLDER' :
      return  state.setIn(['syncFolder' , action.index, 'location' ], action.location);
数据存储: 存: 
immutable的数据已经不是单纯的json数据格式,当我们要做json格式的数据存储的时候,可以使用toJS()方法抛出js对象,并通过JSON.stringnify()将js数据转换成json字符串,存入localstorage中。
1
2
3
4
5
6
7
8
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的Maps和Lists格式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import  { fromJS, Iterable } from  'immutable' ;
export  const  loadState = (name = 'setting'  ) =>  {
  try  {
    const  data = localStorage.getItem(name);
    if  (data === null ) {
      return  undefined ;
    }P
    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_process的exec可以执行系统命令,gulpfile.js本身会调用离自身最近的node_modules,而gulp提供了API可以通过flag的形式(—cwd)来执行不同的路径。以此为思路,以最简单的方式,在按钮上绑定执行状态(dev或者build,包括flag等),通过exec直接运行gulp file.js.
实现 
当按钮点击的时候,判断是否在执行中,如果在执行中则杀掉进程,如果不在执行中则通过exec执行当前按钮状态的命令。然后扭转按钮的状态,等待下一次按钮点击。
命令模式如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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:\Code\work\vd\lottery\v3\src下的路径,那么输出的命令格式为:
1
2
3
4
5
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扭转了按钮状态:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export  function  processing (id, index, name, pid, data ) 
  return  {
    id,
    type : 'PROCESSING' ,
    btns : {
      index,
      name,
      pid,
      data,
      process : true ,
      cmd : name
    }
  };
}
调用dispatch发送给reducer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 上,欢迎斧正。
参考资料:
electron docs babel react-on-es6-plus webpack redux 上次更新:2016-12-22 17:53:25