4

I've looked at a number of questions similar to How to add a script in a partial view in MVC4? and MVC4 partial view javascript bundling Issue and am still struggling to understand ASP.NET MVC architecture when it comes to view-specific script. It seems the answer to others who have tried to include script in their MVC4 partial views is to put the script at a higher level. But some script can't be moved to a higher level where it will run more globally. For example, I don't want to run script that applies knockout.js data bindings for a view model whose controls aren't loaded. And I don't want to run a whole bunch of script for a whole bunch of views that aren't active every time I load a page.

So I started using the view-specific @Section Script blocks in my .vbhtml views to include script specific to a view. However, as pointed out by others, this does not work in a partial view. I am prototyping our architecture to see what we can and can't do here. I'd like to think that I might be able, in some cases, to use a view as a partial view and vice versa. But when you pull in a view to use as a partial view the @Section Script block does not render. I have managed to get all my viewmodel script defined globally in a way such that I need only run one line of code to create and bind a view model, but I still need that one line of code to run only when a particular view is active. Where can I appropriately add this line of code in a partial view?

ko.applyBindings(window.webui.inventoryDetailViewModel(ko, webui.inventorycontext));

Am I going down the right path here? Is this a proper way to architect an MVC application?

Edit Found this question very closely related to my problem, and includes a significant part of my answer: Can you call ko.applyBindings to bind a partial view?

Community
  • 1
  • 1
BlueMonkMN
  • 25,079
  • 9
  • 80
  • 146
  • What controls the loading of the partial? Could whatever controls the loading not also be responsible for applying the bindings? – DoctorMick Jan 17 '14 at 15:38
  • The `@Html.Partial` (Razor syntax) function includes a view as a partial view in its parent. That's a .NET/MVC Framework function and I'd prefer not to gunk up my code with custom functions where a standard has been established. – BlueMonkMN Jan 17 '14 at 15:41
  • Is the partial conditionally rendered or will it always be there? If it is always there I think @JotaBe's solution is a good one, if it's conditional you'd need to dynamically pull in the scripts which could be a pain although you could probably use an AMD like RequireJS. – DoctorMick Jan 17 '14 at 15:47
  • Yes, I'm leaning toward @JotaBe's solution right now, but also just discovered knockout templates, which might also be able to serve as an alternative solution or part of this solution. (http://knockoutjs.com/documentation/template-binding.html) – BlueMonkMN Jan 17 '14 at 15:51
  • I'm curious which direction you've decided to go with this. Did you make any headway in your decision? Find any pitfalls or promise? – Vinney Kelly Jan 22 '14 at 03:47
  • I'm investigating a path closely aligned with JotaBe's answer. I'm trying to find a way to download script on demand, but without the complexity of RequireJS and especially R.js because I want to continue to use MVC4's BundleConfig optimization mechanism. I'm trying JQuery's getScript function to download, and am writing script to smartly manage bundles. Hopefully I'll have something today or tomorrow and be able to post a complete answer. Check back in a bit. – BlueMonkMN Jan 22 '14 at 12:58
  • @VinneyKelly I finally added my answer. Let me know if I can clarify it in any way. – BlueMonkMN Jan 22 '14 at 18:16

3 Answers3

1

This is the best you can do, but there can be still problems:

  • What if your partial view is cached?
  • What if you render the partial view with Ajax?

So, I also recommend don't doing using this hacky trick. (Well, Darin Dimitrov's solution is great, but using it it not a good idea).

The best solution is to have all the scripts available when the partial is rednered:

  • loading them in the contianing page
  • loading them dynamically (that's harder to do)

If you do this, you can run the scripts when they are needed. But then, how do you only run the desired scripts on the desireds parts of your partials? The easier way is to mark them with custom data- attributes. Then you can "parse" the page, looking for your custom data- attributes, and running the scripts that apply: that's unobtrusive javascript.

For example, you can include an script that "parses" the page on jQuery's $(document).ready (when all the page, and all the scripts have finished loading). This script can look for the elements with the custom data- attributes ($('[data-my-custom-attr]').each( MyCustomSccript(this));

You can also take into account that the data- attributes can be used to configure your script, i.e. you can use an attribute to indicate that some kind of script must be run, and extra attributes to configure how the script runs.

And, what about partial views loaded with ajax? No problem. I told you could use $(document).ready, but you also have success callbacks in the functions used to load partial views with ajax, and you can make exactly the same on this callbacks. An you can register a global handler for jQuery.Ajax success, so your scripts will be applied to all your ajax loaded partials.

And you can even use more powerful techniques, like loading dynamically the scripts needed for your partials, as required for the attributes.

Usually, the problem, is that we think that JavaScript should be supplied from the server, but the truth is that JavaScript lives on the browser, and the browser should have more control on it

Description of architecture with dynamic loading of scripts:

  • main page: include a "parser script": this parser script is responsible for:

    • parsing the page (document ready event) or the ajax downloaded partial (ajax success event)
    • downloading, and storing the required scripts in a singleton in the page (the required are defined by `data-' attributes)
    • running the scripts (which are stored in the singleton)
  • partials

    • they have data-attributes on DOM elements so that the parser knows which scripts are required
    • they have additional data- attributes to pass extra data to the scripts

Obviously, it's very important to follow a good convention to name the scripts and the data-attributes, so that the code is easier to use and debug.

A good place to see how the scripts can be dynamically downloaded is: On-demand JavaScript

There are many solutions. Other option: How can I dynamically download and run a javascript script from a javascript console?

Your script should attach itself to the singleton, just like you do when you define a jQUery plugin. the content of a .js would be like this:

if (!MySingleton.MyNamespace) MySingleton.MyNamespe = {};

MySigleton.MyNamespace.ScriptA = {
  myFunction: function($element) { 
    // check extra data for running from `data-` attrs in $element
    // run the script
  },
  scriptConfig: { opt1: 'x', opt2: 23 ... }
}

A little clue on how to implement the parser:

MySingleton = {
   parseElement = function(selector) {
       $(selector).find(`[data-reqd-script]`).each(
          function() {
            var reqdScript = $(this).attr('data-reqd-script');
            // check if Singleton contains script, if not download
            if (!MySingleton.hasOwnProperty(reqdScript)) {
            // donwload the script
            }
            // run the script on $(this) element
            MySingleton[reqdScript].myFunction($(this));
       });
   }
}

// Parse the page !!
$(document).ready(function() {
  MySingleton.Parse('body');
}

// You can also subscribe it to parse all downloaded ajax, or call it 
// on demand on the success of ajax donwloands of partial views

Following the right conventions is absolutely neccessary so that the parser can run the necessary script.

The name of the function to run could be another data- attributes, or be always the same like init. As this function can acces the DOM element, it can find there other parameters and options using other data- attributes.

This can seem hard to implement, but once you have set up a working skeleton you can complete and improve it easily.

Community
  • 1
  • 1
JotaBe
  • 38,030
  • 8
  • 98
  • 117
  • So I think I'm 90%+ of the way toward to the architecture you're talking about here because I'm using knockout for all my data binding and Ajax to load data, which knockout automatically picks up. The one line I didn't know what to do with was in the question. If I read your answer correctly, I just need to put that up at the top level too and make it pick up information from the view and partial views that indicate which viewmodels need to be applied? – BlueMonkMN Jan 17 '14 at 15:07
  • FWIW, I also have a similar implementation of the apparent *best* possible solution which I primarily use to ensure that my script references don't get duplicated by multiple partial view templates with a shared dependency. However, as you can see from my solution, this is not absolutely necessary nor do I consider it to be the best you can do. – Vinney Kelly Jan 17 '14 at 15:59
  • @BlueMonkMN I don't understand very well your question, but I'll try to answer :) You don't necessaryly need to load everything in the top level. However you need the "parser" script there. You can use `data-` attributes to indicate what scripts are needed by each partial (or a div or form inside the partial). And, when you parse the page (in document ready) or the ajax loaded partial (in ajax success), the "parser" script can download the neccessary scripts, the JSON data and so on... (cont'd) – JotaBe Jan 17 '14 at 19:15
  • The parser script can manage a singleton object in the main page where you can store the scripts required by the `data-` attributes. With adequate namespacing (choose a good convention!) you can avoid conflicts between them. So the parser can be responsible for downloading the required scripts, and running them in the DOM elements where it applies. Use the neccessary `data-` attributes to indicate the script to run, the url to get the JSON data from and so on. (This is unobtrusive script... once you get used to it is very easy to implemente, and inredibly powerful). – JotaBe Jan 17 '14 at 19:21
  • @VinneyKelly I imagine you're referring to the singleton to avoid downloading duplicate scripts. If you donwload them dynamically (see http://ajaxpatterns.org/On-Demand_Javascript) and store them in the singleton you have an ond-demand javascript system that solves all problems. In my architecture, the parser decides which script is reuired by a `data-` attribute, and checks if it's available in the singleton. If it isn't available downloads. And finally, the script is run. When the script runs, it can get extra info from other additional `data-` attributes. – JotaBe Jan 17 '14 at 19:29
  • Can you add some sample code to your answer that shows how the script would be executed after it is downloaded? I think I understand what you are saying about using Ajax to download script, but I'm not sure exactly what to do with the script after it is downloaded. Is it a simple "eval" call? – BlueMonkMN Jan 17 '14 at 19:32
  • Not exactly... your script can be stored as a function in your singleton object, or a deeper level. For example `MySingleton.MyScript.MyFunction`, you can run it calling it like this: `MySingleton['MyScript']['MyFunction'](params)` This strings can be taken from the `data-` attributes. The params can be also taken from `data-` attributes (this can need some more parsing). of course one of the params will always be the DOM object where it's applied. You find the DOM element because it's the `data-` holder element, like jQuery $('[data-my-attr]).each( /* this=DOM element in jQuery selector */ )` – JotaBe Jan 17 '14 at 19:44
  • It's much easier! Your script attaches to the singleton. Jus like when you define a jQuery plugin or function... `$.myPlugin = function(){}` --> `MySingleton.MyScript = { MyFunction: function() {}, ExtraData: ... }` If you see how to donwload the script, in the `On-Deman JavaScript` link, you'll see it's not neccessarily by using ajax. And Ajax could simply load a ` – JotaBe Jan 17 '14 at 19:52
  • Above you said "So the parser can be responsible for downloading the required scripts," and the code you just showed doesn't involve any downloading or use of downloaded content. That's what I'm trying to understand. – BlueMonkMN Jan 17 '14 at 19:55
  • I've edited my answer to give some clues on how this works. It's written by heart, so there can be some errors on it, but I'm showing the general implementation – JotaBe Jan 17 '14 at 20:27
1

Here's how I've been composing view models and views:

// ~/scripts/app/viewModels/primaryViewModel.js
var primaryViewModelFactory = (function() {
    return { // this gives a singleton object for defining static members and preserving memory
        init: init
    }

    function init(values) {
        var model = {
            // initialization
            secondaryViewModel: secondaryViewModelFactory.init(values);
        }

        // I've decided to allow root-level view models to call apply bindings directly
        ko.applyBindings(model);
    }
}());

// ~/scripts/app/viewModels/secondaryViewModel.js
var secondaryViewModelFactory = (function() {
    return { 
        init: init
    }

    function init(values, target) {
        return = {
            // initialize object
        };
    }        
}());

In my Views, I do have a Script section in my master template. So my view looks like this:

@section scripts {
    <script src="~/scripts/app/viewModels/....js"></script>
    $(function() {
        var vm = primaryViewModel.init(@Html.Raw(Json.Encode(Model)); 
    });
}

In fact, the more I write these MVVM apps, the more inclined I am use ajax for loading data and not pass model data into the init function. This enables me to move the init call into the factory. So then you get something like:

var primaryViewModelFactory = (function() {
    init();        

    function init(values) {
        var model = {
            // initialization
        }
        model.secondaryViewModel = secondaryViewModelFactory.init(values, model);

        // I've decided to allow root-level view models to call apply bindings directly
        ko.applyBindings(model);
    }
}());

Which reduces my view script to a simple script tag:

@section scripts {
    <script src="~/scripts/app/viewModels/primaryViewModel.js"></script>        
}

Lastly, I like to create script templates for vm components inside of partial views like so:

Partial view at ~/Views/Shared/ScriptTemplates/_secondaryViewModelTemplates.cshtml

<script src="@Url.Content("~/scripts/app/viewModels/secondaryViewModel.js")"></script>
<script id="secondary-view-model-details-readonly-template" type="text/html">...</script>
<script id="secondary-view-model-details-editor-template" type="text/html">...</script>
<script id="secondary-view-model-summary-template" type="text/html">...</script>

A couple of things going on here. First, the associated script is imported. This ensures that the necessary view model factory script is included when the partial is rendered. This allows the master view to remain ignorant to the script needs of the sub-component (of which it may have multiple). Also, by defining the templates in a partial rather than in a script file, we're also able to utilize the wildly helpful HtmlHelper and UrlHelper as well as any other server-side utilities you so chose.

Finally, we render the template in the main view:

@section scripts {
    @* primaryViewModel has a dependency on secondaryViewModel so the order does matter *@
    @Html.Partial("ScriptTemplates/_secondaryViewModelTemplates.cshtml")
    <script src="~/scripts/app/viewModels/primaryViewModel.js"></script>
}

<div data-bind="template: {name: 'secondary-view-model-details-editor-template', with: secondaryViewModel}"></div>

That's a lot of code and it was all written in SO so there could be some errors. I've been evolving this style of MVVM+MVC architecture for the past couple of years and it's really made an improvement in my development cycles. Hopefully this will be beneficial to you as well. I'd be happy to answer any questions.

BlueMonkMN
  • 25,079
  • 9
  • 80
  • 146
Vinney Kelly
  • 4,975
  • 1
  • 25
  • 31
  • Thanks for the edit! Do you find this helpful? I noticed that you recently discovered ko templates. For me, that was the tipping point that made all of this come together. – Vinney Kelly Jan 17 '14 at 16:52
  • Still trying to wrap my head around it before I decide how helpful it is. There's a lot here! – BlueMonkMN Jan 17 '14 at 17:20
  • Indeed. Feel free to fire up a chat if you'd like to bounce any questions off me. – Vinney Kelly Jan 17 '14 at 17:30
  • I think this is helpful as a demonstration that ko templates can be used to answer this question. I'm not sure if I'll go that route. Meanwhile, can you confirm, have you used this in a no-nonsense project with no regrets? Have you run into any drawbacks with this approach? – BlueMonkMN Jan 17 '14 at 17:30
  • I'm using it in my current project which is a inventory control system (very non-trivial). As I stated earlier, I've been honing this development model for quite some time now. I'm sure it's not perfect yet but it's the best MVVM/SPA model I've tried to date. One potential pain point that I haven't looked too closely at yet is the WebGrease script optimization framework. To be honest, I haven't done any minimization yet since we're not in optimization phase yet. Unfortunately, I just don't know enough about that yet to know what issues I'll have with this architecture. – Vinney Kelly Jan 17 '14 at 17:36
  • Since my prototype was created relatively recently, I am already running on optimized bundles. My current thought is to put all the view model scripts in one bundle referenced by the _Layout.vbhtml (global) template, then use something like your template binding to activate the relevant pieces in partial views. Some global script, then, would have to call ko.applyBindings to get the template bindings to be processed. If I follow your example, though, maybe I accept the fact that some views will never be partial views and just reference more-specific bundles from there. – BlueMonkMN Jan 17 '14 at 17:54
  • let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/45499/discussion-between-vinney-kelly-and-bluemonkmn) – Vinney Kelly Jan 17 '14 at 18:14
1

The existing answers weren't quite detailed enough, so allow me to provide a detailed answer with code. I mostly followed the suggestion of JotaBe's answer, and here's exactly how.

First I devised a scheme for what custom ("data") attribute I would use and created a helper function to apply it in a way that would help me be compatible with ASP.Net bundling. The attribute needs to provide the necessary information to download a single bundle file when bundling optimizations are turned on (BundleTable.EnableOptimizations = True) and several independent files otherwise. You can see the format I settled on for a data-model attribute in the comments on the code below. This code went into a file called Helpers.vbhtml which was added to a new folder App_Code in my main project.

App_Code/Helpers.vbhtml

@*
    Purpose:       Retrieve a value for the WebUI-specific data-model attribute which will
                   apply knockout bindings for the current node based on the specified
                   bundle, factory, and context.
    BundleNameUrl: Bundle URL like "~/bundles/inventory"
    FactoryName:   Client side factory class of the view model like "inventoryViewModel"
    ContextName:   Client side context object that provides methods for retrieving
                   and updating the data fromt he client, like "inventorycontext"
    ForceNew:      If True, a new instance of the view model will always be created;
                   If False, a previously created instance will be reused when possible.
    Output:        In debug mode, the escaped (&quot;) version of a string like
                   {"bundle": "~/bundles/inventory", "sources": ["/Scripts/app/inventory.datacontext.js",
                    "/Scripts/app/inventory.model.js","/Scripts/app/inventorydetail.viewmodel.js",
                    "/Scripts/app/inventory.viewmodel.js"], "factory": "inventoryViewModel",
                    "context": "inventorycontext", "forceNew": false}
                   Or in release mode, like
                   {"bundle": "~/bundles/inventory", "sources": 
                    ["/bundles/inventory?v=YaRZhEhGq-GkPEQDut6enckUI6FH663GEN4u2-0Lo1g1"],
                    "factory": "inventoryViewModel", "context": "inventorycontext", "forceNew": false}
*@
@Helper GetModel(BundleNameUrl As String, FactoryName As String, ContextName As String, Optional ForceNew As Boolean = False)
    @Code
        Dim result As New System.Text.StringBuilder()
        result.Append("{""bundle"": """ & BundleNameUrl & """, ""sources"": [")
        Dim httpCtx As New HttpContextWrapper(HttpContext.Current)
        ' When EnableOptimizations = True, there will be one script source URL per bundle
        ' When EnableOptimizations = False, each script in the bundle is delivered separately
        If BundleTable.EnableOptimizations Then
            result.Append("""" & System.Web.Mvc.UrlHelper.GenerateContentUrl( _
                BundleResolver.Current.GetBundleUrl(BundleNameUrl), httpCtx) & """")
        Else
            Dim first As Boolean = True
            For Each bundle In BundleResolver.Current.GetBundleContents(BundleNameUrl)
                If first Then first = False Else result.Append(",")
                result.Append("""" & System.Web.Mvc.UrlHelper.GenerateContentUrl(bundle, httpCtx) & """")
            Next
        End If
        result.Append("], ""factory"": """ & FactoryName & """, ""context"": """ & ContextName & """")
        result.Append(", ""forceNew"": " & If(ForceNew, "true", "false") & "}")
    End Code
@<text>@result.ToString()</text>
End Helper

Then I can apply that attribute on a node like this to have it indicate how it wants knockout bindings applied to itself and its descendants and what scripts are needed before doing so. Notice how my intention is to be able to refer to the same script bundle and model from multiple nodes without duplicating the download or having duplicate instances of the model unless I specifically request separate instances of the model with forceNew. It would probably be better to add a container to house this attribute in a single place, but I want to demonstrate that it's not necessary.

Views/Inventory/Details.html

<a href="#" data-bind="click: loadPrevious" data-model="@Helpers.GetModel("~/bundles/inventory", "inventoryDetailViewModel", "inventorycontext")" title="Previous">Previous</a>
<a href="#" data-bind="click: loadNext" data-model="@Helpers.GetModel("~/bundles/inventory", "inventoryDetailViewModel", "inventorycontext")" title="Next">Next</a>
<fieldset data-bind="with: fsItem" data-model="@Helpers.GetModel("~/bundles/inventory", "inventoryDetailViewModel", "inventorycontext")">

Finally I create a javascript file referenced in an existing bundle that's always pulled in in _Layout.vbhtml. It has the client side code necessary for processing the new "data-model" attribute. The idea is to call ko.applyBindings on these specific nodes, and to only instantiate the view model once unless distinct instances of the model are explicitly requested on multiple nodes.

Scripts/app/webui.main.js

// Make sure we have our namespace carved out, and we
// know we're going to put a scriptCache in it.
window.webui = window.webui || { "scriptCache": {} };

// Copied from http://stackoverflow.com/a/691661/78162
// jQuery's getScript uses a mechanism that is not debuggable
// when operating within the domain, so we use this code to
// make sure the code is always a debuggable part of the DOM.
window.webui.getScript = function (url, callback) {
    var head = document.getElementsByTagName("head")[0];
    var script = document.createElement("script");
    script.src = url;

    // Handle Script loading
    {
        var done = false;

        // Attach handlers for all browsers
        script.onload = script.onreadystatechange = function () {
            if (!done && (!this.readyState ||
                  this.readyState == "loaded" || this.readyState == "complete")) {
                done = true;
                if (callback)
                    callback();

                // Handle memory leak in IE
                script.onload = script.onreadystatechange = null;
            }
        };
    }
    head.appendChild(script);
    // We handle everything using the script element injection
    return undefined;
};

// Call knockout's applyBindings function based on values specified in the
// data-model attribute after the script is done downloading (which is the
// responsibility of the caller).
window.webui.applyBindings = function (cacheObj, forceNew, factory, context, node) {
    // Store instantiated view model objects for each factory in
    // window.webui.scriptCache[bundleName].models for reuse on other nodes.
    cacheObj.models = cacheObj.models || {};
    // If an instance of the model doesn't exist yet, create one by calling the
    // factory function, which should be implemented in a script in the
    // downloaded bundle somewhere. And the context object should have already
    // been instantiated when the script was downloaded.
    if (forceNew || !cacheObj.models[factory])
        cacheObj.models[factory] = window.webui[factory](ko, window.webui[context]);
    // Apply bindings only to the node where data-model attribute was applied
    ko.applyBindings(cacheObj.models[factory], node);
};

// Callback function when a script specified in the data-model attribute is
// done being downloaded on demand.
window.webui.onModelLoaded = function (cacheObj) {
    // Count how many scripts inteh bundle have finished downloading
    cacheObj.loadedCount += 1;
    // If we have downloaded all scripts in the bundle, call applyBindings
    // for all the nodes stored in the onComplete array.
    if (cacheObj.loadedCount == cacheObj.totalCount) {
        for (var callback in cacheObj.onComplete) {
            var onComplete = cacheObj.onComplete[callback];
            window.webui.applyBindings(cacheObj, onComplete.forceNew,
                onComplete.factory, onComplete.context, onComplete.node);
        }
    }
};

// Process the data-model attribute of one HTML node by downloading the related bundle
// scripts if they haven't yet been downloaded and then calling applyBindings based on
// the values embedded in the attribute.
window.webui.require = function (modelAttribute, node) {
    model = $.parseJSON(modelAttribute);
    // Keep a cache of all the bundles that have been downloaded so we don't download the same
    // bundle more than once even if multiple nodes refer to it.
    window.webui.scriptCache = window.webui.scriptCache || {};
    // The cache is keyed by bundle name. All scripts in a bundle are downloaded before
    // any bindings are applied.
    if (!window.webui.scriptCache[model.bundle]) {
        // Store the expectd count and the loaded count so we know when the last
        // script in the bundle is done that it's time to apply the bindings.
        var cacheObj = {
            totalCount: model.sources.length, loadedCount: 0, onComplete:
                [{ "factory": model.factory, "context": model.context, "node": node, "forceNew": model.forceNew }]
        };
        window.webui.scriptCache[model.bundle] = cacheObj;
        // For each script in the bundle, start the download, and pass in cacheObj
        // so the callback will know if it has downloaded the last script and what
        // to do when it has.
        for (var script in model.sources) {
            window.webui.getScript(model.sources[script], function () {
                window.webui.onModelLoaded(cacheObj)
            });
        }
    } else {
        // If the bundle referenced already has a space allocated in the cache, that means
        // its scripts are already downloaded or are in the process of being downloaded.
        var cacheObj = window.webui.scriptCache[model.bundle];
        if (cacheObj.totalCount == cacheObj.loadedCount) {
            // If the bundle is already completely downloadad, just apply the bindings directly
            window.webui.applyBindings(cacheObj, model.forceNew, model.factory, model.context, node);
        } else {
            // If the bundle is still being downloaded, add work to be done when bindings
            // are applied upon completion.
            window.webui.scriptCache[model.bundle].onComplete.push({
                "factory": model.factory, "context": model.context, "node": node, "forceNew": model.forceNew
            });
        }
    }
};

// When the document is done loading, locate every node with a data-model attribute
// and process the attribute value with the require function above on that node.
$(document).ready(function () {
    $('[data-model]').each(function () {
        var model = $(this).data("model");
        window.webui.require(model, this);
    });
});

With this solution, I can rely on the existing ASP.NET MVC4 bundling framework (I don't need r.js) to optimize and combine javascript files, but also implement download on demand and an unobstrusive mechanism for defining the scripts and view models related to knockout bindings.

BlueMonkMN
  • 25,079
  • 9
  • 80
  • 146