コールバック関数の実行回数を間引く
こんにちは!閃光部おばらです。
ウィンドウの"resize"、"scroll"イベント、document の "mousemove"イベント、そしてrequestAnimationFrame()を使用する際など、
イベントハンドラやコールバック関数の実行回数を間引きたい、実行間隔を一定以上に保ちたいケースが良くあると思います。
今回はそんなときに便利なユーティリティクラスを作成してみましょう。
また、そのユーティリティクラスを用い、"mousemove"イベントの回数を間引いてみましょう。
クラス名
一般に、こういった処理を(実行回数を絞るという意味から)throttle と呼ぶため、
クラス名は Throttle とすることにしましょう。
クラス名が格好いいとやる気が倍増しますね!
機能
必要最小限な機能は以下の二つになります。
・最少実行間隔を設定する
・実行する関数を設定する
仕様
"mousemove"イベントを監視し、それに対してコールバック関数を登録する場合を考えます。
仮に、1000ms秒間に、100回 "mousemove" イベントが発火したとします。
またその前後にはイベントが発火しない十分な時間があるとします。
この場合、普通に addEventListener() のみでコールバック関数を登録すると、
100回コールバック関数が実行されてしまいますね。
これを Throttle を用いて 間引いた場合以下の動作となるようにします。
・0回目・100回目(=最後)は必ずコールバック関数を実行する。
・1回目~99回目は、その実行間隔が指定された ms秒以上になるよう間引く
インターフェイス
最少実行間隔の指定
最少実行間隔の指定は簡単のため一度のみとします。
そこでコンストラクタ関数の引数で指定するようにしましょう。
なお、単位は [ms] とします。
var throttle = new Throttle(100);
実行する関数の指定
実行する関数はメソッドに引数として渡す仕様にしましょう。
var throttle = new Throttle(100); throttle.exec(function() { // do something... });
これでインターフェイスは決まりました。
実際に使用する場合は、以下のようになるでしょう。
var throttle = new Throttle(100); document.addEventListener("mousemove", handleMouseMove, true); function handleMouseMove() { throttle.exec(function() { // do something }); }
実装
今回は簡単のため prototype は使わずに、モジュールパターンで実装します。
/** * @param {number} minInterval * @return {Object.<function>} */ function Throttle(minInterval) { /*------------------------------------------- PRIVATE -------------------------------------------*/ /*------------------------------------------- PUBLIC -------------------------------------------*/ /** * @param {function} func * @return {undefined} */ function exec(func) { ; } /*------------------------------------------- EXPORT -------------------------------------------*/ return { exec : exec }; }
変数
実行間隔の計測する必要があるため、
前回の実行時間(タイムスタンプ)を格納するプライベート変数を用意します。
初期値は、インスタンス生成直後に exec() を呼んだ場合
引数に渡された関数を即座に実行させるため 0 ※とします。
(※ +new Date - minInterval 未満の数値であれば何でもよい)
/** * @param {number} minInterval * @return {Object.<function>} */ function Throttle(minInterval) { /*------------------------------------------- PRIVATE -------------------------------------------*/ var _timeStamp = 0; /*------------------------------------------- PUBLIC -------------------------------------------*/ /** * @param {function} func * @return {undefined} */ function exec(func) { ; } /*------------------------------------------- EXPORT -------------------------------------------*/ return { exec : exec }; }
exec()の実装
exec()では
前回の実行からの経過時間を測定し、
それが minInterval 以上であれば引数に渡された関数を実行します
/** * @param {function} func * @return {undefined} */ function exec(func) { var now = +new Date, delta = now - _timeStamp; if (delta >= minInterval) { _timeStamp = now; func(); } }
これだと最後に渡された関数が実行されないケースが出ますね。
(間だけを間引きたいのに)
ちょっと手を加えましょう。
delta が最少実行間隔に満たない場合は、その実行を遅らせる処理を加えます。
/** * @param {function} func * @return {undefined} */ function exec(func) { var now = +new Date, delta = now - _timeStamp; if (delta >= minInterval) { _timeStamp = now; func(); } else { setTimeout(function() { exec(func); }, minInterval - delta); } }
これで最後も必ず実行されることが保障されましたが、この修正により新たな問題が生じます。
このままでは setTimeout() で追加されたキューがどんどんたまってしまいますね。
そこで、タイマーIDを格納するプライベート変数 _timerID を用意し、新たにタイマーを設定する前に、
前のタイマーを解除する処理を加えましょう。
/** * @param {function} func * @return {undefined} */ function exec(func) { var now = +new Date, delta = now - _timeStamp; clearTimeout(_timerId); if (delta >= minInterval) { _timeStamp = now; func(); } else { _timerId = setTimeout(function() { exec(func); }, minInterval - delta); } }
以上で完成です。
※あるコールバック関数が exec() で登録されてから実行されるまで、最大で minInterval だけ遅延が生じますが、そこは仕様とします。
まとめると以下となります。
/** * @param {number} minInterval * @return {Object.<function>} */ function Throttle(minInterval) { /*------------------------------------------- PRIVATE -------------------------------------------*/ var _timeStamp = 0, _timerId; /*------------------------------------------- PUBLIC -------------------------------------------*/ /** * @param {function} func * @return {undefined} */ function exec(func) { var now = +new Date, delta = now - _timeStamp; clearTimeout(_timerId); if (delta >= minInterval) { _timeStamp = now; func(); } else { _timerId = setTimeout(function() { exec(func); }, minInterval - delta); } } /*------------------------------------------- EXPORT -------------------------------------------*/ return { exec : exec }; }
サンプル/使用例
document の "mousemove" イベントを監視し、そのイベントハンドラ内でグラフにプロットしてみましょう
今回作成した Throttle() を使用する場合と使用しない場合でプロット数とその間隔にどのような差が出るでしょうか
(function(win, doc) { "use strict"; ///////////////////////////////////////////////// win.addEventListener("DOMContentLoaded", main, false); function main() { var throttle = new Throttle(100), graph = new Graph(465, 465); doc.addEventListener("mousemove", handleMouseMove, true); function handleMouseMove() { // そのまま graph.plot(-20, "#f06"); // 間引く throttle.exec(function() { graph.plot(20, "#0f6"); }); } } ///////////////////////////////////////////////// /** * @param {number} minInterval * @return {Object.<function>} */ function Throttle(minInterval) { /*------------------------------------------- PRIVATE -------------------------------------------*/ var _timeStamp = 0, _timerId; /*------------------------------------------- PUBLIC -------------------------------------------*/ /** * @param {function} func * @return {undefined} */ function exec(func) { var now = +new Date, delta = now - _timeStamp; clearTimeout(_timerId); if (delta >= minInterval) { _timeStamp = now; func(); } else { _timerId = setTimeout(function() { exec(func); }, minInterval - delta); } } /*------------------------------------------- EXPORT -------------------------------------------*/ return { exec : exec }; } ///////////////////////////////////////////////// /** * @param {uint} width * @param {uint} height * @return {Object.<function>} */ function Graph(width, height) { /*-------------------------------------------- PRIVATE --------------------------------------------*/ var _cvs = doc.createElement("canvas"), _ctx = _cvs.getContext("2d"), _queue = []; /*-------------------------------------------- INIT --------------------------------------------*/ _cvs.width = width; _cvs.height = height; doc.body.appendChild(_cvs); render(); /*-------------------------------------------- PRIVATE --------------------------------------------*/ /** * @return {undefined} */ function render() { var PX_PER_SEC = 400,// [px/msec], X_MIN = -w, PLOT_RADIUS = 3, now = +new Date, c = _ctx, w = width, h = height, hh = h >> 1, arr = _queue, i = arr.length, obj, x, y, _x, _y; c.clearRect(0, 0, w, h); while (i--) { obj = arr[i]; x = PX_PER_SEC * (obj.timeStamp - now) / 1000; y = obj.value; c.lineWidth = 2; c.strokeStyle = obj.color; c.fillStyle = "#000"; // 領域外に出たらループ中止 if (x < X_MIN) { arr.splice(0, i); break; } // 前回と座標が異なる場合のみ描画 if (x !== _x || y !== _y) { c.beginPath(); c.arc( x + w, y + hh, PLOT_RADIUS, 0, Math.PI * 2, true ); c.fill(); c.stroke(); _x = x; _y = y; } } setTimeout(render, 100); } /*-------------------------------------------- PUBLIC --------------------------------------------*/ /** * @param {number} value * @param {string} color * @return {undefined} */ function plot(value, color) { _queue.push({ timeStamp : +new Date, value : value, color : color || "#fff" }); } /*-------------------------------------------- EXPORT --------------------------------------------*/ return { plot : plot }; } }(this, document));
以下のグラフ上でマウスを動かしてみてください。
グラフは横軸が時間です。縦軸は特に意味はありません。
赤い点が、間引いていない生の "mousemove" イベントをプロットしたものです。
緑の点が、最少実行間隔を 100ms として間引き、プロットしたものです。
緑の点がほぼ 100ms 以上の間隔で並んでいることが確認できると思います。
このような仕組みは、特にパフォーマンス向上に利用できますね。
自分好みの仕様にして使ってみてください。