※分割したファイルを読み込む処理の説明が一部漏れていたため追記してます。(2014年12月25日 15時35分
はじめに
jQueryをメインに開発を進めていて、それをベースに機能追加していき、気づいたら肥大化・・・
こんな状況を改善するために
- 別のライブラリを導入したいし必要に応じて最近主流のJavaScriptライブラリやフルスタックなフレームワークに移行したい
- けど、テストも書かれてないからいきなり手を付けるのは怖い
- まずは内部構造を把握しながらリファクタリング
という結論になったのはいいけど、どこから着手すればいいんだろっていうので困ってるという環境って少なからずあるのかなと思ってます。
テキスト要素をクリックすると、編集画面に切り替わり、編集後にその内容が反映されるようなこのような↓サンプルアプリを題材にしてどのようにリファクタリングするのかをまとめてみました。
フロントエンドJavaScriptにおける設計とテストというとてもわかりやすいスライドがあって基本的にはそれを参考にしながら書いてます
ここで想定してる人
- PHPやRailsでWebアプリ開発は経験あるけど、JavaScriptは・・・・となってしまうよう人
上記サンプルアプリのベースとなるHTML/JavaScriptについて
上記サンプルアプリのHTMLについては細かい解説は不要かと思うのですが、JavaScriptの方の方はポイントになる所をいくつか解説が必要かと思うのでコードを示しながら以下まとめてます
HTMLについて
<!DOCTYPE html>
<html>
<head>
<link data-require="bootstrap@*" data-semver="3.2.0" rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.css" />
<link data-require="bootstrap-css@*" data-semver="3.3.1" rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css" />
<script data-require="bootstrap@*" data-semver="3.2.0" src="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.js"></script>
<script data-require="backbone.js@*" data-semver="1.1.2" src="http://backbonejs.org/backbone-min.js"></script>
<script data-require="underscore.js@*" data-semver="1.6.0" src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.6.0/underscore-min.js"></script>
<script data-require="jquery@*" data-semver="2.1.1" src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<link rel="stylesheet" href="style.css" />
<script src="main.js"></script>
</head>
<body>
<div class="container-fluid">
<div class="row">
<div class="col-md-8">
<h3 id="subTitle">ここをクリック</h3>
</div>
<div class="col-md-4"></div>
</div>
</div>
</body>
</html>
JavaScriptについて
上記HTMLで読み込んでいるJavaScriptは、jQueryベースのコードになります。
処理のポイントになる所を以下まとめてます。(数字とソースコード本文中のコメントの数字を対応させてますのでそれらを参照しながら読み進めてください)
- id属性がsubTitleがクリックされた時の処理をこの行以降で定義してます
- クリックした要素の上にテキストエリアを重なるように表示するために現在の要素のoffset()の値を取得してます
- jQueryの機能を使って動的にテキストエリアの要素を生成します
- this.innerHTML.replace(/
/g, '\n')にてテキストエリアに表示したい文字列を取得してます - 動的に生成したsaveボタンのクリックイベントをこれ以降の行で設定してます
- テキストエリア内に入力された文字で改行が含まれることを想定して、改行コードを、HTMLのBRに置換します
- 上記6.で置換した文字列で、元々表示してあったテキスト要素を置換します
- 動的に生成したテキストエリアとsaveボタンを現在表示してるHTMLのBodyタグないに挿入します
$(document).ready(function() {
$("#subTitle").click(function(e){ // (1)
var textarea, savebutton ,offset;
offset = $(this).offset(); // (2)
textarea = $('<textarea>').css({ // (3)
display: 'block',
position: 'absolute',
left: offset.left,
top: offset.top,
width: 300,
height: 100,
fontSize: 14,
fontFamily: "'Open Sans', Helvetica, Arial, sans-serif",
zIndex: 1,
background: 'rgba(255,255,255,0.9)'
}).val(this.innerHTML.replace(/<br\/?>/g, '\n')); // (4)
savebutton = $('<div id="save" class="btn">').css({
display: 'block',
position: 'absolute',
left: offset.left,
top: offset.top + 110,
width: 100,
height: 30,
fontSize: 14,
fontFamily: "'Open Sans', Helvetica, Arial, sans-serif",
zIndex: 1,
background: 'rgba(92,184,92,0.9)',
color: '#f9f9f9',
cursor: 'pointer'
}).text('save');
savebutton.on('click', function() { // (5)
var val;
val = textarea.val().replace(/\n/g, '<br>'); // (6)
textarea.remove();
this.remove();
$("#subTitle").html(val); // (7)
return;
});
$('body').append(savebutton); // (8)
$('body').append(textarea); // (9)
return;
});
});
Backbone.jsの機能を使ってViewを疎結合にする
ベースのサンプルアプリではテキスト要素をクリックした時に得られる文字列=データと考えると、このデータをModelとして定義したいのですが、データの部分とDOMとが密結合になってるので複数人での開発がやりづらく、また、機能拡張もやりづらい要因の1つになってるのかなと。
どのように整理するかという話になるとMVCとかMVVMとかMV**・・・というキーワードがたくさん出てきますが、そういうのは一旦脇において大切な所としては
- Model:データを管理する
- View:DOMを管理する
だと思うので、まずは分割してみました
それぞれのソースコードの役割
役割分担した結果としてMVVMのViewModelの部分を実装したと捉えるとしっくり来ると思います。なお実装にあたってBackbone.jsの機能を一部利用してます。
- view.textelementForm.js
- クリック時のイベンドを捕捉しModelの機能をよびだすことがメインの役割
- model.textelement.js
- これはModelとしての機能を提供しておりデータ管理がメインの役割
- view.textelementView.js
- Modelから呼び出されるタイミングで動的にHTMLの要素を生成
- 実際の生成処理は以下のview.textelementList.jsにまかせてます。(ここはコード分割する必要ないかもしれません。単純に1メソッドあたりのコード量が多くなったので分けました)
- view.textelementList.js
- 必要なパラーメーターを受け取ったらインスタンス生成しテキストエリアとsaveボタンのHTML要素を生成
分割後のそれぞれのソースコードの役割図
Modelのソースコード
JavaScriptのクラス定義がわかりづらい場合には先日書いたPHPでのクラス定義とJavaScriptでのクラス定義を対比させてみたを参考にしてみてください
var TextElement;
TextElement = (function(){
function TextElement(data) {
this.text = data.text;
this.complete = !!data.complete;
}
TextElement.prototype.setComplete =function(complete){
this.complete = !!complete;
this.trigger('change:complete', this);
};
TextElement.list = [];
TextElement.add = function(text){
var textelement;
textelement = new TextElement({ text:text });
TextElement.list.push(textelement);
this.trigger('add', textelement);
};
return TextElement;
})();
$.extend(TextElement.prototype, Backbone.Events);
$.extend(TextElement, Backbone.Events);
Viewのソースコード
view.textelementForm.js
var TextElementFormView;
TextElementFormView = (function(){
function TextElementFormView($el){
this.$input = $el;
this.$input.click(this.onclick.bind(this));
}
TextElementFormView.prototype.onclick = function(e){
console.log(e);
e.preventDefault();
var text;
text = this.$input[0].innerText;
TextElement.add(text);
};
return TextElementFormView;
})();
view.textelementList.js
var TextElementItemView;
TextElementItemView = (function(){
function TextElementItemView(todo, $el) {
this.todo = todo;
this.$text = todo.text;
this.$el = $el;
this.offset = this.$el.offset();
this.textarea = null;
this.savebutton = null;
this.init();
}
TextElementItemView.prototype.init = function(){
this.prepareTextarea();
this.prepareSaveButton();
// この後のthis.savebutton()からは
// TextElementItemViewクラスのメンバー変数にアクセス出来ない
// ので、この時点でのthisであるTextElementItemViewへの参照を
// 得るために変数thatに入れる
var that = this;
this.savebutton.on('click', function() {
var val;
console.log(that.textarea.val());
val = that.textarea.val().replace(/\n/g, '<br>');
// val = that.textarea.val;
that.textarea.remove();
that.savebutton.remove();
that.$el.html(val);
return;
});
$('body').append(that.savebutton);
$('body').append(that.textarea);
return;
};
TextElementItemView.prototype.save = function(e){
var _textElement;
_textElement = new TextElement(this.$el);
_textElement.add();
console.log("TextElement.add() called");
};
TextElementItemView.prototype.prepareTextarea = function(e){
this.textarea = $('<textarea>').css({
display: 'block',
position: 'absolute',
left: this.offset.left,
top: this.offset.top,
width: 300,
height: 100,
fontSize: 14,
fontFamily: "'Open Sans', Helvetica, Arial, sans-serif",
zIndex: 1,
background: 'rgba(255,255,255,0.9)'
}).val(this.$text);
};
TextElementItemView.prototype.prepareSaveButton = function(e){
this.savebutton = $('<div id="save" class="btn">').css({
display: 'block',
position: 'absolute',
left: this.offset.left,
top: this.offset.top + 110,
width: 100,
height: 30,
fontSize: 14,
fontFamily: "'Open Sans', Helvetica, Arial, sans-serif",
zIndex: 1,
background: 'rgba(92,184,92,0.9)',
color: '#f9f9f9',
cursor: 'pointer'
}).text('save');
};
return TextElementItemView;
})();
view.textelementView.js
var TextElementListView;
TextElementListView = (function(){
function TextElementListView($el) {
this.$el = $el;
console.log(this.$el.offset());
TextElement.on('add', this.add.bind(this));
};
TextElementListView.prototype.add = function(todo){
console.log("TextElementListView.prototype.add called");
var item;
item = new TextElementItemView(todo,this.$el);
this.$el.append(item.$el);
};
return TextElementListView;
})();
仕上げにHTMLの編集と元々作っていたmain.jsの修正
HTMLについて
main.jsからいくつかの機能にわけたのでJavaScriptの読み込み箇所が増えます。
全て記載すると長くなるので読み込み箇所の所だけ以下記載します
<!-- ここから↓追加 -->
<script src="js/model.textelement.js"></script>
<script src="js/view.textelementForm.js"></script>
<script src="js/view.textelementView.js"></script>
<script src="js/view.textelementList.js"></script>
<!-- ここまで↑追加 -->
<script src="js/main.js">
main.jsについて
上記のModel、Viewに分割したことでmain.jsの方はわずかこれだけになります。
$(document).ready(function() {
new TextElementFormView($('#subTitle'));
new TextElementListView($('#subTitle'));
});
まとめ
言葉の定義は一旦脇において、Modelはデータを管理、そしてViewはDOMを管理 するという観点で分割するのが大事かなと
HTML要素の生成はBackbone.Viewを使ったほうがもっとスッキリするし、何らかのテストフレームワークを使ってテストを書けるようにしておくと、Backbone.js以外の選択肢を考える時にテストがあることで心理的な安心が生まれると思うのでそのあたりについても書いておきたいと思います。