all articles

Thoughts about front-end dev by implementing a ToDo App(2) -- Model & View

2016-09-16 @sunderls

A_ToDo_App js

游戏进入第二天了。上一次我们做了一个简单的Todo App,但是总感觉各种复杂,首先在html里面需要定义各种标记class,然后在整改js里面操作数组同步显示。我们能不能把掌管数组的data和显示分开呢?我们在addItem方法里面实际上进行了dom操作,感觉耦合行不是太好,我们尝试把dom操作和view操作分开。

关于我们的model

我们的model很简单,一个数组。数组的变化有两种可能,add 和 delete。如果考虑到未来的todo项修改,可能还有update。

关于我们的view

view需要更新count,添加和删除todo item,显示空列表提示等。

最简单的解耦方法 - trigger event

我们再更新model的时候,让model触发对应的事件,然后view来listen这些事件。来,写起。 这里我们就不用jQuery.trigger或者EventEmitter了,我们需要的是一个Model类可以trigger和listen

step1: Model的实现

根据上述分析,我们需要一个Model类,可以trigger/listen events,然后还有一个保存数据。开始写吧

// Model class
function Model(data){
    this.data = data || {};
    this._listeners = {};
}

Model.prototype.trigger = function(events, data){
    var events = events.split(/\W+/);
    var that = this;
    events.forEach(function(event){
        if (that._listeners[event]){
            that._listeners[event].forEach(function(listener){
                listener(data);
            });
        }
    });
};

Model.prototype.listen = function(events, listener){
    var events = events.split(/\W+/);
    var that = this;
    events.forEach(function(event){
        if (!that._listeners[event]){
            that._listeners[event] = [listener];
        } else {
            that._listeners[event].push(listener);
        }
    });
};

Model构造函数接受data,然后提供trigger和listen 方法

step2: View的实现

View对应一片dom。也不知道需要啥方法了,总之先加入自我删除的方法clear()好了。 View是否只能对应一个model,我们先不考虑这么多,让一个view自己去listen需要的model。 这里提供一个wrapper方法,把Model的listen包成View的listenTo,这样更加直观地表示View去关注某个model的感觉。

// View class
function View($dom){
    this.$dom = $dom;
};

View.prototype.clear = function(){
    this.$dom.parentNode.removeChild(this.$dom);
};

View.prototype.listenTo = function(model, event, listener){
    model.listen(event, listener);
};

step3: 重写我们的methods

首先,data将升级为model, 增加view

// app
var appModel = new Model([]);

var $view = document.querySelector('#view');
var appView = new View($view);

我们把整个app dom 看作一个view。更新我们的html

<div id="view">
    <h1>ToDO: <span class="jsCount">0</span></h1>
    <ul class="jsList">
        <li class="jsListEmpty">no item</li>
        <script type="js/tmpl" id="js-tmpl-item">
            <li>{this}<button class="jsDelete">x</button></li>
        </script>
    </ul>
    <p><input type="text" class="jsInput"><button class="jsAdd">add</button></p>
</div>

再addHandler中,我们只需要更新model就好,然后出发add事件,把dom的操作移动到view中。 removeItem我们也不要了,目标就是所有的data操作都经过Model。

appView.listenTo(appModel, 'add', function(item){
        var newItem = Render('#js-tmpl-item', item);
        newItem.querySelector('.jsDelete').addEventListener('click', function(){
            var index = appModel.data.indexOf(item);
            appModel.data.splice(index, 1);
            appModel.trigger('delete', index);
        }, false);
        appView.$list.appendChild(newItem);
    });

删除的时候感觉有点不方便,因为我们不知道该删除哪一个item。这个就没办法了。搜索index删除吧。为了搜索index,我们在todo item中增加新的class 并修改Render。未来如果我们支持model套model,那就可以直接在子model中删除dom了,嗯,后面再说。

appView.listenTo(appModel, 'delete', function(index){
        appView.$list.removeChild(appView.$list.querySelectorAll('li')[index + 1]);
    });

所有的add, delte事件处理中我们都需要更新count。

appView.listenTo(appModel, 'add delete', function(){
    appView.$count.innerHTML = appModel.data.length;

    if (appModel.data.length > 0){
        appView.$listEmpty.style.display = 'none';
    } else {
        appView.$listEmpty.style.display = 'block';
    }
});

var addHandler = function(){
    var input = appView.$input.value.trim();
    if (input.length === 0){
        return;
    }
    appModel.data.push(input);
    appModel.trigger('add', input);
    appView.$input.value = '';
};

appView.$add.addEventListener('click', addHandler, false);

step4: view 相关的都放在view 中

我们可以发现view相关的内容占了绝大部分,确实是这样因为我们都model非常简单,绝大部分都是view的代码,那么我们可以把这些代码直接放在view里面包装好。嗯,确实,说干就干

// View class
function View(conf){
    Object.assign(this, conf);
    this.init();
};

View.prototype.clear = function(){
    this.$dom.parentNode.removeChild(this.$dom);
};

View.prototype.listenTo = function(model, event, listener){
    model.listen(event, listener);
};

var appView = new View({
    $dom: document.querySelector('#view'),
    init: function(){
        this.$count = this.$dom.querySelector('.jsCount');
        this.$list = this.$dom.querySelector('.jsList');
        this.$listEmpty = this.$dom.querySelector('.jsListEmpty');
        this.$input = this.$dom.querySelector('.jsInput');
        this.$add = this.$dom.querySelector('.jsAdd');

        this.$add.addEventListener('click', this._onClickAdd.bind(this), false);

        this.listenTo(appModel, 'add', this.add.bind(this));
        this.listenTo(appModel, 'delete', this.delete.bind(this));
        this.listenTo(appModel, 'add delete', this.update.bind(this));
    },

    add: function(item){
        var newItem = Render('#js-tmpl-item', item);
        newItem.querySelector('.jsDelete').addEventListener('click', function(){
            var index = appModel.data.indexOf(item);
            appModel.data.splice(index, 1);
            appModel.trigger('delete', index);
        }, false);
        this.$list.appendChild(newItem);
    },

    delete: function(index){
        this.$list.removeChild(this.$list.querySelectorAll('li')[index + 1]);
    },

    update: function(){
       this.$count.innerHTML = appModel.data.length;

        if (appModel.data.length > 0){
            this.$listEmpty.style.display = 'none';
        } else {
            this.$listEmpty.style.display = 'block';
        }
    },

    _onClickAdd: function(){
         var input = this.$input.value.trim();
        if (input.length === 0){
            return;
        }
        appModel.data.push(input);
        appModel.trigger('add', input);
        this.$input.value = '';
    }
});

# step5: 等等,触发事件的代码还在view里面 我们把这个拿到model中

// Model class
function Model(conf){
    this.data = {};
    this._listeners = {};

    Object.assign(this, conf);
}

// app
var appModel = new Model({
    data: [],
    add: function(item){
        this.data.push(item);
        this.trigger('add', item);
    },

    remove: function(item){
        var index = this.data.indexOf(item);
        this.data.splice(index, 1);
        this.trigger('delete', index);
    }
});

Done! isn't this BackBone.js?

https://github.com/sunderls/lsdom/commit/88925ffedbb4806fbdeeea8d500485d2d1d8e231

(function(){
    // rendering engine
    var Render = function(selector, data){
        var tmpl =  document.querySelector(selector).innerHTML.trim();
        var matches = tmpl.match(/<(\w+)>(.*)<\/(\w+)>/);
        var newItem = document.createElement(matches[1]);
        newItem.innerHTML = matches[2].replace('{this}', data);
        return newItem;
    };

    // Model class
    function Model(conf){
        this.data = {};
        this._listeners = {};

        Object.assign(this, conf);
    }

    Model.prototype.trigger = function(events, data){
        var events = events.split(/\W+/);
        var that = this;
        events.forEach(function(event){
            if (that._listeners[event]){
                that._listeners[event].forEach(function(listener){
                    listener(data);
                });
            }
        });
    };

    Model.prototype.listen = function(events, listener){
        var events = events.split(/\W+/);
        var that = this;
        events.forEach(function(event){
            if (!that._listeners[event]){
                that._listeners[event] = [listener];
            } else {
                that._listeners[event].push(listener);
            }
        });
    };

    // View class
    function View(conf){
        Object.assign(this, conf);
        this.init();
    };

    View.prototype.clear = function(){
        this.$dom.parentNode.removeChild(this.$dom);
    };

    View.prototype.listenTo = function(model, event, listener){
        model.listen(event, listener);
    };

    // app
    var appModel = new Model({
        data: [],
        add: function(item){
            this.data.push(item);
            this.trigger('add', item);
        },

        remove: function(item){
            var index = this.data.indexOf(item);
            this.data.splice(index, 1);
            this.trigger('delete', index);
        }
    });

    var appView = new View({
        $dom: document.querySelector('#view'),
        init: function(){
            this.$count = this.$dom.querySelector('.jsCount');
            this.$list = this.$dom.querySelector('.jsList');
            this.$listEmpty = this.$dom.querySelector('.jsListEmpty');
            this.$input = this.$dom.querySelector('.jsInput');
            this.$add = this.$dom.querySelector('.jsAdd');

            this.$add.addEventListener('click', this._onClickAdd.bind(this), false);

            this.listenTo(appModel, 'add', this.add.bind(this));
            this.listenTo(appModel, 'delete', this.delete.bind(this));
            this.listenTo(appModel, 'add delete', this.update.bind(this));
        },

        add: function(item){
            var newItem = Render('#js-tmpl-item', item);
            newItem.querySelector('.jsDelete').addEventListener('click', function(){
                appModel.remove(item);
            }, false);
            this.$list.appendChild(newItem);
        },

        delete: function(index){
            this.$list.removeChild(this.$list.querySelectorAll('li')[index + 1]);
        },

        update: function(){
           this.$count.innerHTML = appModel.data.length;

            if (appModel.data.length > 0){
                this.$listEmpty.style.display = 'none';
            } else {
                this.$listEmpty.style.display = 'block';
            }
        },

        _onClickAdd: function(){
             var input = this.$input.value.trim();
            if (input.length === 0){
                return;
            }
            appModel.add(input);
            this.$input.value = '';
        }
    });
})();

结果发表:

嗯,代码清爽了许多。不过,等等,这代码比原来的代码要长一倍啊。哎,没办法,你需要抽象出通用的部分嘛,这部分Model和View的代码一旦稳定拿出去,代码量和原来是一样的。

不知道你发现没有? 这个骨架像不像BackBone,因为还很简陋干脆叫尾椎好了。BackBone里面有更多的方法,还有Collection类,几年前用的人还挺多吧。不过感觉已经慢慢停止更新了。github上面的最新commit事在3个月前( https://github.com/jashkenas/backbone )

感想。

Backbone的思想还是停留在原生的基础上,我们可以看到代码量并没有减少太多。但是通过model和view的关系,我们可以写出更加整洁易于理解的代码,此外model view部分我们也可以拆分成不同的文件,方便进行管理。

说是缺点的话我觉得主要有两点。

  1. 事件需要js进行绑定,我们看到为了绑定时间我们需要定义专门的js class名。(虽然BackBone允许我们直接用mapping的方式直观定义事件绑定,但是没有逃脱出js绑定的本质)。如果能在html中直接定义就好了。
  2. 我们的html非常简单一个list,对于list这种很规整的数据,html能不能自己关注Model自动更新?而不需要js手动添加add, delete方法进行操作。简单的说,这个app能不能再聪明一些?我添加・删除了一个item,你能不能自觉的反应到html中?

对于以上问题,我们下次再尝试!