今天這篇屬於前端開發的單兵基本教練,練習為耗時的 AJAX 呼叫製作載入中狀態顯示,如以下效果:

針對較耗時的 AJAX 呼叫,在等待結果回傳期間顯示轉圈圈之類的載入動畫,安撫使用者情緒提供明確的執行狀態回饋,同時封鎖網頁介面並防止使用者在等待期間繼續操作造成動線混亂。這種效果相信大家一定都看過,類似套件程式庫很多(例如:blockUIKendo UI progressBootstrap Spinner,但這次打算用最基本的 HTML、CSS、JavaScript 自己動手做一個,目標是搞懂原理,從只會用套件,提升到有修改甚至開發套件的能力。

剖析這種載入中效果,大致分為兩部分:遮罩 + 載入中動畫或文字。遮罩是一層蓋在現有網頁正上方的元素,利用 CSS width: 100%; height: 100%; 讓面積與整張網頁相同,再透過 position: absolute; z-index: 9999; 蓋在網頁上方,最後 background-color: #444; opacity: 0.5; 做出半透明灰色效果;至於載入中動畫,可以用 GIF、SVG 甚至用純 CSS 動畫製作,網路上有不少免費素材可資利用。整個遮罩跟動畫的 HTML 跟 CSS 類似這樣:

<!-- CSS -->
<style>
	html,body { height: 100%; margin: 0; }
	.loading { 
		position: absolute; z-index: 9999;
		top: 0; left: 0;
		width: 100%; height: 100%;
		display: none;
	}
	.loading .mask {
		position:  absolute;
		width: 100%; height: 100%;
		background-color: #444; opacity: 0.5;
	}
	.loading .animation {
		width: 64px; height: 64px;
		margin: auto; margin-top: 40px;
		background: url('https://i.imgur.com/6pCtQAW.gif');
	}
</style>

<!-- HTML -->
<div class="loading">
	<div class="mask"></div>
	<div class="animation">
	</div>
</div>

網頁載入時 .loading DIV 預設 display: none 不顯示,呼叫 AJAX 時將 display 改為 block,載入中 DIV 便會蓋在現有網頁上並顯示轉圈 GIF,阻擋使用者繼續操作網頁(注意:此時仍可用鍵盤控制,但正常使用者不致誤用,若要防範惡意破解,則需加入其他安全鎖),待收到結果後再改回 none 恢復正常。

接著來寫一個後端範例配合測試,用一支 getGuid.aspx 模擬耗時數秒的 Web API,並加入模擬執行出錯功能:

<%@ Page Language="C#" %>
<script runat="server">
    void Page_Load(object sender, EventArgs e)
    {
        int delaySec;
        var d = Request["d"];
        if (d == "E") throw new ApplicationException("ERROR");
        if (int.TryParse(Request["d"], out delaySec))
        {
            System.Threading.Thread.Sleep(delaySec * 1000);
        }
        Response.Write(Guid.NewGuid().ToString());
    }
</script>

前端部分,我們先不要用 jQuery 或 axios,試寫香草口味 JavaScript,確認我們知道 XHR 原理,具備用 JavaScript 實作的能力,用 jQuery/axios 是為了省時省力,而非沒有工具就什麼都不會的廢人。關於 XHR (XMLHttpRequest),MDN Web Docs 有權威且完整的說明。以下是用純 JavaScript 實作的完整範例:

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width,initial-scale=1">
		<title>Loading animation demo</title>
		<style>
			html,body { height: 100%; margin: 0; }
			.loading { 
				position: absolute; z-index: 9999;
				top: 0; left: 0;
				width: 100%; height: 100%;
				display: none;
			}
			.loading .mask {
				position:  absolute;
				width: 100%; height: 100%;
				background-color: #444; opacity: 0.5;
			}
                .loading .animation {
                    width: 64px; height: 64px;
                    margin: auto; margin-top: 40px;
                    background: url(https://i.imgur.com/6pCtQAW.gif);
                }
		</style>
	</head>
	<body>
		<select id="selAction">
			<option value="1">Delay 1s</option>
			<option value="3">Delay 3s</option>
			<option value="D">No Host</option>
			<option value="N">HTTP 404</option>
			<option value="E">HTTP 500</option>
		</select>
		<button id='btnTest'>Run Test</button><br />
		Result = <span id="txtResult"></span>
		<div class="loading">
			<div class="mask"></div>
			<div class="animation">
			</div>
		</div>
		<script>
			function toggleLoading(show) {
				document.querySelector('.loading').style.display =	show ? 'block' : 'none';
            }
			function callAjax() {
				var req = new XMLHttpRequest();
				req.addEventListener("load", function () {
					toggleLoading(false);
					if (req.status == 200)
						document.getElementById('txtResult').innerText = req.responseText;
					else {
						alert("XHR Status=" + req.status);
                    }
				});
				req.addEventListener("error", function () {
					//failed to get response from remote server
					alert("ERROR");
					toggleLoading(false);
				});
				var act = document.getElementById('selAction').value;
				if (act == "D") {
					req.open("POST", "http://no-such-host.net/null.html");
				}
                else if (act == "N") {
                    req.open("POST", "no-such-page.aspx");
                }
				else {
					req.open("POST", "getGuid.aspx?d=" + act);
				}
				toggleLoading(true);
				document.getElementById('txtResult').innerText = "";
				req.send();
			}
			document.getElementById('btnTest')
				.addEventListener('click', callAjax);
		</script>
	</body>
</html>

XHR 的使用方式是先 new XMLHttpRequest() 建立物件,在 load 事件檢查傳回結果,由 status 屬性判斷結果,responseText、responseXML 讀取結果,若連不上伺服器或未得到 HTTP 回應(例如:DNS 有錯、TLS 憑證無效、網站無回應... 等等),則會觸發 error 事件。.open() 指定 GET/POST 及網址,.send() 送出 HTTP 請求,在送出請求前將 .loading 的 style.display 設為 block,load/error 事件執行時代表呼叫已結束,此時再將 .loading style.display 設成 none,就這麼簡單。

品嚐完香草口味,順便溫習一下 jQuery 練練手感:

    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script>
    $('#btnTest').click(function() {
        var act = $('#selAction').val();
        var url = "getGuid.aspx?d=" + act;
        if (act == "D") url = "http://no-such-host.net/null.html";
        else if (act == "N") url = "no-such-page.aspx";
        $('.loading').show();
        $.post(url)
            .done(function (guid) { $('#txtResult').text(guid); })
            .fail(function (xhr) { alert("ERROR-" + xhr.status); })
            .always(function () { $('.loading').hide(); });
    });
    </script>

打完收工。

Example of creating a loading mask from scratch when call long-running AJAX.


Comments

# by Alex

你好, 小小離題一下, 我正在作一個 AJAX 文件上傳, 可是發現了一些狀況不知道怎麼解決。 1. 使用此套件作AJAX 文件上傳 https://valums-file-uploader.github.io/file-uploader/ 2. 當網頁閒置一段時間之後再呼叫, 就會有ERROR (XMLHttpRequest: Network Error 0x80030019, An error occurred during a seek operation.) 3. 上網查了一下, 好像沒有什麼解決辦法 不知黑大有沒有一些方法或頭緒, 謝謝

# by Jeffrey

to Alex, 感覺是瀏覽器跟網站的連線中斷造成,建議用 F12 開發工具、Fiddler 或 Wireshark 觀察,看看網路連線是否有異常。(愈進階的工具愈複雜,但能看到的細節愈多)

# by ByTim

載入遮罩可以作在按鈕上,請問跟這個覆蓋全畫面的,有什麼差別?還是看情況使用?

# by Jeffrey

to ByTim, 端看需要,如果 UI 上還有日期選擇器、下拉選單、Checkbox 等元素,如果使用者在等待期間去更動它們會形成困擾的話(甚至可能觸發其他 AJAX 呼叫更新部分頁面元件),若等待時間不長(例如: 3-5秒內),我傾向一口氣封鎖整個網頁比較省事且乾脆。

Post a comment