1. komatsu-kenta

    Posted

    komatsu-kenta
Changes in title
+世界初!?Angular4を使ってtodoMVCよりも先にtodoアプリを作ってみた
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,763 @@
+##はじめに
+先日「Angular 4.0.0」がリリースされました。
+[TodoMVC](http://todomvc.com)ではまだ「Angular 4.0.0」用のtodoアプリが公開されていなかったので素振りがてらに作ってみました。
+
+##環境構築
+まずは、angular-cliをグローバルにインストールします。
+```bash
+npm install -g @angular/cli
+```
+
+事前にtypescriptもインストールしておきましょう。
+```bash
+npm install typescript -g
+```
+
+アプリ用のフォルダを作成します。
+```bash
+ng new todo
+```
+
+npmインストールを行い、サーバーを起動します。
+```bash
+cd todo
+npm install
+ng serve --open
+```
+
+http://localhost:4200 にアクセスし、
+app works!と表示されれば環境構築は成功です。
+
+##実装手順
+
+ファイル構成はこんな下記のような構成にしました。
+```
+.
+├──index.html
+├──main.ts
+├──polyfills.ts
+├──styles.css
+├──test.ts
+├──tsconfig.app.json
+├──tsconfig.spec.json
+├──typings.d.ts
+
+├──app
+│ ├──component.css
+│ ├──component.html
+│ ├──component.ts
+│ ├──module.ts
+│ └──store.ts
+
+├──assets
+│ └──.gitkeep
+
+└──environments
+ ├──environment.prod.ts
+ ├──environment.ts
+ └──favicon.ico
+```
+
+下記がtodoアプリの主要なソースになります。
+Angular2との互換性がかなり高くtodoアプリレベルだと変更する
+場所ががほとんどありませんでした。
+
+```ts:component.ts
+import { Component } from '@angular/core';
+import {TodoStore, Todo} from './store';
+
+@Component({
+ selector: 'todo-app',
+ templateUrl: './component.html',
+ styleUrls: ['./component.css']
+})
+export class TodoApp {
+ title = 'app works!';
+ todoStore: TodoStore;
+ newTodoText = '';
+
+ constructor(todoStore: TodoStore) {
+ this.todoStore = todoStore;
+ }
+
+ stopEditing(todo: Todo, editedTitle: string) {
+ if(editedTitle.length !== 0){
+ todo.title = editedTitle.trim();
+ }
+ todo.editing = false;
+ }
+
+ updateEditingTodo(todo: Todo, editedTitle: string) {
+ editedTitle = editedTitle.trim();
+ todo.editing = false;
+
+ if (editedTitle.length === 0) {
+ return this.todoStore.remove(todo);
+ }
+
+ todo.title = editedTitle;
+ }
+
+ editTodo(todo: Todo) {
+ todo.editing = true;
+ }
+
+ removeCompleted() {
+ this.todoStore.removeCompleted();
+ }
+
+ toggleCompletion(todo: Todo) {
+ this.todoStore.toggleCompletion(todo);
+ }
+
+ remove(todo: Todo){
+ this.todoStore.remove(todo);
+ }
+
+ addTodo() {
+ if (this.newTodoText.trim().length) {
+ this.todoStore.add(this.newTodoText);
+ this.newTodoText = '';
+ }
+ }
+}
+```
+
+```html:component.html
+<section class="todoapp">
+ <header class="header">
+ <h1>todos</h1>
+ <input class="new-todo" placeholder="What needs to be done?" autofocus="" [(ngModel)]="newTodoText" (keyup.enter)="addTodo()">
+ </header>
+ <section class="main" *ngIf="todoStore.todos.length > 0;">
+ <input class="toggle-all" type="checkbox" *ngIf="todoStore.todos.length;" #toggleall [checked]="todoStore.allCompleted()" (click)="todoStore.setAllTo(toggleall.checked)">
+ <ul class="todo-list">
+ <li *ngFor="let todo of todoStore.todos" [class.completed]="todo.completed" [class.editing]="todo.editing">
+ <div class="view">
+ <input class="toggle" type="checkbox" (click)="toggleCompletion(todo)" [checked]="todo.completed">
+ <label (dblclick)="editTodo(todo)">{{todo.title}}</label>
+ <button class="destroy" (click)="remove(todo)"></button>
+ </div>
+ <input class="edit" *ngIf="todo.editing;" [value]="todo.title" #editedtodo (blur)="stopEditing(todo, editedtodo.value)" (keyup.enter)="updateEditingTodo(todo, editedtodo.value)">
+ </li>
+ </ul>
+ </section>
+ <footer class="footer" *ngIf="todoStore.todos.length > 0;">
+ <span class="todo-count"><strong>{{todoStore.getRemaining().length}}</strong> {{todoStore.getRemaining().length == 1 ? 'item' : 'items'}} left</span>
+ <button class="clear-completed" *ngIf="todoStore.getCompleted().length > 0;" (click)="removeCompleted()">Clear completed</button>
+ </footer>
+</section>
+```
+
+```ts:store.ts
+export class Todo {
+ completed: Boolean;
+ editing: Boolean;
+
+ private _title: String;
+ get title() {
+ return this._title;
+ }
+ set title(value: String) {
+ this._title = value.trim();
+ }
+
+ constructor(title: String) {
+ this.completed = false;
+ this.editing = false;
+ this.title = title.trim();
+ }
+}
+
+export class TodoStore {
+ todos: Array<Todo>;
+
+ constructor() {
+ let persistedTodos = JSON.parse(localStorage.getItem('angular2-todos') || '[]');
+ // Normalize back into classes
+ this.todos = persistedTodos.map( (todo: {_title: String, completed: Boolean}) => {
+ let ret = new Todo(todo._title);
+ ret.completed = todo.completed;
+ return ret;
+ });
+ }
+
+ private updateStore() {
+ localStorage.setItem('angular2-todos', JSON.stringify(this.todos));
+ }
+
+ private getWithCompleted(completed: Boolean) {
+ return this.todos.filter((todo: Todo) => todo.completed === completed);
+ }
+
+ allCompleted() {
+ return this.todos.length === this.getCompleted().length;
+ }
+ setAllTo(completed: Boolean) {
+ this.todos.forEach((t: Todo) => t.completed = completed);
+ this.updateStore();
+ }
+
+ removeCompleted() {
+ this.todos = this.getWithCompleted(false);
+ this.updateStore();
+ }
+
+ getRemaining() {
+ return this.getWithCompleted(false);
+ }
+
+ getCompleted() {
+ return this.getWithCompleted(true);
+ }
+
+ toggleCompletion(todo: Todo) {
+ todo.completed = !todo.completed;
+ this.updateStore();
+ }
+
+ remove(todo: Todo) {
+ this.todos.splice(this.todos.indexOf(todo), 1);
+ this.updateStore();
+ }
+
+ add(title: String) {
+ this.todos.push(new Todo(title));
+ this.updateStore();
+ }
+}
+```
+
+```ts:module.ts
+import { BrowserModule } from '@angular/platform-browser';
+import { NgModule } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { HttpModule } from '@angular/http';
+
+import { TodoApp } from './component';
+import { TodoStore } from './store';
+
+@NgModule({
+ declarations: [
+ TodoApp
+ ],
+ imports: [
+ BrowserModule,
+ FormsModule,
+ HttpModule
+ ],
+ providers: [TodoStore],
+ bootstrap: [TodoApp]
+})
+export class AppModule { }
+```
+
+```ts:main.ts
+import { enableProdMode } from '@angular/core';
+import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
+
+import { AppModule } from './app/module';
+import { environment } from './environments/environment';
+
+if (environment.production) {
+ enableProdMode();
+}
+
+platformBrowserDynamic().bootstrapModule(AppModule);
+```
+
+```html:index.html
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Todo</title>
+ <base href="/">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="icon" type="image/x-icon" href="favicon.ico">
+</head>
+<body>
+ <todo-app>Loading...</todo-app>
+</body>
+</html>
+```
+
+```css:component.css
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-font-smoothing: antialiased;
+ font-smoothing: antialiased;
+}
+
+body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #4d4d4d;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-font-smoothing: antialiased;
+ font-smoothing: antialiased;
+ font-weight: 300;
+}
+
+button,
+input[type="checkbox"] {
+ outline: none;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: #e6e6e6;
+}
+
+.todoapp input::-moz-placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: #e6e6e6;
+}
+
+.todoapp input::input-placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: #e6e6e6;
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -155px;
+ width: 100%;
+ font-size: 100px;
+ font-weight: 100;
+ text-align: center;
+ color: rgba(175, 47, 47, 0.15);
+ -webkit-text-rendering: optimizeLegibility;
+ -moz-text-rendering: optimizeLegibility;
+ text-rendering: optimizeLegibility;
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ border: 0;
+ outline: none;
+ color: inherit;
+ padding: 6px;
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-font-smoothing: antialiased;
+ font-smoothing: antialiased;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+label[for='toggle-all'] {
+ display: none;
+}
+
+.toggle-all {
+ position: absolute;
+ top: -55px;
+ left: -12px;
+ width: 60px;
+ height: 34px;
+ text-align: center;
+ border: none; /* Mobile Safari */
+}
+
+.toggle-all:before {
+ content: '❯';
+ font-size: 22px;
+ color: #e6e6e6;
+ padding: 10px 27px 10px 27px;
+}
+
+.toggle-all:checked:before {
+ color: #737373;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: 506px;
+ padding: 13px 17px 12px 17px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ /* auto, since non-WebKit browsers doesn't support input styling */
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none; /* Mobile Safari */
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle:after {
+ content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#ededed" stroke-width="3"/></svg>');
+}
+
+.todo-list li .toggle:checked:after {
+ content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#bddad5" stroke-width="3"/><path fill="#5dc2af" d="M72 25L42 71 27 56l-4 4 20 20 34-52z"/></svg>');
+}
+
+.todo-list li label {
+ white-space: pre-line;
+ word-break: break-all;
+ padding: 15px 60px 15px 15px;
+ margin-left: 45px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+}
+
+.todo-list li.completed label {
+ color: #d9d9d9;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #cc9a9a;
+ margin-bottom: 11px;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .destroy:hover {
+ color: #af5b5e;
+}
+
+.todo-list li .destroy:after {
+ content: '×';
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.footer {
+ color: #777;
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ border-top: 1px solid #e6e6e6;
+}
+
+.footer:before {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a.selected,
+.filters li a:hover {
+ border-color: rgba(175, 47, 47, 0.1);
+}
+
+.filters li a.selected {
+ border-color: rgba(175, 47, 47, 0.2);
+}
+
+.clear-completed,
+html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 20px;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #bfbfbf;
+ font-size: 10px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
+
+@media screen and (-webkit-min-device-pixel-ratio:0) {
+ .toggle-all,
+ .todo-list li .toggle {
+ background: none;
+ }
+
+ .todo-list li .toggle {
+ height: 40px;
+ }
+
+ .toggle-all {
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg);
+ -webkit-appearance: none;
+ appearance: none;
+ }
+}
+
+@media (max-width: 430px) {
+ .footer {
+ height: 50px;
+ }
+
+ .filters {
+ bottom: 10px;
+ }
+}
+```
+
+```css:style.css
+/* You can add global styles to this file, and also import other style files */
+ body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #4d4d4d;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-font-smoothing: antialiased;
+ font-smoothing: antialiased;
+ font-weight: 300;
+ }
+```
+
+
+Angular2との互換性が無い部分は
+「*ngFor="#todo of todoStore.todos" 」だけでした。
+「*ngFor=let todo of todoStore.todos」にする必要がありあます。
+通常のタグでのテンプレート参照変数は現在も使えますが、ループ時には使えない
+仕様になったみたいです。
+
+
+##新機能
+todoアプリを作る際にはほとんどangular4の新機能を使う場面が無かったので
+新機能の一部をこちらで紹介したいと思います。
+
+- *ngIf/Else
+
+これまで*ngifは指定した要素の表示、非表示を切り替える程度のものでしたが、
+今回からはelseを指定することにより下記のように表示させる要素そのものを
+切り替えることが出来るようになりました。
+
+```ts
+<div *ngIf="isValid;else other_content">
+ isValid === true
+</div>
+
+<ng-template #other_content> isValid === false </ng-template>
+```
+
+- ng-template
+
+上記でも使っているng-templateタグですがもともとはtemplateタグとして
+使われていたものです。
+現在はどちらで書いても利用することができますが、次回のアップデートの際には
+ng-templateしか使えなくなるのではないでしょうか。
+
+- as
+これまでは非同期で情報を取得したい場合には
+asyncパイプと?.を組み合わせる必要がありました。
+
+```
+<div>
+ <h2>{{ (user | async)?.name }}</h2>
+ <small>{{ (user | async)?.age }}</small>
+</div>
+```
+
+asを使うことで*ngIf="userの判定結果を
+利用できるようになります。
+
+```ts
+<div *ngIf="user | async as userModel">
+ <h2>{{ userModel.name }}</h2>
+ <small>{{ userModel.date }}</small>
+</div>
+```
+
+?.が消え、asyncパイプも1回にできるので
+単純にみやすいだけでなく、性能面でも
+改善が見込めます。
+
+- Titlecase
+
+uppercase,lowercase等のpipe処理に
+新たに単語の1文字目を大文字にする
+titlecaseが追加されました。
+
+```ts
+<p>{{ 'hoge fuga piyo' | titlecase }}</p>
+<!-- will display 'Hoge Fuga Piyo' -->
+```
+
+##おまけ
+angular2のtodoアプリにはtodoの編集時に文字数を0にしてfocusアウトすると空のtodoができてしまうという現状が置きます。
+他のサンプルアプリにはそんなバグないんですけどね。
+
+##所感
+angularからangular2への変化はすさまじかったですが、angular2からangular4に関しては純粋なバージョンアップといった感じですね。
+angular2に関しては批判も多かったのであまり期待せずに触ってみたのですが、思っていたより使いやすかったのでこれからも使っていこうと思います。
+
+##参考
+
+- TodoMVC
+
+http://todomvc.com/
+
+- What's new in Angular 4?
+
+http://blog.ninja-squad.com/2017/03/24/what-is-new-angular-4/