Backbone.jsとはかつて隆盛した(?)クライアントサイド MVC アーキテクチャを実現するための JavaScript フレームワークであり、jQuery の登場によってカオス化した大規模な JavaScript アプリケーションをきちんと設計するためのフレームワークである。
僕は 2019 年にコードを書き始めたが、フロントエンド界隈ではすでに React/ Vue.js が台頭しており、それ以前の歴史やフロントエンドの変遷は見聞きした程度でしか知らず、jQuery もちょろっと書いたことがある程度の感じだった。(addEventListener
とかほとんど使ったことない1)
今の会社に入社してから Backbone.js の存在を初めて知り、いろいろ調べてキャッチアップを頑張ったが、記事の少なさと古さ&プロダクトのドメインの複雑さが相まって、理解するにはなかなかつらいものがあった。
会社の中では負債化しており、今後新規開発で Backbone.js をゴリゴリ触ることはないが、依然として改修などで触るのと、キャッチアップに困っている同じような境遇の人がいると思い、記事に残しておく。
この記事は完全に Backbone.js を網羅しているわけではないが、僕のような React/ Vue.js が主流となったフロントエンドの世界しか知らない方などはわずかにでも手助けになるはず。
JetBrains 社が JavaScript の歴史をきれいにまとめていたので引用している。この記事ではざっくりと Backbone.js の前後をまとめるが、詳しくはリンク先を参照。 https://www.jetbrains.com/ja-jp/lp/javascript-25/
Year | Event |
---|---|
2006 年 | jQuery 誕生 |
2009 年 | CommonJS プロジェクト開始 Express.js 開発開始 underscore.js 開発開始 CoffeeScript 開発開始 Node.js 誕生 ECMAScript5 誕生 |
2010 年 | npm の誕生 AngularJS 開発開始 Backbone.js のリリース (今回書いているのはココ) |
2011 年 | Browserify 誕生 React 開発開始 |
2012 年 | Webpack 誕生 Bower 誕生 TypeScript 初公開 |
2013 年 | Vue.js 誕生 |
2014 年 | Babel.js 開発開始 Flow.js 誕生 flux 誕生2 |
2015 年 | Redux 誕生 ES2015 誕生 GraphQL 誕生 |
2016 年 | Svelte 誕生 Nuxt.js 誕生 Next.js の OSS 化 Yarn 誕生 ECMA2016 誕生 |
… | … |
こうしてみると JavaScript の歴史は変化が激しい。Rails の初期リリースが 2004 年なので、サーバーサイドのフレームワークの流行と比べると、いかにフロントエンドの変遷が短期間に行われていたかがわかる。
Backbone.js リリースの翌年や 2 年後に現在主流となっている React・Webpack・TypeScript などの開発が開始されたようなので、2012〜2015 年ごろまで大規模アプリにおいては production 環境で Backbone.js を使うのが最適解だったのだと思われる。React などが production に導入されるようになったのは ES2015 が誕生した 2015 年以降ではないだろうか。 (根拠ははっきりしてないが、Backbone.js のあらゆる資料が 2015 年以降少なくなっている&弊社の React 初導入が 2015 年だったので)
Backbone.js の登場までは jQuery 一強であり、1 枚の HTML の中で<script></script>
に JS コードを敷き詰めてリッチな UI を作りあげていました。
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY"></script>
<script>
jQuery(function($){
var map, map_center;
function showMap(_address, _lat, _lng, _title, _url, _zoom) {
$("#map_canvas").css('display', 'block');
map_center = new google.maps.LatLng(_lat, _lng);
var opts = {
center: map_center,
zoom: _zoom ,
mapTypeId: "roadmap"
};
map = new google.maps.Map(document.getElementById("map_canvas"), opts);
var marker = new google.maps.Marker({
map: map,
position: map_center,
animation: google.maps.Animation.DROP,
title:_title
});
var _content;
if (_url) {
_content = '<div id="map_content"><p><a href="'
+ _url + '" target="_blank"> ' + _title + '</a><br />'
+ _address + '</p></div>';
}else {
_content = '<div id="map_content"><p>'
+ _title + '<br />'
+ _address + '</p></div>';
}
var infowindow = new google.maps.InfoWindow({
content: _content,
});
marker.addListener('click', function() {
infowindow.open(map, marker);
});
}
$('p.show_map a').click(function() {
if($(this).hasClass('close')) {
$('#map_canvas').css('display', 'none');
$('#map_container').css('border-width', 0).fadeOut(500);
$('p.show_map a').removeClass('close').text('地図を表示');
}else{
$(this).addClass('close').text('地図を閉じる');
$('p.show_map a').not(this).removeClass('close').text('地図を表示');
var map_info$ = $(this).closest('.map_info');
var lat =parseFloat( map_info$.find(".lat").text());
var lng =parseFloat( map_info$.find(".lng").text());
map_info$.append($('#map_container'));
var address = map_info$.find('p.address').text();
if(map_info$.find('p.web').text() != '') {
var url = map_info$.find('p.web a').attr('href');
}
$('#map_container').css('border-width', '1px').fadeIn(1000);
var zoom = map_info$.find(".zoom").text() ? parseInt(map_info$.find(".zoom").text()): 16;
var title = map_info$.find('p.venue').text();
showMap(address, lat, lng, title, url, zoom);
}
return false;
});
});
</script>
</body>
jQuery のつらいところとして、
DOM 操作の前後関係(記述順序)まで把握しておく必要がある(職人技が必要)
UI とビジネスロジックが複雑に絡みあっている
変更の影響範囲がパッとわからない(まったく関係ない DOM に影響を与えてしまうかも)
大規模になるにつれて解読不可能になる
などが挙げられると思う。(jQuery 経験あまりないので違ったり他にもあったりするかも)
別の会社でインターンをしていたとき、1 クリックだけで 100 行ほどの jQuery による DOM 操作&素の JS コードが実行されるコードが書かれているのを見たことがあり、うんざりしていたのを覚えている。
このようにカオスになっていた大規模開発でフロントエンドに秩序をもたらしたのが Backbone.js である。
やっと本題だが、Backbone.js はJeremy Ashkenas氏によって開発された OSS のフレームワークである。 彼の GitHub ページを見てもらうとわかるが、underscore.js3と CoffeeScript4の作者でもある。 ちなみに Backbone.js は jQuery と underscore.js に依存している。つまり、Backbone.js がある限り、jQuery と underscore.js のコードを読むことになる。
Jeremy Ashkenas 氏はどうやら Ruby・Rails エンジニアだったようで、Rails のエッセンスをフロントエンドにも取り入れようとして Backbone.js を開発したようである。また、実は Backbone.js は React や Vue.js と同じ仲間で、SPA(Single Page Application)5の概念を最初に取り入れた JS フレームワークとも言われている。
要素 | 分類 | 役割 |
---|---|---|
Backbone.Model | Model | 1 つのデータの管理(show/create/destroy などの API を叩く) |
Backbone.Collection | Model | 一連のデータの管理 (index などの API を叩いたり、複数の Backbone.Model など) |
Backbone.View | View Controller | DOM 操作や表示の管理 (freee の場合はここで React コンポーネントのレンダリングを行なったり、 jst ecoという ERB ライクなテンプレートエンジンを利用して HTML を描画する) |
Backbone.Router | Controller | URL による処理の振り分け (SPA なので基本的にはpushState()やhashを用いて Template の入れかえを行う。サーバーへのアクセスは発生しない) |
Backbone.History | Controller | 履歴の管理 |
これまでは Ajax の登場で jQuery の手続き型のようなコードで API 通信から DOM 操作までを 1script タグ内で行われていたのが、いい感じに「UI とビジネスロジック」のように責務を分散し、デバッグや開発がしやすくなる。
Backbone.js はその名の通り、「骨組み」を提供するだけにとどまり、このアーキテクチャの骨組みをさらに拡張したければそれはアプリケーションごとに自由にやれという思想のよう。 つまり、素の Backbone.js だけでは不十分なため、機能追加が必要なので、カスタムしたりラップするなどして使うケースが多かったのではないだろうか。
以下のような簡単な Todo アプリ作成をやってみる。(UI 雑) 機能内容としては、Todo の Title と Memo を入力できる form があり、入力後に Add ボタンを押すと form の下部の一覧に Todo タスクの Card が追加されていく。
done ボタンを押すと JS は done された状態を検知し、DOM の class を書き換えることで背景を暗くする CSS が当たるようにする。普段 React などで開発していると簡単に実現できそうなものだが、React を使わずにやるといかにこの状態を管理するのが面倒なのかちょっとわかるかも。(Todo アプリならまだしも、複雑なドメインや仕様でやるのは大変・・) done を押したあとは done ボタンが restore ボタンに変わり、restore ボタンを押すと done の状態が解除されて、未完了の Todo タスクの状態に戻るようにする。
delete ボタンを押すと Todo タスクが一覧から消える。
フロントエンドのみでデータ操作が完結するアプリで、データの永続化はしていないサーバーレスなのでリロードしたら Todo のデータは消えてしまう。しかし、SPA なのでリロードしない限りはぬるぬるブラウザで動く。(開発環境は webpack-dev-server を使っているのでサーバーがある風だが、JS ファイルの変更を監視しているだけなので、build してしまえばopen dist/index.html
だけで動くはず。)
リポジトリは以下。アプリの開発段階(進捗)はブランチごとに分けているので適宜ブランチを変更して参照
最終的な完成バージョンはfinish ブランチにある。
以下を実行してブラウザ開くと alert が実行される。
$ git clone https://github.com/uki1014/backbone-tutorial
$ cd backbone-tutorial
$ npm install
$ npm run watch
$ npm install backbone jquery underscore
dist/index.html
を作成して、以下をコピペしてください
<html>
<head>
<title>Backbone Tutorial</title>
<style>
.container {
text-align: center;
}
.todo {
list-style: none;
}
.form {
margin-bottom: 2rem;
}
.form-group {
margin: 0 auto;
}
.card {
margin: 0 auto;
}
.completed {
background-color: gray;
text-decoration: line-through;
}
</style>
<link
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1"
crossorigin="anonymous"
/>
</head>
<body>
<div class="container" id="main">
<section class="todoapp">
<h1 class="title m-4">Todo</h1>
<form id="add-form" role="form" class="form" onSubmit="return false">
<div class="form-group" style="width: 40rem; margin-bottom: 1rem;">
<input
type="title"
name="title"
class="form-control"
placeholder="Title"
/>
</div>
<div class="form-group" style="width: 40rem; margin-bottom: 1rem;">
<textarea
rows="5"
name="memo"
class="form-control"
placeholder="Memo"
></textarea>
</div>
<button id="add-todo" class="btn btn-primary">Add</button>
</form>
<div>
<p>残り<span id="count"></span></p>
</div>
<div id="todo-list"></div>
</section>
<script type="text/template" id="todo-template">
<div class="card" style="width: 40rem; margin-bottom: 2rem;">
<div class="card-body <%= completed ? 'completed' : '' %>">
<h3 class="card-title">
<%= title %>
</h3>
<div class="card-text">
<%= memo %>
</div>
<div style="display: flex; justify-content: center;">
<div style="margin: 1rem;">
<button class="toggle btn btn-success">
<%= completed ? 'restore' : 'done' %>
</button>
</div>
<div style="margin: 1rem;">
<button class="delete btn btn-danger">delete</button>
</div>
</div>
</div>
</div>
</script>
<script src="app.js"></script>
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW"
crossorigin="anonymous"
></script>
</div>
</body>
</html>
HTML の内容については細かいことは省略するが、ポイントは ↓ です。
<script type="text/template" id="todo-template">
<div class="card" style="width: 40rem; margin-bottom: 2rem;">
<div class="card-body <%= completed ? 'completed' : '' %>">
<h3 class="card-title">
<%= title %>
</h3>
<div class="card-text">
<%= memo %>
</div>
<div style="display: flex; justify-content: center;">
<div style="margin: 1rem;">
<button class="toggle btn btn-success">
<%= completed ? 'restore' : 'done' %>
</button>
</div>
<div style="margin: 1rem;">
<button class="delete btn btn-danger">delete</button>
</div>
</div>
</div>
</div>
</script>
<script type="text/template">
という script タグは HTML には認識されず、この中身はブラウザに表示されない。
この中身を Backbone.js の View が取得し、Model のデータを載せて Template として扱う。詳しくは後述。
普段 React を書いていると Functional Component ばかり書いていて、JavaScript で class を読むことはあっても書くことはほとんどないので書き方とか忘れまくっていたので以下を読む。
Backbone.js の Model はフロントエンドでの状態を保持する。React で言う state、Redux / Flux でいう Store のようなものだと考えるとわかりやすい。 また、model は単一のデータのことを表す。(≒ 複数 Model のインスタンスのまとまりや配列ではない)
例えば、1 つの User や 1 つの Article などが Model に該当し、複数形の Users や Articles は後述の collection に該当。
Rails アプリに組み込まれた Backbone.js を想定すると、サーバー側から API などを通して DB のデータを取ってきて、その値を Backbone.js の Model の初期値として保持したりするイメージ。
また、Model に定義したメソッドを使用してデータを更新したりすることができる。 自分で定義したメソッドを使ってあらゆる操作もできるし、Backbone.Model に定義されているメソッドを継承したり、override してデータの操作をすることもできる。
Backbone.Model のメソッド一覧 (Backbone.Model で使用できるメソッドの中で、実態は Underscore.js のメソッドのものもある。https://backbonejs.org/#Model-Underscore-Methods )
// src/model/todo.js
import Backbone from "backbone";
import _ from "underscore";
import $ from "jquery";
export default class Todo extends Backbone.Model {
// https://backbonejs.org/#Model-defaults
// modelの初期値を設定(fluxやReduxで言うinitialStateのようなもの)
defaults() {
return {
title: "",
memo: "",
completed: false,
};
}
// 完了の状態を切り替え(現在の状態と逆の状態に切り替える)
toggle() {
// saveはBackbone.jsから継承したメソッド
// https://backbonejs.org/#Model-save
this.save({
completed: !this.get("completed"),
});
}
// https://backbonejs.org/#Model-validate
// Modelとして保存する(=描画する)ときのバリデーション
validate(attrs) {
if (_.isEmpty(attrs.title)) {
return "Error title is empty";
}
}
}
Collection は 1 つの Model を複数扱うものなので、どの Model を扱うのかを指定する。
また、Model と同様 Collection にもメソッドを定義することができ、Backbone.Collection のメソッドを使うこともできる。
this.where
の部分、さすが Rails に影響を受けているだけあって、ActiveRecord っぽく Collection を参照できる。
// src/collection/tasks.js
import Backbone from "backbone";
import Todo from "../model/todo";
export default class TodoList extends Backbone.Collection {
// https://backbonejs.org/#Collection-model
model = Todo;
// completedがfalseなModelのみを抽出する
notCompleted() {
return this.where({ completed: false });
}
}
Backbone.js における view の動きはとてもわかりづらい。 大きく分けて 2 つあり、
がある。
1 の DOM のイベントハンドリングはユーザーの操作(ボタンクリックなど)をトリガーとして API を叩いたり、view の表示を変えたりすることに用いられたりする。 2 の Model / Collection のイベントハンドリングとしては、1 でサーバーの API を叩いた結果、Model や Collection のデータが新しいものに変わることがある。そうしたときにデータの更新を検知し、view のデータの内容を書き換えることをしたりしてくれる。
つまり、Backbone.js における view は UI を描画するための Template(View)を管理する役目と、データの変更を検知して新しいデータを取得する(Controller)の 2 つの役目がある。
Backbone.js の公式チュートリアルなどでは前述した、Underscore.js の_.template
関数を使って、HTML の中に作成した<script type="text/template"></script>
の中の要素を取得し、Model や Collection のデータを載せた template をレンダリングする。
今回作成する Todo アプリでは 3 つの View を作成。
#add-todo
な HTML タグを click したときにaddNew
メソッドを実行するようになっている。
addNew
メソッドは内部で form に入力された値が有効なものかバリデーションをかけていて、通れば this.collection(=TodoList)のインスタンスのadd
メソッドを実行している。events
メソッドで定義されている DOM とメソッド名のマッピングを Backbone.View の内部で読み取り、Event として登録してくれている。events() {~}
と書いてあげれば簡単に DOM 操作と view のメソッドを紐づけられる。// src/view/app_view.js
import Backbone from "backbone";
import _ from "underscore";
import $ from "jquery";
import TodoList from "../collection/todo_list";
import Todo from "../model/todo";
import TodoListView from "../view/todo_list_view";
export default class AppView extends Backbone.View {
events() {
return {
"click #add-todo": "addNew",
};
}
initialize() {
// viewの初期化時にformの要素を取得
this.$title = $("#add-form [name='title']");
this.$memo = $("#add-form [name='memo']");
// validationのためにModelインスタンスを作成する
this.model = new Todo();
// Todo一覧を表示するためのCollectionインスタンス
this.collection = new TodoList();
// TodoListインスタンスを用いてTodo一覧のViewを作成
this.todoListView = new TodoListView({
el: $("#todo-list"),
collection: this.collection,
});
this.render();
}
// 残りのtodoの数を計算
updateCount() {
$("#count").html(this.collection.notCompleted().length);
}
// Modelをtemplateに渡す
render() {
this.$title.val("");
this.$memo.val("");
this.updateCount();
}
// collectionに新たなModelを追加し、viewに反映
addNew() {
const check = this.model.set(
{ title: this.$title.val(), memo: this.$memo.val(), completed: false },
{ validate: true }
);
if (check) {
this.collection.add({
title: this.$title.val(),
memo: this.$memo.val(),
completed: false,
});
} else {
confirm("Titleを空欄で作成することはできません。");
}
this.render();
}
}
<script type="text/template"></script>
の部分)を利用し、入力されたデータ(Title や Memo)を template に流し込んでいる。
// src/view/todo_view.js
import Backbone from "backbone";
import _ from "underscore";
import $ from "jquery";
export default class TodoView extends Backbone.View {
// https://backbonejs.org/#View-events
events() {
return {
"click .delete": "destroy",
"click .toggle": "toggle",
};
}
destroy() {
if (confirm("本当に削除しても良いですか?")) {
this.model.destroy();
this.remove();
}
}
toggle() {
this.model.set({ completed: !this.model.get("completed") });
// インスタンスの状態を変更したので再度レンダリングしてviewを更新
this.render();
}
// underscore.jsのtemplate
// htmlの<script type="text/template">部分をtemplateとして使用し、Modelのデータを入れ込む
template = _.template($("#todo-template").html());
// https://backbonejs.org/#View-render
render() {
// template(#todo-template)に対してTodo Modelの状態を流し込む
// Todoの状態→title, memo, completed
const template = this.template(this.model.toJSON());
this.$el.html(template);
return this;
}
}
addNew
メソッドが実行されたとき、this.collection.add(~)
が実行されたが、この collection に add された状態変化をこの TodoListView が監視することで、Todo 一覧 UI に対しても変更を加えることができる。(ちょっと複雑でわかりずらい、、うまく説明できない)// src/view/todo_list_view.js
import Backbone from "backbone";
import _ from "underscore";
import $ from "jquery";
import TodoView from "./todo_view";
export default class TodoListView extends Backbone.View {
// https://backbonejs.org/#View-constructor
initialize() {
this.listenTo(this.collection, "add", this.addTodoView);
this.listenTo(this.collection, "change", this.updateCount);
}
addTodoView(todo) {
this.$el.append(new TodoView({ model: todo }).render().el);
}
// 残りのtodoの数を計算
// todo_listに定義しているnotCompletedメソッドを使って現在のCollectionの数を計算
updateCount() {
$("#count").html(this.collection.notCompleted().length);
}
}
Model / Collection / View がそろえばあとは Backbone.js アプリを実行するためのエントリーポイントだけ用意してあげれば OK。
やっていることとしては、AppView のインスタンスを生成するだけ。
AppView が内部で画面の Event(ボタンクリック)を監視しているので、データの作成などは Event がトリガーとなって行われる。
el: $('#main')
の部分は、id='main'
のタグを jQuery で見つけて、その main の範囲を対象として監視するということ。(今回で言うと画面全体)
// src/index.js
import $ from "jquery";
import AppView from "./view/app_view";
new AppView({ el: $("#main") });
npm run watch
をターミナルで実行してもらうと、Todo アプリを操作することができているはず。
また、本稿では Backbone.js の Router については取り扱っていない。 (気が向いたら Router と History のハンズオンも追加する。)
素の JS だけ(npm package を使わず)でフロントエンドを書いたことはほとんどないという意味です。 ↩
https://github.com/facebook/flux/releases?after=2.0.2 JetBrains の資料になかったので ↩
https://github.com/jashkenas/underscore JavaScript の便利な関数寄せ集めライブラリ。lodash の昔版という認識(合ってるかわからない) ↩
https://coffeescript.org/ Ruby や Python から影響を受け、当時の JavaScript(ES2015 以前)よりも可読性を向上&機能追加されたもの。JavaScript(ES2015)の仕様は CoffeeScript から影響を受けたものがある。(アロー関数など) ↩
https://devlog.grapecity.co.jp/spa-javascript-framework-in-2020/ わかりやすい解説 ↩