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

适用于高性能 Web 脚本的 ASP.NET 自定义控件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (19投票s)

2009 年 2 月 5 日

CPOL

12分钟阅读

viewsIcon

56713

downloadIcon

562

提供了一个自定义控件来替换 script 标签,该控件可优化网页的 JavaScript。它会自动合并、防止重复、外部化、排序、添加 expires 标头、缓存、最小化并放置您的脚本。

引言

在 Steve Souders 的著作《高性能 Web 站点[1] 中,Steve 概述了提高 Web 应用程序感知性能的 14 个步骤。其中几项与更有效地管理 JavaScript 相关。其中大部分机制以及其他一些机制在 CodeProject 文章《通过示例加速您的网站[2] 中进行了讨论。这些是理解此处实现的某些改进原因的绝佳资源,因此在此不再重复。

此外,还有一些现有的工具可用于合并 JavaScript 文件和压缩。ASP.NET 3.5 SP1 中包含复合脚本就证明了这一点。此机制在另一篇 CodeProject 文章《客户端 ASP.NET 应用程序的性能优化[3] 中进行了描述。该文章还描述了 Microsoft Code 中的 HttpCombiner [4],它被定义为一个 HttpModule,可以在明确请求时合并脚本。这些工具中的每一个都解决了 Steve Souders 描述的一些 JavaScript 问题,但需要深入了解该问题才能使用解决方案。

此处提供的实现主要从可用性的角度设计。它不需要理解所有机制,也不需要开发人员从根本上改变他们的编码方式。相反,开发过程中唯一需要记住的是将 ASPX 和 ASCX 文件中的 `script` 标签声明从传统的 `script` 标签更改为新的 `HP:Script` 标签(HP 代表 High Performance)。虽然高级团队成员应该理解这些更改为什么是可取的,但让所有团队成员使用单一机制会更容易。最后,此机制旨在以 `script` 标签目前使用的简单/不优雅的方式使用,而不会产生负面影响。

使用控件

安装后,集成到 Web 应用程序中非常简单。该控件具有包含 ASP.NET 中脚本的每种常见机制的等效项。这些机制模仿了 ASP.NET 包含脚本的方式。

页面和用户控件中的外部脚本(*.aspx* 和 *.ascx*)

将典型的脚本声明替换为,例如:

<script src="myfile.js" type="text/javascript"></script>

使用 `Script` 自定义控件:

<%@ Register TagPrefix="HP" Assembly="HighPerformanceScript" 
    Namespace="HighPerformanceScript" %>

<HP:Script src="myfile.js" runat="server" />

页面和用户控件中的内联脚本(*.aspx* 和 *.ascx*)

将脚本周围的 `script` 标签替换为,例如:

<script type="text/javascript">
  function doSomething() { ... }
</script>

使用 `Script` 自定义控件:

<%@ Register TagPrefix="HP" Assembly="HighPerformanceScript" 
             Namespace="HighPerformanceScript" %>
...
<HP:Script Location="External" runat="server">
  function doSomething() { ... }
</HP:Script>

(注意:`Location="External"` 属性会将脚本从页面中移除并外部化,以便缓存,这是默认设置。`Location="Inline"` 属性会将脚本保留在内联状态。)

代码隐藏和自定义控件中的外部脚本(*.aspx.cs*,*.ascx.cs*,和 *.cs*)

如果您在用户控件中使用 `Page.ClientScript` 来避免包含多个相同的脚本,例如:

Page.ClientScript.RegisterClientScriptInclude("myfile.js Specific Key", "~/myfile.js");

可以用上面的内联版本或代码基础的等效版本替换:

HighPerformanceScript.Script.RegisterClientScriptInclude("~/myfile.js");

工作原理

该控件与大多数自定义控件不同之处在于,它不会在声明点注入 HTML。相反,它充当一个指令,以确保脚本包含在最合适的位置。这使得它可以自动执行所需的优化。该控件的八个核心功能是:

减少 HTTP 请求数(Souders 规则 1)

在开发和维护过程中将代码拆分为逻辑文件是一个好习惯。然而,这对开发来说很好,但对 Web 客户端的部署却很差。每增加一个脚本都需要在请求期间增加延迟,并略微增加带宽需求。

为了最大程度地减少请求数量,该控件会跟踪页面生命周期内请求的 JavaScript 文件,并将所有这些脚本文件合并成一个脚本。然后,页面中只呈现一个脚本标签,其 URL 对于文件组合是唯一的。合并后的脚本保存在应用程序缓存中以供交付,并且实际上不会写入文件系统。

虽然此机制实现起来相当简单,但确实会带来一些问题,例如此处介绍的顺序依赖性。

管理依赖项

当将 JavaScript 拆分成逻辑文件时,它们之间存在依赖关系是很自然的。不幸的是,JavaScript 没有明确的管理这些依赖项的机制。在 HTML 文件中,脚本会按照包含的顺序合并。在单个页面中,这一点非常明显;但是,在页面中使用多个用户控件或自定义控件会阻止在页面呈现之前轻松看到完整的脚本列表。

由于该控件在服务器端管理依赖项,因此拥有类似于 C# 的语法是有意义的。借用 `include` 关键字并使用 JavaScript 可以解析的语法,该控件允许在 *.js* 文件中使用以下语法:

include("prereq1.js");
// include("prereq2.js")

该控件将扫描 `script` 标签中请求的每个文件,并自动添加任何依赖项。此外,这将确定脚本连接到合并文件中的顺序。页面中 `script` 标签的顺序对聚合脚本的顺序没有影响。include 机制是覆盖默认顺序(按字母顺序)的唯一方法。

(注意:依赖项中的文件名以及控件中的任何其他位置都可以使用绝对或相对 URL,以及应用程序根目录特定的 URL,例如“~/scripts/myscript.js”。)

添加 Expires 标头(Souders 规则 3)

在生产环境中,脚本文件很少更改。然而,在每次页面请求时都请求脚本的开发实践是常态。通过为脚本应用远期 expires 标头,客户端将在首次下载后缓存脚本而不会重新请求。由于此控件动态创建修改后的脚本,因此无法使用 IIS expires 标头机制。

由该控件交付的所有脚本都具有一个 expires 标头,该标头设置为未来 1 年。这将阻止客户端重新请求文件。但是,当 JavaScript 更改时,它确实需要请求一个新文件。

该控件会创建一个特定于合并后的 JavaScript 文件内容的 URL。这是通过将合并后的 JavaScript 文件内容的哈希码附加到查询字符串来完成的。因此,任何对 JavaScript 文件(忽略注释和空格)的更改都会导致查询字符串发生变化,并向客户端浏览器发出信号以重新加载脚本。

将脚本放在底部(Souders 规则 6)

当客户端遇到脚本(无论是外部的还是内联的)时,页面渲染会停止,直到脚本被检索。这是因为客户端假设最坏的情况是脚本会更改页面内容。将脚本放在底部,允许在客户端回服务器请求脚本之前,页面可以渲染并显示给用户。

该控件不会在声明点渲染 `script` 标签。相反,它会等待页面、所有用户控件和所有自定义控件加载,然后在页面末尾(紧靠闭合的 `form` 标签之前)注入一个 `script` 标签。

将 JavaScript 外部化(Souders 规则 8 的一部分)

许多页面和控件直接在 `script` 标签中包含其脚本。由于页面的这一部分比页面本身不太可能动态更改,因此可以通过创建外部文件来提高性能,即使它只有几百字节。

当脚本在控件中内联定义时,它会从页面输出中删除,并添加到合并的输出脚本中。这样,内联编写脚本就没有问题。在希望代码内联的情况下,添加 `Location="Inline"` 属性将阻止此行为,并使控件像普通的 `script` 标签一样工作。

最小化 JavaScript(Souders 规则 10)

JavaScript 的某些部分包含对浏览器没有价值的信息,例如空格和注释。这很容易占 JavaScript 文件大小的 30%-40%。删除这些额外信息将减少首次请求时的带宽需求。

该控件会自动运行 Minify Process [5],该过程会从 JavaScript 文件中删除不必要的数据。

通过压缩剩余的 JavaScript 可以获得一些额外的收益。存在两种通用机制,但每种机制都有其缺点,在此无法包含。第一种机制将函数名和属性更改为更短的等效项。然而,此机制并不总是创建与原始 JavaScript 一致的压缩 JavaScript。第二种机制使用一个 JavaScript 函数来解压缩文本,然后使用 JavaScript `eval` 函数来包含实际的 JavaScript。此机制的缺点是需要客户端处理时间,这会降低性能。

删除重复脚本(Souders 规则 12)

许多脚本自然会被不止一个控件需要,例如,一个启用 AJAX 的脚本。然而,当在多个用户控件中这样做时,可能会导致 JavaScript 冲突。充其量,它会因为多次请求相同的脚本而降低性能。ASP.NET 提供了一个通过页面的 `ClientScript` 属性来解决此问题的方法:

Page.ClientScript.RegisterClientScriptInclude("Ajax.js Specific Key", "Ajax.js");

同样,`HP:Script` 控件会自动将多个脚本包含减少为单个包含。这有效地复制了 `RegisterClientScriptInclude` 的行为,同时允许脚本包含在 *.aspx* 或 *.ascx* 页面中。

自动切换以进行 *localhost* 调试

尝试调试一个脚本已混淆和乱码的应用程序对于胆小的人来说不是件容易的事。然而,该控件无法完全禁用上述功能而不改变脚本的行为方式。因此,需要找到一个折衷方案来启用调试。

当从 *localhost*(或 *127.0.0.1*)加载网页时,它不会将文件合并成一个文件,也不会最小化 JavaScript。这将确保客户端浏览器上的 JavaScript 控制台会引用正确的文件名和该文件中的正确行。所有其他处理都会执行。通过机器名访问本地计算机不会被视为 *localhost* 请求(例如,*https:///MyApp* 和 *http://mymachinename/MyApp* 的处理方式不同)。

实现

该系统由几个实现这些结果的文件组成:一个自定义控件、一个网页和一个用于最小化的支持文件 [5]。在选择使用自定义控件之前,曾考虑过几种可能的实现方式。

一种可能的实现方式是使用 HttpModule 在页面呈现之后、发送给客户端之前拦截该页面。然后,该模块将解析 HTML 并提取脚本标签,合并它们,并在末尾输出单个脚本。这需要更复杂的实现,因为必须正确地进行解析。此外,将无法明确控制何时禁用优化。最后,它需要对 *Web.config* 文件进行设置以附加该模块。通常,这不是问题,但意外移除该模块很可能不会改变系统的行为,只会破坏性能。

另一种可能的实现方式是更手动化的过程,可以完全控制优化。这是 HttpCombiner [4] 所采取的路径。然而,这个过程要求用户始终警惕 HttpCombiner 的使用。这一点,以及存在合适工具的事实,阻止了这种模式。

因此,最终的模式是创建一个自定义控件来管理脚本。该控件会创建一个链接,指向另一个页面,该页面从内存而不是文件系统中提供修改后的 JavaScript。然后,安装只需将页面和控件复制到 Web 应用程序中即可。该控件可以很容易地移入控件库,如果需要的话,但附带的示例使用单个项目。

脚本自定义控件(*Script.cs*)

每当在请求生命周期内加载脚本时,都会将其注册到页面中所有脚本的列表中。第一个进入 `PreRender` 的控件将遍历列表并编译聚合脚本所需的所有信息。此聚合脚本会放入应用程序级别的缓存中。此缓存减少了聚合脚本的重复计算,并且在单台机器上运行时,将充当脚本服务页面的缓存。然后,它使用 `RegisterStartupScript` 在表单末尾发出脚本包含标签。在 `PreRender` 退出之前,它会清除列表,以便 `PreRender` 中的其他控件不会重复此过程。最后,在每个控件的 `Render` 阶段,都不会输出任何内容。

脚本资源服务器页面(*ScriptResource.aspx.cs*)

由控件创建的聚合脚本以前存储在应用程序缓存中,其键对应于脚本的组合名称。脚本资源服务器页面将在可用时提供此缓存版本。在 Web 场中运行时,可能不会从同一个应用程序提供此页面。在这种情况下,页面查询字符串中的信息足以重建聚合页面。如果需要,该页面将重建并重新缓存此资源。最后,它将以适当的 MIME 类型发送回客户端。使用 *.aspx* 扩展名可以通过不要求自定义扩展名处理程序来简化与 Web 应用程序的集成。

JavaScriptMinifier.cs

取自 Crockford [5] 用于最小化,稍作修改以允许内存中最小化。

示例

附带的解决方案包含控件的必要文件,以及一系列测试网页。这些测试将在浏览器中加载,并在浏览器中指示测试是否通过。上面的屏幕截图来自 Google Chrome,显示了包含四个独立脚本的页面的生命周期。橙色条是根据所有四个资源编译的 JavaScript 资源。如果没有聚合,这将是四次独立的服务器请求。在 Chrome、IE 8 和 Firefox 3 之前,这四次请求甚至不会并行执行,而是每次只请求两个。

要测试示例,请下载 zip 文件并打开解决方案文件。使用 ASP.NET 开发服务器运行可能会导致某些测试失败,因为生成的页面将处于 localhost/debugging 模式。启动 IIS,并为项目目录创建一个虚拟目录,然后通过机器名和虚拟目录访问以确保所有测试都成功。

参考文献

  1. S. Souders, High Performance Web Sites, O'Reilly Media, 2007
  2. A. Baig, "Speed Up Your Website - By Example", The Code Project, 2008-05-27 (2009-02-02)
  3. K. Shehzad, "Performance Optimization of ASP.NET Applications on Client-side", The Code Project, 2008-12-18 (2009-02-02)
  4. O. Zabir, "HttpCombiner - combine, compress, and cache multiple CSS, JavaScript, or URL", MSDN Code Gallery, 2008-08-29 (2009-02-02)
  5. D. Crockford, "The JavaScript Minifier", Douglas Crockford's World Wide Web, 2003-12-04 (2009-02-02)

历史

  • 2009-05-13
    • 修复了多个嵌套文件包含时的缓存问题。
    • 使嵌套在页面中的脚本出现在所有包含的脚本之后。
  • 2009-02-05
    • 原始版本。
© . All rights reserved.