Click here to Skip to main content
15,891,423 members
Articles / Web Development / HTML

ASP.NET Custom Control for High Performance Web Scripts

Rate me:
Please Sign up or sign in to vote.
4.97/5 (20 votes)
13 May 2009CPOL12 min read 54.2K   547   78  
Presents a custom control replacement for the script tag that optimizes JavaScript for web pages. Automatically merges, prevents duplicates, externalizes, orders, adds expires headers, caches, minifies, and places your scripts.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Text;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.IO;
using System.Text.RegularExpressions;

namespace HighPerformanceScript {
    /// <summary>
    /// A replacement script tag control that applies high performance tricks to reduce
    /// bandwidth, latency and number of requests when loading web pages.
    /// </summary>
    [ParseChildrenAttribute(ChildrenAsProperties = true, DefaultProperty = "JavaScript")]
    [ToolboxData("<{0}:Script runat=server></{0}:Script>")]
    public class Script : WebControl {
        /// <summary>
        /// On load, add the control to the ScriptsInPage for later processing.
        /// </summary>
        protected override void OnLoad(EventArgs e) {
            base.OnLoad(e);
            ScriptsInPage.Add(this);
        }

        /// <summary>
        /// The inline JavaScript of the control, mutually exclusive with the Src property.
        /// </summary>
        [PersistenceMode(PersistenceMode.InnerDefaultProperty)]
        public string JavaScript {
            get {
                return myJavaScript;
            }
            set {
                myJavaScript = value;
            }
        }
        private string myJavaScript = "";

        /// <summary>
        /// The Src of the file for the external JavaScript, mutually exclusive with the JavaScript property.
        /// </summary>
        public string Src {
            get {
                return mySrc;
            }
            set {
                mySrc = value;
            }
        }
        private string mySrc = "";

        /// <summary>
        /// Indicates whether the OUTPUT of the control will be inline or external. This is only
        /// applicable if the INPUT to the control is inline. If external (the default) the inline
        /// script is served up as if it were an external file.
        /// </summary>
        public ScriptLocation Location {
            get {
                return myLocation;
            }
            set {
                myLocation = value;
            }
        }
        private ScriptLocation myLocation = ScriptLocation.External;

        /// <summary>
        /// All script controls in the page are aggregated into a single script tag.
        /// The first control to pre-render will collect all controls, emit the script tag and clear the list.
        /// Subsequent controls attempting to pre-render will find an empty list and will do nothing.
        /// Each control will not render at the point it is declared, so rendering is suppressed.
        /// </summary>
        protected override void Render(HtmlTextWriter writer) {
            if(String.IsNullOrEmpty(Src) == false && String.IsNullOrEmpty(JavaScript) == false) {
                throw new Exception("Script tag may declare either a source file or inline JavaScript, but not both.");
            }
            if(ScriptsInPage.Count > 0) {
                JavaScriptResource aggregateResource = GetAggregatedPageScripts();
                if(Page.Request.UserHostName == "127.0.0.1") {
                    foreach(string resourceName in aggregateResource.FileUrl.Split('|')) {
                        JavaScriptResource resource = JavaScriptResource.AllResources[resourceName];
                        string script = String.Format("<script src=\"{0}?File={1}&Hash={2}&Output=Full\" type=\"text/javascript\"></script>\n",
                        ResolveUrl("~/ScriptResource.aspx"), resource.FileUrl, resource.JavaScript.GetHashCode());
                        Page.ClientScript.RegisterStartupScript(typeof(Script), resourceName, script);
                    }
                }
                else {
                    string script = String.Format(@"<script src=""{0}?File={1}&Hash={2}"" type=""text/javascript""></script>",
                    ResolveUrl("~/ScriptResource.aspx"), aggregateResource.FileUrl, aggregateResource.MinifiedJavaScript.GetHashCode());
                    Page.ClientScript.RegisterStartupScript(typeof(Script), "singleton?", script);
                    ScriptsInPage.Clear();
                }
            }
            if(Location == ScriptLocation.Inline) {
                writer.WriteBeginTag("script");
                writer.WriteAttribute("type", "text/javascript");
                writer.Write(">");
                writer.Write(JavaScript);
                writer.WriteEndTag("script");
            }
        }

        /// <summary>
        /// ScriptsInPage provides a list of all scripts that are defined in the page. 
        /// The life of this needs to span across the page and into any user or custom controls,
        /// so it is stored in the 'request-cache' provided by HttpContext.Current.Items. 
        /// </summary>
        private static List<Script> ScriptsInPage {
            get {
                string dictionaryKey = "ScriptWebControls";
                if(HttpContext.Current.Items.Contains(dictionaryKey) == false) {
                    HttpContext.Current.Items.Add(dictionaryKey, new List<Script>());
                }
                return (List<Script>)HttpContext.Current.Items[dictionaryKey];
            }
        }

        /// <summary>
        /// Creates a single JavaScript resource out of all scripts declared in this
        /// page or on any control in the page's Controls tree.
        /// </summary>
        private JavaScriptResource GetAggregatedPageScripts() {
            string cachedPageScriptsResource = "cachedPageScriptsResource";
            if(HttpContext.Current.Items.Contains("cachedPageScriptsResource") == true) {
                return (JavaScriptResource)HttpContext.Current.Items["cachedPageScriptsResource"];
            }
            List<JavaScriptResource> currentPageScripts = new List<JavaScriptResource>();
            JavaScriptResource.RemoveFromCache(Page.Request.Path);
            int cachedFiles = JavaScriptResource.AllResources.Count;
            // Iterate through all scripts for file based scripts.
            foreach(Script scriptControl in ScriptsInPage) {
                if(scriptControl.Src != "") {
                    string scriptName = ResolveUrl(scriptControl.Src);
                    JavaScriptResource resource = JavaScriptResource.GetResourceFromFile(scriptName);
                    if(currentPageScripts.Contains(resource) == false) {
                        currentPageScripts.Add(resource);
                    }
                    AddPrerequisites(resource, currentPageScripts);
                }
            }
            currentPageScripts.Sort();
            // Iterate through all scripts for non-file based scripts.
            foreach(Script scriptControl in ScriptsInPage) {
                if(scriptControl.Location == ScriptLocation.Inline) {
                    continue;
                }
                if(scriptControl.Src == "") {
                    JavaScriptResource resource = JavaScriptResource.GetResourceForPage(Page);
                    resource.JavaScript += "\n" + scriptControl.JavaScript;
                    if(currentPageScripts.Contains(resource) == false) {
                        currentPageScripts.Add(resource);
                    }
                }
            }
            StringBuilder aggregateScriptName = new StringBuilder();
            foreach(JavaScriptResource file in currentPageScripts) {
                aggregateScriptName.Append(file.FileUrl);
                aggregateScriptName.Append("|");
            }
            aggregateScriptName.Length--; // trim last pipe from name.
            string name = aggregateScriptName.ToString();
            JavaScriptResource aggregateResource = JavaScriptResource.GetAggregateResource(name);
            HttpContext.Current.Items.Add(cachedPageScriptsResource, aggregateResource);
            return aggregateResource;
        }

        /// <summary>
        /// Given a JavaScript resource and a list of resources, add all of the prerequisites
        /// of the resource to the list. Also, if any prerequisites have prerequisites, those
        /// are added as well. 
        /// </summary>
        private void AddPrerequisites(JavaScriptResource dependantFile, List<JavaScriptResource> files) {
            foreach(JavaScriptResource prerequisite in dependantFile.Prerequisites) {
                if(files.Contains(prerequisite) == false) {
                    files.Add(prerequisite);
                    AddPrerequisites(prerequisite, files);
                }
            }
        }

        /// <summary>
        /// Registers a given URL as a script include that is managed by the high performance script 
        /// control. Absolute or application root relative URLs are required.
        /// </summary>
        public static void RegisterClientScriptInclude(Control controlOrPage, string fileUrl) {
            Script script = new Script();
            script.Src = fileUrl;
            script.Visible = true;
            ScriptsInPage.Add(script);
            controlOrPage.Controls.Add(script);
        }

        /// <summary>
        /// Registers a given URL as a script include that is managed by the high performance script 
        /// control. Absolute or application root relative URLs are required.
        /// </summary>
        public static void RegisterClientScriptBlock(Control controlOrPage, string javascript) {
            Script script = new Script();
            script.JavaScript = javascript;
            script.Visible = true;
            ScriptsInPage.Add(script);
            controlOrPage.Controls.Add(script);
        }

        /// <summary>
        /// Provides access to cached version of simple includes or aggregated scripts.
        /// AggregateName is a pipe-separated list of full paths to scripts. For example:
        /// "/MyRootScript.js|/Scripts/MyScript.js". If the aggregated version does not
        /// yet exist, it is created. This is therefore safe to call from web farms.
        /// </summary>
        public static string GetJavaScriptContent(string aggregateName, ScriptOutput output) {
            JavaScriptResource file = JavaScriptResource.GetAggregateResource(aggregateName);
            if(output == ScriptOutput.Full) {
                return file.JavaScript;
            }
            else {
                return file.MinifiedJavaScript;
            }
        }
        
        /// <summary>
        /// Private internal class that manages JavaScript resources, whether they are 
        /// external files, inline code or aggregated from other resources.
        /// </summary>
        private class JavaScriptResource : IComparable<JavaScriptResource> {
            /// <summary>
            /// No public constructors for JavaScriptResource, use one of: GetResourceFromFile,
            /// GetResourceForPage or GetAggregatedResource.
            /// </summary>
            private JavaScriptResource() {
            }
            
            
            /// <summary>
            /// Loads a JavaScript resource from the indicated file. If the file has already 
            /// been loaded and is not out of date, the cached version is returned. Processes 
            /// any include directives in the file and loads them as well. All resources
            /// are cached and then the specified resource is returned.
            /// </summary>
            public static JavaScriptResource GetResourceFromFile(string fileUrl) {
                FileInfo info = new FileInfo(HttpContext.Current.Request.MapPath(fileUrl));
                if(AllResources.ContainsKey(fileUrl) && AllResources[fileUrl].LastUpdate < info.LastWriteTime) {
                    // Have an out of date file, remove cached version.
                    RemoveFromCache(fileUrl);
                }
                if(AllResources.ContainsKey(fileUrl) == true) {
                    // Cache hit, check for out of date pre-requisites and then return cached version.
                    JavaScriptResource currentResource = AllResources[fileUrl];
                    foreach(JavaScriptResource prereqResource in currentResource.Prerequisites) {
                        GetResourceFromFile(prereqResource.FileUrl);
                    }
                    return currentResource;
                }
                JavaScriptResource resource = new JavaScriptResource();
                resource.myFileUrl = VirtualPathUtility.ToAbsolute(fileUrl);
                resource.myLastUpdate = info.LastWriteTime;
                try {
                    using(Stream inputStream = info.OpenRead()) {
                        using(StreamReader reader = new StreamReader(inputStream)) {
                            resource.JavaScript = reader.ReadToEnd();
                        }
                    }
                }
                catch(FileNotFoundException) {
                    string message = String.Format("Could not find file '{1}', referenced by URL '{0}'.",
                    fileUrl, info.FullName);
                    throw new FileNotFoundException(message);
                }
                Regex includeMatcher = new Regex(@"include\W*\(\W*""(.+\.js)""\W*\)");
                string currentPath = VirtualPathUtility.GetDirectory(resource.myFileUrl);
                foreach(Match match in includeMatcher.Matches(resource.JavaScript)) {
                    string prereqUrl = match.Groups[1].Value;
                    string prereqPath;
                    if(VirtualPathUtility.IsAppRelative(prereqUrl) == false) {
                        prereqUrl = currentPath + prereqUrl;
                    }
                    prereqPath = VirtualPathUtility.ToAbsolute(prereqUrl);
                    JavaScriptResource prereqResource = GetResourceFromFile(prereqPath);
                    resource.Prerequisites.Add(prereqResource);
                }
                AllResources.Add(fileUrl, resource);
                ClearGraphDepths();
                return resource;
            }
            
            /// <summary>
            /// Takes a name of an aggregate resource (e.g. /File1.js|/File2.js) and creates a cached
            /// version of the aggregate from the previously-cached components (i.e. File1.js and File2.js).
            /// </summary>
            public static JavaScriptResource GetAggregateResource(string aggregateName) {
                if(AllResources.ContainsKey(aggregateName) == false) {
                    StringBuilder body = new StringBuilder();
                    foreach(string fileUrl in aggregateName.Split('|')) {
                        body.Append(AllResources[fileUrl].JavaScript);
                        body.Append("\n");
                    }
                    JavaScriptResource file = new JavaScriptResource();
                    file.myFileUrl = aggregateName;
                    file.myGraphDepth = 1; // aggregated files have not prereqs.
                    file.JavaScript = body.ToString();
                    AllResources.Add(aggregateName, file);
                }
                return AllResources[aggregateName];
            }
            
            /// <summary>
            /// Gets a JavaScript resource for the indicated ASPX page. This resource
            /// is used to manage all of the inline scripts.
            /// </summary>
            public static JavaScriptResource GetResourceForPage(Page page) {
                JavaScriptResource resource;
                if(AllResources.ContainsKey(page.Request.Path) == false) {
                    resource = new JavaScriptResource();
                    resource.myFileUrl = page.Request.Path;
                    resource.myGraphDepth = 1; // No dependencies allowed in inline javascript.
                    FileInfo fi = new FileInfo(page.Request.PhysicalPath);
                    resource.myLastUpdate = fi.LastWriteTime;
                    AllResources.Add(page.Request.Path, resource);
                }
                return AllResources[page.Request.Path];
            }
            
            /// <summary>
            /// The absolute file URL for the resource.
            /// </summary>
            public string FileUrl {
                get {
                    return myFileUrl;
                }
            }
            private string myFileUrl;
            
            /// <summary>
            /// List of all resources that are a prerequisite for this resource. This
            /// only applies to resources loaded from file that had a 'include' directive.
            /// </summary>
            public List<JavaScriptResource> Prerequisites {
                get {
                    return myPrerequisites;
                }
            }
            private List<JavaScriptResource> myPrerequisites = new List<JavaScriptResource>();
            
            /// <summary>
            /// For file based scripts and inline scripts, the date the file was last written.
            /// Used to check and expire cache items when files have changed.
            /// </summary>
            public DateTime LastUpdate {
                get {
                    return myLastUpdate;
                }
            }
            private DateTime myLastUpdate;
            
            /// <summary>
            /// The longest distance from the current resource, through prerequisites
            /// to a independent resources. Used to enable quick and dirty graph sorting.
            /// </summary>
            public int GraphDepth {
                get {
                    SetGraphDepths();
                    return myGraphDepth;
                }
            }
            private int myGraphDepth = 0;
            private static bool graphDepthsValid = false;
            
            /// <summary>
            /// Clear graph depths whenever a file is re-read that could change the 
            /// prerequisite graph.
            /// </summary>
            private static void ClearGraphDepths() {
                graphDepthsValid = false;
            }
            
            /// <summary>
            /// When a graph depth is requested, ensure that they are all loaded.
            /// </summary>
            private static void SetGraphDepths() {
                if(graphDepthsValid == false) {
                    foreach(JavaScriptResource file in JavaScriptResource.AllResources.Values) {
                        file.myGraphDepth = 0;
                    }
                    foreach(JavaScriptResource file in JavaScriptResource.AllResources.Values) {
                        file.SetGraphDepth();
                    }
                    graphDepthsValid = true;
                }
            }
            
            /// <summary>
            /// Set the current graph depth as one greater than the large prerequisite.
            /// </summary>
            private int SetGraphDepth() {
                if(myGraphDepth > 0) {
                    // Already calculated, do nothing.
                }
                else if(Prerequisites == null || Prerequisites.Count == 0) {
                    // No prereqs, depth is 1.
                    myGraphDepth = 1;
                }
                else {
                    int maxPrereqDepth = 0;
                    foreach(JavaScriptResource prereq in Prerequisites) {
                        int currentPrereqDepth = prereq.SetGraphDepth();
                        maxPrereqDepth = Math.Max(maxPrereqDepth, currentPrereqDepth);
                    }
                    myGraphDepth = maxPrereqDepth + 1;
                }
                return myGraphDepth;
            }
            
            /// <summary>
            /// The JavaScript that is associated with this script tag, either read from file or 
            /// declared inline.
            /// </summary>
            public string JavaScript {
                get {
                    return myJavaScript;
                }
                set {
                    myJavaScript = value;
                    myMinifiedJavaScript = null;
                    myGraphDepth = 0;
                }
            }
            private string myJavaScript = "";
            
            /// <summary>
            /// The minified version of the JavaScript. This is read-only, set JavaScript property
            /// of object and this property will represent the minified version of the JavaScript.
            /// </summary>
            /// <remarks>
            /// Lazy evaluated property. Only compute it if it is needed and then save the information
            /// unless the JavaScript property changes.
            /// </remarks>
            public string MinifiedJavaScript {
                get {
                    if(myMinifiedJavaScript == null) {
                        JavaScriptSupport.JavaScriptMinifier minifier = new JavaScriptSupport.JavaScriptMinifier();
                        StringBuilder builder = new StringBuilder();
                        using(StringReader reader = new StringReader(myJavaScript)) {
                            using(StringWriter writer = new StringWriter(builder)) {
                                minifier.Minify(reader, writer);
                            }
                        }
                        myMinifiedJavaScript = builder.ToString();
                    }
                    return myMinifiedJavaScript;
                }
            }
            private string myMinifiedJavaScript = null;
            
            /// <summary>
            /// CompareTo is overridden to provide the order of JavaScript files as determined
            /// by their dependencies. That is, if a object 'file2' has object 'file1' as a 
            /// dependency through an 'include' setting, then 'file1' will compare less than 'file2'.
            /// The second part of the compare is alphabetic so that a unique ordering is defined.
            /// </summary>
            public int CompareTo(JavaScriptResource other) {
                if(GraphDepth == other.GraphDepth) {
                    return myFileUrl.CompareTo(other.myFileUrl);
                }
                else {
                    return GraphDepth.CompareTo(other.GraphDepth);
                }
            }
            
            /// <summary>
            /// List of all javascript resources. This is cached with each application.
            /// </summary>
            public static Dictionary<string, JavaScriptResource> AllResources {
                get {
                    string dictionaryKey = "JavaScriptFiles";
                    Dictionary<string, JavaScriptResource> files = HttpContext.Current.Application[dictionaryKey] as Dictionary<string, JavaScriptResource>;
                    if(files == null) {
                        files = new Dictionary<string, JavaScriptResource>();
                        HttpContext.Current.Application.Add(dictionaryKey, files);
                    }
                    return files;
                }
            }
            
            /// <summary>
            /// When a file is identified as having been changed, it is necessary to 
            /// remove that file from the cache as well as any aggregate that may have
            /// used it. 
            /// </summary>
            public static void RemoveFromCache(string FileUrl) {
                List<string> toDelete = new List<string>();
                foreach(string resourceUrl in AllResources.Keys) {
                    if(resourceUrl.Contains(FileUrl) == true) {
                        toDelete.Add(resourceUrl);
                    }
                }
                foreach(string resourceUrl in toDelete) {
                    AllResources.Remove(resourceUrl);
                }
            }
        }
        
        /// <summary>
        /// Indicates whether the script is in an external file through the SRC attribte, or was 
        /// declared inline in the script tag.
        /// </summary>
        public enum ScriptLocation {
            /// <summary>
            /// The script will be rendered in the web page at the point of the script tag.
            /// </summary>
            Inline,
            
            /// <summary>
            /// The script will be aggregated with others and included at the end of the page.
            /// </summary>
            External
        }
        
        /// <summary>
        /// Indicates how to output the script, for debuggin purposes on localhost this will be
        /// full, but during testing and production it will be minified for performance.
        /// </summary>
        public enum ScriptOutput {
            /// <summary>
            /// Output the complete body of all aggregated scripts, retaining comments, whitespace and line numbers.
            /// </summary>
            Full,
            
            /// <summary>
            /// Output a minified version suitable for production.
            /// </summary>
            Minified
        }
    }
}

By viewing downloads associated with this article you agree to the Terms of Service and the article's licence.

If a file you wish to view isn't highlighted, and is a text file (not binary), please let us know and we'll add colourisation support for it.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Team Leader Zuuse Pty Ltd
Australia Australia
I have been a professional software developer for twenty years, starting with C++ and migrated to C#. While I have transitioned into full time management, writing code is still my passion. As I don't write code for work very often, I have had the opportunity to apply my programming skills as a hobby where I have recently authored two Windows 8 store apps. First, an Asteroids tribute game, 'Roid Rage and most recently Shared Whiteboard (which does what it says).

I make a habit of contributing production code to every project I run. Most notably, I have recently run teams to build The Navigator for The Advertiser newspaper and Street Lights Out for SA Power Networks.

Comments and Discussions