Managing Scripts for Razor Partial Views and Templates in ASP.NET MVC

Published on Wednesday, 16 November 2011 by Russ Cam

UPDATE 13-03-2013: I've created a nuget package for the helpers. The original ScriptContext scope had to be brought back in for MVC 4 as the TemplateStack approach no longer worked.

UPDATE 21-11-2011: After some digging through the MVC framework code, I've come up with a slightly cleaner solution that doesn't require ScriptContext to implement IDisposable and therefore removes the need to create a "scope" for the scripts. Skip to the end to see the update.

The view engine in ASP.NET MVC is flexible, very flexible. With great flexibility comes great responsibility however, and managing JavaScript files and code blocks can be particularly cumbersome in conjunction with partial views and templates.

“It’s my script!,” “No, it’s my script!”

It’s a good idea to split discrete sections of page markup into their own partial views and templates, not only to support reuse but also to support the creation of richer interactive applications that take advantage of AJAX, fetching only the markup that is required from the server at any particular time.

If discrete sections of markup require JavaScript for client-side functionality, in my opinion, it’s good to keep the script near to where it’s needed i.e. declared in the partial view or template. Herein lies a problem; putting JavaScript in the middle of page markup will block subsequent rendering of the page until that script has been executed. To avoid this, YSlow recommends putting scripts near the end of the document, just before the closing </body> tag. Doing this however may cause another problem; the partial view script may be reliant on other script to have been executed before it runs, for example, if it is dependent on a JavaScript library. The question is then, how do we render scripts from partials and templates near the end of the document and control the order of rendering?

I get by with a little help from my friends

I’ve put together some HtmlHelper extension methods and a small class that aid in managing scripts and rendering them at the correct time. Note that I have only tried this with the Razor View Engine; the order of execution of the view hierarchies is different between Razor and WebForms views, so I wouldn’t expect this to work with the latter.

public static class HtmlHelperExtensions
{
    public static ScriptContext BeginScriptContext(this HtmlHelper htmlHelper)
    {
        var scriptContext = new ScriptContext(htmlHelper.ViewContext.HttpContext);
        htmlHelper.ViewContext.HttpContext.Items[ScriptContext.ScriptContextItem] = scriptContext;
        return scriptContext;
    }

    public static void EndScriptContext(this HtmlHelper htmlHelper)
    {
        var items = htmlHelper.ViewContext.HttpContext.Items;
        var scriptContext = items[ScriptContext.ScriptContextItem] as ScriptContext;

        if (scriptContext != null)
        {
            scriptContext.Dispose();
        }
    }

    public static void AddScriptBlock(this HtmlHelper htmlHelper, string script)
    {
        var scriptGroup = htmlHelper.ViewContext.HttpContext.Items[ScriptContext.ScriptContextItem] as ScriptContext;

        if (scriptGroup == null)
            throw new InvalidOperationException("cannot add a script block without a script context. Call Html.BeginScriptContext() beforehand.");

        scriptGroup.ScriptBlocks.Add(script);
    }

    public static void AddScriptFile(this HtmlHelper htmlHelper, string path)
    {
        var scriptGroup = htmlHelper.ViewContext.HttpContext.Items[ScriptContext.ScriptContextItem] as ScriptContext;

        if (scriptGroup == null)
            throw new InvalidOperationException("cannot add a script file without a script context. Call Html.BeginScriptContext() beforehand.");

        scriptGroup.ScriptFiles.Add(path);
    }

    public static IHtmlString RenderScripts(this HtmlHelper htmlHelper)
    {
        var scriptContexts = htmlHelper.ViewContext.HttpContext.Items[ScriptContext.ScriptContextItems] as Stack<ScriptContext>;

        if (scriptContexts != null)
        {
            var count = scriptContexts.Count;
            var builder = new StringBuilder();
            var script = new List<string>();
            var urlHelper = new UrlHelper(htmlHelper.ViewContext.RequestContext, htmlHelper.RouteCollection);

            for (int i = 0; i < count; i++)
            {
                var scriptContext = scriptContexts.Pop();

                foreach (var scriptFile in scriptContext.ScriptFiles)
                {
                    builder.AppendLine("<script type='text/javascript' src='" + urlHelper.Content(scriptFile) + "'></script>");
                }

                script.AddRange(scriptContext.ScriptBlocks);

                // render out all the scripts in one block on the last loop iteration
                if (i == count - 1)
                {
                    builder.AppendLine("<script type='text/javascript'>");
                    foreach (var s in script)
                    {
                        builder.AppendLine(s);
                    }
                    builder.AppendLine("</script>");
                }
            }

            return new MvcHtmlString(builder.ToString());
        }

        return MvcHtmlString.Empty;
    }
}

public class ScriptContext : IDisposable
{
    internal const string ScriptContextItems = "ScriptContexts";
    internal const string ScriptContextItem = "ScriptContext";

    private readonly HttpContextBase _httpContext;
    private readonly IList<string> _scriptBlocks = new List<string>();
    private readonly HashSet<string> _scriptFiles = new HashSet<string>();

    public ScriptContext(HttpContextBase httpContext)
    {
        if (httpContext == null)
            throw new ArgumentNullException("httpContext");

        _httpContext = httpContext;
    }

    public IList<string> ScriptBlocks { get { return _scriptBlocks; } }

    public HashSet<string> ScriptFiles { get { return _scriptFiles; } }

    public void Dispose()
    {
        var items = _httpContext.Items;
        var scriptContexts = items[ScriptContextItems] as Stack<ScriptContext> ?? new Stack<ScriptContext>();

        // remove any script files already the same as the ones we're about to add
        foreach (var scriptContext in scriptContexts)
        {
            scriptContext.ScriptFiles.ExceptWith(ScriptFiles);
        }

        scriptContexts.Push(this);

        items[ScriptContextItems] = scriptContexts;
    }
}

These can be used in your layout, view or partial view in one of three ways (all with the same outcome):

@{
    Html.BeginScriptContext();
    Html.AddScriptBlock(@"$(function() { if (console) { console.log('rendered from the view'); } });");
    Html.AddScriptFile("~/Scripts/jquery-ui-1.8.11.js");
    Html.EndScriptContext(); 
}

or

@{ 
  using (Html.BeginScriptContext())
  {
    Html.AddScriptFile("~/Scripts/jquery.validate.js");
    Html.AddScriptFile("~/Scripts/jquery-ui-1.8.11.js");
    Html.AddScriptBlock(@"$(function() { 
        $('#someField').datepicker();
    });");
  }
}

or using the ScriptContext directly

@{
  using (var context = Html.BeginScriptContext())
  {
    context.ScriptFiles.Add("~/Scripts/jquery-1.5.1.min.js");
    context.ScriptFiles.Add("~/Scripts/modernizr-1.7.min.js");
  }   
}

If you reference a script more than once, the helpers will ensure that it is rendered only once and in the ordinal position that matches the expected rendering order of views i.e.

  1. Layout
  2. View
  3. Partial View / Template

All that remains is to call @Html.RenderScripts() in your base layout, before the closing </body> tag.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>@ViewBag.Title</title>
    <link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" />
    <link href="@Url.Content("~/Content/themes/base/jquery.ui.all.css")" rel="stylesheet" type="text/css" />
</head>
<body>
    @RenderBody()
    @Html.RenderScripts()
</body>
</html>

I’ve put up an example up on Bitbucket – let me know what you think :)

An Updated Solution

After digging through the MVC 3 framework code looking for a way to determine when the razor template currently being rendered changes, I came across TemplateStack and the static method GetCurrentTemplate(HttpContext). Using this method, we can do away with creating a scope for a ScriptContext and simply use

@{
    Html.ScriptFile("~/Scripts/jquery-ui-1.8.11.js");
    Html.ScriptBlock(@"$(function() { if (console) { console.log('rendered from the view'); } });");
}

inside of any view, partial view or template. As a more elaborate example, take the following view

@model HomeModel
@{
    ViewBag.Title = "Index";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h2>ScriptContext Example</h2>

<p>4 datepickers rendered from an editor template. The jQuery UI script that contains the datepicker plugin is 
    added inside of the editor template using the script context Html Helpers and rendered out at the end of the page.</p>

@{ Html.RenderPartial("Partial", "partial 1"); }

@Html.EditorFor(m => m.Date1, "DatePicker")
@Html.EditorFor(m => m.Date2, "DatePicker")
@Html.EditorFor(m => m.Date3, "DatePicker")
@Html.EditorFor(m => m.Date4, "DatePicker")

@Html.Partial("Partial", "partial 2")

@{
    Html.ScriptFile("~/Scripts/jquery-ui-1.8.11.js");
    Html.ScriptBlock(@"$(function() { if (console) { console.log('rendered from the view'); } });");
}

This view is using the following Layout(_Layout.cshtml in the shared folder)

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>@ViewBag.Title</title>
        <link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" />
        <link href="@Url.Content("~/Content/themes/base/jquery.ui.all.css")" rel="stylesheet" type="text/css" />
    </head>
    <body>
        @RenderBody()
        @{
            Html.ScriptFile("~/Scripts/jquery-1.5.1.min.js");
            Html.ScriptFile("~/Scripts/modernizr-1.7.min.js");
        }
        @Html.RenderScripts()
    </body>
</html>

With the following editor template(DatePicker.cshtml in the shared folder)

@model DateTime
@Html.TextBox("", ViewContext.ViewData.TemplateInfo.FormattedModelValue)
@{
    Html.ScriptFile("~/Scripts/jquery-ui-1.8.11.js");
    Html.ScriptBlock(
        @"$(function() { 
    $('#" + ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName("") + @"').datepicker();
    if (console) { console.log('rendered from the editor template'); }
});");
}

and finally, the following partial(Partial.cshtml in the shared folder)

@model string
@{
    Html.ScriptBlock(@"$(function() { if (console) { console.log('rendered from " + Model + "'); } });");
}

Looking at the output with Firebug, we see the following

HTML markup seen with firebug

We can see that the external JavaScript file <script> elements have been added first, followed by the script from each of the razor templates involved in rendering the response. In both cases, the order of rendering is bottom-up i.e. those scripts added using the Html helpers near the end of the page have been rendered first.

I've updated the example source code up on bitbucket if you want to have a play :)


Comments

comments powered by Disqus