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

Package that speeds up loading of JavaScript, CSS and image files

Rate me:
Please Sign up or sign in to vote.
4.95/5 (114 votes)
29 Mar 2012CPOL44 min read 879.5K   4.4K   260  
Improves web site performance by combining and minifying JavaScript and CSS files on the fly. Processes ASP.NET AJAX toolkit .axd files too. Improves image caching and loading. Very easy to add to any ASP.NET web site.
using System;
using System.Web;
using System.Web.UI;
using System.Text;
using System.Web.UI.WebControls.Adapters;
using System.Web.UI.WebControls;
using System.Web.UI.Adapters;
using System.Text.RegularExpressions;
using System.Collections.Generic;
using System.IO;
using System.Web.Caching;
using System.Web.UI.HtmlControls;
using System.Linq;


namespace CombineAndMinify
{
    public class HeadAdapter : ControlAdapter
    {
        protected override void Render(HtmlTextWriter writer)
        {
            ConfigSection cs = ConfigSection.CurrentConfigSection();

            // If we are not active, render the head section to the writer as is.
            if (!ConfigSection.OptionIsActive(cs.Active))
            {
                base.Render(writer);
                return;
            }

            // --------------

            UrlProcessor urlProcessor =
                new UrlProcessor(
                    cs.CookielessDomains, cs.MakeImageUrlsLowercase, cs.InsertVersionIdInImageUrls, cs.InsertVersionIdInFontUrls,
                    ConfigSection.OptionIsActive(cs.EnableCookielessDomains), cs.PreloadAllImages,
                    ConfigSection.OptionIsActive(cs.ExceptionOnMissingFile),
                    cs.GeneratedFolder, cs.EnableGeneratedFiles,
                    HttpContext.Current.IsDebuggingEnabled);

            // --------------

            // headHtml holds the html on the page making up the 
            // head element, including the <head> tag itself.
            StringBuilder headHtmlSb = new StringBuilder();
            base.Render(new HtmlTextWriter(new StringWriter(headHtmlSb)));

            // ------------

            HeadAnalysis headAnalysis = null;
            if (cs.HeadCaching == ConfigSection.HeadCachingOption.None)
            {
                headAnalysis = new HeadAnalysis(
                            headHtmlSb.ToString(), null, cs.CombineCSSFiles, cs.CombineJavaScriptFiles,
                            cs.MinifyCSS, cs.MinifyJavaScript, cs.EnableAxdProcessing,
                            urlProcessor);
            }
            else
            {
                string headCacheKey = HeadCacheKey(HttpContext.Current.Request.Url, cs.HeadCaching);
                headAnalysis = (HeadAnalysis)HttpContext.Current.Cache[headCacheKey];
                if (headAnalysis == null)
                {
                    // The urls of the combined CSS and JavaScript files in the new head
                    // are dependent on the versions of the actual files (because they contain the version
                    // ids).

                    // totalFileNames will be filled with a list of the names of all
                    // CSS and JavaScript files loaded in the head (that is, those
                    // that get combined and/or minified).
                    // And also with the other dependent files, such as image files referenced by CSS files.
                    ISet<string> totalFileNames = new HashSet<string>();
                    headAnalysis = new HeadAnalysis(
                            headHtmlSb.ToString(), totalFileNames, cs.CombineCSSFiles, cs.CombineJavaScriptFiles,
                            cs.MinifyCSS, cs.MinifyJavaScript, cs.EnableAxdProcessing,
                            urlProcessor);

                    AddPageFilePaths(totalFileNames);

                    CacheDependency cd = new CacheDependency(totalFileNames.ToArray());
                    HttpContext.Current.Cache.Insert(headCacheKey, headAnalysis, cd);
                }
            }

            // ------------
            // Do all replacements in the head specified in headAnalysis
            //
            // One little gotcha: when HeadAnalysis processes the head html, it actually processes a version
            // without html comments. So if a link tag or script tag was duplicated and one duplicate sat in 
            // an html comment, the code below will modify the html comment.

            foreach(HeadAnalysis.Replacement r in headAnalysis.Replacements)
            {
                headHtmlSb.Replace(r.original, r.replacement);
            }

            // ------------
            // Process all images in the page if needed. 

            if (cs.RemoveWhitespace || urlProcessor.ImagesNeedProcessing())
            {
                ProcessAllImages(Control.Page.Controls, urlProcessor, cs.RemoveWhitespace);
            }

            // ------------

            string headHtml = headHtmlSb.ToString();

            // At this point, urlProcessor and headAnalysis contains all image urls.
            // Build the JavaScript to preload any images that need to be preloaded,
            // and insert it at the start of the head, just after the initial head tag.

            string preloadJS1 = PreloadJS(cs.PreloadAllImages, cs.PrioritizedImages, headAnalysis.ProcessedImageUrls);
            string preloadJS2 = PreloadJS(cs.PreloadAllImages, cs.PrioritizedImages, urlProcessor.ProcessedImageUrls);
            string preloadJS = preloadJS1 + preloadJS2;

            // If any urls need to be preloaded, insert the JavaScript block after the first > (that is, after the
            // head tag).
            if (!string.IsNullOrEmpty(preloadJS))
            {
                headHtml = InsertedAfterFirstTag(headHtml, preloadJS);
            }

            writer.Write(headHtml);
        }

        private void ProcessAllImages(ControlCollection cc, UrlProcessor urlProcessor, bool removeWhitespace)
        {
            bool imagesNeedProcessing = urlProcessor.ImagesNeedProcessing();
            int nbrControls = cc.Count;

            for(int i = 0; i < nbrControls; i++)
            {
                Control c = cc[i];

                if (c is LiteralControl)
                {
                    LiteralControl lit = (LiteralControl)c;
                    lit.Text = ProcessedLiteralControl(lit, removeWhitespace, urlProcessor, imagesNeedProcessing);
                }
                else if (c is DataBoundLiteralControl)
                {
                    DataBoundLiteralControl lit = (DataBoundLiteralControl)c;
                    string processedHtml = ProcessedLiteralControl(lit, removeWhitespace, urlProcessor, imagesNeedProcessing);
                    cc.RemoveAt(i);
                    cc.AddAt(i, new LiteralControl(processedHtml));
                }
                else if ((c is HtmlImage) && imagesNeedProcessing)
                {
                    HtmlImage hi = (HtmlImage)c;
                    hi.Src = urlProcessor.ProcessedUrl(hi.Src, FileTypeUtilities.FuzzyFileType.Image, null, null, hi, false).FinalUrl();
                }
                else if ((c is HyperLink) && imagesNeedProcessing)
                {
                    HyperLink hl = (HyperLink)c;
                    if (!string.IsNullOrEmpty(hl.ImageUrl))
                    {
                        hl.ImageUrl = urlProcessor.ProcessedUrl(hl.ImageUrl, FileTypeUtilities.FuzzyFileType.Image, null, null, hl, false).FinalUrl();
                    }
                }
                else if ((c is Image) && imagesNeedProcessing)
                {
                    Image img = (Image)c;
                    img.ImageUrl = urlProcessor.ProcessedUrl(img.ImageUrl, FileTypeUtilities.FuzzyFileType.Image, null, null, img, false).FinalUrl();
                }
                else
                {
                    ProcessAllImages(c.Controls, urlProcessor, removeWhitespace);
                }
            }
        }

        private string ProcessedLiteralControl(ITextControl textControl, bool removeWhitespace, UrlProcessor urlProcessor, bool imagesNeedProcessing)
        {
            string literalContent = textControl.Text;
            string newLiteralContent = literalContent;

            // In rare cases, literalContent will be null. In that case, not testing on
            // Null or Empty would crash r.Match(literalContent) further down.
            if (!string.IsNullOrEmpty(literalContent))
            {
                if (imagesNeedProcessing)
                {
                    // The "src" group in this regexp doesn't just contain the image url, but also the src= and the quotes.
                    // That allows us to replace the entire src="...", instead of the url. 
                    // If you only replace the old url with the new url, than if you have a tag with url "images/ball3.png" after a tag with "/images/ball3.png"
                    // when the second url ("images/ball3.png") gets replaced, it alsos replace part of the first tag "/images/ball3.png" (because the first tag
                    // contains the second tag). 
                    const string regexpImgGroup =
                        @"<img[^>]*?(?<src>src[^=]*?=[^""']*?(?:""|')(?<url>[^""']*?)(?:""|'))[^>]*?>";

                    Regex r = new Regex(regexpImgGroup, RegexOptions.IgnoreCase);
                    Match m = r.Match(literalContent);

                    while (m.Success)
                    {
                        string oldSrc = m.Groups["src"].Value;

                        string oldUrl = m.Groups["url"].Value;
                        string newUrl = 
                            urlProcessor.ProcessedUrl(
                                oldUrl, FileTypeUtilities.FuzzyFileType.Image, null, null, (Control)textControl, false).FinalUrl();

                        string newSrc = @"src=""" + newUrl + @"""";

                        newLiteralContent = newLiteralContent.Replace(oldSrc, newSrc);

                        m = m.NextMatch();
                    }
                }

                if (removeWhitespace)
                {
                    newLiteralContent = CollapsedWhitespace(newLiteralContent);
                }
            }

            return newLiteralContent;
        }

        /// <summary>
        /// Inserts a string right after the very first tag in a string with html.
        /// For example, if the html contains a head section, the string is inserted
        /// right after the initial head tag.
        /// </summary>
        /// <param name="html">
        /// Contains the html into which the string will be inserted.
        /// </param>
        /// <param name="toInsert">
        /// String to be inserted.
        /// </param>
        /// <returns>
        /// Resulting html after the insertion.
        /// </returns>
        private string InsertedAfterFirstTag(string html, string toInsert)
        {
            int greaterThanIdx = html.IndexOf('>');

            // If > not found, or it is the very last character in the html,
            // append the string.
            if ((greaterThanIdx == -1) || (html.Length <= (greaterThanIdx + 1)))
            {
                return html + toInsert;
            }

            return html.Insert(greaterThanIdx + 1, toInsert);
        }

        /// <summary>
        /// Generates a string with the JavaScript block that preloads images.
        /// </summary>
        /// <param name="preloadAllImages">
        /// True if the urls in ProcessedImageUrls all need to be preloaded.
        /// </param>
        /// <param name="PrioritizedImages">
        /// Urls that need to be preloaded in any case.
        /// </param>
        /// <param name="ProcessedImageUrls"></param>
        /// <returns>
        /// The JavaScript block. Null or empty if there are no images to preload.
        /// </returns>
        private string PreloadJS(
            bool preloadAllImages, List<string> prioritizedImages, List<string> processedImageUrls)
        {
            // Get the list of urls to be loaded. Make sure prioritised urls come first.
            // Get rid of any duplicates.

            IEnumerable<string> preloadedUrls = null;
            if (preloadAllImages)
            {
                preloadedUrls = prioritizedImages.Union(processedImageUrls);
            }
            else
            {
                preloadedUrls = prioritizedImages.Distinct();
            }

            return PreloadJS(preloadedUrls);
        }

        /// <summary>
        /// Takes a list of image urls, and turns it into JavaScript that loads those images.
        /// </summary>
        /// <param name="preloadedUrls"></param>
        /// <returns></returns>
        private string PreloadJS(IEnumerable<string> preloadedUrls)
        {
            if ((preloadedUrls == null) || (preloadedUrls.Count() == 0))
            {
                return null;
            }

            StringBuilder js = new StringBuilder();
            js.AppendLine(@"<script type=""text/javascript"">");
            js.Append(@"( function() {");

            // Note that you need to load each image in its own Image object.
            // If you start loading image1 into an Image object, and then
            // start loading image2 into the same object, the loading of 
            // image1 gets cancelled by the browser (at least on Firefox).

            int sequence = 0;
            foreach (string url in preloadedUrls)
            {
                js.AppendFormat("var img{0}=new Image();img{0}.src='{1}';", sequence, url);
                sequence++;
            }

            js.AppendLine(@"} )();");
            js.Append(@"</script>");

            return js.ToString();
        }

        private string HeadCacheKey(Uri currentUrl, ConfigSection.HeadCachingOption headCachingOption)
        {
            const string keyPrefix = "HeadCacheKey__";

            switch (headCachingOption)
            {
                case ConfigSection.HeadCachingOption.PerSite:
                    // Always return the same key for all pages
                    return keyPrefix;

                case ConfigSection.HeadCachingOption.PerFolder:
                    // Key is based on domain + folder, but not the file
                    //
                    // If no .aspx file is specific, AbsoluteUri will still include
                    // the actual file, such as default.aspx.
                    int idxLastSlash = currentUrl.AbsoluteUri.LastIndexOf('/');
                    if (idxLastSlash == -1)
                    {
                        return keyPrefix + currentUrl.ToString();
                    }

                    return keyPrefix + currentUrl.AbsoluteUri.Substring(0, idxLastSlash);

                case ConfigSection.HeadCachingOption.PerPage:
                    // Key is based on folder + file, but not on query string
                    return keyPrefix + currentUrl.AbsolutePath;

                case ConfigSection.HeadCachingOption.PerUrl:
                    return keyPrefix + currentUrl.ToString();

                default:
                    throw new Exception(
                        "HeadCacheKey - unknown headCachingOption: " + headCachingOption.ToString());
            }
        }

        /// <summary>
        /// Collapses the white space and html comments in a string with HTML.
        /// 
        /// All HTML comments are removed.
        /// Then all runs of white space are replaced with a single space.
        /// If the run of white space contains a newline, it is replaced with a newline instead (so as not to break JavaScript).
        /// </summary>
        /// <param name="html"></param>
        /// <returns>
        /// HTML with white space collapsed and html comments removed.
        /// </returns>
        private string CollapsedWhitespace(string html)
        {
            // Remove html comments.
            string result = CombinedFile.HtmlCommentsReplaced(html, ""); 

            // Replace all runs of white space that contain a newline with a newline.
            // Such a run of white space starts with zero or more spaces or tabs,
            // which is then followed by one or more newlines, spaces or tabs.

            result = Regex.Replace(result, @"[ \t]*[\r\n][\r\n \t]*", "\r\n");

            // Replace the remaining runs of white space that only contain spaces or tabs
            // (but not newlines!) with spaces.

            result = Regex.Replace(result, @"[ \t]+", " ");

            return result;
        }

        /// <summary>
        /// Adds the paths of the files making up the current page to a string list.
        /// The current page consists of the .aspx file, and maybe the .master file.
        /// </summary>
        /// <param name="filePaths"></param>
        private void AddPageFilePaths(ISet<string> filePaths)
        {
            Page currentPage = Control.Page;
            Uri currentPageUri = currentPage.Request.Url;
            string currentPageFilePath = currentPage.MapPath(currentPageUri.AbsolutePath);

            string masterPageUrl = currentPage.MasterPageFile;

            filePaths.Add(currentPageFilePath);
            if (masterPageUrl != null) 
            {
                string masterPageFile = currentPage.MapPath(masterPageUrl);
                filePaths.Add(masterPageFile); 
            }
        }
    }
}


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
Architect
Australia Australia
Twitter: @MattPerdeck
LinkedIn: au.linkedin.com/in/mattperdeck
Current project: JSNLog JavaScript Logging Package

Matt has over 9 years .NET and SQL Server development experience. Before getting into .Net, he worked on a number of systems, ranging from the largest ATM network in The Netherlands to embedded software in advanced Wide Area Networks and the largest ticketing web site in Australia. He has lived and worked in Australia, The Netherlands, Slovakia and Thailand.

He is the author of the book ASP.NET Performance Secrets (www.amazon.com/ASP-NET-Site-Performance-Secrets-Perdeck/dp/1849690685) in which he shows in clear and practical terms how to quickly find the biggest bottlenecks holding back the performance of your web site, and how to then remove those bottlenecks. The book deals with all environments affecting a web site - the web server, the database server and the browser.

Matt currently lives in Sydney, Australia. He recently worked at Readify and the global professional services company PwC. He now works at SP Health, a global provider of weight loss web sites such at CSIRO's TotalWellBeingDiet.com and BiggestLoserClub.com.

Comments and Discussions