all articles

Thoughts about front-end dev by implementing a ToDo App(13) -- Brushup

2017-01-19 @sunderls

A_ToDo_App js

嗯,一直在说这个lsdom是学习用的所以只支持Chrome,不过想了一想,加上webpack让它装逼装的更香一点为何不是一种学习呢。哈,所以这次就把lsdom装得更像一点。

1. 首先加入 license

(略)

2. 使用yarn

个人其实没觉得yarn有什么特别必要的地方,不过用上。w

3. mocha + chai 来测试

> yarn add --dev mocha chai babel-register babel-preset-es2015

首先,我们拿diff.js来开刀。 现在的diff传入了add 和 remove感觉不利于测试, 干脆把diff和patch分开好了。

import logger from './logger';
/**
 * diff array
 * @param {Array} from
 * @param {Array} to
 * @return {Array} diff with [type, arr, start, end],
 *      type:1 -- add, type: -1 remove
 */
export const diff = (from, to) => {
    logger.group('diff', from, to);
    let i = 0;
    let totalFrom = from.length;
    let j = 0;
    let totalTo = to.length;

    let result = [];

    while (i < totalFrom && j < totalTo){
        if (from[i] === to[j]){
            i++;
            j++;
        } else {
            let k = from.indexOf(to[j]);
            if (k > i){
                result.push([-1, from, i, k - 1])
                i = k + 1;
                j++;
            } else {
                let l = to.indexOf(from[i]);
                if (l > j){
                    result.push([+1, to, j, l - 1]);
                    i++;
                    j = l + 1;
                } else {
                    break;
                }
            }
        }
    }

    if (i < totalFrom){
        result.push([-1, from, i, totalFrom - 1]);
    }

    if (j < totalTo){
        result.push([1, to, j, totalTo - 1]);
    }
    logger.groupEnd();
    return result;
}

为了不在test中显示log,console封装为logger:

logger.js

function noop(){}

let logger = console;
if (typeof process !== 'undefined'){
    logger = {
        group: noop,
        groupEnd: noop,
        log: noop
    }
}

export default logger;

然后就是test spec了

test/diff.js

import { diff } from '../lib/diff';
import { expect } from 'chai';

describe('diff', () => {
    it('should return add when array is pushed', () => {
        let a = [1];
        let b = [1, 2];
        expect(diff(a, b)).to.deep.equal([[1, b, 1, 1]]);
    });

    it('should return add when array is spliced', () => {
        let a = [1, 2];
        let b = [1];
        let c = [1, 2, 3];
        let d = [1, 3];
        expect(diff(a, b)).to.deep.equal([[-1, a, 1, 1]]);
        expect(diff(c, d)).to.deep.equal([[-1, c, 1, 1]]);
    });

    it('should return mixed when arrays are different', () => {
        let a = [1, 2];
        let b = [3];
        let c = [1, 2, 3, 4, 5];
        let d = [3, 6, 5];
        expect(diff(a, b)).to.deep.equal([[-1, a, 0, 1], [+1, b, 0, 0]]);
        expect(diff(c, d)).to.deep.equal([[-1, c, 0, 1], [-1, c, 3, 4], [1, d, 1, 2]]);
    });
})

package.json

"scripts": {
    "test": "mocha --require babel-register",
}

>yarn test 可以看到3个测试用例全部通过,yeah。

4. webpack

测试通过了,但是原来的app运行不了了,因为浏览器环境不支持export,ok,上webpack。首先lib更名为src,然后把苦文件打包为dist/lsdom.js,然后就是体力活了,不赘述了,详情请看最后的commit 链接。

有个问题是 watcher.js中在Object.prototype中定义了get, set,这导致babel编译过后的代码无法在Chrome上执行,一直如下错误:

A property cannot both have accessors and be writable or have a value,

再现方式:

Object.prototype.get = function(){}
Object.defineProperty({}, 'foo', {value: 'bar'})

所以需要删除Object.prototype.set,相应的domParser中对Model的处理更新为:

} else if (name === 'model'){
    let parsed = parse(str);
    $dom.addEventListener('input', () => {
        // suppose only can set `scope.xxx` to model
        component.scope[parsed.expression.replace('scope.', '')] = $dom.value;
    });
    bindNode($dom, 'value', component, parsed, {parentWatcher});
}

另外 webpack -p可以minify,把这个加到build里面。

5. travis

免费的CI为何不用,添加.travis.yml文件,登陆travis就OK了。然后readme就能看到闪亮的build:passing图标。

6. component 从Class改为factory

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

上面面这段代码太糟糕了,怎么都想去掉了。想来想去没有啥办法,所以只能把component的创建改为了factory,诶?貌似之前从factory改成class了,原因是避免单例模式,oh,当时没有深入思考偷了懒,实际上可以不用class解决这个问题。

component.js

import { parseDom } from './domParser';
import { defineGetterSetter } from './getterSetter';

/**
 * component class
 */
class Component {

    /**
     * create a component factory
     * @param {String} name
     * @param {options} options - {props, scope, methods, template}
     */
    static create(name, options){

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

    /**
     * render to a dom node
     * @param {String} name - component name
     * @param {DOMNode} target - target dom node
     */
    static render(compnentName, target){
        let component = Component.list[compnentName].create();
        target.innerHTML = component.tmpl;
        parseDom(target, component);
    }
}

Component.instances = [];
Component.list = {};

export default Component;

浅显易懂,把options转换为一个factory。注意由于scope需要不断的复制,感觉深度复制也不是个事儿,干脆规定scope必须是个function,这样create的时候执行就好了。 这个又和vue.js走到一起了。嘛,

7 把lsdom的名字提升到台面

runtime.js

import Component from './lib/component';

window.LSDom = {
    Component
}

8 修改demo

// component todo-item
LSDom.Component.create('todo-item', {
    props: ['todo', 'remove'],
    tmpl:  `<li><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>`
});

LSDom.Component.create('add-todo', {
    props: ['addItem'],
    tmpl: `<input type="text" class="new-todo" placeholder="What needs to be done?" model="scope.newItemName" keypress='(e) => add(e)'>`,
    scope: () => {
        return {
            newItemName: ''
        }
    },
    add(e){
        // add when enter key is pressed
        if (e.which === 13){
            this.props.addItem({
                name: this.scope.newItemName
            });

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

LSDom.Component.create('todo-app', {
    tmpl: `<div class="todoapp">
            <h1>todos</h1>
            <ul class="todo-list">
                <todo-item for="item in scope.todos" todo="item" remove="remove"></todo-item>
            </ul>
            <p><add-todo todos="scope.todos" addItem="add"></add-todo></p>
        </div>`,

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

    remove(item){
        console.log('TodoApp: remove', item.name);
        let index = this.scope.todos.indexOf(item);
        this.scope.todos.splice(index, 1);
    },

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

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

感觉顺畅多了,嗯,虽然LSDom有点不好读。 嘛,以后再说。

同时为了让app好看,我借用了todomvc的css。

<!DOCTYPE html>
<html>
<head>
    <title></title>
    <meta charset="utf8">
    <link rel="stylesheet" href="./index.css">
    <script src="./lsdom.min.js"></script>
</head>
<body>
<div id="app"></div>
<script src="./index.js"></script>
</body>
</html>

9 支持keypress事件

为了实现 todomvc的例子,添加item的时候,需要支持回车键。可以在上面的demo js中看到模版中已经强行加上了keypress='(e) => add(e)',这个感觉还可以再优化一下,下次再聊。这次先总之支持上。需要在parseDom的时候把event传入。

domParser.js

} else if (['click', 'keypress'].includes(name)){
    let parsed = parse(str);
    // suppose event handler expression are all closure functions
    $dom.addEventListener(name, (e) => {
        parsed.update.call(component)(e);
    }, false);

10 gh-pages

怎么能没有一个主页? 在github的设置用点击下按钮就完事儿了。需要做的变化是把demo/改为docs/

结束

嗯,这次没有什么太值得说的,主要做了些边边角角的易用性提升。下次想要针对事件进行优化,敬请关键。

本次的commit在这里