2009年3月17日火曜日

ASP.NET MVCでJavaScriptを上手いこと使う

How to iterate through objects in ViewData via javascript on the page/view? - Stack Overflow

この質問の回答としては、ページにscriptタグを書き、その中でJavaScriptを書きつつサーバーサイドのコード(foreachやViewDataの参照)も埋め込んでしまえばいいじゃないか、というもの。

コレまでもさんざん悩んで、ViewEngineの実装に寄り道したり右往左往したけど、結局はグローバルな変数にJavaScriptで使用したいデータをViewDataから取り出して登録しておき、ロジック(JavaScriptで書くクライアントでのっていう意味で)は外部JSファイルに。外部JSファイルからグローバル変数にアクセスすることで、Ajaxによるサーバーからのデータ取得や、サーバーサイドコードの埋め込みを不要にして、純粋なJavaScriptコードのみの外部JSファイルを作成する感じです。

まずJavaScriptの用途は、サイト全体で共通のコード(jQuery.jsやprototype.jsなどのフレームワーク、またはアプリケーション共通機能)と、そのページでしか必要のない機能に分けられる。さらにデータとしてはJSONPでサーバーサイドのデータをクライアントサイドに渡したり、サーバーサイドのデータをクライアントサイドにJSONにシリアライズしてページに直接埋め込む方法がある。もちろんXHRで取得や、DOMそのものをデータとして使う方法など他にもあるけど、今回はその辺はとりあげません。

整理すると処理コードとして

  • サイト共通
  • ページ固有

データコードとして

  • JSONP
  • JSONシリアライズのページ埋め込み

というのを今回のテーマとします。

サイト共通コードは、通常~/Scriptsフォルダに入れておくのがRC以降のテンプレートになってます。ページ固有コードは通常View内に直接scriptタグを書いて、その中に直接書くのがどこのチュートリアルにも書かれてるやり方になってます。が、View内に直接は書かないでViewと同じフォルダに外部JSファイルとして作成する方法をとります。なんでViewと同じフォルダに入れるかというと、スクリプトファイルを管理しやすくしたいからです。もちろん~/Scriptsに入れてもいいんだけど、そうするとViewと同じようなフォルダ構成(~/Content/Home/Index.jsとか)を作ってその中に入れるか、ルール通りのファイル名(Home.Index.jsとか)にして1つのフォルダ内にすべて保持するか。もちろんルール無用でViewとJSとを管理してもいいとは思うけど、簡単に管理しつつ、極力簡単なヘルパーでscriptタグを出力できるようにしたいので、Viewと同じフォルダ構成にしておき、アクション名.jsと命名するのがいいんじゃないかと。ただ、そうすると全く同じフォルダ構成を用意するというちょっとかっこ悪いことになり、スッキリしない!という理由でページ固有コードはViewと同じフォルダに保持するのがいいんじゃないかと思う次第です。

~/Viewsフォルダには通常aspx/ascxのいずれかを入れておく(WebFormViewEngine)んですが、そこに.jsファイルを置いておき、scriptタグでsrc指定するとどうなるかと言うと、そんなアクションありませんと怒られます。

~/Views/Home/Index.jsがあるからといって

<script type=”text/javascript” src=”/Home/Index.js”></script>

と書いてもきちんとダウンロードされないということです。

そりゃ、そうです。規約でそうなってるから。独自のMasterLocationFormatsやViewLocationFormatsを定義したとしても、BuildManager.CreateInstanceFromVirtualPathでJScriptのコンパイラが走ってあえなくエラー(クライアントサイドのコードなのにサーバーサイドのコードとしてコンパイルしようとする)。ViewEngineがらみをどうのこうのするのは、ちょっと面倒。

簡単な解決方法は、ページ固有コードをscriptタグのsrcとして指定する場合にはパスのルートに何かしら固定のフォルダ名を指定するようにして、そういうルーティングを行うようにしてしまう。これなら簡単。ルーティングが出来てもMvcRouteHandlerを使ったんじゃ結局コンパイルしようとするので、そこはファイルを直接返すだけの簡単なRouteHandlerを書いてしまいましょう。ようするにStaticFileHandlerなんだけど...。

IRouteHandlerの実装と、IHttpHandlerの実装。

  public class ViewScriptRouteHandler : IRouteHandler
 {
   public IHttpHandler GetHttpHandler(RequestContext requestContext)
   {
     string scriptName = requestContext.RouteData.GetRequiredString("scriptName");
     string scriptPath = string.Format("~/Views/{0}", scriptName);
     return new ViewScriptFileHandler(scriptPath);
   }
 }

 public class ViewScriptFileHandler : IHttpHandler
 {
   string virtualPath;

   public ViewScriptFileHandler(string path)
   {
     virtualPath = path;
   }

   public bool IsReusable
   {
     get { return true; }
   }

   public void ProcessRequest(HttpContext context)
   {
     var path = context.Server.MapPath(virtualPath);

     context.Response.ContentType = "application/x-javascript";
     context.Response.TransmitFile(path);
   }
 }

これをRouteCollectionに登録します。ページ固有コードなので/ViewScripts/コントローラ名/アクション名.jsというURLでアクセスさせたいと思います。MapRoute拡張メソッドを使った場合、登録したRouteが返ってくるので、それに直接ハンドラをセットするやり方です。

      routes.MapRoute(
       "ViewScripts",
       "ViewScripts/{*scriptName}",
       null,
       new { scriptName = @"[\w]*\/[.|\w]*\.js" }
     ).RouteHandler = new ViewScriptRouteHandler();

たったこれだけ。これだけのコードを書いておけば~/Views/Home/Index.jsを

<script type=”text/javascript” src=”/ViewScripts/Home/Index.js”></script>

として取得できます。あら簡単。

処理コードはとりあえずこれで組み込めるようになったので、こんどはJSONP。これはもう特に難しいことを考えずにコントローラのアクションとして実装してしまい、JavaScriptResultで返すのが一番簡単。

  [OutputCache(Location = OutputCacheLocation.None)]
 public ActionResult JsonpDate()
 {
   string result = "{}";
   try
   {
     result = Utility.ToJSON(new {Today = DateTime.Today.ToString(“yyyy/MM/dd”)});
   }
   catch { }

   return JavaScript(string.Format("var jsonp = {0};",result));
 }

例えば、HomeControllerに↑こんなアクションがあったとしたら

<script type=”text/javascript” src=”/Home/JsonpDate”></script>

と、書く事でjsonp変数に値が入ります。↓こう書いたのと同じ意味になります。

<script type="text/javascript">
var jsonp = {Today: '2009/03/17'};
</script>

ちなみにUtility.ToJSONの中身は単純。

    public static string ToJSON(object values)
   {
     if (values != null)
     {
#pragma warning disable 0618
       JavaScriptSerializer serializer = new JavaScriptSerializer();
       return serializer.Serialize(values);
#pragma warning restore 0618
     }
     else
       return "";
   }

System.Web.Script.SerializationのJavaScriptSerializerを使います。MVCの中でも同じように使ってるから問題無いでしょう。

最後にページ固有のデータをViewDataに入れておき、View内でヘルパーを使ってscriptタグに展開してしまうコードを作成します。使い方のイメージを先に書いてしまうと↓こんな感じです。

  <% = Html.RenderJSON(new {
      Today = DateTime.Today.ToString("yyyy年MM月dd日"),
      AjaxUrl = ViewData["Url"]
    }) %>

ViewData[“Url”]にアクション内で値を入れてるという前提で。

  public static string RenderJSON(this HtmlHelper helper, object values)
 {
   var modelValues = new RouteValueDictionary(values);
   var dict = new Dictionary<string, object>();
  
   foreach (var modelValue in modelValues)
     dict.Add(modelValue.Key, modelValue.Value);
    
   var formatJSON = "<script type=\"text/javascript\">\n" +
                    "var localjson = {0};\n" +
                    "</script>\n";
   string json = "{}";
  
   if (dict.Count > 0)
   {
     var modelData = dict.Select(v=>new {
                               Key = v.Key,
                               Value = v.Value
                           })
                         .ToDictionary(v=>v.Key, v=>v.Value);
     json = Utility.ToJSON(modelData);
   }

   return string.Format(formatJSON, json);
 }

こんな感じでしょうか。↓ViewData["Url"]に"どこそこ"とアクションで入れてればこう展開されます。

<script type="text/javascript">
var localjson = {Today: '2009/03/17', AjaxUrl: 'どこそこ'};
</script>

これで、共通コードは~/Contentなり~/Scriptsから取得、ページ固有はViewと同じフォルダ内にjsファイルとして作成したものを~/ViewScriptsから取得。Jsonpは通常のアクションとして実装し、ページ固有のデータはViewData経由でActionからViewに渡した物をJsonで展開。クライアントサイドでちゃんとevalするべきところではあるけどその辺はご愛敬ってことで。

これだけでも、コードとデータをきちんと分離は出来てるけど、もっと便利に簡単に使うために、スクリプトとデータのRegisterヘルパーと、それらをページの最後でまとめて書き出す、Renderヘルパーなんかを作っておいて、さらに圧縮(Compress)/縮小化(Minify)/連結(Merge)なんかも実装してたりするんですが、それはまた今度。いつの日か。