令和のBackbone.js入門

backbonejs-logo

Backbone.jsとはかつて隆盛した(?)クライアントサイド MVC アーキテクチャを実現するための JavaScript フレームワークであり、jQuery の登場によってカオス化した大規模な JavaScript アプリケーションをきちんと設計するためのフレームワークである。

僕は 2019 年にコードを書き始めたが、フロントエンド界隈ではすでに React/ Vue.js が台頭しており、それ以前の歴史やフロントエンドの変遷は見聞きした程度でしか知らず、jQuery もちょろっと書いたことがある程度の感じだった。(addEventListenerとかほとんど使ったことない1) 今の会社に入社してから Backbone.js の存在を初めて知り、いろいろ調べてキャッチアップを頑張ったが、記事の少なさと古さ&プロダクトのドメインの複雑さが相まって、理解するにはなかなかつらいものがあった。

会社の中では負債化しており、今後新規開発で Backbone.js をゴリゴリ触ることはないが、依然として改修などで触るのと、キャッチアップに困っている同じような境遇の人がいると思い、記事に残しておく。

この記事は完全に Backbone.js を網羅しているわけではないが、僕のような React/ Vue.js が主流となったフロントエンドの世界しか知らない方などはわずかにでも手助けになるはず。

対象読者

  • Backbone.js ってなに?の人
  • React とか Vue.js しか触ったことない人

目次

JavaScript フレームワークの歴史(ざっくり)

JetBrains 社が JavaScript の歴史をきれいにまとめていたので引用している。この記事ではざっくりと Backbone.js の前後をまとめるが、詳しくはリンク先を参照。 https://www.jetbrains.com/ja-jp/lp/javascript-25/

YearEvent
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 年だったので)

jQuery のつらみ

Backbone.js の登場までは jQuery 一強であり、1 枚の HTML の中で<script></script>に JS コードを敷き詰めてリッチな UI を作りあげていました。

jQueryのコード例
<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 とビジネスロジックが複雑に絡みあっている

    • Ajax などデータ取得のロジックと UI のロジックが同じファイルにあるなど
  • 変更の影響範囲がパッとわからない(まったく関係ない DOM に影響を与えてしまうかも)

  • 大規模になるにつれて解読不可能になる

などが挙げられると思う。(jQuery 経験あまりないので違ったり他にもあったりするかも)

別の会社でインターンをしていたとき、1 クリックだけで 100 行ほどの jQuery による DOM 操作&素の JS コードが実行されるコードが書かれているのを見たことがあり、うんざりしていたのを覚えている。

このようにカオスになっていた大規模開発でフロントエンドに秩序をもたらしたのが Backbone.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.ModelModel1 つのデータの管理(show/create/destroy などの API を叩く)
Backbone.CollectionModel一連のデータの管理
(index などの API を叩いたり、複数の Backbone.Model など)
Backbone.ViewView
Controller
DOM 操作や表示の管理
(freee の場合はここで React コンポーネントのレンダリングを行なったり、
jst ecoという ERB ライクなテンプレートエンジンを利用して HTML を描画する)
Backbone.RouterControllerURL による処理の振り分け
(SPA なので基本的にはpushState()hashを用いて Template の入れかえを行う。サーバーへのアクセスは発生しない)
Backbone.HistoryController履歴の管理

backbonejs-archtecture

これまでは 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だけで動くはず。)

backbonejs-todoapp

リポジトリは以下。アプリの開発段階(進捗)はブランチごとに分けているので適宜ブランチを変更して参照

最終的な完成バージョンはfinish ブランチにある。

環境構築

以下を実行してブラウザ開くと alert が実行される。

$ git clone https://github.com/uki1014/backbone-tutorial
$ cd backbone-tutorial
$ npm install
$ npm run watch

package のインストール

$ npm install backbone jquery underscore

index.html

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 として扱う。詳しくは後述。

Model の作成

普段 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 の作成

Collection は 1 つの Model を複数扱うものなので、どの Model を扱うのかを指定する。 また、Model と同様 Collection にもメソッドを定義することができ、Backbone.Collection のメソッドを使うこともできる。 this.whereの部分、さすが Rails に影響を受けているだけあって、ActiveRecord っぽく Collection を参照できる。

Backbone.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 });
  }
}

View の作成

Backbone.js における view の動きはとてもわかりづらい。 大きく分けて 2 つあり、

  1. DOM のイベントハンドリング
  2. Model / Collection のイベントハンドリング

がある。

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 を作成。

  1. AppView 画面全体の View を管理。Template は持たずに、全体の画面の event や Model / Collection を管理する View。 Todo の追加(POST 的な操作)もここで監視。
    events メソッドを見ると、#add-todoな HTML タグを click したときにaddNewメソッドを実行するようになっている。 addNewメソッドは内部で form に入力された値が有効なものかバリデーションをかけていて、通れば this.collection(=TodoList)のインスタンスのaddメソッドを実行している。
    上記で書いた概要図の中で Backbone.Event というのがあった。
    backbonejs-event こいつはコードに登場していないが、どうやって Model や Collection の Event を検知しているのかというと、eventsメソッドで定義されている DOM とメソッド名のマッピングを Backbone.View の内部で読み取り、Event として登録してくれている。
    Backbone.js のすべてのクラスは Backbone.Events を継承している。 Backbone.Events を用いることで、各インターフェース(View/Model/ Collection..)はオブザーバ・パターンを使ってデータの更新やユーザーの操作を検知することができる。
    Backbone.js のソースで言うと以下。 https://github.com/jashkenas/backbone/blob/master/backbone.js#L1370-L1395 これによって view の内部で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();
  }
}
  1. TodoView 以下の Todo タスク自体の View を管理。done ボタンと delete ボタンの event をキャッチして、Model を更新・削除するメソッドを実行する。
    また、template(本記事では<script type="text/template"></script>の部分)を利用し、入力されたデータ(Title や Memo)を template に流し込んでいる。 backbonejs-event
// 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;
  }
}
  1. TodoListView Todo タスクの Collection を管理する View。いまいくつの Todo タスクが未完了なのか計算したり(「残り 2」の部分)、Add ボタンを押して form に入力されたデータを TodoModel として生成した後に Todo Collection の中に追加する操作などを行なっている。
    AppView でaddNewメソッドが実行されたとき、this.collection.add(~)が実行されたが、この collection に add された状態変化をこの TodoListView が監視することで、Todo 一覧 UI に対しても変更を加えることができる。(ちょっと複雑でわかりずらい、、うまく説明できない)
    要は TodoListCollection の内部を見て、データに変化があったら Todo 一覧 UI にも変更を加えているのが TodoListView の役割。

backbonejs-event

// 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);
  }
}

Entrypoint

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 アプリを操作することができているはず。

backbonejs-event

また、本稿では Backbone.js の Router については取り扱っていない。 (気が向いたら Router と History のハンズオンも追加する。)

Footnotes

  1. 素の JS だけ(npm package を使わず)でフロントエンドを書いたことはほとんどないという意味です。

  2. https://github.com/facebook/flux/releases?after=2.0.2 JetBrains の資料になかったので

  3. https://github.com/jashkenas/underscore JavaScript の便利な関数寄せ集めライブラリ。lodash の昔版という認識(合ってるかわからない)

  4. https://coffeescript.org/ Ruby や Python から影響を受け、当時の JavaScript(ES2015 以前)よりも可読性を向上&機能追加されたもの。JavaScript(ES2015)の仕様は CoffeeScript から影響を受けたものがある。(アロー関数など)

  5. https://devlog.grapecity.co.jp/spa-javascript-framework-in-2020/ わかりやすい解説