all articles

Thoughts about front-end dev by implementing a ToDo App(3) -- simple data biding

2016-09-27 @sunderls

A_ToDo_App js

在上一章Model&View中,我们把js代码中的数据和表现部分进行了分离。完成是完成了,但是总感觉不是很顺畅,今天我们尝试进行优化。

移动html模版到script标签中

上一次的代码中,我们在html中单独定义了单个todo item的<script>模版,这看上去不是很和谐,如果都能写在script标签下,或者直接写在html中就好了。我们先尝试写在script中。

<div id="view">
</div>

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

<script type="js/tmpl" id="js-tmpl-item">
    <li>{this}<button class="jsDelete">x</button></li>
</script>

上面的代码清楚显示,#js-tmpl-app中定义了整个app的模版, #js-tmpl-item定义了单个item的模版。在对应的view中,我们需要对app的html渲染进行手动处理。

更新模版Render方法

首先我们新增加了{count}变量,所以render方法需要进行修改,需要可以支持任意属性:

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

给View增加renderTo方法

其次我们给view新增一个renderTo的办法,表示一个view反应到某个dom 容器中。

View.prototype.renderTo = function($dom){
        $dom.appendChild(this.$dom);
    }

在初始化过程中,appView将变为

var appView = new View({
        $dom: Render('#js-tmpl-app', {
            count: appModel.data.length
        }),
        // ...
});

appView.renderTo(document.querySelector('#view'));

其他部分不做变化,这样模版纯粹化就算完成了。

由 {count} 想到的Bind

在模版中我们定义了 {count},然后通过 this.$dom.querySelector('.jsCount') 获取到dom节点,然后在view的update方法中对count进行了dom更新。初始化渲染的时候,我们向Render方法传入了初始的count:0。想一想因为模版中已经有了{count}这个特殊标记,所以通过class名标记目标dom的方法可以去掉,模版能够自己完成。如何实现呢?

  1. 当检测到{attribute}的时候,我们将这个dom和对应的attribute绑定。我们的例子中是 count
  2. 当model中的list增减的时候,count的变化我们并不知道,所以在更新model的时候,我们需要高速模版count发生了变化。这个过程我们可以复用model已有的event处理系统,响应的event以'set:'打头以是区分。

根据以上分析,传入给Render的data不能再是单纯的data,而只能是model。

原来的appModel的data是一个数组,现在包含了新的count:

var appModel = new Model({
        data: {
            todos: [],
            count: 0
        },
       // ...
}

(其他类似的修改略)

然后我们给Model增加set方法(更新某个attribute):

    Model.prototype.set = function(attr, val){
        this.data[attr] = val;
        this.trigger('set:' + attr, val);
    };

也就是说,我们在appModel的addremove中,需要增加this.set('count', this.data.todos.length);的调用。

最后,在Render中,我们把event的listener给添加上。由于单纯的用replace不能简单的实现,我们现在replace中添加临时的id,声称dom后再选择该id的dom进行注册。

    var Render = (function(){
        var uid = 0;

        return function(selector, model){
            var tmpl =  document.querySelector(selector).innerHTML.trim();
            // regexp /.*/ will not match whitespace
            var matches = tmpl.match(/<(\w+)>([\s\S]*)<\/(\w+)>/);
            var newItem = document.createElement(matches[1]);

            var watchers = {};
            // suppose all {} interpolate is in textnode
            newItem.innerHTML = matches[2].replace(/\{(.*?)\}/g, function(a, key){
                var id = uid++;
                if (!watchers[key]){
                    watchers[key] = [id];
                } else {
                    watchers[key].push(id);
                }
                return '<span id="id' + id + '">' + (key === 'this' ? model.data : model.data[key] ) + '</span>';
            });

            // register
            for(var key in watchers){
                watchers[key].forEach(function(id){
                    var $dom = newItem.querySelector('#id' + id);
                    model.listen('set:' + key, function(val){
                        $dom.textContent = val;
                    });
                });
            }

            return newItem;
        }
    })();

btw: 原来Model的event系统中的正则表达有点问题

// 原来用的是\W有问题: var events = events.split(/\W+/); 
var events = events.split(/\s+/);

这样todo list的count就自动bind上了,html和view里面的.jsCount终于可以删除掉了,https://github.com/sunderls/lsdom/commits/master

模版中attribute的绑定

虽然根据上述的优化,count的显示已经自动bind了,但是空列表文案 no item的显示还是由view中的update方法进行控制的。 源代码:

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

这部分我们也想办法融合到模版中好了。如何实现呢?比如如果我们要实现下面这种直观的代码:

html

<li style={'display': count > 0 ? 'block' : 'none'}>no item</li>

fmfm!不是很简单的样子。我们先看一下还有其他什么模版需求然后争取做出一个通用的解决办法。在我们的view中,我们还用了一个.jsList来引用列表元素的dom来删减todo item,这部分我们也想写到模版中,比如这样:

<li for="item in todos">{item.name}/li>

这样我们就不需要手动进行dom操作了,更新model中的数组,view自动会为我们更新。

最后我们的view中还用到了.jsAdd.jsInput,一个是为了绑定点击事件,一个是为了获取input的值。对于事件,我们直接用最基本的onclick好了,清楚直观。对于input元素的调用,我们新采用一个属性方便view进行直接调用,比如这样:

<p><input type="text" ref="newItem"><button onclick="add">add</button></p>

综上所述,我们需要实现大概如下的模版:

<script type="js/tmpl" id="js-tmpl-app">
<div>
    <h1>ToDO: <span>{count}</span></h1>
    <ul>
        <li style={'display': count > 0 ? 'block' : 'none'}>no item</li>
        <li for="item in todos">{item.name}/li>
    </ul>
    <p><input type="text" ref="newItem"><button onclick="add">add</button></p>
</div>
</script>

以上只是大概的想法,具体的语法上面还需要仔细考量,包括双引号的使用与否。具体的实现我们下一次再一起思考。