all articles

Thoughts about front-end dev by implementing a ToDo App(14) - Computed & Router

2017-01-24 @sunderls

A_ToDo_App js

item的添加,删除,数量的显示都没有问题了,现在需要实现:

  1. 完成done check
  2. 下方的tab切换

1. todo item需要是否已完成的flag

首先 add-todo的add中,默认done:false:

add(e){
    // add when enter key is pressed
    if (e.which === 13){
        if (this.scope.newItemName){
            this.props.addItem({
                name: this.scope.newItemName,
                done: false
            });

            this.scope.newItemName = '';
        }
    }
}

然后todo-app中也需要设置tab的flag

scope: () => {
    return {
        todos: [{name: 'a', done: true}, {name: 'b', done: false}],
        tab: 'all'
    }
},

todo-item中需要根据done的状态添加class

tmpl:  `<li classname="props.todo.done ? 'completed' : ''"><div class="view">
    <input class="toggle" type="checkbox">
    <label>{props.todo.name}</label>
    <button class="destroy" click="(e) => props.remove(props.todo)"></button>
    </div></li>`

这里如果直接用class的话,会给浏览器造成困扰,换成classname好了(不过如果我们是在tmpl给添加到dom之前的话就可以直接用class了)

bindNode.js

case 'className':
    newWatcher = {
        node,
        component,
        closestArrayWatcher,
        expression: parsed.expression,
        val: parsed.update.bind(component),
        update(oldV, newV){
            this.node.className = newV;
        },
        isModel: true
    };
    break;

domParser.js

} else if (name === 'classname'){
    let parsed = parse(str);
    bindNode($dom, 'className', component, parsed, {
        parentWatcher
    });
} else if (name === 'for'){

这样done与否的状态就绑定完毕了。

2. done的勾选

todo-item中加上click事件的处理即可:

// component todo-item
LSDom.Component.create('todo-item', {
    props: ['todo', 'remove'],
    tmpl:  `<li classname="props.todo.done ? 'completed' : ''"><div class="view">
        <input class="toggle" type="checkbox" click="(e) => toggle(props.todo)">
        <label>{props.todo.name}</label>
        <button class="destroy" click="(e) => props.remove(props.todo)"></button>
        </div></li>`,
    toggle(todo){
        todo.done = !todo.done;
    }
});

但是<input>要处理默认的on/off问题,所以checked也需要更名。oh。只好改成ls-checked了。

<input class="toggle" type="checkbox" click="(e) => toggle(props.todo)" ls-checked="{props.todo.done}">

parseDom中默认的attr处理中,ls-checked转换名字为checked然后传给bindNode。

else {
    let parsed = parseInterpolation(str);
    if (typeof parsed !== 'object'){
        $dom.setAttribute(name, parsed);
    } else {
        let match = name.match(/(\w+-)?(\w+)/);
        bindNode($dom, 'attr', component, parsed, {parentWatcher, name: match[2]});
    }
}

然后 {props.todo.done}会被处理成字符串"true", "false",在expressoinParser加入对单个interpolation 的检测。

expressionParser

...
update(){
    // if only 1 interpolation, this prevent returning string
    // such as "true" "false"
    if (segs.length === 1 && hasInterpolation) {
        return segs[0].update.call(this);
    } else {
        return segs.reduce((pre, curr) => {
            if (typeof curr !== 'string'){
                return pre + curr.update.call(this);
            }
            return pre + curr;
        }, '');
    }
}
...

最后,在bindNode中对attr的处理中,如果遇到false的值,就把attribute删除。

bindNode.js

case 'attr':
newWatcher = {
    node,
    component,
    closestArrayWatcher,
    expression: parsed.expression,
    val: parsed.update.bind(component),
    update(oldV, newV){
        if (newV){
            this.node.setAttribute(extra.name, newV);
        } else {
            this.node.removeAttribute(extra.name);
        }
    }
};
break;

3. for loop又出问题了。

之前for下面只有1一个name变化点,现在变成了2个,所以scope.todos的childs变成了4个,这将导致for的增删处理出错。直观的想法是,单个item的watcher需要包裹到一个watcher中。可否实现呢?比如 现在childs有两个watcher:

childs:
    watcher1 :
        name watcher
        class watcher
    watcher2:
        name watcher
        class watcher

增删的时候,因为dom会被删除掉,所以watcher1对应的增删貌似可行,这个watcher对应intermediate component好了。

bindNode.js

case 'for':
....
    add(){
        ...
        intermediate.isIntermediate = true;

        let intermediateWatcher = {
            childs: [],
            component: intermediate,
            parent: newWatcher,
            closestArrayWatcher: newWatcher
        };

        addChildWatcher(newWatcher, intermediateWatcher);
        parseDom(newNode, intermediate, intermediateWatcher);
        ...
    }
    remove(){
    ...
    }
...

watcher.js

/**
 * add child watcher
 */

export const addChildWatcher = function(parent, child){
    if (!parent.childs) {
        parent.childs = [];
    }
    parent.childs.push(child);
}

最后,在bindNode()处理closestArrayWatcher的时候要加入对parent的检测。

bindNode.js

let closestArrayWatcher = extra.parentWatcher && extra.parentWatcher.isArray ?
        extra.parentWatcher : extra.parentWatcher.closestArrayWatcher ? extra.parentWatcher.closestArrayWatcher :
            extra.closestArrayWatcher;

这下问题解决了。

4. filter

<todo-app>中,如何根据scope.tab的不同来更新todos数组呢? 更新tab的时候调用:

scope.tab = 'all';

todos存的是所有的todo,view中显示的是经过tab filter之后的todo。所以,这里需要定义一个新的todos -- todosFiltered, 这个需要动态由tab来计算,所以这需要是一个function,然后在模版中需要表现为一个数组。 这里定义一种新的数据格式 - computed property,这个在Ember 1.x时代就已经出现了,现在我们实现一下。

首先,模版中的todos改为todoFiltered.

<todo-item for="item in todos" todo="item" remove="remove"></todo-item>

其次, scope中增加todosFiltered的定义,为了避免方法太重复,我们不能把todoFilters定义在scope,而是定义在component上。

computed: {
    todosFiltered(){
        return this.scope.todos.filter(item => this.scope.tab === 'all'
            || (this.scope.tab === 'active' && item.done === false)
            || (this.scope.tab === 'completed' && item.done === true)
        );
    },
},

在component的处理中,需要把todosFiltered转换为getter/setter。

component.js

static create(name, options){
    // transform computed property
    if (options.computed){
        Object.keys(options.computed).forEach(key => {
            let func = options.computed[key];
            delete options.computed[key];

            Object.defineProperty(options, key, {
                get: func,
                enumerable : true,
                configurable : true
            });
        });
    }

    let component = {
        create(){
            let instance = Object.create(options);
            if (instance.scope){
                instance.scope = instance.scope();
                defineGetterSetter(instance.scope);
            }
            Component.instances.push(instance);
            return instance;
        }
    }
    Component.list[name] = component;

    return component;
}

在console中对tab进行修改,可以看到todos能够顺利更新:

> LSDom.Component.instances[0].scope.tab = 'all';
> LSDom.Component.instances[0].scope.tab = 'active';
> LSDom.Component.instances[0].scope.tab = 'completed';

5. router

通过上面的computed,list已经可以触发更新了,接下来处理底部的tab切换。如果为了简单,可以不涉及到router,简单绑定点击事件,更新链接的class就ok了,不过这里还是稍微做那么好一点点。我们要处理的共有三个链接:

  1. # -> 全部
  2. #active -> 还没完成的todo
  3. #completed -> 完成的todo

分析一下可以发现,router需要有一下几点:(pushState的类型暂不做考虑)

  1. 首先router需要监听hashchanged事件,然后触发更新
  2. router需要parse url,提供给必要的path信息

把url的hash变化看作数据变化的一种的话,router也是某种component,适合作为root component。然后只要这个component能传递path信息给下面的子component的话,就没什么问题了的感觉,而url和component的mapping在router的定义中完成。

router component定义后初始化的时候需要调用render,所以需要给component提供一个能够在加载后执行的方法,这个涉及到生命周期的概念以后再好好总结,总之这里先添加一个mounted的hook,和一个对自身dom 容器的引用。另外为了让route传递route信息给component,render中增加一个extra参数。

component.js

/**
 * render to a dom node
 * @param {String} name - component name
 * @param {DOMNode} target - target dom node
 */
static render(compnentName, target, extra){
    let component = Component.list[compnentName].create();
    target.innerHTML = component.tmpl;
    component.$container = target;
    Object.assign(component, extra);
    // seems problematic
    parseDom(target, component, Watchers.root);

    if (component.mounted){
        component.mounted();
    }
}

然后我们创建router Component

LSDom.Component.create('router', {
    scope: () => {
        return {
            map: {
                '/:tab?': 'todo-app'
            },
            route: {}
        }
    },
    mounted(){
        // init router
        //
        Object.keys(this.scope.map).some(route => {
            let match = this.__match(route);
            if (match){
                Object.assign(this.scope.route, match);
            }
            return match !== null;
        });

        // transform route to
        LSDom.Component.render('todo-app', this.$container, {
            route: this.scope.route
        });
    },

    __match(route){
        // transform route to regex
        let keys = [];
        let path = location.hash.slice(1);
        let regstr = route.replace(/:\w+/g, (a, b) => {
            keys.push(a.slice(1));
            return '(\\w+)';
        });
        let match = path.match(new RegExp(regstr));
        if (match){
            let params = {};
            keys.forEach((key, i) => {
                params[key] = match[i + 1];
            });
            return params;
        } else {
            return null;
        }
    }
});

// init
LSDom.Component.render('router', document.getElementById('app'));

首先在scope中,定义mapping,然后在mounted hook中检查匹配的route,然后初始化对应的component。

这下在<todo-app>中,可以去掉tab的定义,而直接从route中获得了。

scope: () => {
    return {
        todos: [{name: 'a', done: true}, {name: 'b', done: false}]
    }
},

computed: {
    todosFiltered(){
        return this.scope.todos.filter(item => ['all', ''].includes(this.route.tab)
            || (this.route.tab === 'active' && item.done === false)
            || (this.route.tab === 'completed' && item.done === true)
        );
    },
},

测试后发现,当从active切换到all的时候todo 顺序颠倒了,debug后发现,for的处理中有问题,在删除的时候调整了index但是在增加的时候忘了处理。

首先在for的add中,添加watcher的时候不能简单的push,而是需要插入到特定的index。

watcher.js

/**
 * add child watcher
 */

export const addChildWatcher = function(parent, child, index = null){
    if (!parent.childs) {
        parent.childs = [];
    }

    if (index){
        parent.childs.push(child);
    } else {
        parent.childs.splice(index, 0, child);
    }
}

bindNode.js 中传入index之后,需要对插入部分之后的items的index进行调整。

    ...
            addChildWatcher(newWatcher, intermediateWatcher, i);
            parseDom(newNode, intermediate, intermediateWatcher);
            parentNode.insertBefore(newNode, start.nextSibling || endAnchor);
        }
        start = start.nextSibling;
        i++;
    }

    // adjust watchers after insertions
    i = to + 1;
    while (i < arr.length){
        console.log('set index', newWatcher.childs[i].component.__index, 'to',
            newWatcher.childs[i].component.__index + to - from + 1);
        newWatcher.childs[i].component.__index += to - from + 1;
        i += 1;
    }
},

remove: (arr, from, to) => {

另外,unwatch中需要把childs也unwatch掉。

watcher.js

/**
 * remove setter watchers
 * @param {Watcher} watcher - watcher to bind
 */
export const unwatch = function(watcher){
    let list = watcher.parent.childs;
    list.splice(list.indexOf(watcher), 1);

    if (watcher.locations){
        watcher.locations.forEach(loc => {
            loc.delete(watcher);
        });
    }

    // avoid using forEach, since deleting happens in unwatch
    // babel transform for ... of to iterator.
    // use a new array.
    watcher.childs.slice(0).forEach(unwatch);
}

至此一个router就算完成了,最后需要给tab增加上合适的selected class。

<li>
    <a classname="this.route.tab === 'all' ? 'selected' : ''" href="#/">All</a>
</li>
<li>
    <a classname="this.route.tab === 'active' ? 'selected' : ''" href="#/active">Active</a>
</li>
<li>
    <a classname="this.route.tab === 'completed' ? 'selected' : ''" href="#/completed">Completed</a>
</li>

最后一步,我们需要发布到npm,然后就可以用unpkg cdn了。 最终的npm package 在这里;

本次文章的更新在 github pull