代码改变世界

深入Atlas系列 - 浅析ASP.NET Beta 2中令人疑惑的脚本引入方式

2006-11-08 02:36 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏
  似乎已经有不少朋友在作了ASP.NET AJAX Beta 1到Beta 2的转移之后遇到了这样的问题:如果使用了ScriptManager引入了自定义的JavaScript脚本文件后会发生JavaScript错误。例如:
<asp:ScriptManager ID="ScriptManager1" runat="server" EnablePartialRendering="false">
    
<Scripts>
        
<asp:ScriptReference Path="MyScript.js" />
    
</Scripts>
</asp:ScriptManager>

上面的代码会抛出如下的异常:“Sys.ScriptLoadFailedException: The script 'MyScript.js' could not be loaded or does not contain the Sys.Application.notifyScriptLoaded() callback.”,意思就是说我们的MyScript.js缺少了对于Sys.Application.notifyScriptLoaded函数的调用。

这表示了什么?为什么突然发生了这种状况?我们一点点地来看。


一、代码逻辑简析:

我们从页面的代码看起。上面的使用方式会产生如下的代码:
<script src="/ScriptResource.axd?..." type="text/javascript"></script>

……

<script type="text/javascript">
<!--
Sys.Application.queueScriptReference('MyScript.js');
Sys.Application.initialize();
// -->
</script>

通过ScriptResource.axd文件引入的是“MicrosoftAjax.js”文件,不用多说。我们可以发现,和Beta 1的行为不同,ScriptManager并没有为添加在其中的引用添加<script />元素,而是使用了Sys._Application.queueScriptReference()将需要引入的脚本路径添加进一个队列中,最后通过Sys._Application.initialize()函数真正地引入这些脚本文件。我们来仔细查看这些方法:

首先Sys._Application.queueScriptReference函数会使用Sys._ScriptLoader类的Singleton对象的queueScriptReference函数,将脚本路径添加到那个Sys_ScriptLoader类的一个队列中。代码如下:
function Sys$_Application$queueScriptReference(scriptUrl) {
    
/// <param name="scriptUrl" type="String" mayBeNull="false"></param>
    var e = Function._validateParams(arguments, [
        {name: 
"scriptUrl", type: String}
    ]);
    
if (e) throw e;

    Sys._ScriptLoader.getInstance().queueScriptReference(scriptUrl);
}

Sys._ScriptLoader.getInstance 
= function Sys$_ScriptLoader$getInstance() {
    
var sl = Sys._ScriptLoader._activeInstance;
    
if(!sl) {
        sl 
= Sys._ScriptLoader._activeInstance = new Sys._ScriptLoader();
    }

    
return sl;
}

function Sys$_ScriptLoader$queueScriptReference(scriptUrl) {
    
/// <param name="scriptUrl" type="String" mayBeNull="false"></param>
    var e = Function._validateParams(arguments, [
        {name: 
"scriptUrl", type: String}
    ]);
    
if (e) throw e;

    
if(!this._scriptsToLoad) {
        
this._scriptsToLoad = [];
    }

    Array.add(
this._scriptsToLoad, {src: scriptUrl});
}

在Sys._Application.initialize函数中,会调用_loadScripts函数,而该函数事实上调用的是Sys._ScriptLoader的Singleton实例的loadScripts函数。如下:
function Sys$_Application$initialize() {
    
if (!this._initialized && !this._initializing) {
        
this._initializing = true;
        
this._loadScripts();
    }
}

function Sys$_Application$_loadScripts() {
    debug.assert(
!this._scriptLoaderExecuted, "Cannot load scripts more than once.");
    
this._scriptLoaderExecuted = true;        

    Sys._ScriptLoader.getInstance().loadScripts(
        
this.get_scriptLoadTimeout(), // 30 by default
        Function.createDelegate(
thisthis._allScriptsLoadedHandler),
        Function.createDelegate(
thisthis._scriptLoadFailedHandler),
        Function.createDelegate(
thisthis._scriptLoadTimeoutHandler));
}

在Sys._ScriptLoader.loadScripts函数中,会接受四个参数(关键不在这里):
  1. scriptTimeout:加载所有Script文件时所用的超时时间,单位为秒。
  2. allScriptsLoadedCallback:当所有脚本被加载完毕后使用的回调函数。
  3. scriptLoadFailedCallback:脚本加载失败后使用的回调函数。
  4. scriptLoadTimeoutCallback:脚本加载超时后使用的回调函数。
  Sys._ScriptLoader.loadScripts函数代码如下:
function Sys$_ScriptLoader$loadScripts(scriptTimeout, allScriptsLoadedCallback, scriptLoadFailedCallback, scriptLoadTimeoutCallback) {
    
/// <param name="scriptTimeout" type="Number" integer="true"></param>
    /// <param name="allScriptsLoadedCallback" type="Function" mayBeNull="true"></param>
    /// <param name="scriptLoadFailedCallback" type="Function" mayBeNull="true"></param>
    /// <param name="scriptLoadTimeoutCallback" type="Function" mayBeNull="true"></param>    
    var e = Function._validateParams(arguments, [
        {name: 
"scriptTimeout", type: Number, integer: true},
        {name: 
"allScriptsLoadedCallback", type: Function, mayBeNull: true},
        {name: 
"scriptLoadFailedCallback", type: Function, mayBeNull: true},
        {name: 
"scriptLoadTimeoutCallback", type: Function, mayBeNull: true}
    ]);
    
if (e) throw e;

    
if(this._loading) {
        
throw Error.invalidOperation(Sys.Res.scriptLoaderAlreadyLoading);
    }
    
this._loading = true;
    
this._allScriptsLoadedCallback = allScriptsLoadedCallback;
    
this._scriptLoadFailedCallback = scriptLoadFailedCallback;
    
this._scriptLoadTimeoutCallback = scriptLoadTimeoutCallback;
    
    
if(scriptTimeout > 0) {
        
// 监听超时
        this._timeoutCookie = window.setTimeout(
            Function.createDelegate(
thisthis._scriptLoadTimeoutHandler),
            scriptTimeout 
* 1000);
    }
        
    
this._loadScriptsInternal();
}

在this._loadScriptsInternal函数中会使用Sys._ScriptLoaderTack类来将<script />元素添加到Header中。
function Sys$_ScriptLoader$_loadScriptsInternal() {
    
if (this._scriptsToLoad && this._scriptsToLoad.length > 0) {
        
// 出队列
        var nextScript = Array.dequeue(this._scriptsToLoad);
        
// 构造一个<script />元素,
        var scriptElement = this._createScriptElement(nextScript);
            
        
if (scriptElement.text && Sys.Browser.agent === Sys.Browser.Safari) {
            scriptElement.innerHTML 
= scriptElement.text;
            
delete scriptElement.text;
        }            

        
if (typeof(nextScript.src) === "string") {
            
this._currentTask = new Sys._ScriptLoaderTask(
                scriptElement,
                
// this._scriptLoadedDelegate = Function.createDelegate(this, this._scriptLoadedHandler);
                this._scriptLoadedDelegate);

            
this._currentTask.execute();
        }
        
else { 
            document.getElementsByTagName('HEAD')[
0].appendChild(scriptElement);
            
this._loadScriptsInternal();
        }
    }
    
else {
        
// 加载完了,清除所有的回调函数
        var callback = this._allScriptsLoadedCallback;

        
this._allScriptsLoadedCallback = null;
        
this._scriptLoadFailedCallback = null;
        
this._scriptLoadTimeoutCallback = null;

        
this._loading = null;

        
// 调用回调函数
        if(callback) {
            callback(
this);
        }
    }
}

再细化下去似乎没有必要了,因为问题好像就出在了这里。Sys._ScriptLoaderTask构造函数的第二个参数是一个回调函数,它会在加载一个<script />元素后调用这个回调函数。但是我们来看一下Sys._ScriptLoader._scriptLoadedHandler函数到底做了些什么。如下:
function Sys$_ScriptLoader$_scriptLoadedHandler(loaded) {
    
var currentTask = this._currentTask;
    
var currentScriptElement = currentTask.get_scriptElement();
    currentTask.dispose();
        
    
this._currentTask = null;
    
this._scriptsToLoad = null;
    
this._loading = null;
        
    
if(this._timeoutCookie) {
        window.clearTimeout(
this._timeoutCookie);
        
this._timeoutCookie = null;
    }

    
// 为什么在这里调用scriptLoadFailedCallback这个回调函数?
    var callback = this._scriptLoadFailedCallback;

    
this._allScriptsLoadedCallback = null;
    
this._scriptLoadFailedCallback = null;
    
this._scriptLoadTimeoutCallback = null;

    
if(callback) {
        callback(
this, currentScriptElement);
    }
    
else {
        
throw Error.scriptLoadFailed(currentScriptElement.src);
    }
}

这段代码有些奇怪,它使用了this._scriptLoadFailedCallback回调函数,也就是说,它“准备”了要失败!在这个回调函数里,也就是Sys._Application._scriptLoadFailedHandler方法里就会触发文章一开始提到的异常:
function Sys$_Application$_scriptLoadFailedHandler(scriptLoader, scriptElement) {
        
if(this._disposing) {
            
return;
        }
        
var cancelled = false;
        
var handler = this.get_events().getHandler('scriptLoadFailed');
        
if(handler) {
            
var args = new Sys.ScriptElementEventArgs(scriptElement);
            handler(
this, args);
            cancelled 
= args.get_cancel();
        }
        
if(!cancelled) {
            
// 就是这里
            throw Error.scriptLoadFailed(scriptElement.src);
        }
    }

这里就涉及到了那个要求被调用的回调函数的作用。Sys._Application.notifyScriptLoaded函数会调用Sys._ScriptLoader那个Singleton对象的notifyScriptLoaded函数,而在这个函数里调用了当前Sys._ScriptLoaderTask对象的dispose方法,也就是当场“销毁”了这个对象,也就是说,在Sys_Application._scriptLoadFailedHandler函数也就不会被调用了。


二、解决方法:

解决方法倒非常简单,只要再引入的js文件中加上下面的代码就可以了:
if (typeof(Sys) !== 'undefined') Sys.Application.notifyScriptLoaded();

理论上这段代码可以加在文件的任何地方,虽然在调用了这个回调函数之后马上就会在<head />元素中加入另外一个<script />对象,但是因为浏览器保证了在同一时刻只会有一个外部脚本文件被加载,所以也就保证了脚本加载的顺序。

这也解释了官方的Known Issue:“If you are loading .js files for the Microsoft AJAX library from disk (by setting the ScriptReference property of the ScriptManager control), do not use the library that is installed by default in the %ProgramFiles%\Microsoft ASP.NET\ASP.NET 2.0 AJAX Extensions\v1.0.61025 folder. Instead, install the Microsoft AJAX Library from the http://www.asp.net Web site, copy the .js files from the new installation location to a folder of your application, and then point the ScriptReference property to that location.”。它要求开发人员从“http://ajax.asp.net/downloads/beta/default.aspx?tabid=47&subtabid=471”中下载一个另外的ASP.NET AJAX Library包,当需要从磁盘引入(相对于使用ScriptResource.axd文件引入)时,必须使用这个包内脚本文件,而不是使用安装了ASP.NET AJAX后出现在“%ProgramFiles%\Microsoft ASP.NET\ASP.NET 2.0 AJAX Extensions\v1.0.61025”文件夹里的内容。打开两者进行比较之后就会发现,关键就在于它们的最后一行,一个调用了“Sys.Application.notifyScriptLoader()”,一个没有。

不过事实上,这两种脚本文件的区别不止这些,在“预装”的脚本中,还缺少了“Sys.Res”的定义(Sys.Res中定义了大量的字符串信息),这样的话,比如在一些异常发生时,就会因为找不到Sys.Res中的字符串而出错,这反而掩盖了真正的异常信息。

说到这里,我忽然想说,“预装”的脚本算是什么东西?


三、注意事项:

为了观察脚本的生成,我们来做一些尝试。首先,我们在aspx文件中使用如下的代码:
<body>
    
<form id="form1" runat="server">
        
<asp:ScriptManager ID="ScriptManager1" runat="server" EnablePartialRendering="false">
            
<Scripts>
                
<asp:ScriptReference Path="MyScript1.js" />
                
<asp:ScriptReference Path="MyScript2.js" />
            
</Scripts>
        
</asp:ScriptManager>
        
        
<script language="javascript">
            alert("The script inside the 
<form /> element.");
        
</script>
        
    
</form>
    
    
<script language="javascript">
        alert("The script outside the 
<form /> element")
    
</script>
</body>

然后在aspx.cs文件中添加如下的代码:
protected override void OnPreRender(EventArgs e)
{
    
base.OnPreRender(e);
    
this.ClientScript.RegisterStartupScript(
        
this.GetType(), "OnPreRender"
        
"<script language='javascript'>alert('OnPreRender');</script>");
}

protected override void OnPreRenderComplete(EventArgs e)
{
    
base.OnPreRenderComplete(e);
    
this.ClientScript.RegisterStartupScript(
        
this.GetType(), "OnPreRenderComplete"
        
"<script language='javascript'>alert('OnPreRenderComplete');</script>");
}

然后我们来看一下这些代码生成了什么样的客户端代码呢?如下(经过排版):
<body>
    
<form name="form1" method="post" action="Default.aspx" id="form1">
        ……

        
<script src="/ScriptResource.axd?..." type="text/javascript"></script>

        
<script language="javascript">
            alert(
"The script inside the <form /> element.");
        
</script>
        
        
<script language='javascript'>alert('OnPreRender');</script>

        
<script type="text/javascript">
            
<!--
            Sys.Application.queueScriptReference('MyScript1.js');
            Sys.Application.queueScriptReference('MyScript2.js');
            
// -->
        </script>

        
<script language='javascript'>alert('OnPreRenderComplete');</script>

        
<script type="text/javascript">
            
<!--
            Sys.Application.initialize();
            
// -->
        </script>
    
</form>
    
    
<script language="javascript">
        alert(
"The script outside the <form /> element")
    
</script>
</body>

从这里可以看出使用不同的方式,在不同的位置或者时刻注册脚本代码时,它们在页面中出现的顺序。需要注意的是,Sys.Application.initialize函数被调用后,在大多数情况下后面的代码会先于外部脚本中的代码执行。也就是说,紧跟在Sys.Application.initialize函数之后的代码无法使用外部脚本内的数据。因此,如果有任何需要在页面被加载时执行的代码,一般来说一定要写在pageLoad函数中。如下:
function pageLoad(sender, args)
{
    
if (!args.get_isPartialLoad())
    {
        
// 页面第一次被加载
    }
    
else
    {
        
// UpdatePanel更新
    }
}

args参数是一个Sys.ApplicationLoadEventArgs对象,它的isPartailLoad属性首次出现在ASP.NET AJAX中,它就好比服务器端的IsPostBack属性一样,能够使用它很方便地判断Page Load事件的触发,是因为页面第一次加载,还是因为UpdatePanel被更新了。