Knockoutで作るカウントダウンタイマー(computedのすばらしさ)


[Knockout](knockoutjs.com)を使ってカウントダウンタイマーを作ります。

1. ボタンを押すとカウトダウンを開始
2. 1秒ごとに残り時間を減算
3. もう一度ボタンを押すとカウントダウンを中止

# スクリーンショット
![out.gif](https://qiita-image-store.s3.amazonaws.com/0/15964/c7ef5eee-87c4-07e7-62c8-4c16afe2a730.gif "out.gif")


# 設計

ドメインモデルを共有するアプリケーションが無いので、モデルには制約がありません。
モデルに制約がないため、必ずしもモデルとビューモデルを分ける必要はありません。
ここでは、勉強のためにMVVMに則り、モデルとビューモデルを分けます。

## モデル
タイマーの

- 残り時間（ミリ秒）
- 減算処理

を持ちます。

## ビューモデル
大きく三つの仕事があります。

### 表示用のプロパティ
- 残り時間のラベル
- 開始/中止ボタンのラベル（`開始`または`中止`）

### 表示用の状態とその更新処理
- 残り時間
- タイマーの開始終了状態
- タイマーのカウント 

### イベントハンドラー
- カウントダウンのON/OFF切り替え


## ビュー
- 残り時間（秒）
- 開始/中止ボタン

を表示します。


# 実装
## モデル
残り時間と減算処理を実装します。
ふつうのJavaScriptのオブジェクトです。

残り時間はミリ秒単位で管理します。
初期値は10秒にしました。

```js
model = {
    restTime: 10000,
    decrement(ms) {
        this.restTime -= ms
    }
}
```

## ビューモデル
### 表示用のプロパティ
#### 残り時間

[observable](http://kojs.sukobuto.com/docs/observables)を使って、変更を監視できるようにします。

```js
this.restTime = ko.observable(model.restTime)
```

#### 残り時間のラベル
モデルの持つ残り時間はミリ秒単位ですが、画面上は秒単位で表示します。
[computed](http://kojs.sukobuto.com/docs/computedObservables)を使って、`this.restTime`の値を秒単位に変換する`observable`を作ります。


```js
this.restTimeLabel = ko.computed(() => this.restTime() / 1000)
```

#### ボタンのラベル

[computed](http://kojs.sukobuto.com/docs/computedObservables)を使って、内部状態（bool値）を表示用に変換する`observable`を作ります。

```js
this.toggleSwitchLabel = ko.computed(() => this._isStop() ? '開始' : '中止')
```


`_isStop`は状態判定用の内部関数です。

```js
_isStop() {
    return this.state() === STATE_STOP
}
```

`computed`は関数呼び出しの先の`observable`も検出できます。


### タイマーの開始終了状態

`this.toggleSwitchLabel`から参照する`observable`です。

```js
this.state = ko.observable(STATE_STOP)
```

### イベントハンドラー
カウントダウンのON/OFF切り替え

```js
toggle() {
    if (this._isStop()) {
        this.state(STATE_RUN)
    } else {
        this.state(STATE_STOP)
    }
}
```

`this.state`もobservableです。この状態変更を監視して、カウント処理の開始・終了を制御します。

### タイマーのカウント処理
`subscribe`メソッドを使って`this.state`の状態変更を監視します。

停止状態になったとき、[window.clearInterval](https://developer.mozilla.org/ja/docs/Web/API/WindowTimers/clearInterval)でカウントダウンを止めます。

開始状態になったとき、[window.setInterval](https://developer.mozilla.org/ja/docs/Web/API/window/setInterval)を使って1秒ごとに次の処理をします。

- モデルを減産
- `this.restTime`にモデルの値を反映

```js
let intervalID = null
this.state.subscribe(() => {
    if (this._isStop()) {
        if (intervalID) {
            window.clearInterval(intervalID)
            intervalID = null
        }
    } else {
        intervalID = window.setInterval(() => {
            this.model.decrement()
            this.restTime(this.model.restTime)
        }, 1000)
    }
})
```

このタイマーは自動では止まりません。

## ビュー
残り時間と開始ボタンを表示します。

```html
<span data-bind="text: restTimeLabel">100</span>秒
<button data-bind="text: toggleSwitchLabel, click: toggle">開始</button>
```

## ソースコード全文
```html:CountDown.html
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.0/knockout-debug.js"></script>

<body>
    <span data-bind="text: restTimeLabel">100</span>秒
    <button data-bind="text: toggleSwitchLabel, click: toggle">開始</button>
    <script>
        const STATE_STOP = false,
            STATE_RUN = true,
            model = {
                restTime: 10000,
                decrement(ms) {
                    this.restTime -= ms
                }
            }

        class TimerViewModel {
            constructor(model) {
                this.model = model
                this.restTime = ko.observable(model.restTime)
                this.state = ko.observable(STATE_STOP)

                let intervalID = null
                this.state.subscribe(() => {
                    if (this._isStop()) {
                        if (intervalID) {
                            window.clearInterval(intervalID)
                            intervalID = null
                        }
                    } else {
                        intervalID = window.setInterval(() => {
                            this.model.decrement(1000)
                            this.restTime(this.model.restTime)
                        }, 1000)
                    }
                })

                this.restTimeLabel = ko.computed(() => this.restTime() / 1000)
                this.toggleSwitchLabel = ko.computed(() => this._isStop() ? '開始' : '中止')
            }
            toggle() {
                if (this._isStop()) {
                    this.state(STATE_RUN)
                } else {
                    this.state(STATE_STOP)
                }
            }
            _isStop() {
                return this.state() === STATE_STOP
            }
        }

        //main
        ko.applyBindings(new TimerViewModel(model))
    </script>
</body>
```


# 感想

computedが気持ちよいです。
[ReactiveProperty](https://reactiveproperty.codeplex.com/)と使い心地が似ています。

# 関連記事
[INotifyPropertyChanged実装のありえない面倒くささと、ReactivePropertyの信じられない素晴らしさ](http://qiita.com/ledsun/items/6f4ef754e5ae2507e531)