Including script specific to an ASP.NET MVC4 view

2019-05-06 23:49发布

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?

3条回答
Explosion°爆炸
2楼-- · 2019-05-07 00:40

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.

查看更多
兄弟一词,经得起流年.
3楼-- · 2019-05-07 00:45

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 (") 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.

查看更多
戒情不戒烟
4楼-- · 2019-05-07 00:46

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.

查看更多
登录 后发表回答