JavaScriptの関数カプセル化テクニック:非同期状態遷移の設計改善

良質なJavaScriptコードを記述する上で、関数のカプセル化は非常に重要な概念です。本記事では、非同期に状態が切り替わる信号機の実装例を通じて、関数の結合度を下げ、再利用性を高めるためのリファクタリング手法を解説します。

課題:非同期の状態遷移(信号機)

特定のDOM要素に対して、一定時間ごとにクラス名を変更し、状態を循環させるという要件を考えます。

初期実装:結合度の高いアプローチ

まず、基本的な実装として、ネストされたコールバックを用いたアプローチが考えられます。

const lamp = document.getElementById('signal');

(function init() {
  lamp.className = 'go';
  setTimeout(() => {
    lamp.className = 'caution';
    setTimeout(() => {
      lamp.className = 'halt';
      setTimeout(init, 2000);
    }, 1500);
  }, 5000);
})();

このコードは要件を満たしていますが、設計上の重大な欠陥があります。

  • 外部環境への強い依存: init関数が外部のlamp変数に直接アクセスしています。HTML側のIDが変更されると機能しなくなり、他の箇所で再利用することも困難です。
  • コールバック地獄: setTimeoutがネストしており、状態の追加や削除が非常に手間になります。

これらの問題の根本原因は、関数が適切にカプセル化されていないことです。

データの抽象化:設定とロジックの分離

まず、外部変数の依存を解消するために、DOM要素を関数の引数として渡します。さらに、状態を構成するデータ(クラス名と待機時間)を関数外に切り出し、構造化します。

const signalEl = document.getElementById('signal');

const phases = [
  { mode: 'go', duration: 5000 },
  { mode: 'caution', duration: 1500 },
  { mode: 'halt', duration: 2000 },
];

function startSignal(el, config) {
  function transition(idx) {
    const { mode, duration } = config[idx];
    el.className = mode;
    setTimeout(() => {
      transition((idx + 1) % config.length);
    }, duration);
  }
  transition(0);
}

startSignal(signalEl, phases);

この改善により、DOM要素と状態データが関数の引数として渡され、データ定義を変更しても関数本体を修正する必要がなくなりました。

振る舞いの抽象化:プロセスの分離

次に、処理の手順そのものを抽象化します。ここでは「クラスの変更」と「待機」の2つの操作に着目します。

まず、DOMのクラス変更を行うapplyMode関数を定義します。

function applyMode(el, mode) {
  el.className = mode;
}

次に、setTimeoutをPromiseでラップしたdelay関数を作成します。これにより、非同期処理を同期的な記述スタイルで扱えるようになります。

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

これらをasync/await構文と組み合わせることで、ネストのない直感的なコードが書けます。

const signalEl = document.getElementById('signal');

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function applyMode(el, mode) {
  el.className = mode;
}

async function runSignal(el) {
  while (true) {
    applyMode(el, 'go');
    await delay(5000);
    applyMode(el, 'caution');
    await delay(1500);
    applyMode(el, 'halt');
    await delay(2000);
  }
}

runSignal(signalEl);

振る舞いの高度な抽象化:巡回処理のカプセル化

さらに進んで、状態を順番に切り替える「巡回(ポーリング)」という操作自体を関数としてカプセル化します。

まず、状態の適用と待機を1つの非同期関数executePhaseにまとめます。

async function executePhase(el, mode, ms) {
  el.className = mode;
  await delay(ms);
}

次に、任意の関数リストを順次実行し、最後まで行くと先頭に戻るcreateSequencer関数を定義します。

function createSequencer(...tasks) {
  let cursor = 0;
  return async function execute(...args) {
    const task = tasks[cursor++ % tasks.length];
    return task(...args);
  };
}

これらを利用すると、状態の定義を配列として宣言的に渡すだけで、柔軟かつ拡張性の高い実装が完成します。

const signalEl = document.getElementById('signal');

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function createSequencer(...tasks) {
  let cursor = 0;
  return async function execute(...args) {
    const task = tasks[cursor++ % tasks.length];
    return task(...args);
  };
}

async function executePhase(el, mode, ms) {
  el.className = mode;
  await delay(ms);
}

const signalSequencer = createSequencer(
  () => executePhase(signalEl, 'go', 5000),
  () => executePhase(signalEl, 'caution', 1500),
  () => executePhase(signalEl, 'halt', 2000)
);

(async () => {
  while (true) {
    await signalSequencer();
  }
})();

このように巡回ロジックを抽象化することで、状態の数を増減させる際もcreateSequencerの引数を追加・削除するだけで対応可能になり、高いメンテナンス性と柔軟性を得ることができます。

タグ: javascript 設計パターン 非同期処理 カプセル化 リファクタリング

6月8日 20:33 投稿