接獲報案:

某網頁透過AJAX新增資料,接著以AJAX方式取回資料清單,卻不見剛才新增的項目,重新整理網頁則正常。

經過一番檢測,確認與AJAX的非同步特性有關,循序執行AJAX呼叫的做法先前曾討論過,但這回我們改從問題剖析的角度再切入一次。

試著用以下範例重現問題。假設有一個後端ASP.NET程式如下,傳入參數mode=add時可新增字串元素,mode=clear時清除所有資料,否則傳回字串陣列內容。為求簡化,此處用Session儲存資料取代原本的DB作業。

<%@ Page Language="C#" %>
 
<script runat="server">
    protected void Page_Load(object sender, EventArgs e) 
    {
        //在Session中放入List<string>物件
        var list = Session["Storage"] as List<string>;
        if (list == null)
        {
            list = new List<string>();
            Session["Storage"] = list;
        }
        
        Response.ContentType = "text/plain";
        var mode = Request["mode"];
        //三種模式,add加入字串元素
        if (mode == "add")
        {
            var text = Request["text"];
            list.Add(text);
            Response.Write("Added");
        } //clear清除內容
        else if (mode == "clear")
        {
            list.Clear();
            Response.Write("Cleared");
        }
        else //或傳回字串內容
        {
            Response.Write(string.Join(",", list.ToArray()));
        }
        Response.End();
    }
</script>

前端網頁如下,有兩個JavaScript函式add及display,分別負責以AJAX方式呼叫上述ASPX新增資料及取回資料清單。網頁則有一個TextBox供輸入文字,一顆按鈕送出、一顆按鈕重取資料顯示於下方DIV,在新增鈕Click事件裡先呼叫add()再呼叫display(),原本預期新增後可立刻顯示最新結果。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>AJAX Sync Issue 1</title>
</head>
<body>
    <input type="text" id="t" /><input type="button" id="b" value="Add" />
    <input type="button" id="r" value="Refresh" />
    <br />
    <div id="d"></div>
    <script src="//ajax.aspnetcdn.com/ajax/jQuery/jquery-1.9.0.min.js"></script>
    <script>
        $(function () {
            function add() {
                $.post(
                    "DataService.aspx",
                    { mode: "add", text: $("#t").val() },
                    function (result) {
                        if (result != "Added") alert(result);
                    }
                );
            }
            function display() {
                $.post("DataService.aspx", {}, function (result) {
                    $("#d").text(result);
                });
            }
            display();
            $("#b").click(function () {
                add();
                display();
            });
            $("#r").click(function () { display(); });
        });
    </script>
</body>
</html>

結果發生什麼事? 請看以下示範,新增1後馬上看到結果,新增2後仍然只顯示1,但新增3之後才一次冒出1,2,3。

這是AJAX初心者很容易踩到的陷阱 -- 忘記AJAX呼叫都是非同步的!

jQuery.post(), jQuery.get(), jQuery.ajax()動作都採非同步執行,意思是add()呼叫$.post()新增資料後,並不會等Server端傳回結果才繼續執行下一行指令。因此,不管新增動作完成與否,display()呼叫$.post()查詢的Request便已送出,甚至可能比新增資料的Request更早執行完畢,在此種狀況下接收到的便是尚未新增完成前的結果,不包含新增資料。

由Request時序圖可清楚看出問題所在。在下圖中,我共新增了兩次資料,每次新增都會產生兩個Request(一個mode=add,一個查結果),發現了嗎? 兩個Request幾乎是同時送出,而執行時間長短不一,第一次新增花0.758秒,顯示花0.259秒;第二次反過來,新增只花0.012秒,但查詢耗時0.510秒。當新增很快完成,查詢就能抓到最新結果;反之就會看到未新增前的狀態,足以解釋為什麼前述示範中1,3新增馬上看到結果,2新增後卻沒更新。

要解決這個問題,最簡單的方法就是強迫查詢必須在新增作業完成後再執行,將程式放進jQuery.post的success Callback函式執行,即可滿足需求。

稍加修改程式,在add函式中加入cb函式參數,並將其置入$.post()待收到Server回應後呼叫,而在呼叫add時,傳入呼叫display()的程式當成cb參數,即完成串接:

        $(function () {
            function add(cb) {
                $.post(
                    "DataService.aspx",
                    { mode: "add", text: $("#t").val() },
                    function (result) {
                        if (result != "Added") alert(result);
                        if (cb) cb();
                    }
                );
            }
            function display() {
                $.post("DataService.aspx", {}, function (result) {
                    $("#d").text(result);
                });
            }
            display();
            $("#b").click(function () {
                add(function () { display(); });
            });
            $("#r").click(function () { display(); });
        });

重新執行測試,我們可以看到第二個Request的執行時間一律在第一個Request結束後,解決了有時看不到剛新增資料的問題。

不過,透過Callback參數的做法有個缺點,例如: 還有工作要排在display()完成後進行,Callback層層相套會變成:

add(function() { 
  display(function() { 
     //…blah… 
  }); 
});

看起來有點噁心,對吧? 該jQuery.Deferred上場了!

        $(function () {
            function add() {
                return $.post(
                    "DataService.aspx",
                    { mode: "add", text: $("#t").val() },
                    function (result) {
                        if (result != "Added") alert(result);
                    }
                );
            }
            function display() {
                return $.post("DataService.aspx", {}, function (result) {
                    $("#d").text(result);
                });
            }
            display();
            $("#b").click(function () {
                add().done(function () { display(); });
            });
            $("#r").click(function () { display(); });
        });

我們讓add()、display()拋回$.post()所傳回jQuery.Deferred物件,原本要當成參數的Callback便可改放在done()之中,寫成: add().done(function() { display(); });,順序接在函式之後更加直觀。如果要串接更多作業呢? 去吧! pipe() .then()就決定是你了: (延伸閱讀: 以jQuery循序執行AJAX呼叫,並依結果決定是否繼續) [2013-10-22更新: .pipe()自18.0起已列為過時,應改用then(),感謝Ammon回饋。 ]

            $("#b").click(function () {
                add()
                .then(function () { return display(); })
                .then(function () { return $.get("Lab1.html"); })
                .then(function () { return $.get("Lab2.html"); });
            });

這樣子處理同步是不是優雅多了呢?

正被命運鎖鏈一步步推向前端火坑的朋友們,建議花點時間熟悉一下jQuery.Deferred,保證值回票價。


Comments

# by Phoenix

原來pipe是這樣用,我之前用then,結果第一個執行完,後面的一串都同時執行@@

# by 小黑

這篇文實在太棒了,謝謝黑大; 另外想請教一下,文中觀看 Request時序圖 的工具是甚麼?

# by Jntty

function display() { //略 } display(); 想請問一下,為什麼要寫一次display(); 如果沒有這行會怎樣嗎? 這概念不太通,謝謝!

# by Jeffrey

to 小黑,工具是HttpWatch http://www.httpwatch.com/ to Jntty, 指網頁載入時立刻呼叫一次,才會在一開始時<div>就有內容,否則要等到使用按鈕才能看到。

# by Ammon

.pipe 已被標記為過時,取而代之的是 .then

# by Jeffrey

to Ammon, 感謝指正,已補充於本文。

# by Leo

黑大,我有一點小建議,在你這個案例,應該是不需要使用deferred,似乎在你ajax的function裡面加上 async: false 應該就可以解決這個問題了

# by Jeffrey

to Leo, async: false會導致在Server傳回結果前網頁鎖死完全無法操作,為使用者帶來不好的感受,在設計上我會極力避免。

# by -_-

我是來找jquery post/get的非同步方法。 地方是找到了,但是看了幾遍都不知道你的程式在寫什麽。

Post a comment


71 - 40 =