良質な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の引数を追加・削除するだけで対応可能になり、高いメンテナンス性と柔軟性を得ることができます。