Angular 1.xコンポーネントを、Angular 2にアップグレードする

この記事では、シンプルなTodoアプリのAngular 1のコンポーネントを、Angular 2のコードにアップグレードする方法を見ていきます。APIの違いや文法のテンプレートを比較することで、それがAngular 2へのアップグレードに何らかのヒントを与え、難しさが軽減すると感じてもらえることを願っています。

注意:Angular 2は、まだ”アルファ”の状態なので、APIやこれまでの技法は変化するかもしれません。しかし、この記事は残し、コードをアップグレードしていくつもりです。

Angular 1..x Todoアプリ

この小さなコンポーネントを、Angular 2で書き換えてみます。まずは、このアプリの機能は以下の通りです。

  • Todoリストにアイテムを追加する
  • アイテムを削除する機能がある
  • アイテムが完了するとマークをつける機能がある
  • 未完了のものと、合計のTodoの数を表示する

これがどのように構築されているのか、何が行われているのかを正確に理解するため、ソースコードを見てみましょう。

HTMLは非常にシンプルで、<todo>要素だけです。

<todo></todo>

そしてJavaScriptのディレクティブは、次のようになります。

function todo() {
  return {
    scope: {},
    controller: function () {
      // <input>に空のモデルを設定
      this.label = '';
      // ToDoリスト用の数件のダミーデータ
      // Boolean型の"complete"プロパティで
      // 完了したToDoを表示
      this.todos = [{
        label: 'Learn Angular',
        complete: false
      },{
        label: 'Deploy to S3',
        complete: true
      },{
        label: 'Rewrite Todo Component',
        complete: true
      }];
      // ToDoアイテムについてイテレートして、
      // 未完了のToDoのみをフィルタリングした配列を返す
      // 例の場合、3件中1件という長さを得られる
      this.updateIncomplete = function () {
        return this.todos.filter(function (item) {
          return !item.complete;
        }).length;
      };
      // 各ToDoには削除用のxボタンがある
      // 削除は、シンプルに$indexを使って配列から削除
      this.deleteItem = function (index) {
        this.todos.splice(index, 1);
      };
      // 

<

form>のsubmitイベントにより、<button>へのng-clickの代わりに
      // タイピング後にEnterキーを入力することで確定可能
      // フォームのsubmitに対してprevent defaultして$eventをキャプチャし、
      // labelが入力されていればthis.todosの配列にunshiftしてリストに追加
      // 追加後、this.labelを空文字列に戻す
      this.onSubmit = function (event) {
        if (this.label.length) {
          this.todos.unshift({
            label: this.label,
            complete: false
          });
          this.label = '';
        }
        event.preventDefault();
      };
    },
    // instantiate the Controller as "vm" to namespace the 
    // Class-like Object
    controllerAs: 'vm',
    // our HTML template
    templateUrl: '../partials/todo.html'
  };
}

angular
  .module('Todo', [])
  .directive('todo', todo);

// DOMContentLoadedが発火したら、手動でアプリケーションをブートストラップ
document.addEventListener('DOMContentLoaded', function () {
  angular.bootstrap(document, ['Todo']);
});

このtodo.htmlは、Todoアイテムに繰り返し適用するUIロジックを持つシンプルなテンプレートで、追加/削除などの全機能を管理しています。これは全て、見慣れていますね。

<div class="todo">
  <form ng-submit="vm.onSubmit($event);">
    <h3>Todo List: ({{ vm.updateIncomplete() }} of {{ vm.todos.length }})</h3>
    <div class="todo__fields">
      <input ng-model="vm.label" class="todo__input">
      <button type="submit" class="todo__submit">
        Add <i class="fa fa-check-circle"></i>
      </button>
    </div>
  </form>
  <ul class="todo__list">
    <li ng-repeat="item in vm.todos" ng-class="{
      'todo__list--complete': item.complete
    }">
      <input type="checkbox" ng-model="item.complete">
      <p>{{ item.label }}</p>
      <span ng-click="vm.deleteItem($index);">
        <i class="fa fa-times-circle"></i>
      </span>
    </li>
  </ul>
</div>

完成したアプリは、次のとおりです。

移行の準備

私が強く勧めるデザインパターンの1つは、ディレクティブの定義の中でcontrollerAs構文(私が書いたこちらの記事を参照してください)を使うことです。これにより、Controllerが$scopeを自由に注入できるようになり、より”クラスのような”方法でControllerを書くことができるようになります。thisというキーワードはPublicメソッドを作るために使われます。このメソッドはAngularのランタイムによって、自動的に$scope内のスコープを持つメソッドになります。

私の考えでは、controllerAsを使うことは、Angular 1.xコンポーネントをAngular 2へ移行させる準備には必要不可欠のステップです。Angular 2でコンポーネントを書く場合、Publicメソッドにおいてオブジェクトの定義でthisキーワードを使うからです。

プロジェクトの設定/ブートストラップ

含まれるファイルと、アプリケーションのブートストラップです。

Angular 1.x

Angular 1.xとAngular 2で行う設定を、アプリのブートストラップからコンポーネントの作成まで、1つずつ見ていきます。しっかりついてきてください。

1.4.7バージョンも含め、Angularには基本的なHTMLページがあり、angular.bootstrapを使って手動でブートストラップします。

<!doctype html>
<html>
  <head>
    <script src="//code.angularjs.org/1.4.7/angular.min.js"></script>
  </head>
  <body>
    <todo></todo>
    <script>
      document.addEventListener('DOMContentLoaded', function () {
        angular.bootstrap(document, ['Todo']);
      });
    </script>
  </body>
</html>

Angular 2

ES5で、実際にAngular 2アプリのコンポーネントを作ってみます。ES6やTypeScriptは使いません。なぜなら、これだと簡単にブラウザ内でAngular 2を書けてしまい、さらには最終的に機能する例はJSFiddle内のES5で実行するからです。

しかし、1.xからES5に完全に移行させるデモンストレーションでは、最後にTypeScript/ES6の例を出しています。最終的なES6とTypeScriptの解法です。

まずはAngular 2のインクルードが必要ですが、npm installや依存関係のインストールをちょっとやってみる気はありません。このやり方はangular.ioのサイトにあります。それでは、実行してフレームワークの基本を学び、Angular 1.xアプリを移行しましょう。

まず、<head>の中にAngular 2を含める必要があります。私が2.0.0-alpha.44からはangular2.sfx.dev.jsを使っていることに気付いたと思います。この.sfx.はES5をSystemローダーのポリフィルを使わないことに焦点を当てた自動実行型のバンドルのバージョンですから、プロジェクトにSystem.jsを加える必要はありません。

<!doctype html>
<html>
  <head>
    <script src="//code.angularjs.org/2.0.0-alpha.44/angular2.sfx.dev.js"></script>
  </head>
  <body>
    <todo></todo>
    <script>
      document.addEventListener('DOMContentLoaded', function () {
        ng.bootstrap(Todo);
      });
    </script>
  </body>
</html>

ここまでは、全ては非常に簡単です。グローバルなnamespaceは、window.angularの代わりにwindows.ngになっています。

コンポーネントの定義

ディレクティブをAngular 2のコンポーネントにアップグレードします。

Angular 1.x

ディレクティブからJavaScriptのControllerロジックを全て分離すると次のようになります。

function todo() {
  return {
    scope: {},
    controller: function () {},
    controllerAs: 'vm',
    templateUrl: '../partials/todo.html'
  };
}

angular
  .module('Todo', [])
  .directive('todo', todo);

Angular 2

Angular 2では、変数Todoを作成します。変数Todoは、(ComponentClassに)ひも付けされた定義と同じようにngモジュールの結果を指定します。これは、Angular 2に新しく追加されました。

.Component()の中で、selector: 'todo'を使用するようAngularに通知します。これは、Angular 1.xの.directive('todo', todo)と全く同じです。さらに、Angular 1.xでtemplateUrlプロパティを使用してテンプレートの場所を教えるように、Angularにテンプレートがどこにあるか通知します。

最後に、コンポーネントのロジックを持っているのは.Class()メソッドなので、”コンストラクタ”クラスとして振る舞うconstructorプロパティを使用して始めます。今のところいい感じですね。

var Todo = ng
.Component({
  selector: 'todo',
  templateUrl: '../partials/todo.html'
})
.Class({
  constructor: function () {}
});

document.addEventListener('DOMContentLoaded', function () {
  ng.bootstrap(Todo);
});

コンポーネントのロジック

次に、ControllerロジックをAngular 1.xからAngular 2の.Class()メソッドへと移行するのがいいでしょう。ReactJSを使ったことがあれば、聞いたことがあると思います。そのため、プロセスが非常に簡単なcontrollerAs構文の使用をお勧めします。

Angular 1.x

ここまでtodoコンポーネントに何が入っているか見てみましょう。publicメソッドはthisコンポーネントを使用して$scopeオブジェクトに自動的に結び付けてくれます。DOMで使用されるControllerのインスタンスのnamespaceにcontrollerAs: 'vm'が使用されます。

controller: function () {
  this.label = '';
  this.todos = [{
    label: 'Learn Angular',
    complete: false
  },{
    label: 'Deploy to S3',
    complete: true
  },{
    label: 'Rewrite Todo Component',
    complete: true
  }];
  this.updateIncomplete = function () {
    return this.todos.filter(function (item) {
      return !item.complete;
    }).length;
  };
  this.deleteItem = function (index) {
    this.todos.splice(index, 1);
  };
  this.onSubmit = function (event) {
    if (this.label.length) {
      this.todos.unshift({
        label: this.label,
        complete: false
      });
      this.label = '';
    }
    event.preventDefault();
  };
},
controllerAs: 'vm',

Angular 2

では、Angular 2でControllerを完全になくして、publicメソッドを.Class()の定義に移動します。

.Class({
  constructor: function () {
    this.label = '';
    this.todos = [{
      label: 'Learn Angular',
      complete: false
    },{
      label: 'Deploy to S3',
      complete: true
    },{
      label: 'Rewrite Todo Component',
      complete: true
    }];
  },
  updateIncomplete: function () {
    return this.todos.filter(function (item) {
      return !item.complete;
    }).length;
  },
  deleteItem: function (index) {
    this.todos.splice(index, 1);
  },
  onSubmit: function (event) {
    if (this.label.length) {
      this.todos.unshift({
        label: this.label,
        complete: false
      });
      this.label = '';
    }
    event.preventDefault();
  }
});

ここで分かるのは、”public”メソッドは.Class()に渡されるオブジェクトのプロパティになり、コードのリファクタリングをする必要が一切ありません。これは、Angular 1.xではcontrollerAs構文をthisキーワードと共に使用していたからです。スムーズで簡単です。

この段階で、コンポーネントは動作しますが、テンプレートがまだ完全にAngular 1.xディレクティブベースのため、これをアップデートする必要があります。

テンプレートの移行

移行させる必要のあるテンプレートの全文は次のとおりです。

<div class="todo">
<form ng-submit="vm.onSubmit($event);">
<h3>Todo List: ({{ vm.updateIncomplete() }} of {{ vm.todos.length }})</h3>
<div class="todo__fields">
<input ng-model="vm.label" class="todo__input">
<button type="submit" class="todo__submit">
Add <i class="fa fa-check-circle"></i>
</button>
</div>
</form>
<ul class="todo__list">
<li ng-repeat="item in vm.todos" ng-class="{
'todo__list--complete': item.complete
}">
<input type="checkbox" ng-model="item.complete">
<p>{{ item.label }}</p>
<span ng-click="vm.deleteItem($index);">
<i class="fa fa-times-circle"></i>
</span>
</li>
</ul>
</div>

ここは賢く、必要な機能部分だけを分けて移行していきましょう。まず、<form>から始めます。

<!-- Angular 1.x -->
<form ng-submit="vm.onSubmit($event);">

</form>

<!-- Angular 2 --> <form (submit)="onSubmit($event);">

</form>

まず、主な変更点は新しい(submit)構文です。通常どおり$eventで渡す時に、イベントがバインドされる予定であることを示しています。第2の変更点は、Controllerはもう必要ないということです。プレフィックスvm.が外れていることから分かるようにcontrollerAsは機能しません。これはすばらしいことです。

次は<input>で双方向データバインディングをする方法です。

<!-- Angular 1.x -->
<input ng-model="vm.label" class="todo__input">

<!-- Angular 2 --> <input [(ng-model)]="label" class="todo__input">

これでng-modelに双方向データバインディングを設定し、プレフィックスvm.を外します。完全にリファクタリングを行ったコードの部分は次のようになります。

<form (submit)="onSubmit($event);">
  <h3>Todo List: ({{ updateIncomplete() }} of {{ todos.length }})</h3>
  <div class="todo__fields">
    <input [(ng-model)]="label" class="todo__input">
    <button type="submit" class="todo__submit">
      Add <i class="fa fa-check-circle"></i>
    </button>
  </div>
</form>

Todoアイテムのリストに移りましょう。ここでは多くのことが起きています。ng-repeatでTodoアイテムの配列を展開し、条件付きng-classで完了したアイテムに取り消し線を引き、チックボックスで完了アイテムに印を付け、そして、ng-clickバインディングでtodoアイテムのリストから特定のアイテムを削除します。

<!-- Angular 1.x -->
<ul class="todo__list">
  <li ng-repeat="item in vm.todos" ng-class="{
    'todo__list--complete': item.complete
  }">
    <input type="checkbox" ng-model="item.complete">
    <p>{{ item.label }}</p>
    <span ng-click="vm.deleteItem($index);">
      <i class="fa fa-times-circle"></i>
    </span>
  </li>
</ul>

<!-- Angular 2 --> <ul class="todo__list"> <li *ng-for="#item of todos; #i = index" [ng-class]="{ 'todo__list--complete': item.complete }"> <input type="checkbox" [(ng-model)]="item.complete"> <p>{{ item.label }}</p> <span (click)="deleteItem(i);"> <i class="fa fa-times-circle"></i> </span> </li> </ul>

上の2つの違いは、主にng-repeat構文内と、#item of Array構文を使用するng-forへの置換にあります。面白いことに、Angular 2では、”要求なし”では$indexを入手できません。入手するにはリクエストし、変数を指定して(#i = $index)にアクセスする必要があります。すると、deleteItemに特定の配列インデックスを渡すことができるようになります。

これで、Angular 2コンポーネントのマークアップ移行が完了しました。

<div class="todo">
  <form (submit)="onSubmit($event);">
    <h3>Todo List: ({{ updateIncomplete() }} of {{ todos.length }})</h3>
    <div class="todo__fields">
      <input [(ng-model)]="label" class="todo__input">
      <button type="submit" class="todo__submit">
        Add <i class="fa fa-check-circle"></i>
      </button>
    </div>
  </form>
  <ul class="todo__list">
    <li *ng-for="#item of todos; #i = index" [ng-class]="{
      'todo__list--complete': item.complete
    }">
      <input type="checkbox" [(ng-model)]="item.complete">
      <p>{{ item.label }}</p>
      <span (click)="deleteItem(i);">
        <i class="fa fa-times-circle"></i>
      </span>
    </li>
  </ul>
</div>

Angular 2コンポーネント全体は次のようになります。

var Todo = ng
.Component({
  selector: 'todo',
  template: [
    '<div class="todo">',
      '<form (submit)="onSubmit($event);">',
        '<h3>Todo List: ({{ updateIncomplete() }} of {{ todos.length }})</h3>',
        '<div class="todo__fields">',
          '<input [(ng-model)]="label" class="todo__input">',
          '<button type="submit" class="todo__submit">',
            'Add <i class="fa fa-check-circle"></i>',
          '</button>',
        '</div>',
      '</form>',
        '<ul class="todo__list">',
        '<li *ng-for="#item of todos; #i = index" [ng-class]="{',
          '\'todo__list--complete\': item.complete',
        '}">',
          '<input type="checkbox" [(ng-model)]="item.complete">',
          '<p>{{ item.label }}</p>',
          '<span (click)="deleteItem(i);">',
            '<i class="fa fa-times-circle"></i>',
          '</span>',
        '</li>',
      '</ul>',
    '</div>'
  ].join(''),
  directives: [
    ng.CORE_DIRECTIVES,
    ng.FORM_DIRECTIVES
  ]
})
.Class({
  constructor: function () {
    this.label = '';
    this.todos = [{
      label: 'Learn Angular',
      complete: false
    },{
      label: 'Deploy to S3',
      complete: true
    },{
      label: 'Rewrite Todo Component',
      complete: true
    }];
  },
  updateIncomplete: function () {
    return this.todos.filter(function (item) {
      return !item.complete;
    }).length;
  },
  deleteItem: function (index) {
    this.todos.splice(index, 1);
  },
  onSubmit: function (event) {
    if (this.label.length) {
      this.todos.unshift({
        label: this.label,
        complete: false
      });
      this.label = '';
    }
    event.preventDefault();
  }
});

さらに覚えておくと便利なのが.Component()メソッド内の他のdirectives: []プロパティです。これはコンポーネントにどのディレクティブを含めて使用するかを教えてくれます。COREFORMディレクティブモジュール内の“ng-forng-model“`を使用したので、配列で依存関係を明確に定義する必要があります。

directives: [
   ng.CORE_DIRECTIVES,
   ng.FORM_DIRECTIVES
 ]

これで完了です。正常に動作するはずです。

テンプレートをAngular 1.xから2へリファクタリングする時に、Angular 2 cheatsheet(虎の巻)が大変役に立つのでぜひ見てみてください。

ES6 + TypeScriptバージョン

import {
  Component,
  CORE_DIRECTIVES,
  FORM_DIRECTIVES
} from 'angular2/angular2';

@Component({

  selector: 'todo'
  templateUrl: '../partials/todo.html',
  directives: [
    CORE_DIRECTIVES,
    FORM_DIRECTIVES
  ]
})

export class Todo {

  constructor() {
    this.label = '';
    this.todos = [{
      label: 'Learn Angular',
      complete: false
    },{
      label: 'Deploy to S3',
      complete: true
    },{
      label: 'Rewrite Todo Component',
      complete: true
    }];
  }

  updateIncomplete() {
    return this.todos.filter(item => !item.complete).length;
  }

  deleteItem(index) {
    this.todos.splice(index, 1);
  }

  onSubmit(event) {
    if (this.label.length) {
      this.todos.unshift({
        label: this.label,
        complete: false
      });
      this.label = '';
    }
    event.preventDefault();
  }

}

どのようにES6のimportをTypeScript@ decorator (@Component)とES6クラス構文を使用してエクスポートする新しいクラスを定義しているか注意してください。

すばらしいことにブラウザグローバル(window.ng)ならどれでも良いというわけではありません。directives: []依存関係の配列を含め、必要な依存関係は全て'angular2/angular2'からインポートされます。

他の情報に関しては、angular.ioを参照ください。

Angular 2への移行準備

  • アプリケーションをES6とTypeScriptに変更してください。
  • 分離コンポーネント アプローチを使用してディレクティブをリファクタリングしてください。
  • controllerAs構文を使用するためにはControllerをリファクタリングしてください。