65.9K
CodeProject 正在变化。 阅读更多。
Home

通过在可见内容之后批量下载多个JavaScript来快速加载ASP.NET网页

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (57投票s)

2008年5月9日

CPOL

15分钟阅读

viewsIcon

402457

downloadIcon

2126

在可见内容加载后下载 Web 页面上的所有外部脚本, 以提高感知速度, 并批量下载多个 JavaScript 以提高实际速度。

更新

  • 引入了script标签的新属性,用于固定脚本块,不允许其移动到页面底部
  • 固定属性格式已更改

引言

如果网页中的JavaScript文件能在可见内容加载之后加载,并且多个JavaScript文件可以合并成一次下载,那么网页的加载速度会更快,并且给人的感觉也会更快。浏览器一次只下载一个外部JavaScript文件,有时在下载和执行脚本时会暂停渲染。当页面上有多个外部JavaScript引用时,这会导致Web页面加载和渲染缓慢。对于每个JavaScript引用,浏览器都会停止下载和处理页面上的任何其他内容,而某些浏览器(如Internet Explorer 6)在处理JavaScript时会暂停渲染。这会带来缓慢的加载体验,并且Web页面会经常“卡住”。因此,只有当页面上的外部脚本数量很少,并且脚本在页面可见内容加载之后加载时,Web页面才能快速加载。

例如,当您访问 Dropthings.com 时,您会看到大量JavaScript正在下载。其中大部分来自ASP.NET AJAX框架和ASP.NET AJAX控件工具包项目。

Andysnap_003.png

图:典型的ASP.NET AJAX页面(包含ASP.NET AJAX控件工具包)下载了许多脚本。

页面上总共有15个JavaScript引用。您可以看到,浏览器在下载和处理所有这些外部脚本时会卡住15次。这使得页面的加载“感觉”更慢。实际加载时间也很糟糕,因为这15个HTTP请求在USA内部的网络延迟方面浪费了15 * 100ms = 1500ms。在USA以外,延迟更高。亚洲用户与USA服务器的延迟约为270ms,澳大利亚用户约为380ms。因此,USA以外的用户在网络延迟方面浪费了4到6秒,这段时间没有任何数据被下载。对于任何网站来说,这都是不可接受的性能。

您之所以要支付如此高昂的脚本下载费用,仅仅是因为您使用了AJAX控件工具包中的两个扩展器以及ASP.NET AJAX的UpdatePanel

如果我们能将多个单独的脚本调用合并成一次调用,例如下面的图片所示的Scripts.ashx,并且使用HTTP处理程序将多个脚本一次性下载,这样就可以节省大量的HTTP连接,这些连接可以用于下载页面内容所需的CSS或下载用户可见的图像等更有价值的工作。

Andysnap_002.png

图:通过一次连接下载多个JavaScript,节省调用和延迟。

Scripts.ashx处理程序不仅可以一次性下载多个脚本,而且URL的格式非常简洁。例如:

/scripts.ashx?initial=a,b,c,d,e&/

与传统的ASP.NET ScriptResource URL相比,例如:

/ScriptResource.axd?d=WzuUYZ-Ggi7-B0tkhjPDTmMmgb5FPLmciWEXQLdjNjt
bmek2jgmm3QETspZjKLvHue5em5kVYJGEuf4kofrcKNL9z6AiMhCe3SrJrcBel_c1
&t=633454272919375000

通过一次HTTP调用下载多个JavaScript的好处是:

  • 节省昂贵的网络往返延迟,在这段时间内,浏览器和源服务器都没有在工作,甚至没有传输单个字节。
  • 减少浏览器“暂停”的次数。这样,浏览器就可以流畅地渲染页面的内容,从而给用户快速加载的感觉。
  • 给浏览器腾出时间,并释放HTTP连接来下载页面上可见的元素,从而给用户一种“正在发生事情”的感觉。
  • 当启用IIS压缩时,单独压缩的文件的总大小会大于合并后压缩的多个文件的总大小。这是因为每个压缩的字节流都有一个压缩头,以便解压缩内容。
  • 这减少了页面HTML的大小,因为脚本标签的数量非常少。因此,您可以轻松节省页面HTML中的数百个字节,尤其是在ASP.NET AJAX生成巨大的WebResource.axdScriptResource.axd URL(带有非常大的查询参数)时。

解决方案是动态解析页面发送到浏览器之前的响应,并找出正在发送到浏览器的脚本引用。我构建了一个HTTP模块,可以解析页面的生成HTML,并找出正在发送的脚本块。然后,它会解析这些脚本块,并找到可以合并的脚本。接着,它会将这些单独的脚本标签从响应中移除,并添加一个生成多个脚本块组合响应的脚本标签。

例如,Dropthings.com 的主页会生成以下脚本标签:

<script type="text/javascript">
...
//]]>
</script>
<script src="/Dropthings/WebResource.axd?d=
    _w65Lg0FVE-htJvl4_zmXw2&t=633403939286875000" 

type="text/javascript"></script>
...
<script src="Widgets/FastFlickrWidget.js" type="text/javascript"></script>
<script src="Widgets/FastRssWidget.js" type="text/javascript"></script>

<script src="/Dropthings/ScriptResource.axd?d=WzuUYZ-Ggi7-B0tkhjPDTmMmgb5FPLmciWEXQLdj
Njtbmek2jgmm3QETspZjKLvHue5em5kVYJGEuf4kofrcKNL9z6AiMhCe3SrJrcBel_c1
&t=633454272919375000" type="text/javascript"></script>
<script type="text/javascript">
//<![CDATA[
...
</script>

<script src=
   "/Dropthings/ScriptResource.axd?d=WzuUYZ-Ggi7-B0tkhjPDTmMmgb5FPLmciWEXQLdjNjtbmek2j
gmm3QETspZjKLvHIbaYWwsewvr_eclXZRGNKzWlaVj44lDEdg9CT2tyH-Yo9jFoQij_XIWxZNETQkZ90
&t=633454272919375000" type="text/javascript"></script>
<script type="text/javascript">
...
</script>
<script type="text/javascript">

...
</script>
<script type="text/javascript" charset="utf-8">
...
</script>
<script src="Myframework.js" type="text/javascript"></script>

<script type="text/javascript">
...
</script>
<script type="text/javascript">if( typeof Proxy == 
    "undefined" ) Proxy = ProxyAsync;</script>

<script type="text/javascript">
...
</script>
<script src="/Dropthings/ScriptResource.axd?d=WzuUYZ-Ggi7-B0tkhjPDTmMmgb5FPLmciWEXQLdjN
jtbmek2jgmm3QETspZjKLvH-H5JQeA1OWzBaqnbKRQWwc2hxzZ5M8vtSrMhytbB-Oc1
&t=633454272919375000" type="text/javascript"></script>
<script src="/Dropthings/ScriptResource.axd?d=BXpG1T2rClCdn7QzWc-HrzQ2ECeqBhG6oiVakhRAk
RY6YSaFJsnzqttheoUJJXE4jMUal_1CAxRvbSZ_4_ikAw2
&t=633454540450468750" type="text/javascript"></script>

<script src="/Dropthings/ScriptResource.axd?d=BXpG1T2rClCdn7QzWc-HrzQ2ECeqBhG6oiVakhRA
kRYRhsy_ZxsfsH4NaPtFtpdDEJ8oZaV5wKE16ikC-hinpw2
&t=633454540450468750" type="text/javascript"></script>
<script src="/Dropthings/ScriptResource.axd?d=BXpG1T2rClCdn7QzWc-HrzQ2ECeqBhG6oiVakhRAk
RZbimFWogKpiYN4SVreNyf57osSvFc_f24oloxX4RTFfnfj5QsvJGQanl-pbbMbPf01
&t=633454540450468750" type="text/javascript"></script>
...


<script type="text/javascript">
...
</script>
</body>
</html>

如您所见,有很多大型脚本标签,总共15个。我将在此展示的解决方案将合并脚本链接,并用两个脚本链接替换它们,这两个链接下载了13个单独的脚本。我将ASP.NET AJAX Timer扩展器相关的两个脚本排除在外。

<script type="text/javascript">
...

</script>

<script type="text/javascript" src="Scripts.ashx?initial=a,b,c,d,e,f&/dropthings/">
</script>

<script type="text/javascript">

...
</script>

<script type="text/javascript">
...
</script>
<script type="text/javascript">
...
</script>

<script type="text/javascript">
...
</script>

<script type="text/javascript">if( typeof Proxy == "undefined" ) Proxy = 
    ProxyAsync;</script>

<script type="text/javascript">
...
</script>
<script src="/Dropthings/ScriptResource.axd?d=WzuUYZ-..." 
    type="text/javascript"></script>
<script src="/Dropthings/ScriptResource.axd?d=BXpG1T2..." 
    type="text/javascript"></script>

<script type="text/javascript" src="Scripts.ashx?post=C,D,E,F,G,H,I,J&/dropthings/">
</script>

<script type="text/javascript">
...
</script>

如您所见,13个脚本链接已合并到两个脚本链接中。URL也比大多数脚本引用短。

这里涉及两个步骤:

  1. 找出生成响应HTML中所有script标签,并将它们收集到一个缓冲区中。将它们移动到HTML可见元素之后,特别是包含页面上所有ASP.NET控件生成输出的<form>标签之后。
  2. 解析缓冲区,查看哪些脚本引用可以合并成一组。这些组在配置文件中定义。用组合集引用替换单独的脚本引用。

步骤1:将所有脚本标签延迟到Body内容之后

这种方法已在这篇博文中进行了介绍。这里再次赘述:

ASP.NET ScriptManager控件有一个属性LoadScriptsBeforeUI,当设置为false时,应该在页面内容之后加载所有AJAX框架脚本。但它并不能有效地将所有脚本都推送到内容后面。一些框架脚本、扩展器脚本以及Ajax Control Toolkit注册的其他脚本仍然在页面内容加载之前加载。以下屏幕截图来自www.dropthings.com,显示有几个脚本标签仍然被添加到<form>的开头,这迫使它们在页面内容加载和显示之前就被下载。脚本标签会暂停渲染,尤其是在Internet Explorer中,直到脚本下载并执行。结果是,用户会感觉页面加载缓慢,因为用户盯着白屏幕看一段时间,直到内容前面的脚本完全下载并执行。如果浏览器可以在下载任何脚本之前渲染HTML,用户将在访问站点后立即看到页面内容,而不是看到白屏幕。这将给用户一种该网站速度极快的印象(就像Google主页一样),因为理想情况下,用户在点击URL后会立即看到页面内容(如果内容不是太大)。

图:在内容之前提供的脚本块。

从上面的截图可以看出,有一些来自ASP.NET AJAX框架和一些来自Ajax Control Toolkit的脚本在页面内容之前被添加。在这些脚本下载之前,浏览器不会在UI上显示任何内容,因此会出现渲染暂停,给用户缓慢加载的感觉。每个外部URL的脚本大约会增加USA以外200ms的平均网络往返延迟,因为它会尝试获取脚本。所以,无论用户的互联网连接速度有多快,用户基本上会在白屏幕上盯着至少1.5秒。

这些脚本之所以在form标签的开头渲染,是因为它们是通过Page.ClientScript.RegisterClientScriptBlock注册的。在System.WebPage类中,有一个方法BeginFormRender,它会在form标签之后立即渲染客户端脚本块。

1: internal void BeginFormRender(HtmlTextWriter writer, string formUniqueID)
2: {
3:     ...
4:         this.ClientScript.RenderHiddenFields(writer);
5:         this.RenderViewStateFields(writer);
6:         ...
7:         if (this.ClientSupportsJavaScript)
8:         {
9:             ...
0:             if (this._fRequirePostBackScript)
1:             {
2:                 this.RenderPostBackScript(writer, formUniqueID);
3:             }
4:             if (this._fRequireWebFormsScript)
5:             {
6:                 this.RenderWebFormsScript(writer);
7:             }
8:         }
9:         this.ClientScript.RenderClientScriptBlocks(writer);
0: }
图:从System.Web.Page类反编译的代码。

在这里您可以看到,包括通过调用ClientScript.RegisterClientScriptBlock注册的脚本在内的几个脚本块,都直接在form标签开始后渲染。

要重写BeginFormRender方法并延迟这些脚本的渲染,没有简单的解决方法。这些渲染函数深埋在System.Web中,而且这些函数都不能被重写。因此,唯一的解决方案似乎是使用响应过滤器来捕获正在写入的HTML,并在body标签结束之前抑制脚本块的渲染。当</body>标签即将被渲染时,我们可以安全地假设页面内容已成功发送,然后所有被抑制的脚本块都可以一次性渲染。

在ASP.NET 2.0中,您需要创建一个实现Stream的响应过滤器。您可以将默认的Response.Filter替换为您自己的stream,然后ASP.NET将使用您的过滤器来写入最终渲染的HTML。当调用Response.WritePageRender方法触发时,响应将通过过滤器写入输出流。这样,您就可以拦截发送到客户端(浏览器)的每个字节,并根据需要对其进行修改。响应过滤器可用于多种方式优化Page输出,例如剥离所有空格、对生成的HTML进行格式化,或操纵发送到浏览器的字符等。

我创建了一个响应过滤器,它捕获发送到浏览器的一切字符。如果发现正在渲染脚本块,它不会将其渲染到Response.OutputStream,而是从正在写入的缓冲区中提取脚本块,并渲染其余内容。它将所有脚本块(内部和外部)存储在一个字符串缓冲区中。当它检测到</body>标签即将写入响应时,它会从字符串缓冲区中刷新所有捕获的脚本块。

1: public class ScriptDeferFilter : Stream
2: {
3:     Stream responseStream;
4:     long position;
5:  
6:     /// <summary>
7:     /// When this is true, script blocks are suppressed and captured for 
8:     /// later rendering
9:     /// </summary>
0:     bool captureScripts;
1:  
2:     /// <summary>
3:     /// Holds all script blocks that are injected by the controls
4:     /// The script blocks will be moved after the form tag renders
5:     /// </summary>
6:     StringBuilder scriptBlocks;
7:     
8:     Encoding encoding;
9:  
0:     public ScriptDeferFilter(Stream inputStream, HttpResponse response)
1:     {
2:         this.encoding = response.Output.Encoding;
3:         this.responseStream = response.Filter;
4:  
5:         this.scriptBlocks = new StringBuilder(5000);
6:         // When this is on, script blocks are captured and not written to output
7:         this.captureScripts = true;
8:     }

这是Filter类的开头。当它初始化时,它会获取原始的Response Filter。然后它重写StreamWrite方法,以便捕获正在写入的缓冲区并进行自己的处理。

public override void Write(byte[] buffer, int offset, int count)
{
    // If we are not capturing script blocks anymore, just redirect to response stream

    if (!this.captureScripts)
    {
        this.responseStream.Write(buffer, offset, count);
        return;
    }

    /* 
     * Script and HTML can be in one of the following combinations 
     * in the specified buffer:          
     * .....<script ....>.....</script>.....

     * <script ....>.....</script>.....
     * <script ....>.....</script>
     * <script ....>.....</script> .....

     * ....<script ....>..... 
     * <script ....>..... 
     * .....</script>.....
     * .....</script>

     * <script>.....
     * .... </script>
     * ......
     * Here, "...." means html content between and outside script tags
    */

    char[] content;
    char[] charBuffer = this.encoding.GetChars(buffer, offset, count);
    
    /// If some bytes were left for processing during last Write call
    /// then consider those into the current buffer
    if (null != this.pendingBuffer)
    {
        content = new char[charBuffer.Length + this.pendingBuffer.Length];
        Array.Copy(this.pendingBuffer, 0, content, 0, this.pendingBuffer.Length);
        Array.Copy(charBuffer, 0, content, this.pendingBuffer.Length, charBuffer.Length);
        this.pendingBuffer = null;
    }
    else

    {
        content = charBuffer;
    }

    int scriptTagStart = 0;
    int lastScriptTagEnd = 0;
    bool scriptTagStarted = false;

    int pos;
    for (pos= 0; pos < content.Length; pos++)
    {
        // See if tag start

        char c = content[pos];
        if (c == '<')
        {
            /*
                Make sure there are enough characters available in the buffer to finish 
                tag start. This will happen when a tag partially starts but does not end

                For example, a partial script tag
                <script
                Or it's the ending HTML tag or some tag closing that ends 
                the whole response
                </html>
            */
            if (pos + "script".Length >= content.Length)
            {
                // a tag started but there are less than 10 characters available. 
                // So, let's store the remaining content in a buffer and wait 
                // for another Write(...) or flush call.
                this.pendingBuffer = new char[content.Length - pos];
                Array.Copy(content, pos, this.pendingBuffer, 0, content.Length - pos);
                break;
            }

            int tagStart = pos;
            // Check if it's a tag ending
            if (content[pos+1] == '/')
            {
                pos+=2; // go past the </ 

                // See if script tag is ending
                if (isScriptTag(content, pos))
                {
                    /// Script tag just ended. Get the whole script
                    /// and store in buffer
                    pos = pos + "script>".Length;
                    scriptBlocks.Append(content, scriptTagStart, pos - scriptTagStart);
                    scriptBlocks.Append(Environment.NewLine);
                    lastScriptTagEnd = pos;

                    scriptTagStarted = false;

                    pos--; // continue will increase pos by one again
                    continue;
                }
                else if (isBodyTag(content, pos))
                {
                    /// body tag has just end. Time for rendering all the script
                    /// blocks we have suppressed so far and stop capturing script blocks
                    if (this.scriptBlocks.Length > 0)
                    {
                        // Render all pending html output till now
                        this.WriteOutput(content, lastScriptTagEnd, tagStart - 
                                lastScriptTagEnd);

                        // Render the script blocks
                        this.RenderAllScriptBlocks();

                        // Stop capturing for script blocks
                        this.captureScripts = false;

                        // Write from the body tag start to the end 
                        // of the input buffer and return
                        // from the function. We are done.
                        this.WriteOutput(content, tagStart, content.Length - tagStart);
                        return;
                    }
                }
                else
                {
                    // some other tag's closing. safely skip one character as smallest
                    // HTML tag is one character e.g. <b>. 
                    // just an optimization to save one loop
                    pos++;
                }
            }
            else
            {
                if (isScriptTag(content, pos+1))
                {
                    /// Script tag started. Record the position as we will 
                    /// capture the whole script tag including its content
                    /// and store in an internal buffer.
                    scriptTagStart = pos;

                    // Write HTML content since last script tag closing 
                    // upto this script tag 
                    this.WriteOutput(content, lastScriptTagEnd, 
                        scriptTagStart - lastScriptTagEnd);

                    // Skip the tag start to save some loops
                    pos += "<script".Length;

                    scriptTagStarted = true;
                }
                else

                {
                    // some other tag started
                    // safely skip 2 character because the smallest tag 
                    // is one character e.g. <b>
                    // just an optimization to eliminate one loop 
                    pos++;
                }
            }
        }
    }
    
    // If a script tag is partially sent to buffer, then the remaining content
    // is part of the last script block
    if (scriptTagStarted)
    {            
        this.scriptBlocks.Append(content, scriptTagStart, pos - scriptTagStart);
    }
    else
    {
        /// Render the characters since the last script tag ending
        this.WriteOutput(content, lastScriptTagEnd, pos - lastScriptTagEnd);
    }
}

这里需要考虑几种情况。在Page渲染过程中,Write方法会被调用多次,因为生成的HTML可能会很大。因此,它可能只包含部分HTML。所以,第一个Write调用可能包含一个脚本块的开始,但没有结束的脚本标签。接下来的Write调用可能包含也可能不包含结束的脚本块。因此,我们需要保持状态,以确保我们不会遗漏任何脚本块。每个Write调用也可能在缓冲区中包含多个脚本块。它也可以不包含脚本块,只包含页面内容。

这里的想法是逐个字符地检查是否存在任何起始脚本标签。如果存在,则记住脚本标签的起始位置。如果在缓冲区中找到脚本结束标签,则从缓冲区中提取整个脚本块,并渲染其余的HTML。如果找不到结束标签,但脚本标签在缓冲区中开始,则抑制输出,并将脚本缓冲区中的剩余内容捕获起来,以便下一次调用Write方法时能够获取剩余的脚本并从中提取。

还有四个private函数,它们基本上是辅助函数,不做任何有趣的事情。然而,RenderAllScriptBlocks()函数使用配置设置组合所有脚本标签,并为一组脚本标签发出一个脚本标签。这个技巧稍后在步骤2中解释。

/// <summary>
/// Render collected scripts blocks all together
/// </summary>
private void RenderAllScriptBlocks()
{
    string output = CombineScripts.CombineScriptBlocks(this.scriptBlocks.ToString());
    byte[] scriptBytes = this.encoding.GetBytes(output);
    this.responseStream.Write(scriptBytes, 0, scriptBytes.Length);
}

private void WriteOutput(char[] content, int pos, int length)
{
    if (length == 0) return;

    byte[] buffer = this.encoding.GetBytes(content, pos, length);
    this.responseStream.Write(buffer, 0, buffer.Length);        
}

private bool isScriptTag(char[] content, int pos)
{
    if (pos + 5 < content.Length)
        return ((content[pos] == 's' || content[pos] == 'S')
            && (content[pos + 1] == 'c' || content[pos + 1] == 'C')
            && (content[pos + 2] == 'r' || content[pos + 2] == 'R')
            && (content[pos + 3] == 'i' || content[pos + 3] == 'I')
            && (content[pos + 4] == 'p' || content[pos + 4] == 'P')
            && (content[pos + 5] == 't' || content[pos + 5] == 'T'));
    else
        return false;
}

private bool isBodyTag(char[] content, int pos)
{
    if (pos + 3 < content.Length)
        return ((content[pos] == 'b' || content[pos] == 'B')
            && (content[pos + 1] == 'o' || content[pos + 1] == 'O')
            && (content[pos + 2] == 'd' || content[pos + 2] == 'D')
            && (content[pos + 3] == 'y' || content[pos + 3] == 'Y'));
    else

        return false;
}

isScriptTagisBodyTag函数看起来可能很奇怪。之所以代码如此奇怪,完全是为了性能。与其执行像提取数组的一部分并进行string比较这样的复杂检查,不如这是最快的检查方式。 .NET IL最好的地方在于它的优化,如果&&对中的任何一个条件失败,它甚至不会执行其余的条件。所以,检查特定字符,这是最好的方式。

这里还处理了一些极端情况。例如,如果缓冲区包含一个不完整的脚本标签声明。例如,“....<scr”,仅此而已。剩余的字符没有在缓冲区中完成,而是下一个缓冲区被发送,包含剩余的字符,如“ipt src="..." >.....</scrip”。在这种情况下,script标签将不会被剔除。处理这个问题的一种方法是确保缓冲区中有足够的字符来完成标签名称的检查。如果找不到,则将半完成的缓冲区存储在某处,并在下一次调用Write时,将其与发送的新缓冲区结合起来进行处理。

为了安装Filter,您需要将其挂接到Global.asax BeginRequest或在Response生成之前触发的任何其他事件。

1: protected void Application_BeginRequest(object sender, EventArgs e)
2: {
3:     if (Request.HttpMethod == "GET")
4:     {
5:         if (Request.AppRelativeCurrentExecutionFilePath.EndsWith(".aspx"))
6:         {
7:             Response.Filter = new ScriptDeferFilter(Response);
8:         }
9:     }
0: }

在这里,我只为.aspx页面的GET调用挂接Filter。您也可以将其挂接到POST调用。但异步回发是常规的POST,我不想对生成的JSON或HTML片段做任何更改。另一种方法是仅当ContentTypetext/html时才挂接过滤器。

当安装此过滤器后,www.dropthings.com 会将所有脚本加载延迟到<form>标签完成后。

您可以从本文的源代码附件旁边的Dropthings项目的App_Code\ScriptDeferFilter.cs中获取Filter类。 访问CodePlex网站并下载Dropthings的最新代码以获取最新的过滤器。我将直接在Dropthings代码库中修复问题和进行修改。

此过滤器收集所有脚本标签。因此,我们可以轻松解析脚本标签并进行合并。下一步是合并脚本标签。

步骤2:将多个脚本标签合并成一个脚本标签

首先,我们需要收集将要合并并作为单个脚本标签发出的脚本。您不能简单地合并页面上的所有脚本,因为有时会下载几个脚本,然后一些内联脚本块需要这些脚本。然后又会下载几个脚本,然后一些其他的内联脚本块使用它们。在典型的ASP.NET AJAX页面中,首先会下载ASP.NET AJAX框架、UpdatePanel脚本和一些Extender脚本。然后是Web服务代理、回发代码和其他一些内联脚本标签。这些脚本标签需要先前的框架脚本可用。因此,您需要首先将这些框架脚本标签分组到一个集合中。以下是我实现的方法:

<sets>    
    <set name="initial">

        <url name="a">
           /WebResource.axd?d=_w65Lg0FVE-htJvl4_zmXw2&amp;t=633403939286875000</url>
        <url name="b">Widgets/FastFlickrWidget.js</url>

        <url name="c">Widgets/FastRssWidget.js</url>
        <url name="d">/ScriptResource.axd?d=WzuUYZ-Ggi7-B0tkhjPDTmMmgb5FPLmciW...</url>

        <url name="e">/ScriptResource.axd?d=WzuUYZ-Ggi7-B0tkhjPDTmMmgb5FPLmciW...</url>
        <url name="f">Myframework.js</url>

    </set>

我如何知道这些脚本块可以一次性下载?我查看生成的源代码并找到脚本标签。我复制src="...."并将其粘贴到XML中。粘贴时,我对URL进行XML编码,这意味着URL中的所有&都会转换为&

我发现这些脚本块是在任何内联脚本标签被渲染之前加载的。所以,它们是批处理下载的候选。

接下来的候选是ASP.NET AJAX扩展器和一些ASP.NET AJAX控件工具包扩展器。

<set name="post">
    <!--
    <url name="A">/ScriptResource.axd?d=WzuUYZ-Ggi7-B0tkhjPDT...</url>
    <url name="B">/ScriptResource.axd?d=BXpG1T2rClCdn7QzWc-Hr...</url>

    -->

在这里您可以看到这些脚本可以被另一个批次下载。在这些标签之后,我发现另一个内联脚本块期望这些脚本可用。

这里有一点需要记住,如果您的网站运行在一个虚拟目录中,所有这些URL前面都会带有虚拟目录名。例如,/Dropthings/ScriptResource.axd?....

另外,URL名称是区分大小写的。最后,您会注意到所有ScriptResource.axd URL都有一个t参数。这个参数出现在URL的&之后。当您对整个URL进行XML编码时,它将变成“&amp;t=....”。不要害怕双重编码。它应该就是这样。

合并工作如下:

  1. 选择一个集合,例如“Initial”,然后查看是否有任何script标签包含集合中定义的URL之一。
  2. 如果找到URL,请记住该位置。因为这是合并script标签的位置。
  3. 删除匹配的URL,并记住匹配URL的名称,例如D
  4. 一旦搜索完集合中定义的所有URL,就回到第一个匹配项找到的位置。生成一个合并的脚本标签URL。例如:Scripts.ashx?initial=a,c,f

这是完成所有工作的代码:

public static string CombineScriptBlocks(string scripts)
{
    List<UrlMapSet> sets = LoadSets();
    string output = scripts;

    foreach (UrlMapSet UrlMapSet in sets)
    {
        int setStartPos = -1;
        List<string> names = new List<string>();
            
        output = _FindScriptTags.Replace
                (output, new MatchEvaluator(delegate(Match match)
        {
            string url = match.Groups["url"].Value;

            UrlMap urlMatch = UrlMapSet.Urls.Find(
                new Predicate<UrlMap>(
                    delegate(UrlMap map)
                    {
                        return map.Url == url;
                    }));

            if( null != urlMatch )
            {
                // Remember the first script tag that matched in this UrlMapSet because

                // this is where the combined script tag will be inserted
                if (setStartPos < 0) setStartPos = match.Index;
            
                names.Add(urlMatch.Name);
                return string.Empty;
            }
            else
            {
                return match.Value;
            }
            
        }));

        if (setStartPos >= 0)
        {
            names.Sort();

            string setName = string.Join(",", names.ToArray());
            string urlPrefix = HttpContext.Current.Request.Path.
               Substring(0, HttpContext.Current.Request.Path.LastIndexOf('/')+1);
            string newScriptTag = 
                "<script type=\"text/javascript\" src=\"Scripts.ashx?" 
               + UrlMapSet.Name + "=" + setName + "&" + urlPrefix + "\"></script>";

            output = output.Insert(setStartPos, newScriptTag);
        }
    }

    return output;
}

接下来是执行合并多个脚本为单个脚本工作的HTTP处理程序。处理程序Scripts.ashx查看查询参数,并查看请求的是哪个集合以及集合中的哪些URL。然后它使用HttpWebRequest在内部下载脚本。当所有脚本都被下载后,它将它们合并成一个巨大的string并将其发送到响应。

public void ProcessRequest (HttpContext context) 
{
    string queryString = HttpUtility.UrlDecode(context.Request.QueryString.ToString());

    string[] urlSplit = queryString.Split('&');

    string setInfo = urlSplit[0];
    string urlPrefix = urlSplit[1];

    string[] tokens = setInfo.Split('=');
    string setName = tokens[0];
    string[] urlMaps = tokens[1].Split(',');

    byte[] encodedBytes;

    if (context.Cache[setInfo] == null)
    {

        // Find the set

        UrlMapSet set = CombineScripts.LoadSets().Find(
            new Predicate<UrlMapSet>(delegate(UrlMapSet match)
                {
                    return match.Name == setName;
                }));

        // Find the URLs requested to be rendered
        List<UrlMap> maps = set.Urls.FindAll(
            new Predicate<UrlMap>(delegate(UrlMap map)
                {
                    return Array.BinarySearch<string>(urlMaps, map.Name) >= 0;
                }));

        string urlScheme = context.Request.Url.GetComponents
            (UriComponents.SchemeAndServer, UriFormat.Unescaped);

        StringBuilder buffer = new StringBuilder();
        foreach (UrlMap map in maps)
        {
            /*
             * URLs can be in one of the following formats:
             * a) Relative URL to the page
             * b) Relative URL starting with application path e.g. /Dropthings/....
             * c) Absolute URL with http:// prefix
             */

            string fullUrl = map.Url;
            if (map.Url.StartsWith("http://")) fullUrl = map.Url;
            else if (map.Url.StartsWith(context.Request.ApplicationPath)) 
               fullUrl = urlScheme + map.Url;
            else fullUrl = urlScheme + urlPrefix + map.Url;

            HttpWebRequest request = this.CreateHttpWebRequest(fullUrl);
            using (HttpWebResponse response = request.GetResponse() as HttpWebResponse)
            {
                using (StreamReader reader = 
                    new StreamReader(response.GetResponseStream()))
                {
                    string responseContent = reader.ReadToEnd();
                    buffer.Append(responseContent);
                    buffer.Append(Environment.NewLine);
                }
            }
        }

        string responseString = buffer.ToString();
        encodedBytes = context.Request.ContentEncoding.GetBytes(responseString);
        context.Cache.Add(setInfo, encodedBytes, null, DateTime.MaxValue,
            TimeSpan.FromDays(1), System.Web.Caching.CacheItemPriority.Normal, null);
    }
    else

    {
        encodedBytes = context.Cache[setInfo] as byte[];
    }
    
    context.Response.ContentType = "text/javascript";
    context.Response.ContentEncoding = context.Request.ContentEncoding;
    context.Response.Cache.SetMaxAge(TimeSpan.FromDays(30));
    context.Response.Cache.SetExpires(DateTime.Now.AddDays(30));
    context.Response.Cache.SetCacheability(HttpCacheability.Private);
    context.Response.AppendHeader("Content-Length", encodedBytes.Length.ToString());
    
    context.Response.OutputStream.Write(encodedBytes, 0, encodedBytes.Length);
    context.Response.Flush();
}

该处理程序会生成一个适当的缓存头,以便浏览器缓存生成的脚本30天,这样在重复访问时,脚本就不会被一遍又一遍地请求。此外,它还在内部缓存合并后的脚本,这样就不需要反复获取所有这些单个脚本。

固定脚本标签

如果您想将脚本标签保留在其原始位置,例如Google Adwords或Analytics脚本块,只需为脚本标签添加一个pin属性即可。

<script pin type="text/javascript">document.write('script output comes here');</script>

就是这样!

结论

现在您有了一个很好的HTTP过滤器,您可以将其安装到您的ASP.NET(AJAX可选)网站中,让您重度的JavaScript网页感觉更快,加载速度更快。

© . All rights reserved.