5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-08-22

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

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

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

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

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

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

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

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

$(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を返す同等の確認ダイアログ表示メソッドが存在した場合、次のように書くことができる。

$(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の戻り値を明確にする為に、一旦okUpdateokAgeEmptyといった変数名に格納しているが、戻り値がtrue/falseであるならば、次のように直接書いた方が可読性は向上するかもしれない。

$(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メソッドを自分で新しく追加したい。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

コードサンプル

<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>
5
1
6

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
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?