all articles

Thoughts about front-end dev by implementing a ToDo App(7) -- Angular-like scope

2016-10-20 @sunderls

A_ToDo_App js

首先我们再来看一下目前的结果,以下是逻辑部分的代码:

// app
let appModel = new Model({
    data: {
        todos: []
    }
});

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

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

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

总体上看已经不错的感觉,但是在appModel.data.add里面出现了两次trigger的调用。第一次是为了更新count,第二次是为了更新添加的todo item,这个感觉上还略显麻烦。其中主要的问题就在于{todos.length}的处理。

todos.length能否自动bind。

length是数组的本身所有的属性,但是parse的时候并不知道这一点,只能统统按照可能变动的属性进行处理。为了让todo数组增减的时候count能够更新,上面的trigger就应运而生了。

todos的length变化有很三种情况

case 说明
add.data.todos 新增元素
delete:data.todos 删除元素
set:todos todos 本身发生改变

对于add和delete,我们在parseDom中,二次触发set:data.totos.length。而set:todos的时候,set:todos.length本身就会触发。感觉上可行,我们更新下代码。在bindNodecase 'for'中添加以下代码。

const bindNode = (node, type, model, evts, func, extra) => {
    //...
    case 'for':
        evts.forEach((key) => {
            // add data.items to cetain data.index
            model.listen('add:data.' + key, (data) => {
                //...
                model.trigger('set:data.' + key + '.length');

                //...
            });

            // remove data.length items at data.index
            model.listen('delete:data.' + key, (data) => {
                //...
                model.trigger('set:data.' + key + '.length');
                //...
            });
    //...
};

这样我们的逻辑部分将更加简单。

// app
let appModel = new Model({
    data: {
        todos: [],
    }
});

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

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

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

结果add, remove还是和数组本身的方法重复了

操作数组的方法有很多,比如push(), pop(), shift(), unshift()等,在我们的app中虽然用不到这些,但是保不齐其他地方就会用这些方法对我们的数组进行修改,如果要做支持的话,就需要对这些方法全部进行修改,推广来看,所有属性的修改.property之外的所有方法都需要进行对应,感觉心有余而力不足。

event的事件层级结构给我我们灵感

我们的event事件专门支持了层级结构,理论上讲set:data可以更新所有的listener部分。也就是说,我们做了event的层级结构,实际上是为了避免不必要的更新,所以精准地对listener进行定位到某一层。比如count的更新,我们就只关注set:data.todos.length,todo中的item的变化就不会触发这个更新。

但是我们为了支持length做了很多其他的修改,这会导致应用变的很复杂。反过来讲如果不再从事件的触发上着眼,而是看实际上listener对应的expression到底有没有变化的话,就可以省去不少工作。意思就是: 对于监听事件:set:data.todos.length{count},我们不再监听事件,而是每次数据更新的时候,都去计算一下data.todos.length看它有没有变化, 有的话就更新就完了。 貌似走得通!

如何实现

根据上述分析,listener不再监听事件,而是等着expression值发生变化的时候才被触发,所以appModel的trigger/listen方法,以及event相关的可以删掉了。 数据更新的时候,我们需要检测所有listener对应的expression,这个方法我们命名为dijest()好了。

相信有Angular经验的同学已经发现了,这正是angular中的dijest思想。其中检测各个expression的值变化的行为就叫做 dirty-check。

dijest() & dirty-check

我们需要把parseDom得到的listener用数组保存起来,然后一一对应bindNode后得到的update方法。

parse()

我们不再需要keys

/**
 * parse an expression
 * @param {expression} expression - expression
 * @desc
 * if can be valued, return the value,
 */
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 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
        return b + 'model.data.' + c;
    });

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

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

parseDom()

删除events相关内容,把expression和update一并作为parsed传入给bindNode。

/**
 * traverse a dom, parse the attribute/text {expressions}
 */
const 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);
                    }
                }
            } else if (name === 'for'){
                // add comment anchor
                let forAnchorStart = document.createComment('for');
                $dom.parentNode.insertBefore(forAnchorStart, $dom);

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

                let tmpl = $dom.parentNode.removeChild($dom);
                tmpl.removeAttribute('for');
                let match = /(.*)(\s+)in(\s+)(.*)/.exec(str);
                let itemExpression = match[1];
                let listExpression = match[4];

                let parseListExpression = parse(listExpression);
                bindNode(forAnchorStart, 'for', model, parseListExpression, {
                    itemExpression,
                    forAnchorEnd,
                    tmpl
                });
                hasForAttr = true;
            } else if (name === 'click'){
                let parsed = parse(str);
                $dom.addEventListener('click', ()=>{
                    parsed.update(model);
                }, false);

            } else if (name === 'model'){
                let parsed = parse(str);
                $dom.addEventListener('input', ()=>{
                    model.set(parsed.expression.replace('model.', ''), $dom.value);
                });
                bindNode($dom, 'value', model, parsed);
            } else {
                let parsed = parseInterpolation(str);
                if (typeof parsed !== 'object'){
                    $dom.setAttribute(name, parsed);
                } else {
                    bindNode($dom, 'attr', model, parsed);
                }
            }
        });
    }

    // 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, 'text', model, parsed);
            }
        }
    }

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

bindNode():

bindNode中将parsed.expression和bind过后的update做一个watcher存在全局数组中,这个数组我们命名为watchers。

/**
 * all watchers, to be dirty-checked every time
 */
const watchers = [];

textNode的bind

case 'text':
    watchers.push({
        expression: parsed.expression,
        update: () => node.textContent = parsed.update(model)
    });
    break;

attr的bind

case 'attr':
    watchers.push({
        expression: parsed.expression,
        update: () => node.setAttribute(extra.name, parsed.update(model))
    });
    break;
```js

#### style的bind

```js
case 'style':
    watchers.push({
        expression: parsed.expression,
        update: () => setStyle(node, parsed.update(model))
    });
    break;

value的bind

case 'value':
    watchers.push({
        expression: parsed.expression,
        update: () => node.value = parsed.update(model)
    });
    break;

for的bind

for的bind感觉比较复杂,我们先注释掉。。。从{count}开始做起。

dijest()

appModel

之前也提到了,appModel.data出现了add和remove方法,所以不纯粹是个data了,我们改名为 scope。 然后去掉相关event的触发,并在add喝remove的方法中调用dijest(),dijest我们稍后再写。

appModel也不仅仅是model了,改名为controller,而Model Class已经没有用了。删掉。

// app
let app = {
    scope: {
        todos: [],
    }
};

app.scope.add = function(){
    let item = {
        name: app.scope.newItemName
    };
    app.scope.todos.push(item);
    dijest();
}

app.scope.remove = function(item){
    let index = app.scope.todos.indexOf(item);
    app.scope.todos.splice(index, 1);
    dijest();
}

// start app
parseDom(document.body, app);
dijest();
/**
 * parse string with {expression}
 */
const 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();

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

测试一下

虽然for还没有修改完毕,但是目前的修改没有已经能够让{count}正确的增加了了。nice。 可以在这里查看: https://jsfiddle.net/sunderls/51uwzggg/

在parse, parseDom,bindNode中,直接使用scope而不是Model

这部分修改量大,但没有什么难度,就是把各个方法中的Model的部分替换为scope,有一些细微地方注意一下就好了。代码的话请参考文章结束的部分.

for的处理

首先,现在我们不知道数组的具体内容了。所以数组的增减需要我们自己进行检测。我们使用的是dirty-check方法,所以感觉只需要把数组元素调整好,元素内部的部分会自动更新的。来尝试一下。

为了知道增还是减,update方法需要传入oldV, newV。注意style, attr里面的object的比较。 for的expression parse过后得到的是数组的引用,所以在专门增加isArray flag;

bindNode

const bindNode = (node, type, scope, parsed, extra) => {
    console.log('bindNode:', node, type, scope, parsed, extra);
    switch (type) {
    case 'text':
        watchers.push({
            expression: parsed.expression,
            val: parsed.update.bind(null, scope),
            update: (oldV, newV) => node.textContent = newV
        });
        break;
    case 'attr':
        watchers.push({
            expression: parsed.expression,
            val: parsed.update.bind(null, scope),
            update: (oldV, newV) => node.setAttribute(extra.name, newV)
        });
        break;
    case 'style':
        watchers.push({
            expression: parsed.expression,
            val: parsed.update.bind(null, scope),
            update: (oldV, newV) => setStyle(node, newV)
        });
        break;
    case 'value':
        watchers.push({
            expression: parsed.expression,
            val: parsed.update.bind(null, scope),
            update: (oldV, newV) => node.value = newV
        });
        break;
    case 'for':
        watchers.push({
            expression: parsed.expression,
            isArray: true,
            val: parsed.update.bind(null, scope),
            update: (oldLength, newLength) => {
                console.log(oldLength, newLength);
                let endAnchor = extra.forAnchorEnd;
                let parentNode = endAnchor.parentNode;
                if (newLength > oldLength){
                    // add data.length dom, before the endAnchor
                    let tmpl = extra.tmpl;
                    let i = oldLength;
                    while(i < newLength){
                        let newNode = tmpl.cloneNode('deep');
                        parseConfig.replacement = {
                            from: extra.itemExpression,
                            to: parsed.expression.replace('scope.', '') + '[' + i + ']'
                        };
                        parseDom(newNode, scope);
                        parentNode.insertBefore(newNode, endAnchor);
                        i++;
                    }
                    parseConfig.replacement = undefined;
                } else if (newLength < oldLength){
                    let i = oldLength - 1;
                    while(i > newLength - 1){
                        parentNode.removeChild(endAnchor.previousSibling);
                        i--;
                    }
                }
            }
        });
        break;
    default:
        break;
    }
};

dijest()

/**
 * dijest method, if expression changes, update
 */

const dijest = () => {
    watchers.forEach( (watcher) => {
        let newV = watcher.val();
        if (!watcher.isArray && watcher.oldV !== newV){
            watcher.update(watcher.oldV, newV);
            watcher.oldV = newV;
        } else if (watcher.isArray && newV.length !== watcher.oldV){
            watcher.update(watcher.oldV ? watcher.oldV : 0, newV ? newV.length : 0);
            watcher.oldV = newV.length;
        }
    });
}

这个时候list item的增删已经ok了,出了name的显示。注意在删除item的时候,我们需要把watcher删除,所以需要增加unwatch,为了删除watcher,我们把watcher传给update。

unwatch()

/**
 * unwatch
 */
const unwatch = (watcher) => {
    watchers.splice(watchers.indexOf(watcher), 1);
}

但是同时item下面的watcher我们也要删除。这个。。。我们又遇到问题了,感觉为了删除干净,我们需要增加watcher的层级结构。目前需要处理的只有for了,在parse for以下的dom的时候,我们把父节点的watcher传过去。

watchers结构如下。注意为了删除某个watcher,需要知道它所属的list,所以watcher里面增加了parent字段

watchers: {
    childs: [
        {
            update,
            val,
            expression,
            childs: [
                {

                },
                //...
            ],
            _parent
        },
        //...
    ]
}

这样,在bindNode中的for处理,就可以调用unwatch(watcher.childs[i]);来解除watcher了。

watchers相关

/**
 * all watchers, to be dirty-checked every time
 */
const watchers = {};

/**
 * dijest a watcher, recursively
 */
const _dijest = (watcher) => {
    if (watcher.val){
        let newV = watcher.val();
        if (watcher.isModel){
            watcher.update(watcher.oldV, newV || '');
            watcher.oldV = newV;
        } else if (!watcher.isArray && (watcher.oldV !== newV)){
            console.log(`dijest: ${watcher.expression}, ${watcher.oldV} => ${newV}`);
            watcher.update(watcher.oldV, newV);
            watcher.oldV = newV;
        } else if (watcher.isArray && newV.length !== watcher.oldV){
            watcher.update(watcher.oldV ? watcher.oldV : 0, newV ? newV.length : 0, watcher);
            watcher.oldV = newV.length;
        }
    }

    if (watcher.childs){
        watcher.childs.forEach(_dijest);
    }
};

/**
 * dijest method, if expression changes, update
 */
const dijest = () => {
    _dijest(watchers);
};

/**
 * unwatch
 */
const unwatch = (watcher) => {
    let list = watcher.parent.childs;
    list.splice(list.indexOf(watcher), 1);
}

注意下_dijest里面的isModel。对于用户的输入input,我们更新item过后会设置input值为空,对于dijest而言,一直都是空没有变化,这会导致不能从scope更新input值的问题。所以对于parseDom中遇到model的情况的时候,增加isModel属性,这样dijest的时候强制更新。

bindNode()

/**
 * bind update function to a node & scope
 * @param {Node} node - target node
 * @param {String} type - text, attr, style, for
 * @param {Object} scope - scope
 * @param {Object} parsed - parsed expression: expression & update
 * @param {extra} extra - any other info
 */
const bindNode = (node, type, scope, parsed, extra) => {
    console.log('bindNode:', node, type, scope, parsed, extra);
    let parentWatcher = extra.parentWatcher || watchers;
    let newWatcher = null;
    switch (type) {
    case 'text':
        newWatcher = {
            expression: parsed.expression,
            val: parsed.update.bind(null, scope),
            update: (oldV, newV) => node.textContent = newV,
        };
        break;
    case 'attr':
        newWatcher = {
            expression: parsed.expression,
            val: parsed.update.bind(null, scope),
            update: (oldV, newV) => node.setAttribute(extra.name, newV),
        };
        break;
    case 'style':
        newWatcher = {
            expression: parsed.expression,
            val: parsed.update.bind(null, scope),
            update: (oldV, newV) => setStyle(node, newV),
        };
        break;
    case 'value':
        newWatcher = {
            expression: parsed.expression,
            val: parsed.update.bind(null, scope),
            update: (oldV, newV) => node.value = newV,
            isModel: true
        };
        break;
    case 'for':
        newWatcher = {
            expression: parsed.expression,
            isArray: true,
            val: parsed.update.bind(null, scope),
            update: (oldLength, newLength, watcher) => {
                let endAnchor = extra.forAnchorEnd;
                let parentNode = endAnchor.parentNode;
                if (newLength > oldLength){
                    // add data.length dom, before the endAnchor
                    let tmpl = extra.tmpl;
                    let i = oldLength;
                    while(i < newLength){
                        let newNode = tmpl.cloneNode('deep');
                        parseConfig.replacement = {
                            from: extra.itemExpression,
                            to: parsed.expression.replace('scope.', '') + '[' + i + ']'
                        };
                        parseDom(newNode, scope, watcher);
                        parentNode.insertBefore(newNode, endAnchor);
                        i++;
                    }
                    parseConfig.replacement = undefined;
                } else if (newLength < oldLength){
                    let i = oldLength - 1;
                    while(i > newLength - 1){
                        unwatch(watcher.childs[i]);
                        parentNode.removeChild(endAnchor.previousSibling);
                        i--;
                    }
                }
            },
        };
        break;
    default:
        break;
    }

    if (!parentWatcher.childs){
        parentWatcher.childs = [];
    }

    newWatcher.parent = parentWatcher;
    parentWatcher.childs.push(newWatcher);
};

parseDom()

/**
 * traverse a dom, parse the attribute/text {expressions}
 */
const parseDom = ($dom, scope, parentWatcher) => {
    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', scope, parsed, {
                            parentWatcher
                        });
                    }
                }
            } else if (name === 'for'){
                // add comment anchor
                let forAnchorStart = document.createComment('for');
                $dom.parentNode.insertBefore(forAnchorStart, $dom);

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

                let tmpl = $dom.parentNode.removeChild($dom);
                tmpl.removeAttribute('for');
                let match = /(.*)(\s+)in(\s+)(.*)/.exec(str);
                let itemExpression = match[1];
                let listExpression = match[4];

                let parseListExpression = parse(listExpression);
                bindNode(forAnchorStart, 'for', scope, parseListExpression, {
                    itemExpression,
                    forAnchorEnd,
                    tmpl,
                    parentWatcher
                });
                hasForAttr = true;
            } else if (name === 'click'){
                let parsed = parse(str);
                $dom.addEventListener('click', ()=>{
                    parsed.update(scope);
                }, false);

            } else if (name === 'model'){
                let parsed = parse(str);
                $dom.addEventListener('input', ()=>{
                    scope.set(parsed.expression.replace('scope.', ''), $dom.value);
                });
                bindNode($dom, 'value', scope, parsed, {parentWatcher});
            } else {
                let parsed = parseInterpolation(str);
                if (typeof parsed !== 'object'){
                    $dom.setAttribute(name, parsed);
                } else {
                    bindNode($dom, 'attr', scope, parsed, {parentWatcher});
                }
            }
        });
    }

    // 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, 'text', scope, parsed, {parentWatcher});
            }
        }
    }

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

app

// app
let app = {
    scope: {
        todos: [],
    }
};

app.scope.add = function(){
    let item = {
        name: app.scope.newItemName
    };
    app.scope.newItemName = '';
    app.scope.todos.push(item);
    dijest();
}

app.scope.remove = function(item){
    let index = app.scope.todos.indexOf(item);
    app.scope.todos.splice(index, 1);
    dijest();
}

// start app
parseDom(document.body, app.scope);
dijest();

测试一下:

查看Demo: https://jsfiddle.net/sunderls/51uwzggg/2/ 可以看到正常工作了。给自己点个赞。

dijest loop

在我们的app 逻辑中,dijest被调用了多次。如果我们能预先知道数据可能变化的话,dijest的调用可以变为自动的。 这个Todoapp很简单了,数据的变化之可能发生在click事件中,所以dijest我们移动到parseDom中。 然后app初始化的时候必然调用parseDom和dijest,这个包装在一个controller中好了。

/**
 * Controller
 */
class Controller {
    constructor(){
        this.scope = {};
    }

    init(){
        parseDom(document.body, this.scope);
        dijest();
    }
}

// app
class appController extends Controller {
    constructor(props) {
        super(props);

        this.scope.todos = [];

        this.scope.add = this.add.bind(this);
        this.scope.remove = this.remove.bind(this);

        this.init();
    }

    add(){
        let item = {
            name: this.scope.newItemName
        };
        this.scope.newItemName = '';
        this.scope.todos.push(item);
    }

    remove(item){
        let index = this.scope.todos.indexOf(item);
        this.scope.todos.splice(index, 1);
    }
}

window.app = new appController();

github: https://github.com/sunderls/lsdom/commit/72d30523d3885ffb505c47b25af757ded7b13815

观看最后app的部分,在scope中定义数据和dom中的方法操纵数据。这个感觉是不是有浓浓的angular风味。

at the end

至此,我们可以看到angular的影子了,其实angular相对于前面的pure js也好backbone也好,它最大的功用就是简化了咱们开发者的思维,目前我们的todo app是定义了html模版(当然有一些特殊的语法),然后在js中按照规定的方法创建controller,然后更新数据,dom会自动地更新不需要我们操心。虽然实际angular中的处理远比我们的要复杂,不过思想上面我觉得就是这样来实现的dirty-check的。

下一章我们还是延续现在的步伐,对现有逻辑进行优化,看我们能得到什么样的结果。