How do I use ASP.NET bundling and minification without recompiling?
Asked Answered
A

3

3

Constraints: I'm not using MVC, just regular ol' .aspx files in my web app. Not using master pages either - each page is a different beast, so that solution isn't right for me.

Most examples I've read for bundling and minification require either some special MVC markup or require you to identify the bundled scripts / stylesheets up front and then refer to these bundles. I want to avoid recompiling DLLs every time I add or modify a .js reference in a .aspx page.

I'm a bit stumped from reading the Msft docs.. is there a way (like an ASP.NET control) that I can just wrap a series of script tags (or link tags for CSS) to create and use a bundle dynamically? I don't want to reinvent the wheel, but seriously considering creating my own user control / custom control that handles this. Are there other options?

For example, looking for something like this:

<asp:AdHocScriptBundle id="mypage_bundle" runat="server">
    <script type="text/javascript" src="~/scripts/mypage1.js"></script>
    <script type="text/javascript" src="~/scripts/mypage2.js"></script>
    <script type="text/javascript" src="~/scripts/mypage3.js"></script>
</asp:AdHocScriptBundle>

that, when bundling is enabled, automatically replaces the contents of asp:AdHocScriptBundle with a single script tag that resembles this:

<script type="text/javascript" src="/webappname/bundles/mypage_bundle.js?v=dh120398dh1298dh192d8hd32d"></script>

And when Bundling is disabled, outputs the contents normally like this:

<script type="text/javascript" src="/webappname/scripts/mypage1.js"></script>
<script type="text/javascript" src="/webappname/scripts/mypage2.js"></script>
<script type="text/javascript" src="/webappname/scripts/mypage3.js"></script>

Any thoughts?

About to roll my own anyway, but if there is already a solution for this please share, thanks!

Arrant answered 3/7, 2013 at 0:17 Comment(1)
Similar problem but with different (non web control) solution: #13124718Arrant
A
5

I rolled my own solution and it works great! I created 4 classes that I can use as custom server controls:

  • ScriptBundle
  • Script
  • StyleBundle
  • Link

These call functions around my custom bundling library which is itself a wrapper for the System.Web.Optimization API.

During Render of ScriptBundle and StyleBundle I then check an internal setting (the same one that I use to set EnableOptimizations in the System.Web.Optimization API) that tells the page to either use bundling, or simply write out the normal script / link tags. If Bundling is enabled it calls this function from my custom bundling library (for Scripts, similar code for Styles tho. Bundler in code below is the class for my custom bundling library - just in case Microsoft changes the System.Web.Optimization API I wanted a layer in-between so that I wouldn't have to change my code as much):

    public static void AddScriptBundle(string virtualTargetPath, params string[] virtualSourcePaths)
    {
        var scriptBundle = new System.Web.Optimization.ScriptBundle(virtualTargetPath);
        scriptBundle.Include(virtualSourcePaths);
        System.Web.Optimization.BundleTable.Bundles.Add(scriptBundle);
    }

To make sure that I only create the Bundle if it does not already exist, I first check for the Bundle using this method (before using the above method):

    public static bool BundleExists(string virtualTargetPath)
    {
        return System.Web.Optimization.BundleTable.Bundles.GetBundleFor(virtualTargetPath) != null;
    }

Then I use this function to spit out the URL to the bundle by using System.Web.Optimization:

    public static System.Web.IHtmlString GetScriptBundleHTML(string virtualTargetPath)
    {
        return System.Web.Optimization.Scripts.Render(virtualTargetPath);
    }

Within my .aspx files, I do this:

<%@ Register TagPrefix="cc1" Namespace="AdHocBundler" Assembly="AdHocBundler" %>

...

<cc1:ScriptBundle name="MyBundle" runat="Server">
    <cc1:script src='~/js/script1.js'/>
    <cc1:script src='~/js/utils/script2.js'/>
</cc1:ScriptBundle>

The trick for me was figuring out that I had to convert script and link tags to be work as list items within the ScriptBundle and StyleBundle controls, but after that it works great AND it let me use the tilde operator for easy references relative to app root (using Page.ResolveClientUrl(), which is helpful for creating module content).

Thanks go to this SO answer for helping me figure out how to create a custom collection control: How do you build an ASP.NET custom control with a collection property?

UPDATE: In the interest of full disclosure, I got permission to share the code for ScriptBundle (StyleBundle is almost identical, so did not include it):

[DefaultProperty("Name")]
[ParseChildren(true, DefaultProperty = "Scripts")]
public class ScriptBundle : Control
{
    public ScriptBundle()
    {
        this.Enabled = true;
        this.Scripts = new List<Script>();
    }

    [PersistenceMode(PersistenceMode.Attribute)]
    public String Name { get; set; }

    [PersistenceMode(PersistenceMode.Attribute)]
    [DefaultValue(true)]
    public Boolean Enabled { get; set; }

    [PersistenceMode(PersistenceMode.InnerDefaultProperty)]
    public List<Script> Scripts { get; set; }

    protected override void Render(HtmlTextWriter writer)
    {
        if (String.IsNullOrEmpty(this.Name))
        {
            // Name is used to generate the bundle; tell dev if he forgot it
            throw new Exception("ScriptBundle Name is not defined.");
        }

        writer.BeginRender();

        if (this.Enabled && Bundler.EnableOptimizations)
        {
            if (this.Scripts.Count > 0)
            {
                string bundleName = String.Format("~/bundles{0}/{1}.js",
                    HttpContext.Current.Request.FilePath,
                    this.Name).ToLower();

                // create a bundle if not exists
                if (!Bundler.BundleExists(bundleName))
                {
                    string[] scriptPaths = new string[this.Scripts.Count];
                    int len = scriptPaths.Length;
                    for (int i = 0; i < len; i++)
                    {
                        if (!string.IsNullOrEmpty(this.Scripts[i].Src))
                        {
                            // no need for resolve client URL here - bundler already does it for us, so paths like "~/scripts" will already be expanded
                            scriptPaths[i] = this.Scripts[i].Src;
                        }
                    }
                    Bundler.AddScriptBundle(bundleName, scriptPaths);
                }

                // spit out a reference to bundle
                writer.Write(Bundler.GetScriptBundleHTML(bundleName));
            }
        }
        else
        {
            // do not use bundling. generate normal script tags for each Script
            foreach (Script s in this.Scripts)
            {
                if (!string.IsNullOrEmpty(s.Src))
                {
                    // render <script type='<type>' src='<src'>/> ... and resolve URL to expand tilde, which lets us use paths relative to app root
                    // calling writer.Write() directly since it has less overhead than using RenderBeginTag(), etc., assumption is no special/weird chars in the cc1:script attrs
                    writer.Write(String.Format(Script.TAG_FORMAT_DEFAULT,
                        s.Type,
                        Page.ResolveClientUrl(s.Src)));
                }
            }
        }
        writer.EndRender();
    }
}

public class Script
{
    public const String ATTR_TYPE_DEFAULT = "text/javascript";
    public const String TAG_FORMAT_DEFAULT = "<script type=\"{0}\" src=\"{1}\"></script>";

    public Script()
    {
        this.Type = ATTR_TYPE_DEFAULT;
        this.Src = null;
    }

    public String Type { get; set; }
    public String Src { get; set; }
    public String Language { get; set; }
}
Arrant answered 17/7, 2013 at 0:34 Comment(5)
Sounds like you are injecting your bundled script into the page rather than creating an optimized external link. Is that true? If that's the case, you're defeating half the purpose, which is to be able to cache the script for repeated use.. Essentially, you're cancelling out any benefit you're getting because yes, you're minifying your script, but now you're having to download the whole thing every time you load a page that references the bundle, so in effect you're probably at a net negative.Doscher
This is worse than not doing any bundling at all because as @MystereMan said, your scripts are being sent as part of the payload on every request. Bundling provides caching through HTTP 304 responses so the entire thing doesn't have to be downloaded every time. Your page performance is now slower and your bandwidth costs are higher than where you originally started.Lightfingered
I don't inject bundled script into the response. I use System.Web.Optimization.Scripts.Render() to inject the optimized external link (with v=hash). This is a COTS application, and now we don't have to tell customers to have users manually refresh cache after upgrades. I am definitely open to feedback, but Fiddler shows that (1) bundled script is cached, and (2) cache is updated when .js contents change. Try it out if you have the time.. I understand this may not be a best practice when starting from scratch, but it helped me convert a legacy web app to use bundling.Arrant
@Arrant - It seems you have a potential problem here. What happens if two pages use the same bundle name, but have different script definitions, and two users are using those pages at the same time? You need to make bundle names unique per page, but then that means you're not caching common scripts anymore except on a per page basis. jQuery will get downloaded for each page, for instanceDoscher
The .aspx filename is included in the script src path (via HttpContext.Current.Request.FilePath) which covers case where two pages have same bundle name. (If a single page has two bundles, they must have different Names). If multiple pages share a single .js file, I still create the 'shared' bundles in advance (in Application_start), but was considering allowing an override to the filename-based behavior to make it easier for 'shared' ad-hoc bundles. (For shared bundles that were created in advance, I put this in .aspx: <%=Tools.Bundler.GetScriptBundleHTML("~/bundles/validation.js") %>)Arrant
L
1

This isn't possible with the default Bundling/Minification in ASP.NET. The entire point of bundling is to create one single to file to reduce the number of browser requests to load static files such as .JS and .CSS files.

Rolling your own is your only option. However, please note that each <script> line will result in a browser request. Since most browsers can only handle 6 requests concurrently, you can have wait times just to load these static files.

And FYI, you don't have to recompile DLLs every time you update your .JS files with built-in bundling. You can simply reset the application pool the app is running on. If you're running with an external session persistence model, your users won't notice when this happens.

Lightfingered answered 15/7, 2013 at 23:44 Comment(2)
It is possible, and I accomplished it. See my answer above. I do get what you mean about recompiling not required and I should have been more specific -- the reason I had to do this before was because I had chosen to create the 'core' bundles (with scripts used on every page) during application_start inside of Global.asax.cs, but I realize now that was not strictly necessary - thank you.Arrant
Yes, one benefit is to overcome browser limitations for concurrent requests, but other major benefit is that System.Web.Optimization creates a hash based on file contents and browsers use this to know whether files have changed. If browser sees a different Request URL it will re-request the resource. Otherwise browser will typically (depends on settings) check cache (and TTL: what server uses to set the cache's time-to-live), and if no cache found, or if TTL expired, re-request the resource from server. If server content has changed, server responds with new content, otherwise sends HTTP 304.Arrant
D
0

Your problem here is that you aren't really thinking this problem through. If you were, you would realize that what you are asking for can't work.

Why? Because the script tag ahs to generate an external link reference to a different url. So anything you place in the header of the current file will have no affect on your other URL that actually contains your bundles. As such, there is no way to dynamically change your bundles in the page itself because bundles have to, by definition, be defined in an external resource.

Now, there's nothing that says those bundles have to be compiled into DLL's in your own solution, but they cannot be embedded in the page that's currently being rendered.

You might want to investigate some of the javascript based minification tools out there, since they are typically not compiled.

Doscher answered 15/7, 2013 at 23:56 Comment(4)
I completely disagree. See my answer above.Arrant
Hmm, lots of naysayers, but whether they believe me or not -- my solution works very well and requires minimal code changes to .aspx files. Also - since this is a data-heavy enterprise web application, minification was the least of my concerns. I should have said this before, but my #1 concern was to let users' browsers update cache automatically when .js files' content changed. (Other goal I did not mention before was to make conversion of existing <script> tags as simple as possible, since this web app has hundreds of .aspx files and I have to train other devs on how to do it.)Arrant
@Arrant - so in other words, you didn't want to achieve any of the goals of web optimization.. and yet insisted that you could in fact do something you aren't in fact doing. I'm glad your solution works for you, but it's not what you asked for.Doscher
Not sure I follow. I get (1) minification (less data sent initially), (2) auto cache update (due to hash of file contents), and (3) improved performance during cache updates (due to bundling multiple script files to overcome browsers' limit of concurrent requests to a single domain). Minification is low priority for me since many pages are data-heavy and typically use AJAX to request data in chunks (size of .js files is tiny proportionate to size of XML/Json data requested). Highest priority was to let cache be updated automatically whenever .js files change (like after an upgrade).Arrant

© 2022 - 2024 — McMap. All rights reserved.