Knockout.js勉強中! サンプルアプリ『電卓』

最近、Knockout というJavascriptフレームワークにハマってます。

JSでの開発をラクにするKnockout

Knockoutは一言で言えば、「HTML(CSS)とJavascriptによる開発にMVVMモデルを適用する」ためのフレームワークです。
フレームワークといっても、開発者を制約で縛り上げるようなものではなく、開発がとてもラクになります。

たとえば Knockout では、HTMLの要素と、Javascriptで書いたシンプルなオブジェクトのプロパティを、定義ベースで関連付けます。
関連付けられたプロパティが更新されると、HTMLの要素も上書きされます。
これだけでも、次のようなメリットが考えられます。

  • 「開発効率向上」マークアップエンジニアとスクリプトエンジニアの分業
  • 「保守性向上」マークアップ(View)の変更によるスクリプト(ViewModel)への影響がほとんどない
  • 単純に、書くべきコードの量が減る!

Knockoutがフレームワークとして介入するのは HTML(View) と Javascript(ViewModel) との間を取り持つ部分のみです。
したがってjQuery等の既存のフレームワークも、問題なく利用することができます。

詳細は本家サイトにて公開されているデモムービー(英語だがなんとなく見てるだけでも結構わかる!)などをご参考下さい。
同サイトのチュートリアルをすべてクリアすれば、Knockoutを理解するとともにMVVMパターンのメリットについても少し詳しくなれます。

12/23 追記:
Knockout ドキュメントの翻訳を開始しました。
Knockout.日本語ドキュメント

下記のサンプルは、やや「モンキーレンチで釘を打っている」ような、意味開発者の意図を伝えきれていないという問題があります。Knockout でどんなことができるかを手っ取り早く知りたいという方には、Knockout Demo & Tips をご覧になることをお勧め致します。

勉強中にサンプルつくりました

というわけで、さっそくサンプルとして電卓を作ってみました。
ただの電卓だけだと、MVVMの恩恵をあまり受けられないので、計算履歴を表示するようにしました。

HTML (View)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="calc1.css">
<script type="text/javascript" src="../js/knockout-2.1.0.js"></script>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
<script type="text/javascript" src="vm.calc1.js"></script>
</head>
<body>
 
<section class="calcPanel" data-bind="animateStyle: { width: calcPanelWidth, options: { duration: 'fast', queue: false } }">
 
	<section id="calc">
		<h1>電卓 powered by Knockout</h1>
		<section class="displayPanel">
			<span class="mainArea" data-bind="text: currentNum().toString().substr(0,19)"></span><!-- 入力中の数値や計算結果を表示するエリア -->
			<span class="subArea" data-bind="text: subText"></span><!-- 入力された演算子やその前に入力された数値を表示するエリア -->
		</section>
		<section class="buttonPanel">
			<table>
				<colgroup>
					<col/>
					<col/>
					<col/>
					<col/>
					<col/>
				</colgroup>
				<tbody>
					<tr>
						<td><button data-bind="click: backSpace">BS</button></td>
						<td><button data-bind="click: clearEntry">CE</button></td>
						<td><button data-bind="click: clearAll">C</button></td>
						<td><button data-bind="click: ternSign">±</button></td>
						<td><button data-bind="click: sqrt"></button></td>
					</tr>
					<tr>
						<td><button data-bind="click: inputNum.bind($data,7)">7</button></td>
						<td><button data-bind="click: inputNum.bind($data,8)">8</button></td>
						<td><button data-bind="click: inputNum.bind($data,9)">9</button></td>
						<td><button data-bind="click: inputOperator.bind($data,'/')">/</button></td>
						<td><button data-bind="click: persent">%</button></td>
					</tr>
					<tr>
						<td><button data-bind="click: inputNum.bind($data,4)">4</button></td>
						<td><button data-bind="click: inputNum.bind($data,5)">5</button></td>
						<td><button data-bind="click: inputNum.bind($data,6)">6</button></td>
						<td><button data-bind="click: inputOperator.bind($data,'*')">*</button></td>
						<td><button data-bind="click: reciproc">1/x</button></td>
					</tr>
					<tr>
						<td><button data-bind="click: inputNum.bind($data,1)">1</button></td>
						<td><button data-bind="click: inputNum.bind($data,2)">2</button></td>
						<td><button data-bind="click: inputNum.bind($data,3)">3</button></td>
						<td><button data-bind="click: inputOperator.bind($data,'-')">-</button></td>
						<td rowspan="2" data-bind="click: equal"><button>=</button></td>
					</tr>
					<tr>
						<td colspan="2"><button data-bind="click: inputNum.bind($data,0)">0</button></td>
						<td><button data-bind="click: inputPoint">.</button></td>
						<td><button data-bind="click: inputOperator.bind($data,'+')">+</button></td>
					</tr>
				</tbody>
			</table>
		</section>
	</section>
 
	<section id="history" data-bind="fadeVisible: showHistory">
		<h2>計算履歴(<span data-bind="text: calcHistories().length"></span>) <a href="#" data-bind="click: clearHistories">クリア</a></h2>
		<table>
			<colgroup>
				<col width="18px"/>
				<col/>
			</colgroup>
			<tbody data-bind="foreach: sortedCalcHistories">
				<tr><th data-bind="text: id"></th><td data-bind="text: fullExpr"></td></tr>
			</tbody>
		</table>
	</section>
 
	<div id="historyTab" data-bind="click: toggleHistoryVisible">.<br>.<br>.</div>
 
</section>
 
</body>
</html>

Javascript (ViewModel)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
function unwrapHashObservable(hash) {
	for (var name in hash)
		hash[name] = ko.utils.unwrapObservable(hash[name]);
	return hash;
}
 
/**
 * Custom binding [ fadeVisible ]
 * binding-type: boolean/object
 * binding-object: {
 * 	visible:	true=可視(フェードイン), false=不可視(フェードアウト)
 * 	speed:		フェードにかかる時間。'slow', 'normal', 'fast'
 * 				またはミリ秒にて指定する。デフォルトは'normal'。
 * 	complete:	フェード終了後に実行するコールバックを指定する。
 * }
 */
ko.bindingHandlers.fadeVisible = {
    update: function(element, valueAccessor) {
        var value = ko.utils.unwrapObservable(valueAccessor());
        var defaults = {
        	visible: true,
        	speed: 'normal',
        	complete: null
        };
        value = $.extend(
        	defaults,
        	(
        		typeof value == 'boolean'
        		? {visible: value}
        		: unwrapHashObservable(value)
        	)
        );
        value.visible
        ? $(element).fadeIn(value.speed)
        : $(element).fadeOut(value.speed);
    }
};
 
/**
 * Custom binding [ animateStyle ]
 * binding-type: object
 * binding-object: {
 * 	(styles): 		アニメーションの結果として設定される横幅。
 * 	duration:	アニメーションにかかる時間。'slow', 'normal', 'fast'
 * 				またはミリ秒にて指定する。デフォルトは'normal'。
 * 	easing: 	アニメーションパターン。デフォルトは'swing'.
 * 	complete:	アニメーション完了時に実行されるコールバック
 * 	delay:		アニメーション開始までの待ち時間をミリ秒で指定する。
 * 				デフォルトは 0。
 * }
 */
ko.bindingHandlers.animateStyle = {
	update: function(element, valueAccessor) {
		var value = valueAccessor();
		var defaults = {
			delay: 0
		};
		var options;
		if (value.options) {
			options = $.extend(defaults, unwrapHashObservable(value.options));
			delete value.options;
		} else options = defaults;
		var animate = function() {
			delete options.delay;
			$(element).animate(unwrapHashObservable(value), options);
		};
		if (options.delay > 0) setTimeout(animate, options.delay);
		else animate();
	}
};
 
function CalcHistory(id, expr, result) {
	var self = this;
	self.id = id;
	self.expr = expr;
	self.result = result;
	self.fullExpr = expr + ' = ' + result;
}
 
function CalcViewModel() {
 
	// Const value
	const showHistoryWidth = 594;
	const hideHistoryWidth = 334;
 
	// Private Field
	var self = this;
	var isResult = true;
	var isOperated = false;
	var isAfterOperator = false;
	var historyText = "";
 
	// Private Method
	function calc() {
		var cOperator = self.currentOperator();
		if (cOperator == "") return;
		var oNum = self.operandNum();
		var cNum = self.currentNum();
		switch (cOperator) {
		case '+': self.currentNum(+oNum + +cNum); break;
		case '-': self.currentNum(oNum - cNum); break;
		case '*': self.currentNum(oNum * cNum); break;
		case '/':
			if (cNum == 0) {
				self.currentNum("infinity");
				isResult = true;
				alert("0で割ることはできません。");
				return;
			}
			self.currentNum(oNum / cNum);
			break;
		}
	};
 
	// Properties
	self.operandNum = ko.observable(0);
	self.currentNum = ko.observable(0);
	self.subText = ko.observable("");
	self.currentOperator = ko.observable("");
	self.showHistory = ko.observable(true);
	self.calcPanelWidth = ko.computed(function() {
		return self.showHistory() ? showHistoryWidth : hideHistoryWidth;
	});
	self.calcHistories = ko.observableArray();
	self.sortedCalcHistories = ko.computed(function() {
		return self.calcHistories().sort(function(left, right) {
			return left.id == right.id ? 0 : (left.id < right.id ? 1 : -1);
		});
	});
 
	// Operations
	self.inputNum = function(num) {
		var cNum = '' + (isResult ? 0 : self.currentNum()) + num;
		self.currentNum(+cNum);
		isAfterOperator = isResult = false;
	};
	self.inputOperator = function(operator) {
		var lastNum = self.currentNum();
		if (!isAfterOperator) {
			calc();
			self.operandNum(self.currentNum());
			isAfterOperator = true;
			historyText = self.subText();
			isResult = true;
		}
		self.currentOperator(operator);
		self.subText(historyText + ' ' + lastNum + ' ' + operator);
		isOperated = true;
	};
	self.backSpace = function() {
		if (isResult) return;
		var cNum = '' + self.currentNum();
		self.currentNum(+(cNum.substring(0, cNum.length - 1)));
	};
	self.clearEntry = function() {
		self.currentNum(0);
		isAfterOperator = self.currentOperator() != "";
		isResult = false;
	};
	self.clearAll = function() {
		self.operandNum(0);
		self.currentNum(0);
		self.subText("");
		historyText = "";
		self.currentOperator("");
		isOperated = false;
		isResult = true;
		isAfterOperator = false;
	};
	self.equal = function() {
		if (!isOperated || isResult || isAfterOperator) return;
		var lastNum = self.currentNum();
		calc();
		self.calcHistories.push(
			new CalcHistory(
				self.calcHistories().length + 1,
				self.subText() + ' ' + lastNum,
				self.currentNum()
			)
		);
		self.subText("");
		isResult = true;
		isOperated = false;
		isAfterOperator = false;
		self.operandNum(0);
		historyText = "";
		self.currentOperator("");
	};
	self.inputPoint = function() {
		var cNum = '' + (isResult ? 0 : self.currentNum()) + ".";
		self.currentNum(cNum);
		isAfterOperator = isResult = false;
	};
	self.ternSign = function() {
		if (isResult) return;
		self.currentNum(-self.currentNum());
	};
	self.reciproc = function() {
		var cNum = self.currentNum();
		if (cNum == 0) {
			alert("0で割ることはできません。");
			return;
		}
		self.currentNum(1 / cNum);
		self.subText("reciproc(" + cNum + ")");
		isResult = true;
	};
	self.persent = function() {
		if (isResult || isAfterOperator) return;
		var oNum = self.operandNum();
		var cNum = self.currentNum();
		self.currentNum(oNum * (cNum / 100));
	};
	self.sqrt = function() {
		if (isAfterOperator) return;
		self.currentNum(Math.sqrt(self.currentNum()));
	};
	self.toggleHistoryVisible = function() {
		self.showHistory(!self.showHistory());
		console.log(self.showHistory());
	};
	self.clearHistories = function() {
		self.calcHistories.removeAll();
	};
}
 
$(function() {
	ko.applyBindings(new CalcViewModel());
});

改善したいところ

本来であれば、計算履歴エリアの開閉アニメーションをjQueryではなくCSSアニメーションにして
CSSバインディングのみで動くようにしたいところです。

この例では、アニメーション用のカスタムバインディング(animateStyle)を作っています。
つまり、アニメーションに使用するパラメータをバインドしているわけです。
本来 CSS に書くべきパラメータ width を ViewModel のプロパティ(calcPanelWidth)として公開するはめになっています。

表示幅を修正するときのことを考えると、ViewModelまでチェックしなければならないのはあまり好ましくありませんね。
CSSアニメーションなどもフル活用することで、リッチなUIをより簡潔に記述することができそうです。

以上です。
次はなにつくろうかなぁ…

Posted in: Javascript, Web