all articles

Thoughts about front-end dev by implementing a ToDo App(10) -- Component

2016-12-28 @sunderls

A_ToDo_App js

在上一篇文章中,我们实现了directive(虽然只是最简单的部分),从结果上看还不错,通过scope的继承机制,父scope可以像子scope传递数据和方法。我们再一次回顾一下app部分的代码:

// 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();

目前app的核心是scope和将scope闭包过后的watchers,在可能产生数据变化的时候,dijest回去执行watcher的变化监测,如果有变化,会执行watcher中定义的callback更新dom。入口是Controller,controller将提供初始的scope,模版中的自定义directive由此继承scope。

这里出现了一个问题, 从代码上看,都有scope以及在其上的数据和方法(目前的controller中没有模版的定义是因为我们图简单默认controller跑在<body>上,这个很容易修改。), 那controller和directive有什么区别 ?

重新思考app中的各个角色

从第一篇文章最原始的实现开始到现在,坚持的原则是分工明确,减少没必要的代码。

阶段 角色分工 好处 问题
最原始实现 app - 数据管理,绑定点击事件
renderer - 模版渲染helper
理解自然,写起来方便 代码逻辑不易管理,重复代码多,没法扩展
model view分离 Model - 管理数据,触发事件
View - 绑定事件,触发model更新,监听model事件更新dom
Renderer - 模版渲染
数据和显示得到了一定程度分离,更易管理 事件绑定,dom更新等需要手动操作
data binding Model - 管理数据,触发事件
domParser - 遍历dom,绑定model数据
model和view分离,view会自动处理model的数据变化 , 减少了手动处理的代码量 model中包含了数据,也包含了event listener,处理dom的event listener定义的时候有点小麻烦
controller & scope & directive controller - app入口,初始化scope, domParser
scope - 定义数据,定义方法,用于data binding
directive - app中的子组件,可以复用的功能单元
scope概念的出现更佳容易理解,相当于模版(view)一一对应的数据源(viewModel) controller和directive关系暧昧

对于一个真实的app环境,可以按照如下方法进行模块设计

  1. 创建一个app controller
  2. 根据模版语法,创建模版
  3. 根据模版显示所需要的数据和方法,定义scope
  4. 数据model的管理,api的调用等在controller中完成,和view无关,可以自由划分模块。

scope到底是什么

和传统的MVC世界对比的话,以上设计中的scope处于view和model中间的位置,并且包含了传统Controller的部分,所以Angular 1.x自称为MVVM,scope是view-model,仔细细分的话,scope包含3部分

# 内容 说明
1 从model过来的数据 例如我们的例子中的todos(当然,真正的api调用还没有做,这里假设todo的初始化时调用的api完成)
2 纯view部分所需要的状态数据 比如例子中的newItemName,db里面是不需要这部分的,只是添加功能中所需要的中间数据。另外更好的例子是,实际app中的部分dom的显示与否等。
3 view中可以触发的方法 比如例子中的click(), delete(),名字上看像是直接操作的model,但是只是scope(view-model)上定义的方法,作为中转,再去调用真实的Model中的方法

directive到底是什么

首先说明的是,我们的例子中的directive和Angular 1.x的directive相比,只完成了很少一部分,尤其是真实directive中的link方法我们还没有实现。 抛开这个不谈,directive本身的出现,是为了强化模版部分,让其支持扩展,支持本身html不支持的自定义tag等。

但是,正如前面的代码所示,在本例中,并没有看出controller和directive的本质区别,除了controller做了更多的内容, controller做了如下directive中没有做的部分

  1. 初始化scope
  2. 初始化parseDom
  3. 初始dijest

注意,这3个部分,directive如果包含的话,也没有啥问题的样子。以目前的代码为例,如果directive可以处理初始化的工作的话,定义个一个<app>的directive可以包括完整的逻辑。如果能做到这一步的话,我们的app逻辑就更简单了,但是directive负责的内容将更多,因为它包含了初始化的方法以及需要能够渲染到具体dom的方法等,所以Directive的名字将不再合适,我们为它取一个新名字 - Component。 w

来,试一下。

1. 首先扩展directive

directive要支持render方法,其中包含parse方法,另外,由于原来传给构造方法的scope实际上是一个从父scope继承scope的方法,严格上看不能叫做scope,所以这里一并修改,命名为scopeConfig好了。

/**
 * directive class
 * @param string - tmpl string
 * @param number - priority
 * @param object - scopeConfig
 */
class Directive {
    constructor(option){
        Object.assign(this, option);

        // transfer scope to Scope
        this.scope = new Scope(this.scope || {});

        // bind all scope method to this, for extension
        Object.keys(this.scope)
            .filter(key => typeof this.scope[key] === 'function')
            .forEach(key => this.scope[key] = this.scope[key].bind(this))
    }

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

    /**
     * render to a dom node
     * @param DOMNode - target
     */

    static render(name, target){
        let directive = Directive.instances[name];
        target.innerHTML = directive.tmpl;
        parseDom(target, directive.scope);
        dijest();
    }

}

Directive.instances = {};

注意几点的是:

  1. 传入的scope需要转换为Scope
  2. 传入的方法,需要wrap到directive本身,因为不这么做方法传递给子组件的时候 this会变化
  3. render是static方法,这样便于调用 Directive.render()

2. 创建 app directive

demo/index.js

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

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

            this.newItemName = '';
        }
    }
});
// directive todo-app
Directive.create('todo-app', {
    tmpl: `<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"></todo-item>
            </ul>
            <p><add-todo todos="todos" addItem="addItem"></add-todo></p>
        </div>`,
    scope: {
        todos: [],
        remove: function(item){
            this.remove(item);
        },
        addItem: function(item){
            this.add(item);
        }
    },

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

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

// init
Directive.render('todo-app', document.getElementById('app'));

可以看到,在<todo-app>的创建参数中,传入了模版和初始化scope和tmpl, tmpl中包含了对<todo-item><add-todo>的利用。

3. 修改其他部分

首先是html

demo/index.html

<!DOCTYPE html>
<html>
<head>
    <title></title>
    <script src="../lib/diff.js"></script>
    <script src="../lib/scope.js"></script>
    <script src="../lib/expressionParser.js"></script>
    <script src="../lib/domParser.js"></script>
    <script src="../lib/directive.js"></script>
    <script src="../lib/dijest.js"></script>
    <script src="../lib/bindNode.js"></script>
</head>
<body>
<div id="app"></div>

<script src="./index.js"></script>
</body>
</html>

然后是删掉 controller.jsclass Scope需要可以接受参数传入。

scope.js

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

domParser.js里面的scope继承的时候需要从scope更名为scopeConfig

// 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.scopeConfig) {
            nextScope = scope.$new();
            for (let key in directive.scopeConfig){
                if (directive.scopeConfig[key] === '='){
                    nextScope[key] = parse($dom.getAttribute(key)).update(scope);
                } else {
                    nextScope[key] = directive.scopeConfig[key];
                }
            }
        }
    }
}

嗯,试了试,没啥问题,到目前为止的diff在这里: https://github.com/sunderls/lsdom/commit/3679bceb7ed038b2cff5e8dba9d6a6d08bbfd4e0

4. 可以改名字了 directive -> component

到这里,可以安心地把directive改名为Component了,嗯。这里纯粹体力劳动就不写过程了,最终代码在 github

5. 总结

终于见证了Component的诞生,隐隐约约快看到了React.js的影子,不过还有一段距离。最终看一下我们的逻辑部分的代码:

// directive todo app
Component.create('todo-app', {
    tmpl: `<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"></todo-item>
            </ul>
            <p><add-todo todos="todos" addItem="addItem"></add-todo></p>
        </div>`,
    scope: {
        todos: [],
        remove: function(item){
            this.remove(item);
        },
        addItem: function(item){
            this.add(item);
        }
    },

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

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

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

看上去清爽很多,但是还有很多可以优化的地方。比如,之前说的scope的三种角色可以分的更明白,比如component的销毁还没有完成,因为里面涉及到事件绑定等。另外,过了这么久,watcher系统都快忘掉了。w。 咱们下次再聊。