古い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している)。この時、引数confirmed
にtrue
が渡されるので、それを見て「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
の戻り値を明確にする為に、一旦okUpdate
やokAgeEmpty
といった変数名に格納しているが、戻り値が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
メソッドを自分で新しく追加したい。
そんな事は果たして可能だろうか?
その為のコードは実は非常にシンプルだ。
ConfirmDialog.showAsync = function(message) {
// Promiseを返す
return new Promise((resolve) => {
// 確認ダイアログを表示する
this.show(message, function(confirmed) {
// ダイアログの結果をresolveに渡す
resolve(confirmed);
});
});
};
showAsync
が実行されると、まず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
に代入されることになる。
分かってしまえば単純な話だが、このあたりが頭に入るまではPromise
やasync
/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>