LoginSignup
1
0

More than 3 years have passed since last update.

叩きまくられる「非同期のリクエスト」を自作の「throttle」で間引いてみた

Last updated at Posted at 2020-03-06

throttle/debounce というのをご存知ですか?

私は知りませんでした。笑

今では、position: sticky;intersectionObservermedia query などなど、便利なものがたくさんあるのでそこまで気にしなくなったかもしれませんが、
昔は、position: fixed、scroll イベント、 resize イベント を駆使して、UI を実現していました。今でも IE のせいで、レガシー環境のせいで いろいろな事情によってそう処理をされている方も多いのではないのでしょうか。

とはいえ、監視するにしても、大量に発行される scroll や resize イベントを全部見なくたっていいことだってたくさんあります。

window.addEventListener('scroll', event => console.log(window.scrollY));

この一行を、chrome などの開発者ツールで叩いてみて、window をスクロールしてみてください。

すると、ちょっとスクロールしただけなのに、もうそれはそれは、マーライオンのように現在のスクロール位置を吐き出してくれると思います。

そこで、監視を実装する方は思うわけです。

「そんなにやる? やりすぎじゃない?」

なんでもやりすぎは良くないのです。酒と一緒です。飲みすぎるとマーライオンです。

イベントの発火量に対して、毎回そこまで処理をしなくても十分に担保できることはたくさんあります。
逆にやりすぎるからパフォーマンスが落ちてしまうこともありますね。酒と一緒です。飲みすぎると、

これは jQuery でもプラグインとして提供されていて、かなり昔からある考え方です。

[jQuery] scroll や resize イベント時の処理回数を減らすプラグイン – jQuery throttle / debounce

「jquery throttle」 でググった際に一番上に出てきたサイトが上記のものです。(2020/03/03 現在)

throttle/debounce プラグインではイベント中の処理を、「イベント発生中に随時実行」ではなく「イベント発生中、指定した秒数毎に実行」(throttle)又は、「イベントが終了してから、指定秒数後に実行」(debounce)というタイミングに変更することができます。

上記記事から引用させていただきました。

具体例を用いてお話すると、throttle というのは10秒間に100回イベントが発火しているものを、1秒に一回だけ処理を実行するようにして、10回だけ処理をするように抑えるというものです。

それに対して debounce というのは、イベントが発生したときに処理を実行するのを1秒間待って、その間にもう一度イベントが発生したら、前に発生したイベントは実行しないというものです。

このように、不要なイベントを間引いて、なるべく処理を軽くしながらもやりたいことを実現するというのは、今でもパフォーマンスを気にする上では重要な考え方です。

間引くのはイベントだけでいいのか?

先ほどの話はイベント発火に対しての処理でした。
大量に発火してしまうイベントを間引いて、少し粗くともユーザ体験にはそこまで影響しない範囲内で間引いて、パフォーマンスチューニングをするものでした。

とはいえ、処理を間引きたいものって、イベント以外にも色々ありますよね?

私は、そんな中で、、、

「非同期通信の間引きをしたい」

と思いました。

例えば、検索窓に文字を入力すると、その都度再検索をかけてくれる機能を作りたいとします。

ユーザとしては大変便利ですよね。ただ文字を入力すれば、今入力された文字で自動的に検索をしてくれて、そのまま結果を返してくれる優れものです。

ただ、そのリクエストを投げられる側からしたらどうでしょうか。

大変な騒ぎです。マスクが買えないときの薬局の電話と同じ状態になってしまいます。
何度電話されたって、他の電話の対応をしていたら出られないわけですからね。

なので、非同期通信自体を間引く処理を実装してみたいと思います。

やりたいこと

ひとまず、例えばこんなコードを書きたいとします。

// fetcher 第一引数に URL 、第二引数に query を含めたオブジェクトを投げる「想定」のもの
import fetcher from './fetcher';
// 第一引数に構造体を渡してインスタンスを生成すると、DOM を生成する「想定」のもの
import SearchResult from './views/SearchResult';

document
  .querySelector('input[type="search"]')
  .addEventListener('input', async (event) => {
    const response = await fetcher.get('https://example.com/api/', {
      query: event.target.value
    });

    // 描画
    new SearchResult(response);
  });

想定が多くて分かりづらいかもしれませんので解説します。

検索窓(<input type="search">) を取得して、input イベントを監視し、ユーザの文字入力の操作をキャッチします。
そのイベントの発火ごとに fetcher で検索結果のデータを取得します。
この fetcher は想定なので中身はよくわかりませんが、axios みたいなもんだと思ってください。
await を使っているので、fetcher の処理を待ってくれています。処理が終わると次の処理へと移ります。
最後に、response を SearchResult の引数としてインスタンスを生成して、検索結果の DOM の生成が完了するという流れです。(これも描画してくれる処理が書いてある「想定」です)。

これをそのまま実行すると、大変なことになります。

文字が入力されるたびに fetcher を呼び出すことになるので、キーボードを長押しなんてされた日には、とんでもない量のリクエストがサーバに飛ぶことになります。
最早サイバー攻撃です。

それを防ぐために、こんなふうに書き換えたいと思います。

import fetcher from './fetcher';
import SearchResult from './views/SearchResult';
// 今回作る throttle
import throttle from './throttle';

// fetcher の get 関数を throttle 化
const throttleGetter = throttle(
  (...rest) => fetcher.get('https://example.com/api/', ...rest),
  1000
);

document
  .querySelector('input[type="search"]')
  .addEventListener('input', async (event) => {
    const response = await throttleGetter({
      query: event.target.value
    });

    // 描画
    new SearchResult(response);
  });

肝となるのは、throttleGetter の部分です。

第一引数に実行したい関数を用意して、第二引数に間引く時間を入れるようにします。

こうするだけで、非同期の間引きができたら最高です。

非同期の間引きは具体的にどんなことができればいいか?

非同期の間引きと、イベントの間引きにどんな違いがあるべきかを述べていこうと思います。

おさらいとなりますが、イベント(通常)の throttle は、最初にイベントが発火したらタイマーが実行されます。
そのタイマーが実行されている間はどんなにイベントが発行されても処理は実行されません。
そして、そのイベントのタイマーが切れたときに、もう一度イベントが発行されると、そのイベントの処理はめでたく実行され、またタイマーが実行されて間引かれていくという形です。

しかし、非同期の間引きは、上記の間引きだけではうまくいかないのです。
非同期 throttle がうまくいかない説明図

こちらはうまくいかないときの図です。

処理3まではいい感じなのですが、処理4に入ったときから少しずつおかしくなっていきます。ホラーやサスペンスのようですね(?)

「図の描き方の都合がいいぞ!!」と言われたらそれまでなのですが、処理6が始まった頃合いに、処理3が実行されてしまっています。

throttle というのは、新しい処理が追加されたら、その処理を実行しなければなりません。
それを実現できているのが処理1〜処理3ですね。処理1のタイミングで待機が始まっていて、待機が終わると処理3が実行されています。ここまでは良いのです。

しかし、処理4が始まったタイミングでは、処理4と処理5しかブロックできていません
本来であれば、処理3もブロックしなければならないはずです。

なので、次の図のような処理を実現できなければなりません。

基本的な考え方はイベントの throttle と違いはありませんが、大きな違いは「非同期中の処理をどうするか」になります。
正しい非同期 throttle の説明図

こちらの図だと、処理3の非同期処理を「無効化」するようになっています。
なので、ちゃんと処理6だけが実行できるように制御できているということです。

つまり、非同期処理の throttle を実装するときは、その非同期通信自体をキャンセルするような処理にしなければならないのです。

2個目の図を再現させたコードが以下になります。

AsyncSetTimeout

まずはこちらのコードから。
このコードは throttle で使用するものです。

普通の setTimeout だと実現できないので、setTimeout を拡張したものになります。

AsyncSetTimeout.js
export default class AsyncSetTimeout {
  constructor(func, ms) {
    this.timerId;
    this.func = func;
    this.ms = ms;
    this.rejector = null;

    return this;
  }

  execute() {
    return new Promise((resolve, reject) => {
      // reject の関数を外部化
      this.rejector = reject;

      this.timerId = setTimeout(async () => {
        this.cancel();
        resolve(await this.func());
        this.rejector = null;
      }, this.ms);
    });
  }

  cancel() {
    if (this.timerId) {
      clearTimeout(this.timerId);
      this.timerId = null;
    }
  }

  reject(...rest) {
    if (typeof this.rejector === 'function') {
      this.rejector(...rest);
      this.rejector = null;
    }

    this.cancel();
  }
}

シンプルに言うと、setTimeout の async 版です。
以下の記事を参考にさせていただきました!超ありがとうございます…!

キャンセル可能でPromiseなsetTimeout()を作る

.execute() で実行するという形を取るのであれば、インスタンスを生成する形の方がわかりやすいかなと思ったので class で作成しています。

基本的な使い方はこんな感じです。

const asyncSetTimeout = new AsyncSetTimeout(
  async () => await doSomething(),
  1000
);

const data = await asyncSetTimeout.execute();

console.log(data);

第一引数で登録した関数の実行を第二引数で設定した時間が来たら実行します。
ただ、execute() の実行自体が await で実行できるので、第一引数が async の関数であっても処理を待つことができます。
なので、第二引数分実行時間を待ち、さらに第一引数の処理を待つこともできるようになっています。

処理を途中で中断したい場合は、以下のとおりです。

const asyncSetTimeout = new AsyncSetTimeout(
  async () => await doSomething(),
  1000
);

// cancel を実行するボタン
document.querySelector('button.cancel').addEventListener('click', () => {
  asyncSetTimeout.cancel();
});

// reject を実行するボタン
document.querySelector('button.reject').addEventListener('click', () => {
  asyncSetTimeout.reject(new SomeException());
});


const data = await asyncSetTimeout
  .execute()
  .catch(exception => doSomething(exception));

.cancel() が実行されたときはただタイマーが止まるだけです。
なので .catch() は実行されませんし、.execute() の処理も完了することはありません。

それに対して、.reject() は、インスタンス生成時に登録した非同期処理自体も止めたいときに叩きます。

前述しましたが、AsyncSetTimeout は登録された関数の処理も待つことができます。
なので、その待機処理自体も中断したいときに使うのが、.reject() です。登録した処理そのものを中断したい場合は、.reject() を叩いてください。

そして、一点注意しないといけないのは、.cancel() とは違い、.execute() は完了したことになるということです。
内部で reject() を叩いているために、.execute()は完了したことになります。
.catch() の引数に関数を登録しないと、undefined が返ることになり、.cancel() のときの「実行されない」とは異なるのでここに注意してください。
(自分で作って自分でドハマリしていました。笑)

throttle

throttle 本体は、大きく分けて handlerexecute の2つの関数で構成されています。

throttle.js
import AsyncSetTimeout from './AsyncSetTimeout';

class ThrottleException {
  constructor(...rest) {
    this.rest = rest;

    return this;
  }
}

export function throttle(func, interval = 0) {
  let executeTime = null; // 実行予定時間
  let asyncSetTimeout = null;
  let executing = false; // func が実行中かどうか判定
  let executeRejector = null; // func の実行を外部から中断するためのもの

  const execute = (...rest) => async () => {
    executing = true;

    // 非同期中に関数実行を中止できるように promise を発行する
    const asyncExecuter = () =>
      new Promise(async (resolve, reject) => {
        executeRejector = reject;
        resolve(await func(...rest));
      });

    return await asyncExecuter()
      .then(done => {
        executing = false;
        return done;
      })
      .catch(throttleException => {
        executing = false;
        if (asyncSetTimeout instanceof AsyncSetTimeout) {
          asyncSetTimeout.reject(throttleException);
        }
      });
  };

  const handler = async (...rest) => {
    // 非同期実行中に再度実行されたら中断
    if (executing && typeof executeRejector === 'function') {
      executeRejector(new ThrottleException(...rest));
      executeRejector = null;
      return;
    }

    if (executeTime > performance.now()) {
      // 間引き中ならタイマー削除
      if (asyncSetTimeout instanceof AsyncSetTimeout) {
        asyncSetTimeout.cancel();
      }
      asyncSetTimeout = null;
    } else {
      // 間引きしていないなら実行時間を決定
      executeTime = performance.now() + interval;
    }

    if (asyncSetTimeout === null) {
      asyncSetTimeout = new AsyncSetTimeout(
        execute(...rest),
        executeTime - performance.now() // interval させるタイミングを短くしていく
      );

      return await asyncSetTimeout
        // タイマー後実行
        .execute()
        .then(done => {
          asyncSetTimeout = null;
          return done;
        })
        // 非同期実行中に reject された場合、reject 時の引数の値でリトライする
        .catch(async throttleException => {
          asyncSetTimeout = null;
          return await handler(...throttleException.rest);
        });
    }
  };

  return handler;
}
  • handler:間引き処理をするのか、登録された関数を実行に移すのかをハンドリングする関数です。非同期処理自体をキャンセルするかどうかのハンドリングもしています。
  • execute:登録された関数を実際に実行する関数です。途中で非同期処理を中断できるように、実行自体を reject できるようにする準備もこの関数が行っています。

executeTime の考え方

ちょっと変数名が良くなかったかもしれませんが、executeTime「実行予定時間」のことを指します。

今回は debounce ではなく throttle を実装していて、定期的に関数を実行しなければならないので、setTimeout を削除して、また同じ時間待機するための AsyncSetTimeout を設定するという処理にしたくありません。

  let executeTime = null; // 実行予定時間
  let asyncSetTimeout = null;

  // 省略

  const handler => async (...rest) => {

    // 省略

    if (executeTime > performance.now()) {
      // 間引き中ならタイマー削除
      if (asyncSetTimeout instanceof AsyncSetTimeout) {
        asyncSetTimeout.cancel();
      }
      asyncSetTimeout = null;
    } else {
      // 間引きしていないなら実行時間を決定
      executeTime = performance.now() + interval;
    }

    if (asyncSetTimeout === null) {
      asyncSetTimeout = new AsyncSetTimeout(
        execute(...rest),
        executeTime - performance.now() // interval させるタイミングを短くしていく
      );

      // 省略

    }
  };

肝となるのは、handler の中にあるこの処理です。

「間引き中ならタイマーを削除」と書いてあるので、さっき「したくありません」と言っていたことを平気な顔をして実施しているサイコパスみたいな感じになっていますが、実際は違います。笑

タイマーを削除している理由は、タイマーが登録されたときに実行しようとしている処理を完全にストップさせたいためです。

ただ、これで、interval の値をそのまま AsyncSetTimeout に設定してしまうと、debounce のような処理になってしまいます。

ここで肝になるのが「実行予定時間」という考え方です。

最初に AsyncSetTimeout を指定したときに「実行予定時間」を決めておけば、今の時間から「実行予定時刻」の差分を算出することができます。
その「差分」を AsyncSetTimeout に設定してあげれば、タイマーは止めたとしても、次にセットするタイマーは最初に決めた「実行予定時間」に実行できるという仕組みです。

非同期の処理を中断する仕組み

まずは、コードを抜粋したので見ていただければと思います。

  let executing = false;
  let executeRejector = null; // func の実行を外部から中断するためのもの

  const execute = (...rest) => async () => {
    executing = true;

    // 非同期中に関数実行を中止できるように promise を発行する
    const asyncExecuter = () =>
      new Promise(async (resolve, reject) => {
        executeRejector = reject;
        resolve(await func(...rest));
      });

    return await asyncExecuter()
      .then(done => {
        executing = false;
        return done;
      })
      .catch(throttleException => {
        executing = false;
        if (asyncSetTimeout instanceof AsyncSetTimeout) {
          asyncSetTimeout.reject(throttleException);
        }
      });
  };

  const handler = async (...rest) => {
    // 非同期実行中に再度実行されたら中断
    if (executing && typeof executeRejector === 'function') {
      executeRejector(new ThrottleException(...rest));
      executeRejector = null;
      return;
    }

    // 省略

    if (asyncSetTimeout === null) {
      asyncSetTimeout = new AsyncSetTimeout(
        execute(...rest),
        executeTime - performance.now() // interval させるタイミングを短くしていく
      );

      return await asyncSetTimeout
        // タイマー後実行
        .execute()
        .then(done => {
          asyncSetTimeout = null;
          return done;
        })
        // 非同期実行中に reject された場合、reject 時の引数の値でリトライする
        .catch(async throttleException => {
          asyncSetTimeout = null;
          return await handler(...throttleException.rest);
        });
    }
  };

関係のあるところを抜き出しました。(抜き出しのくせに長いですね。。すみません。。)

ここで肝となるのは、asyncExecuterPromise に渡している関数の中で executeRejector = reject; と書いている部分です。

超強引ですが、executeRejector というグローバル変数を用意しておき、asyncExecuter の reject を executeRejector に格納して、それを任意のタイミングで外部から叩けるようにしてしまえ!という考えです。

私は、コードの説明に入る前にこんなことを言いました。

つまり、非同期処理の throttle を実装するときは、その非同期処理自体をキャンセルするような処理にしなければならないのです。

これを実現させるのが、executeRejector になります。
これが「無効化」を実現させています。

では、その executeRejector をどこで叩いているのかというと、handler になります。

handler の最初の処理は、まず、処理が実行中なのかどうかを確認します。

グローバルで定義されている executing という変数は execute の最初に true、そして、asyncExecuter()Promise が終了したときに false になるようにしています。

この executing を確認して、true なのであれば、次の処理を実行しようとしているので、今実行している処理は即刻中断しなければなりません。

なので handler で即刻 executeRejector を叩いて、非同期処理を「無効化」しているわけです。

これで、無効化の第一歩を踏み出すことができたわけです!

ということはつまり、これだけではまだうまくいきません。

その executeRejector が実行されると、今度は、execute 内にある asyncExecuter の実行時に付与した .catch() が実行されることになります。

.catch() に書かれているのは、asyncSetTimeout.reject() です。

なぜこの処理をいれなければならないのか?

asyncSetTimeout.execute() は、setTimeout で実行時間を遅らせられるものでしたが、.execute() が「完了」となるタイミングは setTimeout が実行されるタイミングではなく、インスタンス生成時に登録した非同期処理も含めて完了するときでした。

なので、executeRejector が実行されたタイミングでは、現在実行中の処理を登録した asyncSetTimeout.execute() がなお実行中ということになります。

なので、asyncSetTimeout.reject() を叩いて、明示的に「中断」させます。

これにて目立たく、前の非同期処理を止めることができたわけです。

しかし、それでもまだ問題があります!

それは、非同期処理を止めるトリガーとなったときの処理が宙に浮いたことになってしまうという点です。

ちょっと分かりづらいと思うので、先ほどの図を出します。
正しい非同期 throttle の説明図
「宙に浮いた処理」というのは、こちらの図の「処理4」となります。

上記の図を見れば、いや別に中に浮いたっていいじゃんね?と思われると思います。
それはその図がちょっとイケてないからです。笑

なので、もう一つ図を出します。
待機中に処理が来なかったときの図

上記のように「処理4」のタイミングで executeRejector を叩いて「無効化」したわけですが、待機中に追加の処理が来なければ、そのまま「処理4」が実行されなければなりません。

なので、reject が終わったタイミングで、もう一度その処理を実現させなければいけないのです

それを実現させているのが、asyncSetTimeout.execute() にメソッドチェーンでつないでいる .catch() の処理です。

.catch() のところの処理をもう一度載せておきます。

        .catch(async throttleException => {
          asyncSetTimeout = null;
          return await handler(...throttleException.rest);
        });

処理を見てみると、hander がもう一度実行されているのがわかります。
reject されると .catch() が動き、そして最後にこの箇所の記述によって、宙に浮いた処理をもう一度実行することができるわけです。

また、もう一点注目したい記述があります。

それは、handler(...throttleException.rest) のところです。

なぜ、handler(...rest) ではいけないのか?という疑問が生まれるかと思います。

それは、handler の処理自体は、reject が完了したときにはとっくに終わっているので、reject したときの ...rest.catch() 内で実行されるときの ...rest は別の値になっているのです。

なので、reject したときの引数値は .catch() のときまで引き継ぎ回さないといけないわけです。

それを実現するために、実は、executeRejector の実行文は以下のように書かれています。

executeRejector(new ThrottleException(...rest));

引数に exception のインスタンスを渡しています。
今回はこの exception は作り込んでいないので、ただインスタンスの rest にそのまま値を入れているだけです。
※ throttle 全体のプログラムに、ThrottleException の記述もあるのでそちらをご覧になってください。

この exception は asyncExecuter.catch() でも受け取っており、それをそのまま asyncSetTimeout.reject() に引数で渡し、 asyncSetTimeout.execute().catch() で受け取っているという流れとなります。

そして、最後に handler に渡され、また非同期処理が始まるという仕組みになっています。

これにて、非同期を間引く throttle の完成となります!

さいごに

いかがだったでしょうか?

自分にとってはかなり複雑な処理だったので、あえて人に説明して自分でもちゃんと理解しようと思い記事にしました。

この throttle をちゃんと使ったプロダクトを用意しているので、こちらもご覧になってください。

・Code
https://github.com/hiraryo0213/github-repository-search/

・Product
https://hiraryo0213.github.io/github-repository-search/

GitHub の Repositories を検索するだけのものです。笑
冒頭にあったように、文字を入力するだけで検索が実行される仕組みになっています。

もっとうまく書けるよ!とかあれば教えて下さい〜

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0