all articles

Thoughts about front-end dev by implementing a ToDo App(4) -- further data binding

2016-10-02 @sunderls

A_ToDo_App js

在上一章「简单Data Bind」中我们提出了一个也许更理想的方案--一个更强大的模版解释器,让我们可以更专注于数据Model层而不需要关心显示层View的相关东西。 在最后一节中给出了一个假设性的的模版语法,今天我们重新整理一下这个思绪。

模版语法

首先我们梳理一下所需要的一些语法功能:

  1. 值计算 {expression} : 比如我们使用过的{count}, 如果可以的话能进行简单的计算和属性获取就好了,比如 {todos.length}, {todos.length > 0 ? "no empty" : "empty"}
  2. textNode绑定,也就是1的表达式显示在textNode中,比如 <span>{count}</span>,还需要支持 <span>count: {count}</span>
  3. attribute绑定,1的表达式支持dom的属性绑定,比如<span class="{className}">title</span>, <span class="{todos.length > 0 ? '' : 'emptyList'}">title</span><span class="length{totos.length}">title</span> 这里我们先假定引号是需要的(也没有啥原因)。
  4. for loop: 来显示我们的todo list。这个比较复杂一些,我们假定支持 <li for="{todo in todos}">{todo.name}</li>
  5. style绑定: style中支持很多属性,我们用json object来绑定好了,比如 <span style="{'color': todos.length > 0 ? 'red': 'yellow', 'backgroundColor': '#000'}">title</span>
  6. if : 一些简单的条件判断,虽然我们可以通过5中的"display:none"来控制,但比如某些我们不需要更改数据的情况下渲染dom也是不必要的,不仅多余的dom多余的listener也会产生。

暂且以上的几种语法已经满足我们的需求了,我们来实现实现。

parsing of {expression}

给一个{expression},我们需要基于一个data进行计算。input是data, output是值,所以我们需要得到一个基于expression产生的function:

function parse(expression){
    return function(data){
        // return ...
    }
}

比如 {todos.length},我们需要:

function parse(expression){
    return function(data){
        return data.todos.length;
    }
}

比如 {todos.length > 0 ? "not empty" : "empty"}: 我们需要

function parse(expression){
    return function(data){
        return data.todos.length > 0 ? 'not empty' : 'empty';
    }
}

style中的object计算感觉也是复杂的地方,比如 {'color': todos.length > 0 ? 'red': 'yellow', 'backgroundColor': '#000'}:

function parse(expression){
    return function(data){
        return {color: data.todos.length > 0 ? 'red': 'yellow', backgroundColor: '#000'};
    }
}

style的expression和其他的表达式一样也是包裹在括号{}当中,但是返回的是object。这个,嗯,我们就在干脆把括号当作expression中好了--当expression是object形式的话就返回object,否则就是值。

好了,得想想怎么实现了。

根据上面的例子,我们可以看到只需要把expression中的属性值添加上data.,貌似真实情况下结果表达式就出来了,然后包装成一个function,貌似就可以了的样子,大概是这样的:


function parse(expression){
    return function(data){
        return eval(expression.replace(/attribute/, 'data.attribute');
    }
}

但是eval这么使用貌似不行,我们处理的是字符串,我们用new Function好了。属性的替换我们用最老土的replace试试。(为了简单起见 我们首先假定属性名只能以字母,下划线或者美元符号开头,虽然实际上可以存在 var foo = { '1a': 3} 这种方式来使用任意字符。)

function parse(expression){
    // expression example:
    //    length
    //    todos.length
    //    todos.length + 1
    //    todos.length > 0 ? 'empty' : todos.length
    var newExpression = expression.replace(/([^a-zA-Z0-9\._\$])([a-zA-Z_\$][a-zA-Z0-9\._\$]+)(?!\s*:)([^a-zA-Z0-9\._\$])/g, '$1data.$2$3');

    // if expression stays the same, it means the value is not related to input data, just eval it
    if (newExpression === expression){
        return eval(expression);
    }
    return new Function('data', 'return ' + newExpression);
}

需要注意的正则表达的中间部分(?!\s*:),一部分我们剔除了分号用来避免把返回对象的属性替换掉,意思就是 {count: todos.length * 3}中,我们不能把count替换为data.count

以上parse估计还有很多的问题,不过大概意思说明了,而且大概能用。哈哈。

dom的parsing

以上我们处理了expression,接下来我们处理dom 字符串的parsing。 根据上面的实例,我们大概需要下面几种情况:

情况 例子 bind target 如何更新
textNode里面有expression <span>{count}</span><span>count: {count}, count * 3 : {count * 3} </span> textNode的父节点 有变化的话,更新target的textContent
attribute里面有expression <span>{count}</span><span>count: {count}, count * 3 : {count * 3} </span> 该节点 有变化的话,更新target的attribute
存在for <li for="{todo in todos}">{todo.name}</li> 该节点的父节点 把该节点的html存储为模版,然后当数组增减的时候,从父节点进行处理dom的增删。 这里有一个问题,比如第二个todo item的name变化的时候怎么办? 我们的model通知view的时候发出的有事件,我们可以假设事件是set: todos(index),然后从target根据index进行定位更新
style中有expression <span style="{color:todos.length > 0 ? 'red': 'black'}"></span> 该节点 有变化的时候,根据变化后的object进行更新
存在if <span if="todos.length === 0">empty</span> 该节点 有变化的时候,根据expression的真假我们对dom进行删减。删除还好说,添加的时候我们怎么添加呢?已经删除了的dom我们并不知道恢复的时候应该添加到哪儿。这个。。我们可以在删除的时候添加一个comment 节点,不影响渲染,然后恢复的时候,我们把comment节点替换掉就ok。

遍历dom,而不是模版

在我们之前的render方法中,我们是直接对dom模版字符串进行replace处理。但根据上述列出来的内容,我们需要对dom树结构进行遍历绑定,其中对节点的属性也需要进行便利。这个感觉单纯字符串处理有点难度,所以我们先直接便利dom好了,不再使用script模版。所以我们的html内容将变为:

<body>
<div>
    <h1>ToDO: <span>{todos.length}</span></h1>
    <ul>
        <li style="{display: todos.length > 0 ? 'inherit' : 'none'}">no item</li>
        <li for="item in todos">{item.name}</li>
    </ul>
    <p><input type="text" class="jsInput"><button class="jsAdd">add</button></p>
</div>
</body>

便利方法就很简单了。我们从body开始一层一层遍历就完了。遍历一个节点的时候check其子节点和自身的属性。我们采取递归的策略。

遍历dom,从body开始

    /**
     * traverse a dom, parse the attribute/text {expressions}
     */
    function parseDom($dom, model){
        var hasForAttr = false;
        // if textNode then
        if ($dom.attributes){
            Array.prototype.forEach.call($dom.attributes, (attr) => {
                let name = attr.nodeName;
                let str = attr.nodeValue;

                // for style, if it is object expression
                if (name === 'style'){
                    if (str[0] === '{'){
                        let parsed = parse(str);
                        if (typeof parsed.update === 'undefined'){
                            $dom.setStyle($dom, parsed);
                        } else {
                            bindNode($dom, 'style', model, parsed.keys, parsed.update);
                        }
                    }
                } else if (name === 'for'){
                    // TODO for
                    hasForAttr = true;
                } else {
                    let parsed = parseInterpolation(str);
                    if (typeof parsed !== 'object'){
                        $dom.setAttribute(name, parsed);
                    } else {
                        bindNode($dom, 'attr', model, parsed.keys, parsed.update)
                    }
                }
            });
        }

        // 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 {
                    bindNode($dom.parentNode, 'text', model, parsed.keys, parsed.update);
                }
            }
        }

        if (!hasForAttr){
            Array.prototype.forEach.call($dom.childNodes, (node) => parseDom(node, model));
        }
    }

对了,Chrome已经支持es6了,所以从现在开始,代码逐渐地开始写成es6的风格。

在parseDom中,我们依次查看所有可能有绑定expression的地方,然后将expression parse掉,然后调用bind方法,把dom节点,数据model和parse后的update方法进行绑定。注意的有:

  1. style的更新因为有可能是object,需要单独处理
  2. dom节点属性和文本节点中可能出现部分expression的情况,我们需要把字符串拆开然后依次进行parse然后结合在一起,所以需要parseInterpolation方法。这个类似于一个支持多个expression的parse方法。
  3. 我们需要知道一个expression 到底需要监听哪一部分数据变化,所以在parse方法中我们需要提取出被监听的事件key,如果没有key的话说明这个expression可以直接计算出结果,比如{'To' + 'Do'},我们直接更新dom到实际的数据即可。

更新后的parse方法

(之前写的正则有点小错误,这里进行了更新)

如果parse后的expression和传入的一样,则说明可以直接计算value。

/**
 * parse an expression,
 * if can be valued, just return the value
 * if not, return keys & update function, used in data biding
 */
function parse(expression){
    // 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);
    }

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

parseInterpolation

这个方法中,我遍历了字符串,检测mustache {, }的存在与否,然后用了一个数组存储字符串片段,依次调用parse。然后把parse后的update方法用一个 closure包裹起来,得到的返回值和parse方法一致。

/**
 * parse string with {expression}
 */
function parseInterpolation(str){
    var i = j = 0;
    var segs = [];
    var hasInterpolation = false;

    while (j < str.length){
        if (str[j] === '{'){
            hasInterpolation = true;
            if (j > i){
                segs.push(str.slice(i, j));
            }
            i = j + 1;
        } else if (str[j] === '}'){
            if (j > i){
                segs.push(parse(str.slice(i, j)));
            }
            i = j + 1;
        }
        j++;
    }

    if (j > i){
        segs.push(str.slice(i, j));
    }

    if (hasInterpolation){
        let keys = new Set();
        segs.forEach((seg) => {
            if (typeof seg === 'object'){
                seg.keys.forEach(keys.add.bind(keys));
            }
        });

        if (keys.size === 0){
            return segs.reduce((pre, curr) => {
                    return pre + curr;
                }, '');
        }

        return {
            keys,
            update(data){
                return segs.reduce((pre, curr) => {
                    if (typeof curr !== 'string'){
                        return pre + curr.update(data);
                    }
                    return pre + curr;
                }, '');
            }
        }
    } else {
        return str;
    }
}

bindNode

parseDom方法中得到parse结果后,我们就需要进行最核心的bind了,其实就是我们上一篇文章中所写的Render方法中的事件监听。注意这里和parseDom一样需要对不同的bind类别进行检测,这里包含了 text/attr/style。 style的更新和attr不一样,所以我单独写了一个方法。

/**
 * bind update function to a node & model
 */
function bindNode(node, type, model, keys, func, extra){
    console.log('bind', node, type, model, keys);
    if (type === 'text'){
        node.textContent = func(model.data);
        keys.forEach((key) => model.listen('set:' + key, () => node.textContent = func(model.data)));
    } else if (type === 'attr'){
        node.setAttribute(extra.name, func(model.data));
        keys.forEach((key) => model.listen('set:' + key, () => node.setAttribute(extra.name, func(model.data))));
    } else if (type === 'style'){
        setStyle(node, func(model.data));
        keys.forEach((key) => model.listen('set:' + key, () => setStyle(node, func(model.data))));
    }
};

/**
 * update style attribute of a node, by an obj
 */
function setStyle(node, styleObj){
    Object.assign(node.style, styleObj);
}

rewrite model in es6 , delete View

我们把model重新用es6写了一遍,View class可以删除好多代码,其中事件绑定和For的处理我们暂时先不管了。最后App的启动其实就是一个parseDom方法的调用而已。

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

        Object.assign(this, conf);
    }

    /**
     * trigger events
     */
    trigger(evts, data){
        let events = evts.split(/\s+/);
        events.forEach((event) => {
            if (this._listeners[event]){
                this._listeners[event].forEach((listener) => listener(data));
            }
        });
    }

    /**
     * add listeners to events
     */
    listen(evts, listener){
        let events = evts.split(/\s+/);
        events.forEach((event) => {
            if (!this._listeners[event]){
                this._listeners[event] = [listener];
            } else {
                this._listeners[event].push(listener);
            }
        });
    }

    /**
     * update a property
     */
    set(attr, val){
        this.data[attr] = val;
        this.trigger('set:' + attr, val);
    }
}

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

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

});

// start app
// appModel.add('item1');
parseDom(document.body, appModel);

效果:

倒数第二行appModel.add('item')的存在与否,决定了实际html中count和 no item的显示,你可以自己在jsfiddle中试一试。

根据上面的效果我们至少能看到初始化状态情况下没有发生错误,包括

  1. {'to' + 'DO'}应该直接被渲染
  2. {todos.length}应该被正确解释
  3. style="{display: todos.length > 0 ? 'none' : 'inherit'}"应该被正确解释。

而且代码量明显减少(我是说app逻辑部分的话),感觉还不错。

至于剩下的用户交互事件,以及数据更新,For 和 If的处理,我们下次再尝试。今天就到这里~ 感谢观看。

github: https://github.com/sunderls/lsdom/commit/eb3bda63132c5c2ec7c7f044cd7b03863c5e33c5