It’s a common problem. You have a partial view, let’s say a carousel, with an associated javascript file. You want to include it on multiple pages of your site. The usual solution is to include the script in a @section scripts { }, but you can’t do that in a partial view. What to do?
The simple answer is to remember to include the script in the appropriate page view files, but I’m an extremely forgetful person, liable to not do this and then spend half an hour trying to figure out why my component isn’t working… Not ideal.
If we wanted to stay in familiar territory, we could keep using the scripts section, nesting them for each view. This means that the layout and partial files can render or use the section as normal, but all intermediate files would need to include the following:
@section scripts {
@RenderSection("scripts", required: false)
}
Again, not ideal. Plus I’ve not actually tested to see if this works.
While I was working at Enjoy Digital, I came across an alternative, which I’ve refined since. At its simplest, it uses the HttpContext to store a list of scripts to output in the layout. The HttpContext stores all information related to the active request, like the user’s cookies and the active session. This means we can also use it to store any scripts that we may need to output.
public enum ScriptPosition
{
HeadEnd,
BodyStart,
BodyEnd
}
public static class HtmlHelperExtensions
{
private class RequiredScript
{
public string Source { get; set; }
public IHtmlString RawCode { get; set; }
public bool Async { get; set; }
public bool Defer { get; set; }
public ScriptPosition Position { get; set; }
}
private static IList<RequiredScript> Scripts
{
get
{
return HttpContext.Current.Items["RequiredScripts"] as IList<RequiredScript> ?? new List<RequiredScript>();
}
set
{
HttpContext.Current.Items["RequiredScripts"] = value;
}
}
public static void RequireScriptUrl(this HtmlHelper html, string url, bool async = true, bool defer = true, ScriptPosition position = ScriptPosition.BodyEnd)
{
var scripts = Scripts;
if (scripts.All(s => s == null || !s.Source.Equals(url, System.StringComparison.InvariantCultureIgnoreCase)))
{
scripts.Add(new RequiredScript { Source = url, Async = async, Defer = defer, Position = position });
Scripts = scripts;
}
}
public static void RequireScriptCode(this HtmlHelper html, IHtmlString code, ScriptPosition position = ScriptPosition.BodyEnd)
{
var scripts = Scripts;
if (scripts.All(s => s == null || !s.RawCode.ToString().Equals(code.ToString())))
{
scripts.Add(new RequiredScript { RawCode = code, Position = position });
Scripts = scripts;
}
}
public static IHtmlString RenderScripts(this HtmlHelper html, ScriptPosition position)
{
var builder = new StringBuilder();
foreach (var script in Scripts.WhereNotNull().Where(s => s.Position == position))
{
if (script.RawCode != null && !string.IsNullOrWhiteSpace(script.RawCode.ToString()))
{
builder.AppendLine("<script>" + script.RawCode + "</script>");
}
else if (!string.IsNullOrWhiteSpace(script.Source))
{
builder.AppendLine("<script src=\"" + script.Source + "\"" + (script.Defer ? " defer" : null) + (script.Async ? " async" : null) + "></script>");
}
}
return new HtmlString(builder.ToString());
}
}
That’s a lot to unpack! Let’s go through it bit by bit. Firstly we have the ScriptPosition enum, allowing us to define the different parts of the HTML that scripts can appear in. We could add additional members to this, but these three are the only ones I’ve needed so far.
We then have the RequiredScript class. This simply defines what a script consists of, so we can store them easily. Its properties allow us to track what scripts should have the defer and async attributes, as well as their intended position in the final HTML.
We define a property for easy access to our HttpContext item. The string value doesn’t particularly matter, as long as we use the same one throughout and it doesn’t clash with anything else. Feel free to change it if needed.
What follows are two extension methods to the HtmlHelper, one for including scripts with a particular URL, the other for raw code. We would use these wherever we want to include our scripts, so in views, partials, even controllers! For our carousel example, we can include the script directly from our partial view:
@Html.RequireScriptUrl("//cdn.jsdelivr.net/npm/<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="790a15101a12541a180b160c0a1c15394857415748">[email protected]</a>/slick/slick.min.js")
@Html.RequireScriptUrl("/assets/js/carousel.js")
Finally, we have a third extension method for outputting our stored scripts. This is called where we want to render the scripts associated with each position, usually the layout file. Mine looks something like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sharper Pencil</title>
<link href="/assets/css/style.css" rel="stylesheet">
@Html.RenderScripts(ScriptPosition.HeadEnd)
</head>
<body>
@Html.RenderScripts(ScriptPosition.BodyStart)
@Html.Partial("Header")
<div class="main-content">
@RenderBody()
</div>
@Html.Partial("Footer")
<script src="/assets/js/common.js"></script>
@Html.RenderScripts(ScriptPosition.BodyEnd)
</body>
</html>
Because the layout file is rendered last, it doesn't matter where we've included the scripts from, they will always be rendered in the appropriate position.
For an added bonus, we could prioritise our scripts by adding a Priority integer property to our RequiredScript class, and a similar parameter to the appropriate extension methods, then ordering by this in the render method. This would mean that if a script has any dependencies, we can ensure that the dependencies are loaded first by giving them a higher priority.
We could also use similar methods to render anything else into our layout file - CSS, meta tags, even additional HTML snippets! All we'd need would be the appropriate Require and Render methods, using the HttpContext.Current.Items dictionary to store everything. I'd recommend not going too crazy with this though; only use it if you have to!
And that's it! No more forgetting to include the appropriate scripts for each partial in every single view, and no more hacky workarounds (except this one). You can finally associate your component directly with its scripts!