JS 物件導向 - 自行開發客製化 Dialog ui component 汰換 JQuery Dialog

Posted by Young on 2023-06-21
Estimated Reading Time 17 Minutes
Words 3.9k In Total
Viewed Times

舊寫法

舊的 JQuery Dialog。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function changePasswordDialog() {
var htmlbody =
"<input type.. id='input'>";
htmlbody +=
"......";
$("#memberdialog").html(htmlbody);
dialog = $("#memberdialog").dialog({
title: "XXX",
autoOpen: true,
height: 400,
width: 520,
modal: true,
closeOnEscape: true,
buttons: {
修改: changePassword,
取消: function () {
$("#memberdialog").html("");
dialog.dialog("close");
},
},
open: function (event, ui) {
$("#").val("");
...........
},
close: function () {
$("#memberdialog").html("");
},
});
}

按「修改」後,會執行以下這段 changePassword() 函數。

1
2
3
4
5
6
7
8
9
10
function changePassword() {
.............
$.ajax({
...........
success: function (rt) {
................
$("#memberdialog").dialog("close");
},
});
}

新寫法

  1. 用 Vanilla JS 取代 JQuery 的 overlay 以及 scrollable 元件
  2. 用 ES6 模版字串符重構原本用 + 串的 JS 程式碼
  3. 用 ES6 解構賦值來取代原本的 rt[0].mid 這種寫法
  4. 用 TailwindCSS 取代原本的 inline-style 以及原始凌亂的 CSS class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function changePasswordDialog() {
var htmlContent = `
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-xl leading-6 font-medium text-gray-900" id="modal-headline">修改會員資料</h3>
<div class="mt-2">
<div class="mt-2">請輸入舊密碼:<input type="text" id="oldPassword" name="oldPassword" class="border-2 border-slate-500 rounded-md"></div>
<div class="mt-2">請輸入新密碼:<input type="text" id="newPassword" name="newPassword" class="border-2 border-slate-500 rounded-md"></div>
<div class="mt-2">請再輸入一次新密碼:<input type="text" id="newPassword2" name="newPassword2" class="border-2 border-slate-500 rounded-md"></div>
</div>
</div>
</div>
</div>`;
CustomDialog.open({
htmlContent: htmlContent,
buttonsHTML: `
<button type="button" class="confirm-button inline-flex justify-center w-full rounded-md border border-transparent px-4 py-2 mx-2 bg-indigo-600 font-bold text-white shadow-sm hover:bg-indigo-500 focus:outline-none transition ease-in-out duration-150 sm:text-sm sm:leading-5">
查詢
</button>
<button type="button" class="cancel-button inline-flex justify-center w-full rounded-md border border-gray-300 px-4 py-2 mx-2 bg-white font-bold text-gray-700 shadow-sm hover:text-gray-500 focus:outline-none transition ease-in-out duration-150 sm:text-sm sm:leading-5">
取消
</button>
`,
onConfirm: function () {
changePassword();
},
onCancel: function () {
// 这里可以放置在点击取消按钮时要执行的代码
},
});
}

我也順便將整段程式碼重構

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function changePassword() {
var oldPassword = $("#oldPassword").val();
var newPassword = $("#newPassword").val();
var newPassword2 = $("#newPassword2").val();
if (newPassword != newPassword2) {
alert("新的密碼,兩次輸入不相同");
$("#oldPassword").focus();
return;
}
if (newPassword.length < 4) {
alert("請輸入4個字元以上的新密碼!");
$("#oldPassword").focus();
return;
}
$.ajax({
type: "post",
url: "/member/func",
dataType: "json",
data: {
op: "changePassword",
oldPassword: oldPassword,
newPassword: newPassword,
},
complete: function () {},
success: function (rt) {
if (rt["level"] == 1) {
appendMessage(rt["msg"], true);
// alert(rt["msg"]);
} else {
appendMessage(rt["msg"], false);
// alert(rt["msg"]);
}
},
});
}

將 Dialog Component 封裝成 IIFE

關於 IIFE 的基礎介紹可以參考此文章:JavaScript IIFE 筆記:使用立即呼叫函示改善程式碼結構

完整程式碼如下,但這不會是最終的版本,因為到時會依照後續遇到的各種不同情況來對這個 Dialog Component 程式碼進行微調及擴充。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// dialog.js
const CustomDialog = (function () {
var dialogElement;
// 私有方法,用于创建对话框元素
function createDialogElement(htmlContent, buttonsHTML) {
var dialogHTML = `
<div class="fixed z-10 inset-0 overflow-y-auto flex items-center justify-center">
<div class="inline-block align-middle bg-white rounded-lg text-left overflow-hidden shadow-2xl transform transition-all" role="dialog" aria-modal="true" aria-labelledby="modal-headline">
${htmlContent}
<div class="bg-gray-50 px-4 py-3 flex flex-row-reverse">
${buttonsHTML}
</div>
</div>
</div>
`;
var div = document.createElement("div");
div.innerHTML = dialogHTML;
return div;
}

function openDialog(config) {
// const dialog = document.getElementById("custom-dialog");
dialogElement = createDialogElement(config.htmlContent, config.buttonsHTML);

document.body.appendChild(dialogElement);

const confirmButton = dialogElement.querySelector(".confirm-button");
const cancelButton = dialogElement.querySelector(".cancel-button");

confirmButton.addEventListener("click", function () {
config.onConfirm && config.onConfirm();
closeDialog();
});

cancelButton.addEventListener("click", function () {
config.onCancel && config.onCancel();
closeDialog();
});

document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
closeDialog();
}
});
}
// 私有方法,用于关闭对话框
function closeDialog() {
if (dialogElement) {
dialogElement.remove();
dialogElement = null;
}
}

// 公開 open 方法,讓外部可以調用打開對話框
return {
open: openDialog,
// close: closeDialog // 這個方法不需要公開
};
})();

為何要封裝成 IIFE?

一開始這段程式初期是直接寫在 Login screen<script> 底下,但經過審思後發現此專案在非常多的頁面以及功能區都會用到 Dialog,因此若不將 Dialog 的程式碼模組化的話,會造成程式碼出現大量重複區塊,導致不易維護。

程式碼說明

先大致講解一下程式碼的主要結構和功能:

  • var dialogElement;:用於存儲當前打開的對話框元素,這個變數在 openDialog 函數中被賦值,並在 closeDialog 函數中被使用和重置。
    在 closeDialog 函數中,dialogElement 用於移除當前的對話框元素並將其設置為 null:

    1
    2
    3
    4
    if (dialogElement) {
    dialogElement.remove();
    dialogElement = null;
    }

    這樣做的目的是當對話框被關閉時,將其從 DOM 中移除並釋放相關資源。將 dialogElement 設置為 null 是一種清理動作,以避免不必要的記憶體佔用。

  • function createDialogElement(htmlContent, buttonsHTML):私有函數,用於創建對話框的 HTML 元素。它接收兩個參數:htmlContentbuttonsHTML,分別表示對話框的內容和按鈕的 HTML。

  • function openDialog(config):私有函數,用於打開對話框,此函數會創建對話框的 HTML 元素,並綁定按鈕和鍵盤事件。。它接收一個配置對象 config,該對象包含以下屬性:

    • htmlContent: 對話框的 HTML 內容。
    • buttonsHTML: 對話框的按鈕 HTML。
    • onConfirm: 按下確認按鈕時的回調函數。
    • onValidate: 按下驗證按鈕時的回調函數。
    • onCancel: 按下取消按鈕時的回調函數。
  • function closeDialog():私有函數,用於關閉和移除對話框。

最後,IIFE 返回一個物件,該物件包含一個公開的 open 方法。這意味著,你可以使用 CustomDialog.open(config) 來打開一個自定義的對話框。closeDialog 函數在這個例子中是私有的,外部無法直接調用,只能通過在 openDialog 函數中綁定的事件來間接調用。

1
2
3
4
5
// 公開 open 方法,讓外部可以調用打開對話框
return {
open: openDialog,
// close: closeDialog // 這個方法不需要公開
};

這種寫法典型的使用場景是創建一個模組,並將一些功能封裝起來,只暴露出需要被外部調用的接口,

Dialog 功能擴充

增加掌控何時該關閉 Dialog 的功能

在一開始的 Dialog 程式能看到在我們自己寫的這個 CustomDialog Component 中就很單純的判斷當 User 不管是按下確認、驗證按鈕後都會直接調用 closeDialog() 函數來關閉 Dialog。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// dialog.js
....
confirmButton.addEventListener("click", function () {
config.onConfirm && config.onConfirm();
closeDialog();
});
....
// 私有方法,用于关闭对话框
function closeDialog() {
if (dialogElement) {
dialogElement.remove();
dialogElement = null;
}
}

現在問題來了,當 User 填寫 Dialog 資料完畢,程式將資料送去後端經過檢查機制、判斷式、驗證完後,回傳的 response 未必每次都只有「成功」,也需考慮到可能 User 填寫的手機號碼、手機條碼…等等資料格式不正確,這時除了回傳錯誤訊息外,還有最重要、攸關使用者介面體驗的一點就是:若送出的資料格式有誤或是有其他狀況,則 Dialog 應保持開啟狀態讓使用者能不必在去按一下觸發 openDialog 的程式 。

那接下來直接看看怎麼修改程式碼:

為了讓我們在調用 Dialog 這個 IIFE 時,能夠掌控何時該關閉 Dialog,可以通過在 CustomDialog 的內部向 onConfirm 回調函數傳遞 closeDialog 函數作為參數來實現

1
2
3
4
5
6
// dialog.js
......
confirmButton.addEventListener("click", function () {
config.onConfirm && config.onConfirm(closeDialog);
});
.....

接下來就能在外部的 onConfirm 回調函數中根據邏輯判斷式來決定是否調用 closeDialog 函數

1
2
3
4
5
6
7
8
CustomDialog.open({
htmlContent: htmlContent,
buttonsHTML: buttonsHTML,
onConfirm: function (closeDialog) {
updateMemberData(closeDialog);
},
onCancel: function () {},
});

接下來就能在這邊根據後端回傳的 response 來決定是否調用 closeDialog 函數來關閉 Dialog。

1
2
3
4
5
6
7
8
9
10
11
12
13
function updateMemberData(closeDialog) {
// ... existing code ...
success: function (rt) {
if (rt["level"] == 1) {
appendMessage(rt["msg"], true);
closeDialog(); // Close the dialog if the response is successful
} else {
appendMessage(rt["msg"], false);
// Do not close the dialog if the response is unsuccessful
}
},
// ... existing code ...
}

擴充不同的按鈕功能

由於當初自己客製化這個 Dialog 沒有料想到為了應映不同的功能,在不同頁面的 Dialog 都各會有各自的按鈕功能&數量。

例如:發送手機簡訊驗證碼的 Dialog 有「發送驗證碼」、「取消」、「驗證」三個按鈕,而重置密碼的 Dialog 則只有「重置」、「取消」兩個按鈕。

因此為了避免發生 addEventListener reading null 的錯誤,我們必須在 CustomDialog.open 中加上判斷式,來判斷當前頁面的 Dialog 是否有此按鈕,若有則加上監聽事件,若無則不加 (代表此 Dialog 沒有用到這個功能)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
................
const confirmButton = dialogElement.querySelector(".confirm-button");
const cancelButton = dialogElement.querySelector(".cancel-button");
const validateButton = dialogElement.querySelector(".validate-button");
// 加上 && 是為了避免有些沒有用到此元素時,會出現 addEventListener read null 錯誤
// 查詢、確認、發送驗證碼
confirmButton && confirmButton.addEventListener("click", function () {
config.onConfirm && config.onConfirm(closeDialog);
// closeDialog();
});
// 驗證 memberdata.js 的 mobileauth 有用到
validateButton && validateButton.addEventListener("click", function () {
config.onValidate && config.onValidate(closeDialog);
});
// 取消
cancelButton && cancelButton.addEventListener("click", function () {
config.onCancel && config.onCancel();
closeDialog();
});
.............

能看到多增加了 validateButton 這個變數及與其關聯的事件處理器(addEventListener)。

但並不是所有 Dialog 都會用到此功能,因此 validateButton 常常會是 null 的狀態,所以才要在每個 xxxButton.addEventListener 加上 && 就可以避免在任何情況出現 addEventListener reading null 錯誤。也能不斷持續擴充 Dialog 的按鈕功能。

BUG 修正

由於剛剛將 JQuery 的 Dialog 改成自己客製化的 Dialog 組件了,

現在其中一個汰換程式的「忘記密碼的 Dialog」 內容已經從原本的 HTML 改成 JS 動態產生,不在是像此舊寫法 forgetpwform 一樣透過 $("#memberdialog").html(htmlbody); 來產生 Dialog 的內容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 舊的表單寫法
function forgetpwform() {
var htmlbody = '請輸入您註冊時的個人資料(至少輸入二項資料,聯絡電話必填)<br><br>';
htmlbody += '<table border=0 width=100% id=forgetpwtable>';
.............
htmlbody += '<tr><td>電 話 必 填:</td><td><input type=text name=usermobile></td><td>E-mail:</td><td><input type=text name=useremail></td></tr>';
..............
$("#memberdialog").html(htmlbody);
dialog = $("#memberdialog").dialog({
title: "查詢會員帳號",
autoOpen: true,
height: 360,
width: 600,
modal: true,
closeOnEscape: true,
buttons: {
"查詢": resetPassword,
"取消": function() {
$("#memberdialog").html("");
dialog.dialog("close");
},
"清空": function() {
$('input[name=username]').val('');
..............
}
},
open: function(event, ui) {},
close: function() {
$("#memberdialog").html("");
}
});
};

而是改為用 htmlContent 來產生 Dialog 的內容,並且將原本的 forgetpwform() 改成 openresetPassworddDialog() 來呼叫自己寫的客製化 Dialog Component並傳入createDialogHTML

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// 寫的使用客製化 Dialog Component 的寫法
function createDialogHTML() {
...............
var htmlContent = `
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
..................
<p>請輸入您註冊時的個人資料(至少輸入二項資料,聯絡電話必填)</p>
<div class="mt-2">E-mail:<input type="text" name="useremail" class="border-2 border-slate-500 rounded-md"></div>
............
</div>`;
return htmlContent;
}

function openresetPassworddDialog() {
CustomDialog.open({
htmlContent: createDialogHTML(),
buttonsHTML: `
.........
`,
onConfirm: function() {
resetPassword();
},
onCancel: function() {
}
});
}

function resetPassword(resettag) {
if (resettag == 1) var resetnow = 1;
else resetnow = 0;
$.ajax({
type: 'POST',
url: '/member/func',
dataType: 'JSON',
data: {
.......
username: $('input[name=username]').val(),
PID: $('input[name=userpid]').val(),
usermobile: $('input[name=usermobile]').val(),
..........
resetnow: resetnow
},
complete: function() {},
success: function(rt) {
// console.log("???"+rt);
var count = rt.count;
var check = rt.check;
var mem_LogID = rt.mem_LogID;
//alert(rt.sql);
if (mem_LogID) {
if (confirm("您的登入帳號為" + mem_LogID + "\n是否要立即重置密碼?")) {
resetPassword(1);
}
} else {
if (count == 0 || count == 1) alert("資料不正確或查無資料...\n可能是您剛剛資料填寫錯誤,請詳細填寫資料後,再試一次");
........
else if (count == -2) { // 密碼重置成功
alert(check);
document.getElementById('memberdialog').style.display = 'none'; // 成功後自動關閉 dialog
}
}
}
});
};

進而導致了表單的所有 data.val() 在 confirm 這邊再次重新調用 resetPassword() 時,由於剛剛動態產生的 Dialog 已經被關閉,所以此時若在 success 中再次 console.log 剛剛 createDialogHTML 表單提交的值的話會發現是 undefined

1
2
3
4
if (confirm("您的登入帳號為" + mem_LogID + "\n是否要立即重置密碼?")) {
resetPassword(1);
console.log("Success後的DATA:" + usermobile); // 由於 alert 會中斷 undefined
}

簡而言之,此問題的發生的原因 = DOM的變化resetPassword 函數遞迴地調用自己。這本身並不總是一個問題,但在這種情況下,它與 DOM 的變化相結合,導致無法在第二次調用時獲取輸入值。

解決方案

為了防止 DOM 發生變化導致動態產生的表單被關閉而讓 resetPassword() 無法取得表單的值,我們可以將 resetPassword()AJAX 請求邏輯移動到另一個內部函數 executeSearch 中。

將 AJAX 請求邏輯移到新的內部函數

如下方程式碼所示,將 resetPassword()AJAX 請求邏輯移動到另一個內部函數 executeSearch() 中,第一次調用時將表單的 input 值儲存在變數中。

再第一次調用 executeSearch 函數時獲取表單的值,這樣即使 DOM 在第二次調用時發生變化,我們仍然可以擁有用戶的 input 值。而不會變成 undefined

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function resetPassword() {
// 首次調用時獲取表單的值
var username = $('input[name="username"]').val();
var usermobile = $('input[name="usermobile"]').val();
...............
console.log("Success前的data:"+usermobile);
// 內部函數用於執行 AJAX 請求
function executeSearch(resetnow) {
$.ajax({
type: 'POST',
url: '/member/func',
dataType: 'JSON',
data: {
// 使用從外部函數獲取的值
query_op: 'user_info_check',
username: username,
PID: $('input[name=userpid]').val(),
usermobile: usermobile,
........
resetnow: resetnow
},
complete: function() {},
success: function(rt) {
console.log("rt" + rt);
.........
if (mem_LogID) {
if (confirm("您的登入帳號為" + mem_LogID + "\n是否要立即重置密碼?")) {
executeSearch(1);
console.log("Success後的DATA:"+usermobile);
}
} else {
....................
}
}
});
}
executeSearch(0);
};

使用閉包來存儲值

通過在一個外部函數resetPassword 中使用一個內部函數 executeSearch ,我們可以利用 JavaScript 的閉包來存儲值。這樣即使在外部函數執行完畢後,內部函數仍然可以訪問這些值。透過這樣利用內部函數將輸入值作為參數傳遞,就能避免遞迴調用 resetPassword 導致的 DOM 變化而無法取得表單的值。

可以看到此時的 console.logAJAX傳送給後端程式碼執行回傳後,也已經可以正常取得表單的值了。

data-status


若您覺得這篇文章對您有幫助,歡迎分享出去讓更多人看到⊂◉‿◉つ~


留言版