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

在 ASP.NET MVC 中合并/压缩/最小化 JS 和 CSS 文件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.73/5 (7投票s)

2009年3月10日

CPOL

4分钟阅读

viewsIcon

75485

本文将介绍一种在 ASP.NET MVC 应用程序中向客户端交付 CSS 和 JavaScript 文件的方法。更具体地说,它将展示如何在 ASP.NET MVC 应用程序的上下文中交付压缩、最小化、合并和缓存的 CSS/JavaScript 文件。

引言

本文将介绍一种在 ASP.NET MVC 应用程序中向客户端交付 CSS 和 JavaScript 文件的方法。更具体地说,它将展示如何在 ASP.NET MVC 应用程序的上下文中交付压缩、最小化、合并和缓存的 CSS/JavaScript 文件。其目标是提高客户端的整体性能。同样,它还将展示我们如何强制客户端刷新相同的 CSS 和 JavaScript 文件(这意味着我们无需更改文件名)。当使用引入新功能和功能的 AJAX 技术时,这一点变得越来越重要。简而言之,我们可以确保我们的客户端拥有必要升级的 JavaScript 文件,以执行新功能,而不是浏览器缓存中存储的文件。

代码

本文的灵感来自于 Khaled Atashbahar 的文章。我使用了许多相同的技术,但主要区别在于我使用了一个自定义 Controller 和 ActionResult,而不是 HttpHandler 来处理最小化/合并/渲染。我想通过利用我的路由来保持与 MVC 模式的一致性。此外,我使用了 Structure Map 依赖注入。当我在标记中使用指向 HTTPHandler 的路径,而该路径未在我的 IContollerFactory 工厂中注册时,DI、MVC 和 HTTP handler 就无法很好地协同工作。为此,让我们直接进入代码,展示它是如何工作的。首先,在我的母版页中,我放置了以下标记。对于 CSS 文件,我有:

<link type="text/css" rel="Stylesheet" 
	href="https://codeproject.org.cn/cache/cachecontent/CSSInclude/
	<%=GetApplicationVersionID() %>/css" />

对于 JavaScript 文件,我有:

<script type="text/javascript" 
	src="https://codeproject.org.cn/cache/cachecontent/JavaScriptInclude/
	<%=GetApplicationVersionID() %>/javascript" />  

这些是控制器 URL 路径,它将负责将文件写入客户端。现在,为了理解路径引用是如何工作的,让我们看一下我在 Global.asax 文件中定义的适用路由。

    routes.Add(new
        Route ("cache/{action}/{key}/{version}/{type}", new MvcRouteHandler()) {
            Defaults = new RouteValueDictionary(new { controller = "Cache", 
		action = "CacheContent", key = "", version = "", type = ""
            }),
            }
           ); 

那么这个路由是什么意思呢?它意味着当我们遇到格式为 /cache/cachecontent/CSSInclude/12345/css 的 URL 时,请求将被转交给 Cache 控制器,特别是 CacheContent 操作方法来处理该事件。关于 URL 格式的一些说明:

{action} = 控制器接收请求时调用的方法。

{key} = web.config 中保存所有要合并/压缩和最小化文件的逗号分隔值列表的键的名称。让我们看一个 web.config 文件中的示例片段。

 <add key="CssInclude" value="~/Content/styles/globalStylesheet.css,
					~/Content/styles/ie6.css"/>
<add key="JavaScriptInclude" value=
"~/Content/scripts/jquery=1.2.6.min.js,
      ~/Content/scripts/jquery-DatePickerUI.js,
      ~/Content/scripts/jQuery.BlockUI.js,
      ~/Content/scripts/jquery.selectboxes.min.js,
      ~/Content/scripts/jquery.swfobject.js,
      ~/Content/scripts/MicrosoftAjax.js,
~/Content/scripts/MvcAjax.js,~/Content/scripts/DataServices.js,
~/Content/scripts/s_code.js,
      ~/Content/scripts/template.js,
      ~/Content/scripts/ValidationServices.js" />

在这里,我们看到 CSS 和 JavaScript 都有一个键名。如果我们使用 URL 中的 JavaScriptInclude,我们将合并值列表中的所有 JavaScript 文件。

{version} = 我们将在渲染文件时使用的版本号。我在 URL 路径中使用了一个数字,以便我们可以更改 URL,让浏览器认为它之前没有见过该文件。版本号可以来自数据库,甚至可以来自 web.config。关键在于我们可以利用版本值来控制客户端缓存。在开发中,我使用一个随机数,但在生产环境中,我使用在 web.config 中配置的某个值。使用随机数的原因是我可以确保我的浏览器始终使用最新、最棒的 JavaScript 文件版本。这样在开发过程中就不需要清除浏览器缓存了。我敢肯定,我们都曾因忘记在跟踪一个不起作用的功能时清除缓存而吃过苦头。

{type} = 一个名称值,用于告知我们正在处理 CSS 还是 JavaScript 请求。

接下来,让我们看看我们的缓存控制器。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Mvc.Ajax;
using System.Drawing;
using System.Web.Configuration;
using System.Reflection;
using System.Web.Routing;
using System.IO;
using System.Web.Caching;

namespace Cache
{
    public class CacheResult : ActionResult
    {
      private string _keyname;
      private string _version;
      private string _type;

      public CacheResult(string keyname, string version, string type)
      {
        this._keyname = keyname;
        this._version = version;
        if ( type.ToLower().Contains("css")   )
        {
          this._type = @"text/css";
        }

        if ( type.ToLower().Contains("javascript") )
        {
          this._type = @"text/javascript";
        }
      }

      public override void ExecuteResult(ControllerContext context)
      {
        if (context == null)
          throw new ArgumentNullException("context");
        ScriptCombiner myCombiner = new ScriptCombiner
			( this._keyname, this._version, this._type);

        myCombiner.ProcessRequest(context.HttpContext);
      }
    }

  public static class CacheControllerExtensions
  {
    public static CacheResult RenderCacheResult
		(string keyname, string version, string type)
    {
      return new CacheResult(keyname, version, type);
    }
  }

    public class CacheController :Controller
    {
      #region Constructor Definitions
      public CacheController()
        : base()
      {
      }
      #endregion

    #region Method Definitions
    #region public
      public CacheResult CacheContent(string key, string version, string type)
      {
        return CacheControllerExtensions.RenderCacheResult(key, version, type);
      }

      public CacheResult ClearCache()
      {
        //LOGIC TO CLEAR OUT CACHE
      }
    #endregion
    #endregion

    } // End class CacheController
}

在这里,我们看到我们的 CacheContent 方法调用了一个名为 RenderCacheResultControllerExtension 方法。它传递了要渲染的文件键名、它应该使用的版本以及它将渲染的内容类型(CSS 或 JavaScript)。繁重的工作由自定义 CacheResult 类中的 ExecuteResult 方法完成。此方法使用了 Khaled Atashbahar 在文章开头提到的技术。为了完整起见,我将代码包含在下面。不过,我想指出我对他代码所做的一些更改。首先,我更改了类,使其能够通过向 CacheResult 构造函数添加类型参数来处理 CSS 和 JavaScript 文件。接下来,我删除了查询字符串参数的使用,转而使用传递给构造函数的值。我还更改了 GetScriptFileNames,使其能够利用存储在 web.config 文件中的文件名,而不是通过查询字符串传递的值。最后,在 ProcessRequest 期间,我省略了最小化步骤,以便在调试模式下更容易调试 JavaScript 文件。以下是供您参考的代码。

using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using System.Web;
using System.Web.Mvc;

namespace SomeNameSpace
{
  public class ScriptCombiner
  {
    private readonly static TimeSpan CACHE_DURATION = TimeSpan.FromDays(30);
    private System.Web.HttpContextBase context;
    private string _ContentType;
    private string _key;
    private string _version;

    public ScriptCombiner(string keyname, string version, string type)
    {
      this._ContentType = type;
      this._key = keyname;
      this._version = version;
    }

    public void ProcessRequest(System.Web.HttpContextBase context)
    {
      this.context = context;
      HttpRequestBase request = context.Request;

      // Read setName, version from query string
      string setName = _key;
      string version = _version;
      string contentType = _ContentType;
      // Decide if browser supports compressed response
      bool isCompressed = this.CanGZip(context.Request);

      // If the set has already been cached, write the response directly from
      // cache. Otherwise generate the response and cache it
      if (!this.WriteFromCache(setName, version, isCompressed, contentType))
      {
        using (MemoryStream memoryStream = new MemoryStream(8092))
        {
          // Decide regular stream or gzip stream based on 
	 // whether the response can be compressed or not
          //using (Stream writer = isCompressed ?  (Stream)(new GZipStream
	 // (memoryStream, CompressionMode.Compress)) : memoryStream)
          using (Stream writer = isCompressed ?
               (Stream)(new GZipStream(memoryStream, CompressionMode.Compress)) :
               memoryStream)
          {
            // Read the files into one big string
            StringBuilder allScripts = new StringBuilder();
            foreach (string fileName in GetScriptFileNames(setName))
              allScripts.Append(File.ReadAllText(context.Server.MapPath(fileName)));

            // Minify the combined script files and remove comments and white spaces
            var minifier = new JavaScriptMinifier();
            string minified = minifier.Minify(allScripts.ToString());
#if DEBUG
            minified = allScripts.ToString();
#endif
            byte[] bts = Encoding.UTF8.GetBytes(minified);
            writer.Write(bts, 0, bts.Length);
          }

          // Cache the combined response so that it can be directly written
          // in subsequent calls
          byte[] responseBytes = memoryStream.ToArray();
          context.Cache.Insert(GetCacheKey(setName, version, isCompressed),
              responseBytes, null, System.Web.Caching.Cache.NoAbsoluteExpiration,
              CACHE_DURATION);

          // Generate the response
          this.WriteBytes(responseBytes, isCompressed, contentType);
        }
      }
    }
    private bool WriteFromCache(string setName, string version, 
			bool isCompressed, string ContentType)
    {
      byte[] responseBytes = context.Cache[GetCacheKey
			(setName, version, isCompressed)] as byte[];

      if (responseBytes == null || responseBytes.Length == 0)
        return false;

      this.WriteBytes(responseBytes, isCompressed, ContentType);
      return true;
    }

    private void WriteBytes(byte[] bytes, bool isCompressed, string ContentType)
    {
      HttpResponseBase response = context.Response;

      response.AppendHeader("Content-Length", bytes.Length.ToString());
      response.ContentType = ContentType;
      if (isCompressed)
        response.AppendHeader("Content-Encoding", "gzip");
      else
        response.AppendHeader("Content-Encoding", "utf-8");

      context.Response.Cache.SetCacheability(HttpCacheability.Public);
      context.Response.Cache.SetExpires(DateTime.Now.Add(CACHE_DURATION));
      context.Response.Cache.SetMaxAge(CACHE_DURATION);
      response.ContentEncoding = Encoding.Unicode;
      response.OutputStream.Write(bytes, 0, bytes.Length);
      response.Flush();
    }

    private bool CanGZip(HttpRequestBase request)
    {
      string acceptEncoding = request.Headers["Accept-Encoding"];
      if (!string.IsNullOrEmpty(acceptEncoding) &&
           (acceptEncoding.Contains("gzip") || acceptEncoding.Contains("deflate")))
        return true;
      return false;
    }

    private string GetCacheKey(string setName, string version, bool isCompressed)
    {
      return "HttpCombiner." + setName + "." + version + "." + isCompressed;
    }

    public bool IsReusable
    {
      get { return true; }
    }

    // private helper method that return an array of file names 
    // inside the text file stored in App_Data folder
    private static string[] GetScriptFileNames(string setName)
    {
      var scripts = new System.Collections.Generic.List<string>();

      string setDefinition =
                  System.Configuration.ConfigurationManager.AppSettings[setName] ?? "";
      string[] fileNames = setDefinition.Split(new char[] { ',' },
          StringSplitOptions.RemoveEmptyEntries);

      foreach (string fileName in fileNames)
      {
        if (!String.IsNullOrEmpty(fileName))
          scripts.Add(fileName);
      }
      return scripts.ToArray();
    }
  }
}

总结一下本文,我们介绍了一个名为 CacheController 的自定义控制器,以及该控制器中的一个路由,它允许我们在 ASP.NET MVC 应用程序中对 JavaScript 和 CSS 文件进行最小化/压缩/合并和缓存。我们还介绍了控制器中的一种机制,我们可以利用它来强制客户端刷新 JavaScript 和 CSS 文件。

希望这对您有所帮助。请告诉我您的想法或您可能有的改进此技术的想法。

历史

  • 2009 年 3 月 8 日:初始版本
© . All rights reserved.