TodoMVCという1つのTodoアプリを色々なJavaScriptのフレームワークを使い作ってみようというプロジェクトがあります。これのjqueryバージョンがあったのでソースコードを読んでみました。
これがすごい整理されていて、1ファイルに読みやすく処理がまとまっています。今回はjQueryで動的にDOMを生成してUIを構築するアプリを作成する場合の、綺麗なコードを書くのにとても参考になりました。このコードから学んだことを記していきます。
HTMLの構造
TODOリストのHTMLの構造は下記のとおりです。labelとeditと、contenteditable属性は使われていません。HTMLの後に、テンプレートのscriptタグ、外部ファイル読み込みのscriptタグが記述されています。
ul#todo-list
li[data-id]
div.view
input.toggle[type=checkbox]
label
input.edit
jsの構造
最初に行っていることは次の3つです。
- テンプレートのヘルパー関数の登録
- 定数の設定
- utilityメソッドの用意
その後にTodoアプリの処理が書かれています。これは全てオブジェクトのプロパティに処理を関数としてまとめています。変数Appは関数の整理、グルーピングに使われているわけです。クラスではないのでnew App()
とするとエラーとなります。クラスにするならfunction App() {}
と定義しなければなりません。
わざわざクラスにしなくても整理できることがわかりました。
/*global jQuery, Handlebars, Router */
jQuery(function ($) {
'use strict';
Handlebars.registerHelper('eq', function (a, b, options) {
return a === b ? options.fn(this) : options.inverse(this);
});
var ENTER_KEY = 13;
var ESCAPE_KEY = 27;
var util = {
uuid: function () {
// 省略return uuid;
},
pluralize: function (count, word) {
// 省略
},
store: function (namespace, data) {
// 省略
}
};
var App = {
init: function () {
this.todos = util.store('todos-jquery');
this.cacheElements();
this.bindEvents();
new Router({
'/:filter': function (filter) {
this.filter = filter;
this.render();
}.bind(this)
}).init('/all');
},
cacheElements: function () {
// 省略
},
bindEvents: function () {
// 省略
},
render: function () {
// 省略
},
renderFooter: function () {
// 省略
},
toggleAll: function (e) {
// 省略
},
getActiveTodos: function () {
// 省略
},
getCompletedTodos: function () {
// 省略
},
getFilteredTodos: function () {
// 省略
},
destroyCompleted: function () {
// 省略
},
indexFromEl: function (el) {
// 省略
},
create: function (e) {
// 省略
},
toggle: function (e) {
// 省略
},
edit: function (e) {
// 省略
},
editKeyup: function (e) {
// 省略
},
update: function (e) {
// 省略
},
destroy: function (e) {
// 省略
}
};
App.init();
});
package.jsonって何?
Node.jsで作られたパッケージモジュールを管理するツールです。npm install パッケージ名
でnode_modulesというフォルダが作成され、その中にインストールされます。
Node.js で作られたパッケージモジュールを管理するツール
node_modulesはいつ作られる?
上記の通り、npmコマンドでpackage.jsonのパッケージをインストールする際に自動で作成されます。
CSSやjQueryをパッケージとして管理
node.jsを使わなくてもpackage.jsonで依存ファイルをまとめておけばいいのですね。CSSまでパッケージとして登録されていることには驚きました。これはぜひ活用していきたいです。
handlebarsは何者?
ヘルパーモジュールです。テンプレートとして用意したhtmlに変数を埋め込んでレンダリングするのに使います。
テンプレートファイルはどこにある?
テンプレートファイルはどこにあるのだと思いましたが、index.htmlに埋め込まれていました。scriptタグにもidはつけられるのですね。始めて見ました。タグだから当たり前か。これで$('#todo-template').html()
でscriptタグの中身を取り出しています。今回の場合だとテンプレートですね。
type="text/x-handlebars-template"
はこのライブラリでのお約束みたいです。type属性がどういう風に使われているかはわかりません。
<script id="todo-template" type="text/x-handlebars-template">
{{#this}}
<li {{#if completed}}class="completed"{{/if}} data-id="{{id}}">
<div class="view">
<input class="toggle" type="checkbox" {{#if completed}}checked{{/if}}>
<label>{{title}}</label>
<button class="destroy"></button>
</div>
<input class="edit" value="{{title}}">
</li>
{{/this}}
</script>
ヘルパー関数のoptions引数とは?
下記はeqという自作のヘルパー関数を登録しています。
Handlebars.registerHelper('eq', function (a, b, options) {
return a === b ? options.fn(this) : options.inverse(this);
});
実際のテンプレートで使われているの見るとわかりますが、引数aにfilter、引数bに'all'が渡されていますが、引数opstinsは渡していません。どうやら、最後の引数は自動で渡されるようでoptionsには色々なメソッドが含まれているようです。
<script id="footer-template" type="text/x-handlebars-template">
//省略
<li>
<a {{#eq filter 'all'}}class="selected"{{/eq}} href="#/all">All</a>
</li>
//省略
</script>
options.fn(Object)
はObjectをエスケープします。logに出力させてみるとfnを使っている方はオブジェクトではなく文字列として表示されます。そしてthisはテンプレートのブロックの中身を指すようです。
Handlebars.registerHelper('eq', function (a, b, options) {
console.log(this); // => Object(dir表示)
console.log(options.fn(this)); // class="selected"
console.log(options.inverse(this)); // 空文字
return a === b ? options.fn(this) : options.inverse(this);
});
options.inverse()はelseブロックを使えば役割が分かる!
options.fn()はわかりましたがoptions.inverse()が何をしているかがわかりませんでした。今回の場合だと何も表示されていません。役割としては、class="selected"
を表示しないので、現在の選択しているリンクのみにclass="selected"
がつけられることになります。
このoptions.inverse()はテンプレートでelseを使った時に効果がわかります。次のコードはelseの後にinverseという文字列を入れてみました。これでリロードすると、a === b
がfalseの時はinverseと表示されます。
<a {{#eq filter 'all'}}class="selected"{{else}}inverse{{/eq}} href="#/all">All</a>
つまり、if文というより逆のパターンのテンプレートを表示するということです。a === b
のような比較はしなくてもいいところがif文との違いです。inverseの意味は「逆の」なので逆のテンプレートとイメージすればいいかもしれません。
そのアプリの中だけで使う関数はutilにまとめる。
utilはutilityの略だと思うのですが、便利ツールという意味です。今回は3つ用意されています。pluralizeとは複数形にするという意味です。
一つの関数で読み書きを両方を担う
storeはこの関数1つでlocalStorageへの保存と読み出しを行っています。引数が1つの時は読み出しで、2つで保存です。これでset~、get~というアクセサを作り分ける必要がなくなります。引数の数で処理を変えているのですね。
store: function (namespace, data) {
if (arguments.length > 1) {
return localStorage.setItem(namespace, JSON.stringify(data));
} else {
var store = localStorage.getItem(namespace);
return (store && JSON.parse(store)) || [];
}
}
uuid
新しいTODOを登録される際に使わるuuidメソッドですが、ただのランダムな値を作るだけです。下記のことを知っておけば、読みやすくなると思います。
- 数値は0以上16未満のランダムの数値が使われます。
- 9,13,16,20文字目で-(ハイフン)で区切ります。
uuid: function () {
/*jshint bitwise:false */
var i, random;
var uuid = '';
for (i = 0; i < 32; i++) {
random = Math.random() * 16 | 0;
if (i === 8 || i === 12 || i === 16 || i === 20) {
uuid += '-';
}
uuid += (i === 12 ? 4 : (i === 16 ? (random & 3 | 8) : random)).toString(16);
}
return uuid;
},
Routerはどのライブラリのもの?
ちょうどbackborn.jsを勉強していたのでbackborn.jsのが使われてるかと思ってしまいました。どうやら、これはnode.jsのパッケージで指定されているdirectorのRouterです。下記のコードだと、urlの#以降の文字列が引数filterとして受け取ることが出来ます。(先頭のスラッシュは除く)
そしてinitの引数で最初に遷移させるURLのハッシュを指定しているわけです。index.htmlにアクセスすると、index.html#/allに飛ばされます。initの引数を変更して確かめてみてください。ただし、これはindex.htmlにアクセスした時のみです。index.html#/allにアクセスしてもこのinitはは実行されません。おそらくリロードしているわけではないのかと思います。
init: function () {
this.todos = util.store('todos-jquery');
this.cacheElements();
this.bindEvents();
new Router({
'/:filter': function (filter) {
this.filter = filter;
this.render();
}.bind(this)
}).init('/all');
}
jQueryオブジェクトを格納する変数名の接頭辞には$(ドル)をつける
$をつけるとひと目でそれがjQueryを使って取得した要素なのかがわかり便利です。
cacheElements: function () {
this.todoTemplate = Handlebars.compile($('#todo-template').html());
this.footerTemplate = Handlebars.compile($('#footer-template').html());
this.$todoApp = $('#todoapp');
this.$header = this.$todoApp.find('#header');
this.$main = this.$todoApp.find('#main');
this.$footer = this.$todoApp.find('#footer');
this.$newTodo = this.$header.find('#new-todo');
this.$toggleAll = this.$main.find('#toggle-all');
this.$todoList = this.$main.find('#todo-list');
this.$count = this.$footer.find('#todo-count');
this.$clearBtn = this.$footer.find('#clear-completed');
}
同じ要素に複数イベントを記述する場合でも、1行1イベントに分ける
私は、1つの要素に複数イベントを設定する時は、1つのonメソッドにオブジェクトで渡してなおかつイベントハンドラには無名関数を使用していました。
# td要素のdelegate
$table.on {
dblclick: ->
# 省略
dragstart: (event)->
$(event.target).attr 'data-dragging', ''
dragend: (event)->
$(event.target).removeAttr 'data-dragging'
dragover: (event)->
event.preventDefault()
drop: (event)->
event.preventDefault()
# 省略
}, 'td'
onメソッドを何度も使うのが、処理速度を落とすかはわかりませんが、下記のように1つのonに1つのイベントハンドラを設定するようにすると、とてもコードが見やすくて感動しました。あと、やっぱりイベントハンドラは別関数で定義した方が全体の動きが抽象化されて把握しやすいですね。
var list = this.$todoList;
list.on('change', '.toggle', this.toggle.bind(this));
list.on('dblclick', 'label', this.edit.bind(this));
list.on('keyup', '.edit', this.editKeyup.bind(this));
list.on('focusout', '.edit', this.update.bind(this));
list.on('click', '.destroy', this.destroy.bind(this));
toggleを条件で実行
jQueryのtoggleは廃止されたと思っていましたが、どうやら1.9で廃止されたのはtoggleイベントというものだそうです。toggleメソッドは残っています。また、引数にBooleanを渡すことでtrueの時に表示して、falseの時に非表示にすることができます。こんな便利な使い方があったとは知りませんでした。
this.$main.toggle(todos.length > 0);
.toggle() | jQuery API Documentation
配列を降順に走査する時はwhlileを使ってスッキリ
いつもはCoffeeScriptでfor i in [0...Array.length]
と書いていました。.(ドット)が2つと3つで挙動が変わるのですが、全ての要素を走査したいのであれば下記のようにwhile文を使ったほうがそのことを明記できます。ドットの数に悩むことはなくなります。
var i = todos.length;
while (i--) {
if (todos[i].id === id) {
return i;
}
}
$input.val()は記述ミス?
input要素の値に、自身の値で上書きしてからフォーカスしている処理があります。$input.focus();
でも編集ができたのでミスかなと思っています。
edit: function (e) {
var $input = $(e.target).closest('li').addClass('editing').find('.edit');
console.log ($input.val());
$input.val($input.val()).focus();
}
固定値の変数宣言は先頭に
変数valの宣言の後に、data属性abortを見てエスケープキーが押されてか判断されています。押された場合は、更新せずに編集を元に戻すという分岐です。このif条件の後で初めて変数valが使われるので、if文の後に変数valの宣言を書くこともできます。ですが、使うときにではなくまとめて先頭に変数を宣言した方がわかりやすいということでしょうか?
私はC言語をかじっていたので、むしろどこでも変数宣言ができること最初は驚きました。今回の場合は、例外として変数iがif文の後に宣言されています。おそらく変数iのようなインデックスは変動しやすい値だから後に書いたということでしょうか。次の可能性を含んだ変数は先頭で宣言した方がいいと思います。変数iは直後でしか使わないからという理由かもしれません。
- メインとなる変数
- そのブロック全体で使う
update: function (e) {
var el = e.target;
var $el = $(el);
var val = $el.val().trim();
if ($el.data('abort')) {
$el.data('abort', false);
this.render();
return;
}
var i = this.indexFromEl(el);
// 変数valはここまで使わない
if (val) {
this.todos[i].title = val;
} else {
this.todos.splice(i, 1);
}
this.render();
}