categories: [Design]

开发中使用到的技术:React + Redux + Gulp + Browserify。利用Browserify,可以将JSX转化为JS代码,同时允许使用require实现项目的模块化。Gulp完成代码的打包。BrowserifyGulp的相关代码在gulpFile.js中,比较简单,不在此赘述。

selector_first

项目中,需要实现四个组件:CanvasComponent、InputComponent、ListComponent 和 MenuComponent。

selector_second

Redux


Action/type.js

Redux中,type告诉reducer这是何种action

const actionType = {
    INSERT_ITEM: 'INSERT_ITEM',
    DELETE_ITEM: 'DELETE_ITEM',
    DISPLAY_MENU: 'DISPLAY_MENU',
    TOGGLE_BTN: 'TOGGLE_BTN'
};

module.exports = actionType;

Action/action.js

根据上面的type,列出相应的action creator

var type = require('./type');

var action = {
    insertItem: function (value) {
        return {
            type: type.INSERT_ITEM,
            value
        }
    },
    deleteItem: function (id) {
        return {
            type: type.DELETE_ITEM,
            id
        }
    },
	// 菜单的展示与隐藏
    displayMenu: function () {
        return {
            type: type.DISPLAY_MENU
        }
    },
	// 转换按钮的状态
    toggleBtn: function () {
        return {
            type: type.TOGGLE_BTN
        }
    }
} ;

module.exports = action;

Reducer/data.js

存储默认的数据,格式为:

var menuList = [
    {
        'value': '螺狮粉',
        'id': 1
    },
	...
];
module.exports = menuList;

Reducer/reducer.js

首先判断localStorage中是否存在数据,若不存在,用data.js中的数据初始化,若存在,读取localStorage中的数据。

var menuList = require('./data.js');
var type = require('../Action/type.js');

// localStorage
var local = {
    // load data.js
    initLocal: function (data) {
        var self =  this;
        self.set('nextId', data.length + 1);
        data.forEach(function (item) {
            self.set(item.id, item.value);
        });
    },
    isEmpty: function () {
        return !localStorage.length;
    },
    set: function (key, value) {
        localStorage.setItem(key, value);
    },
    get: function (key) {
        return localStorage.getItem(key);
    },
    remove: function (key) {
        localStorage.removeItem(key);
    },
    // Copy the data to menuList
    clone: function () {
        var copy = [];
        var keySet = [];
        for (var i = localStorage.length-1; i >= 0; i--) {
            var key = localStorage.key(i);
            if (key != 'nextId') {
                keySet.push(key);
            }
        }
        // Reverse order to make the inserted item at the top of the list
        keySet.sort(function (pre, next) {
            return -(pre - next);
        });
        for (var j = 0; j < keySet.length; j++) {
            copy.push({
                id: keySet[j],
                value: this.get(keySet[j])
            });
        }
        return copy;
    }
};

/* state = {
        beginSelect: false,
        isMenuShowed: false,
        isEmptyTextShowed: false,
        menuList: [{id: '123', 'value': 'abc'}]
   }
*/
function updateState(beginSelect, isMenuShowed, isEmptyTextShowed) {
    if (local.isEmpty()){
        local.initLocal(menuList);
    }
    return {
        beginSelect: beginSelect,
        isMenuShowed: isMenuShowed,
        isEmptyTextShowed: isEmptyTextShowed,
        menuList: local.clone()
    };
}

根据actiontype做相应的处理。

function reducer(state, action) {
    if (typeof state === 'undefined') {
        return updateState(false, false, false);
    }
    switch(action.type){
        case type.TOGGLE_BTN:
            if (!state.isMenuShowed) {
                if (state.menuList.length) {
                    return updateState(!state.beginSelect, state.isMenuShowed, false);
                } else {
                    return updateState(state.beginSelect, state.isMenuShowed, true);
                }
            }
            break;
        case type.DISPLAY_MENU:
            if (!state.beginSelect){
                return updateState(state.beginSelect, !state.isMenuShowed, state.isEmptyTextShowed);
            }
            break;
        case type.DELETE_ITEM:
            local.remove(action.id);
            return updateState(state.beginSelect, state.isMenuShowed, state.isEmptyTextShowed);
        case type.INSERT_ITEM:
            local.set(local.get('nextId'), action.value);
            local.set('nextId', parseInt(local.get('nextId')) + 1);
            return updateState(state.beginSelect, state.isMenuShowed, false);
    }
    return state;
}
module.exports = reducer;

React


canvas.js

canvas.js渲染主界面,包含人的眼睛、嘴、显示框和按钮。该组件需要外部传入三个props: btnText = {start: “xxx”, stop: “xx”}, initText = “xxxx”, blankText = “xxxx”。分别表示按钮的文本、显示框初始文本和菜单无数据时的显示框的文本。

var React = require('react');
var ReactRedux = require('react-redux');
var connect = ReactRedux.connect;
var action = require('../Action/action.js');
var CanvasComponent = React.createClass({
    eyes: {
        leftEye: {
            x: 125,
            y: 350,
            radius: 40
        },
        rightEye: {
            x: 265,
            y: 350,
            radius: 40
        }
    },
    timer: null,
    componentDidMount: function () {
        this.initCanvas({count: 0, text: this.props.initText, smile: false});
    },
    componentDidUpdate: function () {
        var self = this;
        var menuLength = self.props.menuList.length;
        if (self.props.beginSelect) {
            var count = 0;
            self.timer = setInterval(function () {
                self.initCanvas({count: count++, text: self.props.menuList[Math.floor(Math.random() * menuLength)].value, smile: false});
            },100);
        } else if(self.props.isEmptyTextShowed) {
            self.initCanvas({count: 0, text: self.props.emptyText, smile: false});
        } else if(self.timer){
            clearInterval(self.timer);
            self.timer = null;
            self.initCanvas({count: 5, text: self.props.menuList[Math.floor(Math.random() * menuLength)].value, smile: true});
        } else {
            self.initCanvas({count: 0, text: self.props.initText, smile: false});
        }
    },
    drawEyes: function (option) {
        var ctx =  option.ctx;
        var params = this.eyes[option.direction];
        var deg = 2 * Math.PI / 360 * option.deg;
        var x_offset = 21 * Math.cos(deg);
        var y_offset = 21 * Math.sin(deg);
        ctx.lineWidth=5;
        ctx.strokeStyle="#fff";
        ctx.beginPath();
        ctx.arc(params.x, params.y, params.radius, 0, Math.PI * 2, true);
        ctx.stroke();
        ctx.closePath();
        ctx.fillStyle = '#fff';
        ctx.beginPath();
        ctx.arc(params.x  + x_offset, params.y  + y_offset, params.radius / 4, 0, Math.PI * 2, true);
        ctx.fill();
        ctx.closePath();
    },
    drawMouse: function (option) {
        var ctx = option.ctx;
        ctx.fillStyle="#fff";
        ctx.beginPath();
        if (option.smile) {
            ctx.arc(195, 420, 40, 0, Math.PI, false);
        } else {
            ctx.arc(195, 440, 10, 0, Math.PI * 2, true);
        }
        ctx.fill();
        ctx.closePath();
    },
    drawBox: function (option) {
        var ctx = option.ctx;
        var text = option.text;
        ctx.strokeStyle="#fff";
        ctx.moveTo(264, 275);
        ctx.lineTo(250, 240);
        ctx.moveTo(263, 275);
        ctx.lineTo(285, 240);
        ctx.lineTo(325, 240);
        ctx.arcTo(345, 240, 345, 220, 20);
        ctx.arcTo(345, 60, 325, 60, 20);
        ctx.arcTo(55, 60, 55, 80, 20);
        ctx.arcTo(55, 240, 75, 240, 20);
        ctx.lineTo(252,240);
        ctx.stroke();
        ctx.fillStyle = '#fff';
        ctx.font = "32pt Calibri";
        ctx.textAlign = 'center';
        ctx.fillText(text,200,165,240);
    },
    initCanvas: function (option) {
        var deg = option.count* 36 + 90;
        var ctx = this.refs.canvas.getContext('2d');
        ctx.fillStyle = '#82ebc6';
        ctx.fillRect(0,0,400,500);
        this.drawEyes({ctx: ctx, direction: 'leftEye', deg: deg});
        this.drawEyes({ctx: ctx, direction: 'rightEye', deg: deg});
        this.drawMouse({ctx: ctx, smile: option.smile});
        this.drawBox({ctx: ctx, text: option.text});
    },
    render: function () {
        var currentText = this.props.beginSelect? this.props.btnText.stop: this.props.btnText.start;
        return (
            <div className="canvas-wrapper">
                <canvas ref="canvas" width={400} height={500} />
                <div className="btn-wrapper">
                    <div onClick={this.props.clickHandler} className="btn-primary">{currentText}</div>
                </div>
            </div>
        );
    }
});

function mapStateToProps(state)  {
    return {
        beginSelect: state.beginSelect,
        isEmptyTextShowed: state.isEmptyTextShowed,
        menuList: state.menuList
    };
}

function mapDispatchToProps(dispatch) {
    return {
        clickHandler: function() {
            dispatch(action.toggleBtn());
        }
    };
}

module.exports = connect(
    mapStateToProps,
    mapDispatchToProps
)(CanvasComponent);

其中mapStateToPropsstate映射为组件的propsmapDispatchToProps实现action的分发。

list.js

list.js展现菜单中的一条数据,同时可实现该数据的删除。该组件需要外部传入的props为:listText = “xxx”。

var React = require('react');
var ReactRedux = require('react-redux');
var connect = ReactRedux.connect;
var action = require('../Action/action.js');
var ListComponent = React.createClass({
    render: function () {
        return (
            <li className="menu-item">{this.props.listText}<span className="btn-delete" onClick={this.props.deleteHandler}><i className="icon-trash icon-large"></i></span></li>
        );
    }
});


function mapDispatchToProps(dispatch, ownProps) {
    return {
        deleteHandler: function() {
            dispatch(action.deleteItem(ownProps.id));
        }
    };
}

module.exports = connect(
    null,
    mapDispatchToProps
)(ListComponent);

input.js

input.js实现了为菜单列表添加数据的功能。

var React = require('react');
var ReactRedux = require('react-redux');
var connect = ReactRedux.connect;
var action = require('../Action/action.js');

var inputValue = '';
var InputComponent = React.createClass({
    handleChange: function (e) {
        inputValue = e.target.value;
    },
    render: function () {
        return (
            <div className="input-wrapper">
                <input type="text" name="input-add" className="menu-input" onChange={this.handleChange}/>
                <button className="btn-add" onClick={this.props.addHandler}>Add</button>
            </div>
        );
    }
});

function mapDispatchToProps(dispatch) {
    return {
        addHandler: function() {
            dispatch(action.insertItem(inputValue));
        }
    };
}
module.exports = connect(
    null,
    mapDispatchToProps
)(InputComponent);

menu.js调用input.jslist.js,为用户展示备选数据,同时包含一个点击按钮,实现菜单的展示与隐藏。

var React = require('react');
var ReactRedux = require('react-redux');
var connect = ReactRedux.connect;
var action = require('../Action/action.js');
var ListComponent = require('./list');
var InputComponent = require('./input');
var MenuComponent = React.createClass({
    render: function () {
        var menuDisplay = this.props.isMenuShowed? 'block': 'none';
        var icon = this.props.isMenuShowed? 'icon-arrow-right': 'icon-arrow-left';
        return (
            <div className="menu-wrapper">
                <div className="btn-menu" onClick={this.props.clickHandler}><i className={icon}></i></div>
                <div className="menu-body" style={/{display: menuDisplay}/}>
                    <InputComponent/>
                    <ul className="menu-detail">
                        {
                            this.props.menuList.map(function(item, index){
                                return <ListComponent listText ={item.value} key ={index} id = {item.id}/>;
                            })
                        }
                    </ul>
                </div>
            </div>
        );
    }
});

function mapStateToProps(state)  {
    return {
        isMenuShowed: state.isMenuShowed,
        menuList: state.menuList
    };
}

function mapDispatchToProps(dispatch) {
    return {
        clickHandler: function() {
            dispatch(action.displayMenu());
        }
    };
}

module.exports = connect(
    mapStateToProps,
    mapDispatchToProps
)(MenuComponent);

main.js

main.js实现最终的展示。

var React = require('react');
var ReactDOM = require('react-dom');
var Redux = require('redux');
var ReactRedux = require('react-redux');
var Provider = ReactRedux.Provider;
var createStore = Redux.createStore;
var reducer = require('./Reducer/reducer.js');


// Store
var store = createStore(reducer);

var MenuComponent = require('./Component/menu.js');
var CanvasComponent = require('./Component/canvas.js');

ReactDOM.render(
    <Provider store={store}>
        <div>
            <MenuComponent />
            <CanvasComponent className = 'canvas-wrapper' btnText = {/{start: 'Start', stop: 'Stop'}/} initText = '吃什么?' emptyText = "菜单空空如也"/>
        </div>
    </Provider>
    , document.getElementById('main-wrapper'));

为了让所有组件都能获取到store,根组件必须包裹在Provider中,并将store作为参数传给Provider

其它


项目中所用的CSSHTML在此就不贴出来了,值得一提的是,为了能够在 PC 端和移动端都有良好的展示效果,HTML中设置initial-scale为 0.8(根据canvas大小和不同机型的分辨率所计算得到)。

完整代码

项目展示