全ての記事

ToDo Appの実現でフロントについての思考(6) ーData Bindのevent listener

2016-10-05 @sunderls

A_ToDo_App js

ようやくユーザーアクションにたどり着いた。

分析

  1. ボタンにclick handlerをつけられる。html自身のonClickとconflict出ないように,click="add()"にする。なぜaddじゃなくadd()なのかは複雑なexpressionをサポートするため、例えば:click="item.name = '3'"
  2. ユーザーのinput:うん、これについてModelに自動bindしたらいいなきがする、例えばこんな風に: <input type="text" model="newItemName">。Modelにbindできれば、わざわざinputのreferenceを取得する必要はない。

event handling

まずclickの検知はparseDomで判断すればいい。問題はadd()がどこで定義されるべきでしょう。parseDomではattribute:clickの値をparseするので、一旦modelにしよう。(これでmodelは変になるのね。。別途で考えよう)

テンプレートを更新する。

<p><input type="text" model="newItemName"><button click="add()">add</button></p>

clickの判別を追加。parseするために、clickの値はexpression出なければならないので、コロン;は一旦ないと想定しよう。

parseDom

//...
} else if (name === 'click'){
    let parsed = parse(str);
    $dom.addEventListener('click', ()=>{
        parsed.update(model.data);
    }, false);
} else {
//..

簡単だよね。でもこれで実際ユーザーがclickしたら、appModel.dataのadd()が呼ばれる。ちょっとappModelを直す。

appModel

let appModel = new Model({
    data: {
        todos: [],
    },
    remove(item){
        let index = this.data.todos.indexOf(item);
        this.data.todos.splice(index, 1);
        this.trigger('set:todos.length', this.data.todos.length);
        this.trigger('delete:todos', {index:index, length: 1});
    }

});

appModel.data.add = function(){
    let item = {
        name: 'new'
    };
    appModel.data.todos.push(item);
    appModel.trigger('set:todos.length', appModel.data.todos.length);
    appModel.trigger('add:todos', {index: appModel.data.todos.length - 1, length: 1});
}

一旦テストする。ボタンをクリックしてください。wow!,問題なく追加されました。nice!

これからinputについて作業しましょう。

input

考えは直感的に、parseDomの中で、何かのattribut:modelにあったら、このinputのinput eventをlistenして、modelに対するexpresssionの値を更新する。 ここれはね、parse後のexpressionが欲しいので、今parse()がupdate functionを返してて、改修する必要がある。

parse

/**
 * parse an expression
 * @param {expression} expression - expression
 * @desc
 * if can be valued, return the value,
 * if not, return keys & update function, used in data biding
 */
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 keys = new Set();
    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,d){
        // for something like foo[1].bar, change it to foo.1.bar
        keys.add(c.replace(/\[|\]/g, '.'));

        return b + 'data.' + c + d;
    });

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

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

新しいparseではparseごのexpressionを返してる。これで、parseDomの時inputのeventをbindしよう。 Object.prototype.setがあるので、意外に簡単。

parseDom

//...
} else if (name === 'model'){
    let parsed = parse(str);
    $dom.addEventListener('input', ()=>{
        model.set(parsed.expression, $dom.value);
    });
} else {
//...

でもObject.prototype.setにバグがあった「keyは上書きできない」。直します:

/**
 * set consequential peroperty a value
 * @example:
 * var foo = {};
 * foo.set('foo.bar', 3);
 * foo is now {foo: {bar: 3}}
 */
Object.prototype.set = function(prop, val){
    const segs = prop.split('.');
    let target = this;
    let i = 0;
    while(i < segs.length){
        if (typeof target[segs[i]] === 'undefined'){
            target[segs[i]] = i === segs.length - 1 ? val : {};
        } else if (i === segs.length - 1){
            target[segs[i]] = val;
        }

        target = target[segs[i]];
        i++;
    }

    if (this.constructor.name === 'Model'){
        this.trigger('set:' + prop, val);
    }
}

appModelのadd メソードで直接appModel.dataからユーザー入力が取得できる。

let item = {
    name: appModel.data.newItemName
};

ちょっとテスト。oh,動いた。けれど、追加ボタンクリックしたらinputをクリアしたい、現状では無理だな、domのreferenceがなくなったので。 我々はbindの方式なので、add()でappModel.set('data.newItemName', '')を実行すれば、inputの値を更新できるようにしたい。そのために、parseDomでmodelにあったら、bindNodeする必要がある。

//...
} else if (name === 'model'){
    let parsed = parse(str);
    $dom.addEventListener('input', ()=>{
        model.set(parsed.expression.replace('model.', ''), $dom.value);
    });
    bindNode($dom, 'value', model, parsed.keys, parsed.update);
} else {
//...

event keyを更新する。

今までparseで取得したexpressionはmodel.dataをbaseにしてる、でもちょっと乱れてる気がするな、なぜならeventはmodelから発火される者で、model.dataじゃない、dataただmodelの中にデータを保存する場所だ。 なので現状のjs codeを一回整理しよう。func(model.data)func(model)の形にする。詳しくここは説明しない、後ほどコードは全部出すから。

これでinputのクリアもできた!

けれど削除機能がなくなった!!

本当だ!いつの間になくなった!でもテンプレートに削除ボタンはある!どうして! debugしたら、parseDomにバグがあったことがわかった。例えばこんなテンプレート <li>{item.name}<button></button></li>、parseDomを実行すると:

  1. <li>をparse:なにもない、子ノードに入る。
  2. 子node{item.name}をparse:これはtextNodeなので、<li>にbindNodeする、textContentを更新。

つまり<button>は兄弟要素がparseされた時にはもうdomから消された。。。。

textNodeのbindはtextNodeでやろう、親ノードじゃなくて。直します。

parseDom

// if it is text node
if ($dom.nodeType === 3){
    let text = $dom.nodeValue.trim();
    if (text.length){
        let parsed = parseInterpolation($dom.nodeValue);
        if (typeof parsed !== 'object'){
            $dom.textContent = parsed;
        } else {
            // prviously:
            // bindNode($dom.parentNode, 'text', model, parsed.keys, parsed.update);
            bindNode($dom, 'text', model, parsed.keys, parsed.update);
        }
    }
}

次はparseの中の正規表現のバグが見つかった。todos[1].nameの場合は、todos.1..nameのeventsを算出した。直します。

// previously: keys.add(c.replace(/\[|\]/g, '.'));
keys.add(c.replace(/\[|(\]\.?)/g, '.'));

削除機能。

テンプレートにclickを追加する。

<li for="item in todos">{item.name}<button click="remove(item)">x</button></li>

modelでremoveを実現する。

appModel.data.remove = function(item){
    let index = appModel.data.todos.indexOf(item);
    appModel.data.todos.splice(index, 1);
    appModel.trigger('set:data.todos.length', appModel.data.todos.length);
    appModel.trigger('delete:data.todos', {index:index, length: 1});
}

テストしたら、click="remove(item)"はちゃんと処理してない、parse()の結果についてmodel.data.remove(model.data.totos[0])を望むけど、model.data.remove(todos[0])をもらった。 つまりremove(todos[0])をparseする時、エラーがあった。

debugしたら、また正規表現のせいです。正規表現の最後の()の部分がマッチされて、位置が記憶され、次回のマッチが開始したのはこの位置からなのだ。

例えばこんな文字列:

"aaa(bbb)"
  1. 初めてのマッチは"aaa("で、"model.data.aaa("に変換された。
  2. 次回からのマッチはまず"bbb)"に会う、当然マッチしない、首位の文字はアルファベットだ。

改修方法は最後の()をマッチさせるけど、位置は記憶しない。このために(?=)を使う。この表記について詳しくはMDN js regexpをみてください。私のブログ記事「js正規表現を理解してみた」にも説明があります。

parseを直す

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
    keys.add(c.replace(/\[|(\]\.?)/g, '.'));

    return b + 'model.data.' + c;
});

ちょっとテストしたら、削除もできた,oh yeah!

これでevent listenerの部分も終わった、残ってるのは:

  1. if —— これはforより簡単かな。
  2. event listener —— マウスで動かせるようにしないと、web appとは言えるか!。
  3. for nestingの場合はバグりそう。
  4. forの処理がかっこ悪い。一つのitemの増減に、たくさんのdomを操作した、一つのdomを増減するだけで済ませそうなのに。改善できるかな。
  5. jsコードが複雑になってきた。unit testが必要。

附: 全部のjsコード https://github.com/sunderls/lsdom/commit/8f28f2cc2f0c42b0f4dc4417432331a1227abcc7