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.