Hello World
Spiga

当心异步刷新后的脚本文件加载

2007-04-09 08:43 by 老赵, 6798 visits

重现问题

我们现在编写一个示例来重现一个异步刷信的问题。

首先,我们建立一个名为“ScriptHandler.ashx”的Generic Handler,它的作用是模拟一个脚本文件。可以看出,加载这么一个脚本文件是一个很耗时的操作。

<%@ WebHandler Language="C#" Class="ScriptHandler" %>

using System;
using System.Web;

public class ScriptHandler : IHttpHandler
{    
    public void ProcessRequest (HttpContext context)
    {
        context.Response.ContentType = "text/javascript";
        System.Threading.Thread.Sleep(3000);
        context.Response.Write("Sys.Application.notifyScriptLoaded();");
    }

    // ...
}

然后我们创建一个简单的页面,放置一个UpdatePanel和两个按钮。
<asp:UpdatePanel ID="UpdatePanel1" runat="server">
    <ContentTemplate>
        <%= DateTime.Now %><br />
        <asp:Button ID="Button1" runat="server" Text="Load Script File"
            OnClick="Button1_Click" />
        <asp:Button ID="Button2" runat="server" Text="Partial Rendering"
            OnClick="Button2_Click" />
    </ContentTemplate>
</asp:UpdatePanel>

下面的代码是响应按钮Click事件的实现。当我们点击“Load Script File”按钮时,ScriptHandler.ashx会被作为脚本文件添加到页面上。而“Partial Rendering”则会发起一个需要等待很长时间的异步刷新。

protected void Button1_Click(object sender, EventArgs e)
{
    ScriptManager.RegisterClientScriptInclude(this.Page, this.GetType(), "key",
        "ScriptHandler.ashx?m=" + new Random(DateTime.Now.Millisecond).Next());
}

protected void Button2_Click(object sender, EventArgs e)
{
    Thread.Sleep(5000);
}

您可以点击这里下载这个重现问题的示例并将它部署在您的机器上,您也可以点击这里察看这个页面。请一步一步跟着我来浏览这个页面,我会示范一下这个问题。

  1. 打开页面,我们可以看到时间和两个按钮。 
  2. 点击“Load Script File” 按钮,并等待时间更新。
  3. 在时间更新后,点击“Partial Rendering” 按钮。

一般来说,最后一步之后大约5秒多钟,时间将会被跟新。但是现在您会发现,直到您重新点击某个按钮之后时间才会更新。事实上最后一步的任何操作,例如脚本加载,Hidden Field的注册都失败了,客户端生命周期的事件也不会触发。

 

原因何在?

在我分析客户端异步刷新的机制之前,我想简单的解释一些JavaScript语言和DOM操作的基本特性。使用JavaScript来操作页面中的DOM是AJAX技术的基础。有人说,JavaScript编程是没有多线程的,因此我们能够认为它始终线程安全。我同意这一点。JavaScript的编程模型的确没有多线程的机制,它是线程安全的——从理论上来说的确是这样。

但是,使用JavaScript进行编程还是会遇到同步问题,因为有些操作是异步得,尤其是在我们作一些DOM操作时。在AJAX编程中最著名的异步操作自然就是XMLHttpRequest对象的send方法。当我们调用了send方法之后,下面的代码并不会被阻塞,而是会继续执行下去。我们还会遇到别的异步操作。例如,开发人员经常会发现,他们无法在页面中动态创建了图片(<img />)或者添加了脚本文件引用(<script />)之后立即获得图片得尺寸或者执行文件中定义的方法,这是因为下载图片和加载脚本文件都是异步操作。在大多数情况下,异步操作无法立即生效,它往往会使用一些类似于回调函数的机制来通知开发人员事情已经准备好了。

我们不难理解异步操作可能会带来同步性方面的问题。我画了一幅示意图来展示异步刷新机制中可能存在的同步和异步操作。请注意,在ASP.NET AJAX的设计中,PageRequestManager使用了标准的Singleton模式,因此在整个页面中只存在一个PRM实例。这看起来还真是一个同步问题的温床。

1

这并不是一幅客户端生命周期的示意图。因为我要指出问题是如何实现的,因此需要表现的是异步刷新过程中的一些细节。 请注意图中橙色的箭头,它代表了异步操作中的等待实现,它们是唯一可能造成同步问题的地方。过程中其余部分不会被中断,这是语言特性决定的。

图中深蓝色的三个部分导致了同步问题的发生。如果我说,这些部分的本意是为了避免问题的发生,您是否会觉得惊讶呢?让我们通过分析相关实现来看一下这三个关键步骤是如何工作的:

function Sys$WebForms$PageRequestManager$_onFormSubmit(evt)
{
    // ...

    // prepare the request object
    var request = new Sys.Net.WebRequest();
    // ...

    // initialize request
    var handler = this._get_eventHandlerList().getHandler("initializeRequest");
    // ...

    // Step 1 - 1: abort the existing async postback
    this.abortPostBack();
    // Step 1 - 3: replace the request object
    this._request = request;

    // invoke the request
    request.invoke();
    //...
}

function Sys$WebForms$PageRequestManager$abortPostBack()
{
    if (!this._processingRequest && this._request)
    {
        this._request.get_executor().abort();
        // Step 1 - 2: clear the request object
        this._request = null;
    }
}

function Sys$WebForms$PageRequestManager$_onFormSubmitCompleted(sender, eventArgs)
{
    this._processingRequest = true;

    // ...

    // Step 2: validate the request
    if (!this._request || sender.get_webRequest() !== this._request)
    {
        return;
    }

    // ...

    // execute and load scripts
    scriptLoader.loadScripts(0, Function.createDelegate(this, this._scriptsLoadComplete), null, null);
}

function Sys$WebForms$PageRequestManager$_scriptsLoadComplete()
{
    //...

    // Page loaded
    this._pageLoaded(false);

    // Step 3 - 1: end postback
    this._endPostBack(null, this._response);

    //...
}

function Sys$WebForms$PageRequestManager$_endPostBack(error, response)
{
    this._processingRequest = false;

    // Step 3 - 2: clear the request
    this._request = null;

    //...
}

从上面的代码中我们可以发现这三个步骤都是基于当前异步刷新的Request对象进行的。当一个新的异步刷新被发起时,之前的那个异步刷新将被取消。与此同时,旧的Request对象将从PRM对象中除去,并使用新的对象来替换它(step 1)。在得到了服务器端的Response之后,我们会检验Response的Request对象是否为PRM对象上的那个。如果两个Request对象并不是同一个,则表示获得的Response对象并不是当前的Request对象所对应的那个,我们则会将其直接丢弃(step 2)。在异步刷新结束之后,PRM对象上的Request对象则会被去除(step 3)。

下面的示意图向您展示了用户连续发出两个异步请求时的状况。

2

这是用户在前一个异步刷新等待服务器端回应时发起第二个异步刷新的情况。那么如果一个信息的异步刷新请求在前一个正在加载脚本文件时被发起了,又会出现什么状况呢?我们可以通过下一幅示意图来观察这个状况:

3

第二个请求在第一次异步刷新加载脚本时发起。如果在第二次请求得到服务器端的结果之前脚本文件加载完成,则PRM对象上的Request对象就被去除了——即时目前的对象并不属于第一次异步刷新。这时,当第二次异步刷新得到服务器端的回应之后,PRM就会立即将它丢弃,因为Request对象已经不存在了。

 

如何避免

新的异步刷新被取消了,是吗?并非如此。如果一个异步请求被取消的话,endRequest事件将会被触发,但是新的异步刷新在我们无法控制的情况下被中断了。由于客户端生命周期中的事件无法被触发,开发人员设计的一些逻辑也有可能会被中断。我们究竟该如何防止这样的情况出现呢?幸运的是,我们很容易做到这一点:

  • 优化脚本加载时间。
  • 避免一个已经发起的异步刷新被取消了。
  • 避免在PRM的“_processingRequest”变量为true的时候取消一个异步刷新。

其中的最后一点可能还需要再多解释一下。PRM对象上的“_processingRequest”变量会在收到服务器端回应时被设为true,并且在整个异步刷新过程结束时设为false。如果您的代码发现这个值为true的话就必须当心了,由于此时PRM正在异步地加载脚本文件。这正是产生问题的主要原因。

 

English Version

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

28 条回复

  1. 非我
    *.*.*.*
    链接

    非我 2007-04-09 09:18:00

    学习

  2. 老赵
    admin
    链接

    老赵 2007-04-09 09:26:00

    @非我
    这片文章将的其实是比较极端的情况,实际过程中遇上的机会非常小,呵呵。

  3. 网魂小兵
    *.*.*.*
    链接

    网魂小兵 2007-04-09 09:28:00

    支持,希望好文章能够天天出来:)

  4. heweitykc[未注册用户]
    *.*.*.*
    链接

    heweitykc[未注册用户] 2007-04-09 09:52:00

    老赵的E文Blog怎么登录不了啊?

  5. calmzeal
    *.*.*.*
    链接

    calmzeal 2007-04-09 09:54:00

    不错
    学习

  6. 老赵
    admin
    链接

    老赵 2007-04-09 10:05:00

    @heweitykc
    不行吗?唉,空间的质量不好啊。
    // 不过我现在可以打开的,您再试试看?

  7. Leepy
    *.*.*.*
    链接

    Leepy 2007-04-09 10:11:00

    问个提外话好吗?
    现在好象很多关于ajax框架的都用到了类似updatepanel的东西,如anthem.net,magicajax等,asp.net ajax中的updatepanel和它们比较,
    有哪些区别吗?或者什么好的地方呢?
    我现在想从其中一个入手

  8. City22
    *.*.*.*
    链接

    City22 2007-04-09 10:12:00

    这个只是Atlas里面的问题吧。。。

    function Sys$WebForms$PageRequestManager$_onFormSubmit(evt)
    为什么要执行request.invoke();方法?

    很久以前看Atlas CTP June的版本的时候好像不是这样,记着那个时候是每一个请求都new一个xmlHttpRequest

    我看老赵怎么长的根老罗(罗永浩)似的,哈哈

  9. 老赵
    admin
    链接

    老赵 2007-04-09 10:39:00

    @Leepy
    园子里有过一些比较的文章。我觉得还是ASP.NET AJAX比较好。:)

  10. 老赵
    admin
    链接

    老赵 2007-04-09 10:45:00

    @City22
    Atlas?您是指什么呢?我没有听懂……
    现在客户端的任何通信都是建立在异步通讯层上的,invoke方法之类的也是异步通讯层的内容。

  11. 阿新
    *.*.*.*
    链接

    阿新 2007-04-09 11:04:00

    请问,你是用什么工具画出这么好看的图片的;
    另外,我在用UpdatePanel 的时候,发现这么一个问题,第一次在页面page_load的时候帮定一个数据源,然后在通过一个button来refresh数据源,发现多了一个gridView,原来的那个没有变化,为什么会这样子的

  12. 老赵
    admin
    链接

    老赵 2007-04-09 11:28:00

    @阿新
    就用Word里的自选图形,编辑图片也用到Paint.NET,呵呵。
    关于问题,您能够具体描述一下吗?

  13. Clark Zheng
    *.*.*.*
    链接

    Clark Zheng 2007-04-09 11:33:00

    差距太大,只能是学习了

  14. 玉开
    *.*.*.*
    链接

    玉开 2007-04-09 11:49:00

    向老赵同志学习,虽然这种情况不是经常遇到了,但是遇到的时候发现看过这篇随笔,那就太美了。

  15. 阿新
    *.*.*.*
    链接

    阿新 2007-04-09 12:00:00

    我又测试了一下,如果我把UPDATEPLANEL放在USERCONTROL里面,就会出现,我说的那种情况,后来我把USERCONTROL放在一个PAGE里面,然后在UPDATEPANEL包起来就正常了

  16. City22
    *.*.*.*
    链接

    City22 2007-04-09 12:56:00

    @Jeffrey Zhao
    呵呵,刚才是写错了,为什么要取消当前的request呢?我不太明白这里this._request.get_executor().abort();

    这好像就是一个XMLHttpRequest提交的问题,以前的CTP版本中我记着每一个提交要new 一个Request。不会有abort的问题啊,这里为什么要about呢?两个XMLHttpRequest提交不会有这样的问题了啊?

    我以前自己写过一个框架,实现了UpdataPanel的功能,但会遇到这样的问题,A先提交,然后立刻提交B,A处理的时间长,于是B返回了,刷新了panel,然后A返回,则有按照A的返回刷新了panel。B虽然做了动作但被A刷新了。

    我想about()是不是为了解决这个问题的,把A给about了就不用担心B刷新后再次刷新了。
    不过我当时的解决方案是做一个提交的队列让B排队。。。。

  17. 老赵
    admin
    链接

    老赵 2007-04-09 13:47:00

    @阿新
    能不能发一个出现问题的最简单的示例到jeffz@live.com呢?

  18. 老赵
    admin
    链接

    老赵 2007-04-09 13:50:00

    @City22
    一个异步刷新能够被取消啊,调用request的executor的abort方法。
    这是UPdatePanel的设计,不让同时出现两个异步刷信的提交。第二次提交会abort第一次提交。
    这个其实完全就是设计的缘故,它也可以设计成为支持两个同时刷新,但是这样就容易阻塞网络而且如果一个UPdatePanel同时提交两次的话,就会有更复杂的同步问题出现了。
    // 感觉排队是个不太好的做法,呵呵。

  19. City22
    *.*.*.*
    链接

    City22 2007-04-09 14:15:00

    @Jeffrey Zhao
    我当然知道可以about()啦,只是说about()这样的做法时候可行。

    这样做的有个致命的缺陷。举个例子来说:按钮A提交要求加一个文本框,然后按钮B提交也要求加一个文本框,A被取消了,刷新后只加了一个文本框。
    如果做成队列,等A加了一个文本框后,B再提交加一个文本框,这就好多了

    这样好像跟同步似的,但同步最大的缺陷就是不会阻塞脚本运行。如果在button的onlick事件里写同步的提交结果就是按钮都弹不起来,还是按下状态-_-b.......

    这个about()算是Asp。net Ajax的一个bug吧?

  20. 老赵
    admin
    链接

    老赵 2007-04-09 15:15:00

    @City22
    就是这样的设计的,第一个请求被取消我觉得很合理,这个模型非常直观好用。如果您真的想“队列”一系列的请求,您可以自己进行管理。这是您的特殊要求,就算照您这么做了,那么就会有别人说这么设计不能满足它的要求了。所以这怎么能说是个缺陷呢?
    您说的同步问题和我说的同步问题不是同一个,我是指多线程情况下对于同一个对象进行操作导致状态不一致的问题。您说的问题,只要用setTimeout(xxx, 0)就可以解决了。
    还有abort我没有看到任何bug啊,您说的bug是指什么呢?

  21. 巫云
    *.*.*.*
    链接

    巫云 2007-04-09 17:27:00

    终于看到帅哥长啥样了,哈哈~~

  22. 老赵
    admin
    链接

    老赵 2007-04-09 21:28:00

    @巫云
    猪样,哈哈。

  23. luckydog[未注册用户]
    *.*.*.*
    链接

    luckydog[未注册用户] 2007-04-10 17:12:00

    我想知道NonstaticPageMethod.dll在哪里可以找到的啊?是你自己做的dll吗???

  24. 怪怪[未注册用户]
    *.*.*.*
    链接

    怪怪[未注册用户] 2007-04-15 20:06:00

    @Jeffrey Zhao
    没错,abort是合理的。因为UpdatePanel从根本上讲,与重新提交整个页面是等价的,只是用类似于Hack的方法来实现局部的刷新。对于传统的ASP.NET WebForm来讲,状态一时进行了操作A导致状态二,状态一进行了操作B导致状态三,如果状态一时进行了操作A,在浏览器没有返回时,有进行了操作B,实际上将获得状态三,好像操作A根本没有被执行一样。先不考虑UpdatePanel的问题,实际上如果操作A导致状态二这一过程改变了应用程序内部的状态(比如修改了数据库),很有可能操作B将会出错(也是我们本应考虑但大多数时候没有考虑的),但这个是无法避免的,而非是WebForm模型的BUG。

    返回到UpdatePanel上,由于本质上它并没有改变传统ASP.NET WebForm的模型与流程,它所有的情形应该与传统情况一一对应,表现形式上就是第二个中断第一个。其实我有一种看法,很多时候这种问题只要抓住数学上的特征(想清楚即可,并非用数学语言在脑海里描述,我数学就很烂也做不到 :(),有时就无需细想为什么非得这么做为什么非得不那么做,因为直接选用了最正确的方法,很自然的麻烦就被避免了,只可惜这种境界很难达到 :(。

  25. coofucoo[未注册用户]
    *.*.*.*
    链接

    coofucoo[未注册用户] 2007-05-08 11:30:00

    @Jeffrey Zhao
    我在firefox2.0.3下访问本文的例子是正常的,IE6不行,会出现如文中描述的样子。似乎又是一个浏览器不兼容问题。

  26. 老赵
    admin
    链接

    老赵 2007-05-09 07:07:00

    @coofucoo
    应该是您的例子有特殊情况,我在IE6下也尝试过的。这部分内容没有什么浏览器兼容的问题。:)

  27. sky[未注册用户]
    *.*.*.*
    链接

    sky[未注册用户] 2007-06-11 13:43:00

    浏览器出现兼容性问题!!

  28. 老赵
    admin
    链接

    老赵 2007-06-11 20:53:00

    @sky
    能否具体一些?

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我