古いWebシステムにありがちなjQuery UI Dialogによるcallbackのネストをawaitで解決する

# 古いWebシステムにありがちなcallback関数

比較的古いWebシステムをメンテナンスしていると、jQuery UI Dialogを用いた確認ダイアログによる処理を見かけることがある。

それらは多くの場合、次のようにcallback関数を用いたダイアログとのやり取りを前提にしている。
`ConfirmDialog.show`は、このシステムが用意した確認ダイアログ表示用のメソッドである。

```javascript
$(document).ready(function() {
    $('#btnUpdate').on('click', function() {
    	// 更新の確認
		ConfirmDialog.show('入力内容で更新してよろしいですか？', function(confirmed){
			if (confirmed) {
				// フォームをsubmitする
				form.submit();
			}
		});
    });
});
```

確認ダイアログの「OK」が押されると、`ConfirmDialog.show`の第二引数に渡したcallback関数が実行される（ここではフォームをsubmitしている）。この時、引数`confirmed`に`true`が渡されるので、それを見て「OKが押された」と判断できる。ダイアログの「キャンセル」が押されたり、ダイアログが「x」ボタンで閉じられたりESCキーが押下されたりした場合には、`false`が渡される。

# callbackのネストが深くなるcallback地獄

上記のようなシンプルな処理であれば良いが、確認が複数回連続で行われる場合、callback関数のネストが深くなり、コードの視認性やメンテナンス性が大きく下がってしまう。

例えば次のように、更新実行の確認後、入力値が特定の条件を満たす時に「年齢が未入力ですがよろしいですか？」とさらに確認したい場合等がある。もしこれが必須入力なのであれば単純にエラーメッセージを赤字で画面内に表示して更新処理を中断すればよいが、これは警告メッセージである為、OKを押された場合は更新処理を続行したい。

```javascript
$(document).ready(function() {
    $('#btnUpdate').on('click', function() {
    	// 更新の確認
		ConfirmDialog.show('入力内容で更新してよろしいですか？', function(confirmed){
			if (confirmed) {
				if (isAgeEmpty())
				{
					// 未入力の確認（年齢）
					ConfirmDialog.show('年齢が未入力ですがよろしいですか？', function(confirmed){
						if (confirmed) {
							// フォームをsubmitする
							form.submit();
						}
					});
				}
                else
                {
                    // フォームをsubmitする
                    form.submit();
                }
			}
		});
    });
});
```

これだけでも既に、だいぶネスト・・・もとい、闇が深い。サンプルは敢えて書かないが、もしこれがさらに「住所が未入力ですがよろしいですか？」などのように続けていきたい場合、闇はどんどん深くなっていく。

# もしawaitが使えたら…

しかし、もしここで`ConfirmDialog.show`メソッドが`Promise`を返すようになっていれば、`await`を使って可読性を大きく向上させることができる。

仮に`ConfirmDialog.showAsync`という、Promiseを返す同等の確認ダイアログ表示メソッドが存在した場合、次のように書くことができる。

```javascript
$(document).ready(function() {
    $('#btnUpdate').on('click', async function() {
    	// 更新の確認
    	const okUpdate = await ConfirmDialog.showAsync('入力内容で更新してよろしいですか？');
		if (!okUpdate)
		{
			return;
		}

		// 未入力の確認（年齢）
		if (isAgeEmpty())
		{
			const okAgeEmpty = await ConfirmDialog.showAsync('年齢が未入力ですがよろしいですか？');
			if (!okAgeEmpty)
			{
				return;
			}
		}
		
		// フォームをsubmitする
		form.submit();
    });
});
```

それぞれのダイアログ処理が並列して、独立して記述されている為、処理の流れが非常にすっきりする。これなら処理の追加や削除の際にもコード修正を間違えたりもしないだろう。

`await`の戻り値を明確にする為に、一旦`okUpdate`や`okAgeEmpty`といった変数名に格納しているが、戻り値が`true`/`false`であるならば、次のように直接書いた方が可読性は向上するかもしれない。

```javascript
$(document).ready(function() {
    $('#btnUpdate').on('click', async function() {
    	// 更新の確認
		if (!(await ConfirmDialog.showAsync('入力内容で更新してよろしいですか？')))
		{
			return;
		}
		
		// 未入力の確認（年齢）
		if (isAgeEmpty() && !(await ConfirmDialog.show('年齢が未入力ですがよろしいですか？')))
		{
			return;
		}
		
		// フォームをsubmitする
		form.submit();
    });
});
```

# ConfirmDialogをAsync対応させる手順

既存の`ConfirmDialog.show`メソッドはそのままの形で残しつつ、先ほどのような`ConfirmDialog.showAsync`メソッドを自分で新しく追加したい。

そんな事は果たして可能だろうか？

その為のコードは実は非常にシンプルだ。

```javascript:showAsyncを追加する
ConfirmDialog.showAsync = function(message) {
	// Promiseを返す
    return new Promise((resolve) => {
    	// 確認ダイアログを表示する
        this.show(message, function(confirmed) {
        	// ダイアログの結果をresolveに渡す
            resolve(confirmed);
        });
    });
};
```

`showAsync`が実行されると、まず`Promise`オブジェクトが生成され、`Promise`のコンストラクタに渡された無名関数（確認ダイアログの表示）が実行される。

```javascript:Promiseコンストラクタに渡された無名関数の中身
    	// 確認ダイアログを表示する
        this.show(message, function(confirmed) {
        	// ダイアログの結果をresolveに渡す
            resolve(confirmed);
        });
```

そして、生成された`Promise`オブジェクトが呼び出し元に返され、`showAsync`メソッドは終了する。

```javascript:呼び出し元
	const okUpdate = await ConfirmDialog.showAsync('更新してもよろしいですか？');
```

しかし呼び出し元では上記のように`await`付きで呼び出される為、この処理は一旦ここで中断されており、`okUpdate`への結果の代入処理はまだ行われていない。

`await`による中断が再開されるのは、`Promise`の生成時に渡した無名関数の引数`resolve`（これは関数オブジェクトである）が呼び出された時だ。

`resolve`は、確認ダイアログのボタンが押されたり、キャンセルされたりでダイアログが閉じた時のcallback関数内で呼ばれている。

```javascript
    return new Promise((resolve) => {
    	// 確認ダイアログを表示する
        this.show(message, function(confirmed) {
        	// ダイアログの結果をresolveに渡す
            resolve(confirmed);
        });
    });
```

`resolve`関数には、引数としてダイアログの戻り値を渡す。これが、`await`の結果として`okUpdate`に代入されることになる。

分かってしまえば単純な話だが、このあたりが頭に入るまでは`Promise`や`async`/`await`周辺はやたら難解に見えてしまい、なかなかAsync版メソッドの導入に踏み切れないこともあるかもしれない。

この記事が古いコードのリファクタリングの際の何らかの手助けになれば幸いである。

最後に、全体のコードサンプルを掲載する。

# コードサンプル

```html
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8">
<title>Await Confirm Dialog</title>
<meta name="viewport" content="width=device-width, initial-scale=1">

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>

</head>
<body>

<div id="confirm-dialog" title="確認" style="display: none;">
  <p id="dialog-message">本当によろしいですか？</p>
</div>

<button id="trigger">ダイアログを表示</button>
<button id="triggerAsync">ダイアログを表示(async)</button>

<script>
const ConfirmDialog = (function() {
    return {
        show(message, cb) {
            var wasTriggeredByButton = false; // ボタンによってトリガーされたかどうかを示すフラグ

            $("#confirm-dialog").dialog({
                autoOpen: true,
                modal: true,
                buttons: {
                    "OK": function() {
                        wasTriggeredByButton = true;
                        $(this).dialog("close");
                        cb(true);
                    },
                    "キャンセル": function() {
                        wasTriggeredByButton = true;
                        $(this).dialog("close");
                        cb(false);
                    }
                },
                closeOnEscape: true,
                beforeClose: function(event, ui) {
                    // ボタンクリックによるクローズでなければ（例：ESCキーやクローズアイコンによるものであれば）
                    if (!wasTriggeredByButton) {
                        cb(false);
                    }
                    wasTriggeredByButton = false; // フラグをリセット
                }
            });

            $("#dialog-message").text(message);
        },
        
        // 新たに追加した非同期版メソッド
        showAsync(message) {
            return new Promise((resolve) => {
                this.show(message, function(confirmed) {
                    resolve(confirmed);
                });
            });
        }
    };
})();

// サンプルのトリガーボタン
$("#trigger").click(function() {
    ConfirmDialog.show("本当によろしいですか？", function(isConfirmed) {
        if (isConfirmed) {
            ConfirmDialog.show("本当に本当によろしいですか？", function(isConfirmed) {
                if (isConfirmed) {
                    ConfirmDialog.show("本当に本当に本当によろしいですか？", function(isConfirmed) {
                        if (isConfirmed) {
                            console.log("OKを選択しました。");
                        } else {
                            console.log("キャンセルされました。");
                        }
                    });
                } else {
                    console.log("キャンセルされました。");
                }
            });
        } else {
            console.log("キャンセルされました。");
        }
    });
});


// サンプルのトリガーボタン（非同期版）
$("#triggerAsync").click(async function() {
    if (!await ConfirmDialog.showAsync("本当によろしいですか？")) {
        console.log("キャンセルされました。");
        return;
    }

    if (!await ConfirmDialog.showAsync("本当に本当によろしいですか？")) {
        console.log("キャンセルされました。");
        return;
    }

    if (!await ConfirmDialog.showAsync("本当に本当に本当によろしいですか？")) {
        console.log("キャンセルされました。");
        return;
    }

    console.log("OKを選択しました。");
});

</script>
</body>
</html>
```