Git Product home page Git Product logo

lsdom's People

Watchers

 avatar  avatar

Forkers

hibop

lsdom's Issues

better packaging

嗯,一直在说这个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在这里

router

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 在这里;

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.