all articles

Thoughts about front-end dev by implementing a ToDo App(9) -- Directive

2016-12-21 @sunderls

A_ToDo_App js

嗯,今天来到Angular 1.x中最经常用的,也是(最核心?)的概念了-directive, 中文翻译应该叫做指令,先不管这个翻译如何,直接看一下目前我们的app模版:

<div>
    <h1>{'To' + 'DO'}: {todos.length}</h1>
    <ul>
        <li style="{display: todos.length > 0 ? 'none' : 'inherit'}">no item</li>
        <li for="item in todos">{item.name}<button click="remove(item)">x</button></li>
    </ul>
    <p><input type="text" model="newItemName"><button click="add()">add</button></p>
</div>

有一个想法是,item和input的部分,能不能抽离出来单独处理,尽量拆分成单元有利于维护和重用,如果我们能按照如下方式书写的话:

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

是不是简单易懂很多? 好,试着实现一下。

分析

首先再次回顾目前app的运作逻辑

  1. parseDom,检测dom中的特殊逻辑(directive)
  2. 将directive中的逻辑和dom进行绑定(bind)
  3. 根据scope中的数据进行一遍 dijest
  4. 当scope的数据可能发生了变化的时候, dijest

可以看出要支持<todo-item>的话,有一下事情要做

  1. 为了在parseDom中可以识别出来,需要有一个包含所有directive的数组。
  2. directive需要有html模版,由于模版分开了,表达式也跟着需要变化,{item.name} => {todo.name}
  3. directive需要支持callback的传递,比如remove(item)add()
  4. directive需要支持传递数据,比如todos="todos"todo="item",这导致directive有维护一个scope的需求,scope将出现层级结构。
  5. <todo-item for="item in todos"></todo-item>这种情况,需要优先识别for,directive 需要有优先级。

step 1. 创建directive基础

  1. (静态方法) Directive.create(),创建的directive会加入到Directive.instances中。
  2. 当parseDom遇到自定义directive的时候,如果有模版,需要注入(init);如果有数据需要传递,需要新建一个scope。
  3. directive需要单例化,因为directive只是帮助创建watcher,没必要重复创建。

base class

directive.js

/**
 * directive class
 * @param string - tmpl string
 * @param number - priority
 */
class Directive {
    constructor({tmpl = null, priority = 0}){
        this.tmpl = tmpl;
        this.priority = priority;
    }

    static create(name, options){
        Directive.instances[name] = new Directive(options);
    }
}

Directive.instances = {};

parseDom

parseDom中添加对自定义directive的判断,如果是的话,注入其模版

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

html 修改

index.html

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

创建 directive

index.js

// directive todo-item
Directive.create('todo-item', {
    tmpl: '<li>{item.name}<button click="remove(item)">x</button></li>'
});

// directive add-todo
Directive.create('add-todo', {
    tmpl: '<input type="text" model="newItemName"><button click="add()">add</button>'
});

结果

结果可以看到,由于我们只是把模版部分移到了外边,模版中的表达式还没有更换,app运行没有问题。

github: https://github.com/sunderls/lsdom/tree/1ca63173d78aad7be75d98005efaf71b9426420c

step 2. 向directive传入数据

目前为止,parseDom中并没有产生新的scope,所以在 for循环中产生的item可以在新环境下继续用。和Angular 1.x中的概念类似,如果directive定义了scope字段的话,我们创建新的scope进行bind。

Directive class增加scope字段

constructor({tmpl = null, priority = 0, scope = null}){
    this.tmpl = tmpl;
    this.priority = priority;
    this.scope = scope;
}

修改 directive声明和模版

// directive todo-item
Directive.create('todo-item', {
    tmpl: '<li>{todo.name}<button click="remove(todo)">x</button></li>',
    scope: {
        todo: '='
    }
});

// directive add-todo
Directive.create('add-todo', {
    tmpl: '<input type="text" model="newItemName"><button click="add()">add</button>',
    scope: {
        todos: '='
    }
});

有一个问题是,在<todo-item>的模版中,还能否使用{item}或者{todos.length}之类的expression,这里和Angular 1.x一样,默认可以,所以新的scope需要继承自旧的scope。 为了支持这一点,需要scope提供创建方法$new.

scope class

scope.js

/**
 * scope class
 */
class Scope {
    /**
     * create a new scope based on this
     * @param Object conf - configuration
     */
    $new(conf){
        return Object.create(this);
    }
}

controller.js

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

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

domParser

最后tagName的检测,和childNode的parse中,根据需要传入新的scope

// only traverse childnodes when not under for
if (!hasForAttr){
    let nextScope = scope;
    // if there are custom directives
    if ($dom.nodeType === 1) {
        let directive = Directive.instances[$dom.tagName.toLowerCase()];
        if (directive){
            if (directive.tmpl){
                $dom.innerHTML = directive.tmpl;
            }

            if (directive.scope) {
                nextScope = scope.$new();
                for (let key in directive.scope){
                    if (directive.scope[key] === '='){
                        nextScope[key] = parse($dom.getAttribute(key)).update(scope);
                    }
                }
            }
        }
    }

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

github : https://github.com/sunderls/lsdom/tree/479303bac8d3ab870e254d669f679006a6752064

step 3 处理事件

由于scope父子关系采用的原型继承,所以目前在调用add()remove()的时候,实际上是原型链中的父scope的方法,现在进行修正。为了显示两种情况,我们将add()放在<add-todo>中,而remove()继续留在appController中。

demo/index.js

// directive todo-item
Directive.create('todo-item', {
    tmpl: '<li>{todo.name}<button click="remove(todo)">x</button></li>',
    scope: {
        todo: '='
    }
});

// directive add-todo
Directive.create('add-todo', {
    tmpl: '<input type="text" model="newItemName"><button click="add()">add</button>',
    scope: {
        newItemName: '',
        add(){
            this.todos.push({
                name: this.newItemName
            });
            this.newItemName = '';
        }
    }
});
// app
class appController extends Controller {
    constructor(props) {
        super(props);
        this.scope.todos = [];
        this.scope.remove = this.remove.bind(this);
        this.init();
    }

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

window.app = new appController();

domParser.js

if (directive.scope) {
    nextScope = scope.$new();
    for (let key in directive.scope){
        if (directive.scope[key] === '='){
            nextScope[key] = parse($dom.getAttribute(key)).update(scope);
        } else {
            nextScope[key] = directive.scope[key];
        }
    }
}

可以看出现在的app在删除上有问题,始终删除了最后一条。原因是虽然diff成功的删除了该删除的dom,但是dijest中的watchers中的index并没有变化,这是不是原来就有的bug? oh,原因在与for中调整watcher的时候忘了更新update方法

bindNode.js


// update child watchers
while(i < total - to + from - 1){
    // 修复之前
    //  newWatcher.childs[i].node = newWatcher.childs[i + to - from + 1].node;
    //  newWatcher.childs[i].oldV = newWatcher.childs[i + to - from + 1].oldV;
    Object.assign(newWatcher.childs[i], newWatcher.childs[i + to - from + 1]);
    i++;
}

done

至此,directive就算完成了,其中,scope能够很好的支持继承,主要是因为之前的parse阶段传入了scope,进行了闭包。 app运行似乎良好,但是感觉有一下一些不妙的地方。

问题 子节点修改了父节点的数据。

由于scope采用了继承,所以在子directive中可以直接操纵父scope的todo数组,这实际上不是一种好的设计。因为在appController中看的话,并不知道todos的添加时在<add-todo>中进行的。解决办法有3种:

  1. <add-todo>中放弃自己的scope,但这样会导致newItemName出现在app的scope中,从UI上看,<add-todo>分开。
  2. <add-todo>放弃直接操纵todos,而是由父节点代为处理,采用scope: {add : '='}的方法传入 add方法。
  3. <add-todo>放弃操纵todos,而是触发事件,由需要的节点(此处为父节点)处理,这个能极大的减少耦合。

在Angular 1.x时代,directive可以是className,可以是attribute,可以是element tag,directive提供了link插槽,可以让directive完成几乎所有的事情,因为所有的逻辑核心都是修改了scope,触发watcher更新而已。而scope也有父scope的引用,可以进行操作,非常自由的同时容易发生混乱。在本系列的文章中,不再实现directive的其他功能,只专注于scope中数据的传递问题。

对于上述3种解决办法:

  1. 不可行的,因为子节点中必然有自己的scope的需求。
  2. 看似可行,directive接受传入明确,add方法所属scope也正确,可以朝这个路试一试。
  3. 看似可行,虽然需要有另外一套事件系统。这个路也可以试一试。

按照2方式修改

demo/index.js

// directive todo-item
Directive.create('todo-item', {
    tmpl: '<li>{todo.name}<button click="remove(todo)">x</button></li>',
    scope: {
        todo: '='
    }
});

// directive add-todo
Directive.create('add-todo', {
    tmpl: '<input type="text" model="newItemName"><button click="add(newIem)">add</button>',
    scope: {
        newItemName: '',
        addItem: '=',
        add() {
            this.addItem({
                name: this.newItemName
            });

            this.newItemName = '';
        }
    }
});

// app
class appController extends Controller {
    constructor(props) {
        super(props);
        Object.assign(this.scope, {
            todos: [],
            remove: this.remove.bind(this),
            addItem: this.add.bind(this)
        });
        this.init();
    }

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

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

window.app = new appController();

demo/index.html

<p><add-todo todos="todos" addItem="addItem"></add-todo></p>

总结

以上按照2的方式修改的结果,直接修改父节点数据的情况得到了改善,但是scope之间的数据传递还是没有怎么理顺,还需要继续努力,努力的方向是分析清楚directive的使命,到底如此模块化后得到了什么好处,有什么问题需要解决,到底值还是不值。这些问题,下次再继续,敬请期待。

github代码: https://github.com/sunderls/lsdom/tree/06665c5d33aad05ccad91f3048834f029f0dcc4e