2008年9月9日火曜日

JavaScriptとCSSをまとめて圧縮する

もちろんASP.NET MVCで作るときの話。

ところでタイトル変だね。全部一緒にまとめて圧縮ってわけじゃないですよ。 JavaScriptもCSSも、外部ファイルにするでしょ。で、外部ファイルにする単位ってやっぱり機能や役割で分割すると思うんですよ。 分割したはいいけど、それらを個別にscriptタグやらlinkタグで読み込むとそれはそれはたくさんのHTTP要求が発生しますよ。

サイトのパフォーマンスをチェックするときに、YSlowって使うでしょ。使いませんか?そうですか。 いやいや、使うんですよ。 で、ハイパフォーマンスWebサイトにも書いてるけど ・HTTP要求の回数を減らしましょう。 ・JavaScriptとCSSは圧縮しましょう。 ・CSSはページの上部、JavaScriptは下部でインクルードしましょう。 ・キャッシュの有効期限設定しましょう。 っていうのが、オーソドックスなパフォーマンスを上げる方法になりますよね。 調べてみたんですよ。今作ってるやつどんなかな、って。 ひどいもんですよ。軒並みF判定ですよ。悲しいよね。 でも、そんなの分かってたことなんだよね。だって、最適化なんてしてないし。そろそろこの辺もちゃんと手をつけようと考えますわね。

ASP.NET MVC Action Filter - Caching and Compression - Kazi Manzur Rashid's Blog

もう、答え出ちゃってる感あるけど、まずはこのサイトを参考に。というか、もうそのまま。Cacheに関してはここのクラスを使わずOutputCacheフィルターを使います。 まずは、テスト用にプロジェクトを作成。 で、スタイルシートのStylesheet1.css、Stylesheet2.css、Stylesheet3.cssと、JavaScriptのJScript1.js、JScript2.js、JScript3.jsをContentの中に作成(中身は適当で)。 で、Helpersフォルダをルートに作って、その中にCompressAttribute.csとCompressHelper.csをそれぞれ“クラス”テンプレートで作成。

img.aspx

↑こんな感じになりましょう。

CompressAttribute.csの中身は先のページのままコピペ。 ヘッダをみて、圧縮ロジックを決めて、Response.Filterにセットするだけ。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

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

namespace MvcApplication1.P5
{
 /// <summary>
 ///
 /// </summary>
 /// <seealso href="http://weblogs.asp.net/rashid/archive/2008/03/28/asp-net-mvc-action-filter-caching-and-compression.aspx">Original Source by Kazi Manzur Rashid</seealso>
 public class CompressAttribute : ActionFilterAttribute
 {
   /// <summary>
   /// Initializes a new instance of the <see cref="CompressAttribute"/> class.
   /// </summary>
   public CompressAttribute()
   {
   }

   /// <summary>
   /// Called when [action executing].
   /// </summary>
   /// <param name="filterContext">The filter context.</param>
   public override void OnActionExecuting(ActionExecutingContext filterContext)
   {
     HttpRequestBase request = filterContext.HttpContext.Request;

     string acceptEncoding = request.Headers["Accept-Encoding"];

     if (string.IsNullOrEmpty(acceptEncoding))
       return;

     acceptEncoding = acceptEncoding.ToUpperInvariant();

     HttpResponseBase response = filterContext.HttpContext.Response;

     if (acceptEncoding.Contains("GZIP"))
     {
       response.AppendHeader("Content-encoding", "gzip");
       response.Filter = new GZipStream(response.Filter, CompressionMode.Compress);
     }
     else if (acceptEncoding.Contains("DEFLATE"))
     {
       response.AppendHeader("Content-encoding", "deflate");
       response.Filter = new DeflateStream(response.Filter, CompressionMode.Compress);
     }
   }
 }
}

続いて、ちょっと便利に使うためのヘルパー関数群を定義したCompressHelper.cs↓。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

using System.Web.Mvc;
using System.Text;

namespace System.Web.Mvc
{
 public static class CompressHelper
 {
   public static string ItemsKey = "_compressKeys";
   static string _formatScript = "<script type=\"text/javascript\" src=\"{0}\"></script>";
   static string _formatCss = "<link rel=\"stylesheet\" href=\"{0}\" type=\"text/css\" />";

   static Dictionary<string, List<string>> getItems(System.Web.UI.Page page)
   {
     var items = page.Items[ItemsKey] as Dictionary<string, List<string>>;
     if (items == null)
       page.Items[ItemsKey] = items = new Dictionary<string, List<string>>();

     return items;
   }

   public static void AddCompressSrc(this System.Web.UI.Page page, string key, string srcPath)
   {
     var items = getItems(page);
     if (!items.ContainsKey(key))
       items[key] = new List<string>();

     items[key].Add(srcPath);
   }

   public static string ScriptTags(this System.Web.UI.Page page, string key)
   {
     var items = getItems(page);
   
     var sb = new StringBuilder();
     foreach (var item in items[key])
       sb.Append(string.Format(_formatScript, item));
   
     return sb.ToString();
   }

   public static string CompressScriptTag(this System.Web.UI.Page page, string key)
   {
     return CompressTag(page, key, "application/x-javascript", _formatScript);
   }

   public static string CompressCssTag(this System.Web.UI.Page page, string key)
   {
     return CompressTag(page, key, "text/css", _formatCss);
   }

   public static string CompressCssTag(this System.Web.UI.Page page, string[] srcList)
   {
     var key = Guid.NewGuid().ToString();
     var items = getItems(page);
     items[key] = new List<string>();
     foreach (var src in srcList)
       items[key].Add(src);

     return CompressTag(page, key, "text/css", _formatCss);
   }

   private static string CompressTag(this System.Web.UI.Page page, string key, string type, string format)
   {
     var items = getItems(page);
     var list = items[key] as List<string>;
     if (list != null && list.Count > 0)
       return string.Format(format,
         string.Format("/Home/Compress?src={0}&type={1}",
                       page.Server.UrlEncode(list.Aggregate((s, ss) => s + "," + ss)),
                       page.Server.UrlEncode(type)));

     return "";
   }
 }
}

namespaceをSystem.Web.Mvcにしてるのは、ページでImport書くのが面倒だから。HtmlHelperにしてないのはPage.Itemsを使いたいから。 Page.Items にそのページで使うJavaScriptやらCSSやらを入れておいてまとめて出力するのに使ってます。Page.Itemsに入れておけば、マスターファイルとページファイルのそれぞれで入れた値をマスターファイル(Site.Masterね)から簡単に取り出せるから。同じコンテキスト内(HTTP 要求内って意味でのコンテキスト)でのデータの受け渡しができる(Session使う必要ないし)。 CSSの場合はページ上部にまとめて書いちゃうからPage.Itemsに入れる必要はないんだけど、その辺は好みの問題ってことで。 名前をつけて置けば、名前ごとの圧縮ファイルにできると思って、keyを渡すようにします。ぶっちゃけ意味なし。 どうやって使うかというと、まずはSite.Masterのheadタグの部分を↓こんな感じに変えましょう。

<head>
   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
   <title><%= Html.Encode(ViewData["Title"]) %></title>
   <%=Page.CompressCssTag(new[] { "/Content/Site.css", "/Content/Stylesheet1.css", "/Content/Stylesheet2.css", "/Content/Stylesheet3.css" })%>
   <% Page.AddCompressSrc("msajax", "/Content/MicrosoftAjax.js"); %>
   <% Page.AddCompressSrc("msajax", "/Content/MicrosoftMvcAjax.js"); %>
</head>

CSSは文字列配列でがっつり渡す。JavaScriptはまだタグのレンダリングしたくないので、Page.Itemsに入れておく。headに書いてるのは定義場所が上の方が見やすいかな、と思ったから。 で、同じくSite.Masterのbody閉じ以降を↓こんな感じで。

</body>
<%=Page.CompressScriptTag("msajax")%>
<%=Page.CompressScriptTag("myscript")%>
</html>

ここまでで、"msajax"をキーにしたスクリプトは2つ定義してるけど、"myscript"をはどこで入れ点だよ!って思った?それはHome/Index.aspxの中で入れてるんですね~。 各ページごとのスクリプトがサイト共通のスクリプトよりも上位に展開されると、悲しい結末が訪れるから、ちゃんとHTMLの最後に展開されるようにしましょう。

で、Home/Index.aspxの最後の部分を↓こんな感じで。

    <p>
       To learn more about ASP.NET MVC visit <a href="http://asp.net/mvc" title="ASP.NET MVC Website">http://asp.net/mvc</a>.
   </p>
   <% Page.AddCompressSrc("myscript", "/Content/jscript1.js"); %>
   <% Page.AddCompressSrc("myscript", "/Content/jscript2.js"); %>
   <% Page.AddCompressSrc("myscript", "/Content/jscript3.js"); %>
</asp:Content>

これを実行すると展開されるHTML(Home/Index)は↓こう。

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
   <title>Home Page</title>
   <link rel="stylesheet" href="/Home/Compress?src=%2fContent%2fSite.css%2c%2fContent%2fStylesheet1.css%2c%2fContent%2fStylesheet2.css%2c%2fContent%2fStylesheet3.css&type=text%2fcss" type="text/css" />
 
</head>

<body>
~省略~
</body>
<script type="text/javascript" src="/Home/Compress?src=%2fContent%2fMicrosoftAjax.js%2c%2fContent%2fMicrosoftMvcAjax.js&type=application%2fx-javascript"></script>
<script type="text/javascript" src="/Home/Compress?src=%2fContent%2fjscript1.js%2c%2fContent%2fjscript2.js%2c%2fContent%2fjscript3.js&type=application%2fx-javascript"></script>
</html>

これだけだと、外部ファイルはちゃんと読み込めませんね。 HomeコントローラーにCompressアクションを実装してませんでした。

    [Compress]
   [OutputCache(Duration=3600,VaryByParam="src")]
   public ActionResult Compress(string src, string type)
   {
     var items = src.Split(',');
     if (items != null && items.Length > 0)
     {
       var sb = new StringBuilder();
       foreach (var script in items)
       {
         string path = Server.MapPath(script);
         if (File.Exists(path))
           sb.Append(File.ReadAllText(path));
       }

       return new ContentResult() { Content = sb.ToString(), ContentType = type };
     }

     return new EmptyResult();
   } 

srcパラメータにまとめるファイル名をカンマ区切りで渡すと(渡すときにUrlEncodeしても、自動でUrlDecodeしてくれます)、ファイルを読み込んで1個にまとめて出力するようにしてます。 圧縮はCompressAttributeにお任せ。OutputCacheを指定して、パラメータ毎(外部ソース毎)に1時間キャッシュするように指定してみました。 CompressとOutputCacheを外してFirebugで見た結果が↓。

img.aspx2

※CSSのレスポンス

img.aspx3 ※JavaScriptのレスポンス。 どっちも圧縮されてないし、Expiresヘッダもないね。 で、CompressとOutputCacheをつけてFirebugで見た結果が↓。

img.aspx4 ※CSSのレスポンス。

img.aspx5 ※JavaScriptのレスポンス。

いずれもファイルサイズも小さくなってるし、Expiresヘッダもついてクライアントキャッシュが有効になってます。 ここまで来て圧縮なら「IISの静的ファイル圧縮使えば?」という思いが当然のごとく出てくるよね。もちろん試しましたよ!ファイルを1つにまとめる機能はないけど、圧縮はできるはずだもんね。でもね、なんかうまく出来なかったんですよ。CSSは出来てるのにJSが出来なかった。 httpCompressionのstaticTypesの指定が悪いのかなんなのか。何にせよ、設定でちゃんと思い通りに出来なかったらコードを書けばいいじゃないか! ※負け犬...。