all articles

Thoughts about front-end dev by implementing a ToDo App(6) -- Event Listener

2016-10-05 @sunderls

A_ToDo_App js

嗯,终于来到事件绑定的部分了。目前我们的app在console中输入命令的话还算运行得不错,现在我们增加点击等操作的支持

需求分析

  1. 在添加按钮上,我们需要绑定一个事件。为了不和已有的html冲突,我们采用click="add()" 的语法, 为什么是add()而不是add,是因为为了支持click="item.name = '3'" 这种情况,我们需要支持更复杂的expression。
  2. 获取用户输入,我们尝试自动把input的内容绑定到model里面好了,比如: <input type="text" model="newItemName">,这样我们也不需要在js里面获取dom的信息了,直接从model就可以获取到。

事件处理

首先关键词click的检测很好办,在parseDom中加入if就ok。问题是add()方法我们写在哪里? 因为我们要对click的值进行parse,所以add我们也写在appModel中好了。这样一做,model就不再是model了,就是一个app了,我们后面再进行更名。

首先我们更新模版

<p><input type="text" model="newItemName"><button click="add()">add</button></p>

parseDom中加入click关键词的检测。由于我们现有的parse是返回expression的值,所以我们需要假定模版中的click的值是一个合法的运算式,不能含有分号。嗯,我们先这么假定好了。

parseDom

//...
} else if (name === 'click'){
    let parsed = parse(str);
    $dom.addEventListener('click', ()=>{
        parsed.update(model.data);
    }, false);
} else {
//..

挺简单明了。但是这样parse的结果是,click触发的是appModel.data中的add()方法,因此我们对appModel进行修改:

appModel

let appModel = new Model({
    data: {
        todos: [],

    },
    remove(item){
        let index = this.data.todos.indexOf(item);
        this.data.todos.splice(index, 1);
        this.trigger('set:todos.length', this.data.todos.length);
        this.trigger('delete:todos', {index:index, length: 1});
    }

});

appModel.data.add = function(){
    let item = {
        name: 'new'
    };
    appModel.data.todos.push(item);
    appModel.trigger('set:todos.length', appModel.data.todos.length);
    appModel.trigger('add:todos', {index: appModel.data.todos.length - 1, length: 1});
}

小小测试一下,点击按钮,wow!,item准确无误地被添加进行了dom,nice!接下来我们尝试接受用户输入。

input

我们的想法很简单,parseDom的时候,如果遇到了attribute:model,就绑定这个input的input事件,把model的对应expression的值给更新。这个地方比较特别,我们需要的是parse过后的expression,而不是一个callback,我们需要更新parse方法予以支持。

parse

/**
 * parse an expression
 * @param {expression} expression - expression
 * @desc
 * if can be valued, return the value,
 * if not, return keys & update function, used in data biding
 */
const parse = (expression) => {
    if (typeof parseConfig.replacement !== 'undefined'){
        expression = expression.replace(parseConfig.replacement.from, parseConfig.replacement.to);
    }
    // expression example:
    //    length
    //    todos.length
    //    todos.length + 1
    //    todos.length > 0 ? 'empty' : todos.length
    let keys = new Set();
    let newExpression = expression.replace(/([^a-zA-Z0-9\._\$\[\]]|^)([a-zA-Z_\$][a-zA-Z0-9\._\$\[\]]+)(?!\s*:|'|")([^a-zA-Z0-9\._\$\[\]]|$)/g, function(a,b,c,d){
        // for something like foo[1].bar, change it to foo.1.bar
        keys.add(c.replace(/\[|\]/g, '.'));

        return b + 'data.' + c + d;
    });

    if (newExpression === expression){
        return eval(expression);
    }

    return newExpression === expression ? eval(expression) : {
        keys,
        expression: newExpression,
        update: new Function('data', 'return ' + newExpression)
    };
};

在parseDom的时候,我们绑定input事件,然后更新expression的值就好了。

parseDom

//...
} else if (name === 'model'){
    let parsed = parse(str);
    $dom.addEventListener('input', ()=>{
        model.set(parsed.expression, $dom.value);
    });
} else {
//...

不好意思,在这里发现了一个Object.prototype.set的bug——不能覆盖已有的key, 现做如下修改:

/**
 * set consequential peroperty a value
 * @example:
 * var foo = {};
 * foo.set('foo.bar', 3);
 * foo is now {foo: {bar: 3}}
 */
Object.prototype.set = function(prop, val){
    const segs = prop.split('.');
    let target = this;
    let i = 0;
    while(i < segs.length){
        if (typeof target[segs[i]] === 'undefined'){
            target[segs[i]] = i === segs.length - 1 ? val : {};
        } else if (i === segs.length - 1){
            target[segs[i]] = val;
        }

        target = target[segs[i]];
        i++;
    }

    if (this.constructor.name === 'Model'){
        this.trigger('set:' + prop, val);
    }
}

然后在app model的添加方法中,获取newItemName:

let item = {
    name: appModel.data.newItemName
};

小小测试一下,oh,能够正确获取数据了。但是有一个问题,我们点击按钮的时候需要清空input以便下一次输入,现在没办法清空。

因为我们是bind的方式,所以我们在add()方法中调用appModel.set('data.newItemName', '')的话,可以通过model之间更新input的值。为此,parseDom中遇到model的时候也需要进行bind。

//...
} else if (name === 'model'){
    let parsed = parse(str);
    $dom.addEventListener('input', ()=>{
        model.set(parsed.expression.replace('model.', ''), $dom.value);
    });
    bindNode($dom, 'value', model, parsed.keys, parsed.update);
} else {
//...

更新event key

目前为止我们parse得到的expression都是base在model.data上的,但是感觉有点乱了,event什么的都应该是model直接触发的,而不是model.data触发的,data只是Model的一个存储数据的地方而已。所以我们对现有代码中的event key相关进行一下整理,简单的说就是bindNode中的func(model.data)全部改为支持func(model)的形式。具体就不细说了,待会儿会上全部代码。

小小测试一下,现在添加能够正常工作,而且input也会按照预期地被清空。

删除功能不见了!!

我们明明在模版中写的有删除按钮,但是渲染后的结果中却没有。啥情况。debug后发现, 对于 <li>{item.name}<button></button></li>的情况,经过了以下步骤

  1. parse <li>
  2. parse 子node{item.name},这是个textNode,bind目标是<li>,更改的是textContent,

这导致在bindNode阶段,<button>就已经被抹杀掉了,我们进行修复,bind text的时候绑定到textNode而不是父节点。

parseDom

// if it is text node
if ($dom.nodeType === 3){
    let text = $dom.nodeValue.trim();
    if (text.length){
        let parsed = parseInterpolation($dom.nodeValue);
        if (typeof parsed !== 'object'){
            $dom.textContent = parsed;
        } else {
            // prviously:
            // bindNode($dom.parentNode, 'text', model, parsed.keys, parsed.update);
            bindNode($dom, 'text', model, parsed.keys, parsed.update);
        }
    }
}

其次parse中的正则表达又发现有问题,对于todos[1].name的情况,我们得到了todos.1..name的错误结果,修复parse中的正则可以解决:

// previously: keys.add(c.replace(/\[|\]/g, '.'));
keys.add(c.replace(/\[|(\]\.?)/g, '.'));

删除功能

首先,模版改为:

<li for="item in todos">{item.name}<button click="remove(item)">x</button></li>

然后我们在model中实现remove功能:

appModel.data.remove = function(item){
    let index = appModel.data.todos.indexOf(item);
    appModel.data.todos.splice(index, 1);
    appModel.trigger('set:data.todos.length', appModel.data.todos.length);
    appModel.trigger('delete:data.todos', {index:index, length: 1});
}

测试了一下发现,我们的parse没有能很好处理 remove(item)的情况,我们期待得到的parse过后的expression是 model.data.remove(model.data.totos[0]),却得到了model.data.remove(todos[0]),显然在parse remove(todos[0])的时候,我们出现了错误。

仔细查了一下发现原因是我们的正则中最后一个匹配位置被保存了下来,第二次匹配的时候进行了略过,比如这个字符串:

"aaa(bbb)"
  1. 正则匹配了第一个区域 "aaa(",替换为了"model.data.aaa("
  2. 尝试匹配"bbb)",然而是第一个字符是字母,而且并不是字符串的开头,所以"bbb"没有被正确替换。

修改方法是让第一步中的"("虽然被匹配,但是忽略它的位置,这样第二步的时候就可以匹配上"(bbb)",为了完成这个目的我们可以使用(?=) ,关于这个标记详细参见MDN js regexp,也可以参考我的文章「深入了解js的正则表达式)」。

修改parse

let newExpression = expression.replace(/([^a-zA-Z0-9\._\$\[\]]|^)([a-zA-Z_\$][a-zA-Z0-9\._\$\[\]]+)(?!\s*:|'|")(?=[^a-zA-Z0-9\._\$\[\]]|$)/g, function(a,b,c){
    // for something like foo[1].bar, change it to foo.1.bar
    keys.add(c.replace(/\[|(\]\.?)/g, '.'));

    return b + 'model.data.' + c;
});

小小测试一下,删除也能正常使用了,oh yeah!

这样event listener就完成了。剩下的task还有:

  1. 支持 if —— 这个应该比for要简单
  2. event listener —— 用户的点击不支持的话,app没法跑啊
  3. 有种for循环嵌套的时候会出问题的感觉。
  4. js代码越来越复杂了,增加一点新功能可能对已有内容造成影响。需要引入测试。
  5. Model部分感觉有点不直观,代码需要进行整理。

附: 完整js代码 https://github.com/sunderls/lsdom/commit/8f28f2cc2f0c42b0f4dc4417432331a1227abcc7