all articles

Thoughts about front-end dev by implementing a ToDo App(11) -- Props

2017-01-13 @sunderls

A_ToDo_App js

目前为止,app的代码看上去还算清爽。不过scopeConfig和scope的关系还有点暧昧,我们优化一下。

1分析

1.1 scopeConfig -> props

代码执行时,如果遇到scope,会初始化scope。在parse的时候,如果遇到scopeConfig,会根据scope的指令,从父scope继承产生新的scope。但是如果同时有二者的话,就会出现被覆盖的问题。在当初更名为scopeConfig的时候,是为了将scope初始化和scope继承分开,但是我们后来修改的目标是,子组件不要修改父节点的数据,所以如果数据是从父节点而来,就没必要自己存在自己的scope中,所以继承是没必要的,这里应该清楚的将scope自身的数据和父节点的数据分开为妙,比如:

  1. 对于<todo-item>而言,todo是<todo-app>传来的数据
  2. 对于<add-todo>而言,newItemName是自己的数据,addItem<todo-app>传来的方法。

干脆分开好了,自己的数据自己存着ーーscope,传过来的数据用另外的名字表示ーーprops。

1.2 scope + props + methods

更名后,对应一个Component有两种数据,一种是自己的scope,一种是传递而来的props,这直接导致模版崩溃。因为目前模版的解析的目标是scope,没办法加入props。为了解决这个问题,模版绑定的对象需要是scope 和props的公共父节点,那就是component本身。嗯。

模版数据源变成Component自身后,模版中势必会出现props.todo.name,scope.newItemName,props.remove的情况。如此一来,scope的定义中就不能包含方法了,比如<add-todo>就会出现 scope.add(newItem),为了简单纯粹,我们将方法从scope剥离 - methods

到这里,顿时觉得vuejs非常自然。w

1.3 但是模版要崩溃

因为我们在parse的时候用正则加入了scope.给变量名才使得模版可以简写。现在绑定对象变成了组件自身的话,可以想象模版为变成:

todo-item

<li>{props.todo.name}<button click="props.remove()">x</button></li>

add-todo

<input type="text" model="scope.newItemName"><button click="methods.add()">add</button>

不好看啊!!太罗嗦! 除了props.可以留下意外,另外的如果都能不要前缀就好了。结果,这个是可以做到的,用Object.defineProperty 进行代理就行。

1.4 expressionParser

这个,历史原因一直忘了优化。 parse方法中的正则表达实际上没有必要的,用with就好了,一并处理之。

2 coding开始

2.1 首先修改expressionParser

目前的代码:

let parseConfig = {
    replacement: undefined
};
/**
 * 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 + 'scope.' + c;
    });

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

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

首先,parseConfig的存在,是为了处理for之类的存在的,for="item in todos"将使得for以下的模版支持{item},这个不归parse管,修改为parseDom的时候进行属性帮绑定,总之,先把parse修改为with(this)的形态,其中对于可以直接求值的表达式,也先留作TODO。

修改后

/**
 * parse an expression to function
 * @param {expression} expression - expression
 */
const parse = (expression) => {
    // [TODO] handle unchanged expressions
    return {
        expression: expression,
        update: new Function('', 'with(this){ return ' + expression + '}')
    }
};

另外 parseInterpolation的最后update方法也需要修改this关键字:

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

2.2 component

由于数据绑定到component,之前的单例假设不再成立,修改为class。

// component todo-item
class TodoItem extends Component {
    constructor() {
        super();
        this.props = ['todo'];
        this.tmpl = '<li>{todo.name}<button click="props.remove(props.todo)">x</button></li>'
    }
}

class AddTodo extends Component {
    constructor() {
        super();

        this.props = ['addItem'];
        this.tmpl = `<input type="text" model="scope.newItemName"><button click="add()">add</button>`;
        this.scope = {
            newItemName: ''
        }
    }

    add(){
        this.addItem({
            name: this.scope.newItemName
        });

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

class TodoApp extends Component {
    constructor(){
        super();

        this.tmpl = `<div>
                <h1>{'To' + 'DO'}: {scope.todos.length}</h1>
                <ul>
                    <li style="{display: scope.todos.length > 0 ? 'none' : 'inherit'}">no item</li>
                    <todo-item for="item in scope.todos" todo="item"></todo-item>
                </ul>
                <p><add-todo todos="scope.todos" addItem="addItem"></add-todo></p>
            </div>`,
        this.scope = {
            todos: [],
        }
    }

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

    add(item){
        this.scope.todos.push(item);
    }
}

Component.list = {
    'todo-item': TodoItem,
    'add-todo': AddTodo,
    'todo-app': TodoApp
}

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

由于class声明得到的是全局方法但是不是全局变量,暂时先用list来管理列表。

2.3 parseDom

首先parseDom的参数需要从scope改为component实例, 另外目前是根据scopeConfig来修改子scope,需要修改为根据props创建新的scope。

这里只放最关键的修改。

// if there are custom directives
if ($dom.nodeType === 1) {
    let component = Component.list[$dom.tagName.toLowerCase()];
    if (component){
        if (component.tmpl){
            $dom.innerHTML = component.tmpl;
        }
    }
}

// only traverse childnodes when not under for
if (!hasForAttr){
    let nextComponent = component;
    // if there are custom directives
    if ($dom.nodeType === 1) {
        let nextComponentClass = Component.list[$dom.tagName.toLowerCase()];
        if (nextComponentClass){
            let nextComponent = new nextComponentClass();
            if (nextComponent.tmpl){
                $dom.innerHTML = nextComponent.tmpl;
            }

            if (nextComponent.props) {
                // have to bridge props
                for (let key in nextComponent.props){
                    nextComponent.props = {};
                    Object.defineProperty(nextComponent.props, key, {
                        get(){ return component[key]; },
                        set(newValue){ console.error('direct modify parents\' data')},
                        enumerable : true,
                        configurable : true}
                        );
                }
            }
        }
    }

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

可以看到如果有props的话,将会在子component中定义getter/setter,中转其属性。

2.4 bindNode

这里面把scope改为component,同时在调用update的时候改为:

// val: parsed.update.bind(null, scope),
val: parsed.update.call(component),

目前位置的commit: https://github.com/sunderls/lsdom/commit/84dc4ca2e4a1a521bae2eaf3a5109712019f0578

改动有点大,稍微休息一下。 不过测试可以发现初始化显示是没有问题的,接下来修改新增item时的问题。

2.5 添加item时

首先,parseDom中处理click的时候,update方法调用出了问题,修改为:

let parsed = parse(str);
$dom.addEventListener('click', ()=>{
    parsed.update.call(component);
    dijest();
}, false);

由于props是数组,不是对象,parseDom出现了错误。

另外key需要getAttribute进行中转一下,为了支持addItem="add"

同时我们假设,如果传给子component的是方法的话,是this包装好的方法;

// have to bridge props
if (nextComponentClass){
    nextComponent = new nextComponentClass();
    if (nextComponent.tmpl){
        $dom.innerHTML = nextComponent.tmpl;
    }

    if (nextComponent.props) {
        // have to bridge props
        nextComponent.props.forEach(key => {
            nextComponent.props = {};
            Object.defineProperty(nextComponent.props, key, {
                get(){
                    let val = component[$dom.getAttribute(key)];
                    if (typeof val === 'function'){
                        return val.bind(component)
                    } else {
                        return component[$dom.getAttribute(key)];
                    }
                },
                set(newValue){ console.error('direct modify parents\' data')},
                enumerable : true,
                configurable : true}
                );
        });
    }
}

2.6 for的处理。

可以看到点击添加按钮后,标题的数字已经得到更新。现在又来到了for的处理。目前for是在list增删的时候,修改dom,并且修改watchers的index,因为item删除时会出现错位。

在新的计算方法之下,<todo-item>的todo是通过props中转到父component的,todo=item,但是父component里面没有item,所以出错了。 目前for的使用共有两种情况:


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

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

如何处理呢,由于parseDom传入的是component,为了让新增dom中支持item,需要新建一个中间的component对象,传递item,暂时先这么搞起。

注意的是,在之前的处理中,<todo-item>的模版中的todo被赋予了对象值,为什么呢?先看parseDom中的处理 :

if (component.scopeConfig[key] === '='){
    nextScope[key] = parse($dom.getAttribute(key)).update(scope);
} else {
    nextScope[key] = component.scopeConfig[key];
}

这样导致 for的处理中,对index的处理将消失,不需要动态调整watcher了,也就是说,自从出现scope层级结构的时候,这样的处理就不必要了。现在进行删除。

bindNode.js

 case 'for':
    newWatcher = {
        expression: parsed.expression,
        isArray: true,
        val: parsed.update.bind(component),
        update: {
            add: (arr, from, to) => {
                let endAnchor = extra.forAnchorEnd;
                let parentNode = endAnchor.parentNode;
                let tmpl = extra.tmpl;
                let i = from;
                while (i <= to){
                    let newNode = tmpl.cloneNode('deep');
                    let intermediate = Object.create(component);
                    intermediate.item = parsed.update.call(component)[i];
                    parseDom(newNode, intermediate, newWatcher);
                    parentNode.insertBefore(newNode, parentNode.childNodes[to]);
                    i++;
                }
            },

            remove: (arr, from, to) => {
                console.log(`remove ${from} to ${to}`);
                let endAnchor = extra.forAnchorEnd;
                let parentNode = endAnchor.parentNode;
                let i = from;
                let target = endAnchor.parentNode.childNodes[i];
                let total =  newWatcher.childs.length

                // delete dom & unwatch
                i = from;
                while (i <= to){
                    console.log('remove dom', i);
                    parentNode.removeChild(target);
                    target = target.nextSibling;
                    console.log('unwatch', i);
                    unwatch(newWatcher.childs[i]);
                    i++;
                }
            }
        }
    };
    break;

但是intermediate里面传入的事对象的引用,不会随着数组变化变化。如果数组存的是字符串,数字的话,就变成了值传递,todos来的关系就断掉了,todos更新,子component的props得不到更新。这是个bug。很尴尬。

如果在intermediate中通过getter setter动态返回todos的值的话,当index错位的时候,子Component的props的值也会发生变化,所以老老实实删除掉最后一个dom节点不用diffing就ok了。但是这么一来,diffing的好处就没了。真是令人头疼。

如果在intermediate中再传入一个index值呢? 按照原来的办法,调整index值。 可以试试,如果component是intermediate的话,在子component增加__parent字段。

bindNode.js


case 'for':
    newWatcher = {
        expression: parsed.expression,
        isArray: true,
        component,
        val: parsed.update.bind(component),
        update: {
            add: (arr, from, to) => {
                let endAnchor = extra.forAnchorEnd;
                let parentNode = endAnchor.parentNode;
                let tmpl = extra.tmpl;
                let i = from;
                while (i <= to){
                    let newNode = tmpl.cloneNode('deep');
                    let intermediate = Object.create(component);
                    intermediate.__index = i;
                    Object.defineProperty(intermediate, 'item', {
                        get(){
                            return parsed.update.call(component)[this.__index];
                        },
                        set(newValue){ console.error('direct modify parents\' data')},
                        enumerable : true,
                        configurable : true}
                        );
                    intermediate.isIntermediate = true;

                    parseDom(newNode, intermediate, newWatcher);
                    parentNode.insertBefore(newNode, parentNode.childNodes[to]);
                    i++;
                }
            },

            remove: (arr, from, to) => {
                console.log(`remove ${from} to ${to}`);
                let endAnchor = extra.forAnchorEnd;
                let parentNode = endAnchor.parentNode;
                let i = from;
                let target = endAnchor.parentNode.childNodes[i];
                let total =  newWatcher.childs.length

                // update child watchers
                while(i < total - to + from - 1){
                    console.log('set index', i + to - from + 1, 'to', i);
                    newWatcher.childs[i + to - from + 1].component.__parent.__index = i;
                    i++;
                }

                // delete dom & unwatch
                i = from;
                while (i <= to){
                    console.log('remove dom', i);
                    parentNode.removeChild(target);
                    target = target.nextSibling;
                    console.log('unwatch', i);
                    unwatch(newWatcher.childs[i]);
                    i++;
                }
            }
        }
    };
    break;

domParser.js

 if (!hasForAttr){

    ...

    nextComponent.props = props;
    // if component is intermediate component, pass down the index
    if (component.isIntermediate){
        nextComponent.__parent = component;
    }
}

3 总结

总算写完了,本次代码可以在这里看到: github

可以看到很多时候都是被for搞晕的,现在的思路还算比较清晰:

  1. parseDom的时候,有一个全局的Component,随着子component的出现,Component可能会变化
  2. html中的expression被parse为可求值的function,这个function和全局的component以及dom节点关联在一起,形成一个watcher
  3. 用户事件等触发dijest,dijest中计算所有的watcher值,如果发生变化,就更新dom节点
  4. 当遇到for的时候,每一次循环产生一个带有item__index的中间component,这个item不是引用,而是根据__index动态计算。
  5. 当for所对应的数组变化时,diffing会得出变化的index,通过index依次watcher->component->parent 得到中间component,然后想修改其index值。这样for下面的item使用的地方,在dijest的时候都会得到更新。

虽然代码越来越蛋疼,不过通过遇到的一点一点的问题,思路却变得越来越清晰。敬请期待下一篇文章。