all articles

Thoughts about front-end dev by implementing a ToDo App(5) -- for loop

2016-10-05 @sunderls

A_ToDo_App js

啊,感觉最麻烦的事情来了。在上一章「更进一步Data Bind」中我们实现了text/attribute/style的绑定。今天我们来实现for的语法。

需求分析 - for

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

1. for的检测

这个好办,在之前的parseDom中对attribute进行对比就ok了。"item in todos" 我们进行一个正则匹配就可以获取到目标 todos

2. item的数据更新

如果单独的item中的name发生的话,我们可以通过监听set:todos[index]之类的方式来更新对应的item,但是如果某个item删除的话,index就会发生变化,所以这个地方点监听不能放在单独的item中,而是放在for上面。for来根据index更新目前有的<li>列表。

但是怎么更新呢?比如第一个item的name发生了变化,我们收到set:todos[0].name,然后更新第0个item中需要name发生变化时跟着变化的部分进行更新。对于"item.name"的expression,我们目前parse得到的关注点是"item",需要更新为"name"。

3. item数据的删除

比如删除了第i个位置的item,从i之后的item均发生了数据的变动,我们依次进行更新,除了最后一个<li>我们需要删除。

我们第一次遇到dom的删除,对应的listener我们也需要删除,避免内存泄漏。

4. item数据的插入

比如在第i个位置插入了新的item,从i之后的item均发生了变动,我们依次进行更新,并且添加最后一个<li>为我们所用。

因为是新添加模版,所以最原始的模版我们需要保存一份,这样才能进行添加。如果是clone现有的dom呢?不能parse所以不行。

5. dom中的位置

比如最开始没有todo的时候,我们为了要插入<li>,需要在dom中留下一个开始记号。为了方便,感觉结束记号添加了更好。

6. 事件的层级结构

一个问题是,当插入某个item导致元素错位的时候,<li>中的数据需要全部进行更新。嗯,比如: <li>{item.name} + {item.text}</li>。所以我们前面2中的分析有瑕疵,event需要支持层级结构,比如监听 a.b.c的时候,aa.ba.b.c这三种情况下,均需要出发listener。

7. for嵌套

我们上述的策略支持嵌套么?比如 for="item in todos"中还有一个for="history in item.histories"

当histories变动的时候,触发的事件是"set:todos[0].histories[0]",上述分析适用。 当todos变动的时候,触发的事件是"set:todos[0]", 第二个for所涉及的view会被删除,或者添加。删除的时候没有问题,添加的时候因为缓存了模版,所以会被添加出来重新parse 和bind,感觉没啥大问题。

总结

  1. 需要实现event监听的层级结构。
  2. parse到For的时候需要
    • 添加起始结束标记
    • 缓存模版
    • 注册事件绑定
    • 处理数据新增
    • 处理数据删除

event的层级结构

之前我们的listen方法是直接调用的,listener存储对象只有1层,如果我们每一次trigger的时候,都去匹配一下所有的listener的话呢? 感觉可行,但是有点效率低,比如我们在数组中插入元素的时候,因为元素错位势必要触发多个event,这样对于n个元素的数组就变成O(n2)的复杂度了。不行不行,得用空间换取时间。

存储listener的对象如果也有层级的话,就不用这么麻烦的样子了,比如我们有如下的_listner结构:

todos: {
    listeners: [],
    1: {
        listeners: [listener0]
        name: {
            listeners: [listener1]
        }
    },
    2: {
        listeners: [listener2]
        name: {
            listeners: [listener3]
        }
    },
    3: {
        listeners: [listener4]
        name: {
            listeners: [listener5]
        }
    }
}
  1. 触发set:todos的时候,todos下方所有的listener均被调用。
  2. 触发set:todos[2]的时候,只有listener2和listener3被触发。

由于在js中,数组也是对象,所以我们去掉[2]这种表示方法,统一为.2的形式,触发的event将变为set:todos.2.name,所以event的层级结构将变得简单和一致。

嗯,来写代码。

首先我们给Object一个获取连续属性串的方法。

/**
 * enable fetching object consequential peroperties
 * example:
 * var foo = {bar: {foo: 3}}
 * foo.get('bar.foo') === 3
 */

Object.prototype.get = function(prop){
    const segs = prop.split('.');
    let result = this;
    let i = 0;

    while(result && i < segs.length){
        result = result[segs[i]];
        i++;
    }

    return result;
}

/**
 * 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 : {};
        }
        target = target[segs[i]];
        i++;
    }

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

注意到这个set 和Model中的set是重复的,所以我们删除掉Model中的方法,把event的trigger加入到object prototype当中。

更新model的trigger和listen方法

在trigger中,我们需要递归trigger 对象的property,所以写了一个triggerListener的静态方法。

/**
 * trigger events
 * @param {String} evts - multiple events seperated by space
 * @param {Object} data - event data
 */
trigger(evts, data){
    let events = evts.split(/\s+/);
    events.forEach((event) => {
        let target = this._listeners.get(event);
        Model.triggerListener(target, data);
    });
}
/**
 * trigger events
 * @param {String} evts - multiple events seperated by space
 * @param {Object} data - event data
 */
static triggerListener(target, data){
    for(let attr in target){
        if (target.hasOwnProperty(attr)){
            if (attr === 'listeners'){
                target[attr].forEach(listener => listener(data));
            } else {
                Model.triggerListener(target[attr]);
            }
        }
    }
}

/**
 * add listeners to events
 * @param {String} evts - multiple events seperated by space
 * @param {Function} listener - callback
 */
listen(evts, listener){
    let events = evts.split(/\s+/);
    events.forEach((event) => {
        let key = event + '.listeners';
        let target = this._listeners.get(key);
        if (typeof target === 'undefined'){
            this._listeners.set(key, [listener]);
        } else {

            target.push(listener);
        }
    });
}

小小测试一下

我们去掉js中的最外层的closure, 暴露变量给window以便在console中可以进行调试。

现在我们的js 逻辑代码非常短,只有20行左右。奔跑一下在console中可以看到,目前代码有个问题 - 注册的listener的目标是"todos.length", 这个length是数组的固有属性,不在我们的监听范围中。

暂时为了测试,我们强制性的在app的add和remove方法中添加上述事件。

let appModel = new Model({
    data: {
        todos: [],
    },
    add(name){
        let item = new Model({
            data: {
                name: name
            }
        });
        this.data.todos.push(item);
        this.set('todos.length', this.data.todos);
    },

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

});

在console中,输入以下命令

> appModel.add('test')  // 可以看到count => 2
> appModel.remove(appModel.data.todos[0]) // 可以看到count => 1
> appModel.data.todos.push({name:'test'});
> appModel.trigger('set:todos'); // 可以看到 count => 2
> appModel.data.todos.splice(0,1);
> appModel.trigger('set:todos'); // 可以看到 count => 1

也就是说,我们的event 层级结构暂时看没有什么问题。 YEAH!

for的处理

添加起始结束标记

首先我们在parseDom 方法中添加对for的判断,发现有for存在的时候添加一个标记。标记可以选择一个空的<div>? 为了不影响渲染,我们加入注释(注: 这个不是我想出来的,从angular处学来的),将for的target绑定到注释上面。为了添加和删除dom的便利性,在开始标记之外,我们添加一个结束标记比较方便的样子。

//...
if (name === 'for'){
    let forAnchorStart = document.createComment('for');
    $dom.parentNode.insertBefore(forAnchorStart, $dom);
    hasForAttr = true;

    let forAnchorEnd = document.createComment('end');
    if ($dom.nextSibling){
        $dom.parentNode.insertBefore(forAnchorStart, $dom.nextSibling);
    } else {
        $dom.parentNode.appendChild(forAnchorEnd);
    }
}

注意我们动态地修改了dom内容,原来的遍历子节点的forEach方法会导致for的注释被添加了两遍,所以我修改为循环nextSibling的方式。

if (!hasForAttr){
    let start = $dom.childNodes[0];
    while(start){
        parseDom(start, model);
        start = start.nextSibling;
    }
}

打开console,可以看到<!--for--><!--end-->已经被成功添加。

缓存模版

由于我们采取的是事件监听机制——for循环内容的增删事件将被bind在前面两个comment node上,所以我们把模版用一个closure的方法直接写在listener里面好了。因为bindNode支持extra字段,所以也可以写在哪里面,干脆就以extra的方式传进去吧。

bindNode第一个参数我们传入起始标记comment, 结束comment我们也写在extra里面传入。直接利用cloneNode 方法。

let tmpl = $dom.cloneNode('deep');

bind

首先我们需要计算evts,也就是for="item in todos"todos,用一个正则进行获取。

if (name === 'for'){
    let forAnchorStart = document.createComment('for');
    $dom.parentNode.insertBefore(forAnchorStart, $dom);

    let tmpl = $dom.cloneNode('deep');
    let match = /(.*)(\s+)in(\s+)(.*)/.exec(str);
    let itemExpression = match[1];
    let listExpression = match[4];
    bindNode(forAnchorStart, 'for', model, [listExpression], undefined, {
        itemExpression,
        tmpl
    });
    hasForAttr = true;
}

在bindNode方法中,对for进行单独处理。以下了两个抽象的变化可以组合为所有的数组增减:

  1. 在某个位置开始删除了一些item, remove:index, length
  2. 在某个位置添加某些item, add:index, length

以上事件在数据model中的时候我们进行触发。需要注意的是,dom的顺序不能改变,所以对于上述1和2的事件,dom的变换只能从末端开始,比如如果在4个元素的数组第二个位置插入一个元素,我们实际上触发的是以下事件:

  1. 原来的第二,第三,第四个元素数据发生了变化:set:todos.1set:todos.2, set:todos.3
  2. 尾部新增一个元素

对于删除第二个元素来说,触发的是以下事件:

  1. 删除了最后一个元素
  2. 第二和第三元素发生了变化: set:todos.2

另外,我们在appModel中把todo item设置为单独的Model的,这个感觉不行,还是回归普通的object。因为根据上述的分析,event还是得for来触发。

以上事件的分解,我们在bindNode中进行处理。注意在for中处理的时候,数据已经发生了变化,是事后。

bindNode()

case 'for':
evts.forEach((key) => {
    // add data.items to cetain data.index
    model.listen('add:' + key, (data) => {
        let list = func(model.data);

        // update listeners
        let i = data.index;
        while(i < list.length - data.length){
            model.trigger('set:' + key + '.' + i);
            i++;
        }

        // add data.length dom, before the endAnchor
        let endAnchor = extra.forAnchorEnd;
        let parentNode = endAnchor.parentNode;
        let tmpl = extra.tmpl;
        while(i < list.length){
            parentNode.insertBefore(tmpl.cloneNode('deep'), endAnchor);
            i++;
        }
    });

    // remove data.length items at data.index
    model.listen('delete:' + key, (data) => {
        let list = func(model.data);
        let endAnchor = extra.forAnchorEnd;
        let parentNode = endAnchor.parentNode;

        let i = list.length - 1 + data.length;
        while(i > list.length - 1){
            // remove listeners
            model.unlisten('set:' + key + '.' + i);
            parentNode.removeChild(endAnchor.previousSibling);
            i--;
        }

        // update rest listeners
        while(i > data.index - 1){
            model.trigger('set:' + key + '.' + i);
            i--;
        }
    });
});

为了解除listener,Model增加了unlisten方法:

/**
 * remove listener to events
 * @param {String} evts - multiple events seperated by space
 * @param {Function}
 */
unlisten(evts, listener){
    let events = evts.split(/\s+/);
    events.forEach((event) => {
        let key = event;
        let target = this._listeners.get(key);
        if (typeof target !== 'undefined' && typeof target.listeners !== 'undefined'){
            if (typeof listener !== 'undefined'){
                target.listeners.splice(target.listeners.indexOf(listener), 1);
            } else {
                this._listeners.set(key, undefined);
            }
        }
    });
}

小小测试一下

appModel中的add和remove方法中触发的事件我们需要进行调整:

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

    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.add('test');
> appModel.remove(appModel.data.todos[0]);

可以看到todo count和 列表都进行了准确更新, yeah!

但是<li>还是模版的形态 {item.name},这个需要再一次parse才行,下面我们来操作操作:

for的子dom的parse

如果用现有的parse来跑的话 item.name会被解释为 data.item.name,这是不行的,我们其实需要data.todos[i].name。只好更新 parse方法了,让parse接受一个目标字符串"item"和替换字符串"todos[i]",替换过后再parse貌似就可以了。

为了实现这个,我们给parse增加一个config. parse()

let parseConfig = {
    replacement: undefined
};

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){
        keys.add(c);
        return b + 'data.' + c + d;
    });

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

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

在bindNode()中遇到for的时候,我们设置一下这个config对象。最后加入初始化语句。

case 'for':
evts.forEach((key) => {
    // add data.items to cetain data.index
    model.listen('add:' + key, (data) => {
        let list = func(model.data);

        // update listeners
        let i = data.index;
        while(i < list.length - data.length){
            model.trigger('set:' + key + '.' + i);
            i++;
        }

        // add data.length dom, before the endAnchor
        let endAnchor = extra.forAnchorEnd;
        let parentNode = endAnchor.parentNode;
        let tmpl = extra.tmpl;
        while(i < list.length){
            let newNode = tmpl.cloneNode('deep');
            parseConfig.replacement = {
                from: extra.itemExpression,
                to: key + '[' + i + ']'
            };
            parseDom(newNode, model);
            parentNode.insertBefore(newNode, endAnchor);
            i++;
        }
        parseConfig.replacement = undefined;
    });

    // remove data.length items at data.index
    model.listen('delete:' + key, (data) => {
        let list = func(model.data);
        let endAnchor = extra.forAnchorEnd;
        let parentNode = endAnchor.parentNode;

        let i = list.length - 1 + data.length;
        while(i > list.length - 1){
            // remove listeners
            model.unlisten('set:' + key + '.' + i);
            parentNode.removeChild(endAnchor.previousSibling);
            i--;
        }

        // update rest listeners
        while(i > data.index - 1){
            model.trigger('set:' + key + '.' + i);
            i--;
        }
    });

    let list = func(model.data);
    model.trigger('add:' + key, {length: list.length, index: 0});
});

break;

小小测试一下

> appModel.add('item 2');
> appModel.remove(appModel.data.todos[1]);
> appModel.add('item 2');
> appModel.remove(appModel.data.todos[0]);

发现当删除第一个元素的时候,dom显示的是删除第二个元素。估计哪儿有问题,debug过后发现,parse中没有正确识别括号todos[1].name,我们更新一下正则表达式,然后添加key的时候,把括号替换为.

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;
});

再试,发现能够正确显示了,oh yeah!

嘛啊,因为是console输入命令,所以貌似不能体验,但是完整代码是可以看的。 github: https://github.com/sunderls/lsdom/commit/11b73ccb269c657b2813fa50405591b8ca69f662

余下工作

目前我们实现了简单的for loop,感觉上可以大致工作。但是还有如下剩下的工作:

  1. 支持 if —— 这个应该比for要简单。
  2. event listener —— 用户的点击不支持的话,app没法跑啊。
  3. 有种for循环嵌套的时候会出问题的感觉。
  4. for的处理感觉效率太低,增删一个item的时候明明操作对应的元素就行了,但是因为我们采用的trigger/listen机制不允许,所以每次受影响的dom很多。
  5. js代码越来越复杂了,增加一点新功能可能对已有内容造成影响。需要引入测试。