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


Knockoutを使ってカウントダウンタイマーを作ります。

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

スクリーンショット

設計

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

モデル

タイマーの

  • 残り時間(ミリ秒)
  • 減算処理

を持ちます。

ビューモデル

大きく三つの仕事があります。

表示用のプロパティ

  • 残り時間のラベル
  • 開始/中止ボタンのラベル(開始または中止

表示用の状態とその更新処理

  • 残り時間
  • タイマーの開始終了状態
  • タイマーのカウント

イベントハンドラー

  • カウントダウンのON/OFF切り替え

ビュー

  • 残り時間(秒)
  • 開始/中止ボタン

を表示します。

実装

モデル

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

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

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

ビューモデル

表示用のプロパティ

残り時間

observableを使って、変更を監視できるようにします。

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

残り時間のラベル

モデルの持つ残り時間はミリ秒単位ですが、画面上は秒単位で表示します。
computedを使って、this.restTimeの値を秒単位に変換するobservableを作ります。

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

ボタンのラベル

computedを使って、内部状態(bool値)を表示用に変換するobservableを作ります。

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

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

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

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

タイマーの開始終了状態

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

this.state = ko.observable(STATE_STOP)

イベントハンドラー

カウントダウンのON/OFF切り替え

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

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

タイマーのカウント処理

subscribeメソッドを使ってthis.stateの状態変更を監視します。

停止状態になったとき、window.clearIntervalでカウントダウンを止めます。

開始状態になったとき、window.setIntervalを使って1秒ごとに次の処理をします。

  • モデルを減産
  • this.restTimeにモデルの値を反映
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)
    }
})

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

ビュー

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

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

ソースコード全文

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と使い心地が似ています。

関連記事

INotifyPropertyChanged実装のありえない面倒くささと、ReactivePropertyの信じられない素晴らしさ