在ASP.NET MVC4视图或局部视图中包含特定于脚本的方法

4

我查看了很多类似于如何在MVC4中的partial view中添加脚本?MVC4 partial view javascript bundling Issue的问题,但仍然难以理解ASP.NET MVC架构在视图特定脚本方面的使用。对于其他人试图将脚本包含在MVC4部分视图中的答案似乎是将脚本放在更高的级别上。但是,某些脚本无法移动到更高的级别,这会使其在全局范围内运行。例如,我不想运行适用于未加载控件的视图模型的knockout.js数据绑定脚本。我也不想每次加载页面时都运行大量视图的大量脚本。

因此,我开始在我的.vbhtml视图中使用视图特定的@Section Script块来包含特定于视图的脚本。但是,正如其他人指出的那样,这在部分视图中不起作用。我正在原型测试我们的架构以查看在此处我们能够做什么和不能做什么。我想在某些情况下,可以使用视图作为部分视图,反之亦然。但是,当您拉入一个视图以用作部分视图时,@Section Script块不会呈现。我已经设法将所有的ViewModel脚本定义为全局的,以便只需要运行一行代码来创建和绑定ViewModel,但我仍然需要该行代码仅在特定视图处于活动状态时运行。在部分视图中,应在哪里适当地添加此行代码?

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

我现在走的这条路正确吗?这是一种构建MVC应用程序的恰当方式吗?

编辑 我发现这个问题与我的问题非常相关,并且包含了我答案的一个重要部分:您可以调用ko.applyBindings来绑定局部视图吗?


@Html.Partial(Razor语法)函数将视图作为部分视图包含在其父级中。这是一个.NET/MVC框架函数,我更喜欢不使用自定义函数来混淆我的代码,因为已经有了标准。 - BlueMonkMN
这部分内容是有条件渲染还是始终存在?如果它始终存在,我认为@JotaBe的解决方案很好;如果它是有条件的,那么你需要动态地拉入脚本,这可能会比较麻烦,但你可以使用类似RequireJS的AMD。 - DoctorMick
是的,我现在倾向于@JotaBe的解决方案,但我也刚刚发现了knockout模板,它可能也能作为替代方案或这个解决方案的一部分。(http://knockoutjs.com/documentation/template-binding.html) - BlueMonkMN
我正在研究与JotaBe答案紧密相关的方法。我试图找到一种按需下载脚本的方式,但又不想使用RequireJS和特别是R.js那样复杂的东西,因为我想继续使用MVC4的BundleConfig优化机制。我尝试使用jQuery的getScript函数进行下载,并编写脚本智能地管理bundle。希望今天或明天我会有所收获,并能发布完整的答案。稍后再来看看。 - BlueMonkMN
@VinneyKelly,我终于添加了我的答案。如果需要澄清,请告诉我。 - BlueMonkMN
显示剩余2条评论
3个回答

1

这是你能做的最好的,但仍可能存在问题:

  • 如果您的部分视图被缓存怎么办?
  • 如果您使用Ajax渲染部分视图怎么办?

因此,我也建议不要使用这种hacky trick。(好吧,Darin Dimitrov的解决方案很棒,但使用它并不是一个好主意)。

最好的解决方案是在呈现部分时使所有脚本可用:

  • 在容器页面中加载它们
  • 动态加载它们(这更难做到)

如果您这样做,可以在需要时运行脚本。但是,如何仅在所需部分的所需部分上运行所需脚本?更简单的方法是使用自定义data-属性标记它们。然后,您可以“解析”页面,查找您的自定义data-属性,并运行适用的脚本:这是无侵入式JavaScript。

例如,您可以包含一个脚本,在jQuery的$(document).ready(当页面和所有脚本都已完成加载)上“解析”页面。此脚本可以查找具有自定义data-属性的元素($('[data-my-custom-attr]').each( MyCustomSccript(this)); 您还可以考虑使用data-属性来配置您的脚本,即您可以使用属性来指示必须运行某种类型的脚本,并使用额外的属性来配置脚本运行方式。
那么关于使用ajax加载的部分视图呢?没问题。我告诉过您可以使用$(document).ready,但您还可以在用于使用ajax加载部分视图的函数中使用success回调,并且您可以对此回调执行完全相同的操作。并且您可以为jQuery.Ajax成功注册全局处理程序,因此您的脚本将应用于所有ajax加载的部分。
甚至可以使用更强大的技术,例如根据需要为属性加载所需的脚本。
通常,问题在于我们认为JavaScript应该由服务器提供,但事实上JavaScript存在于浏览器中,浏览器应该对其具有更多控制权。
动态加载脚本的架构描述:
主页面:包含“解析器脚本”,这个解析器脚本负责:
- 解析页面(文档准备事件)或下载的ajax部分(ajax成功事件) - 下载并将所需脚本存储在页面的单例中(所需脚本由“data-”属性定义) - 运行存储在单例中的脚本
局部:
- 它们在DOM元素上具有“data-”属性,以便解析器知道需要哪些脚本 - 它们还具有其他“data-”属性,以向脚本传递额外数据
显然,非常重要的是遵循良好的约定来命名脚本和“data-”属性,以便代码更易于使用和调试。

一个很好的地方可以看到脚本如何被动态下载:按需JavaScript

有很多解决方案。另一个选择是:如何在javascript控制台中动态下载和运行javascript脚本?

你的脚本应该像定义jQUery插件时那样附加到单例上。.js的内容应该像这样:

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 ... }
}

一个关于如何实现解析器的小提示:
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

遵循正确的约定非常重要,这样解析器才能运行必要的脚本。
要运行的函数名称可以是另一个"data-"属性,也可以始终相同,例如"init"。由于此函数可以访问DOM元素,因此它可以使用其他"data-"属性在其中查找其他参数和选项。
这似乎很难实现,但一旦您设置了一个可工作的框架,您就可以轻松地完成和改进它。

所以我认为我已经完成了你在这里谈论的架构的90%以上,因为我正在使用knockout进行所有的数据绑定和Ajax来加载数据,而knockout会自动捕捉到。唯一一行我不知道该怎么做的是在问题中。如果我正确地理解了你的答案,我只需要将其放在顶层,并从视图和部分视图中获取指示需要应用哪些视图模型的信息? - BlueMonkMN
就我个人而言,我也有一个类似的实现,看起来是最佳解决方案,我主要用它来确保我的脚本引用不会被多个共享依赖项的部分视图模板重复。然而,正如您从我的解决方案中所看到的那样,这并非绝对必要,我也不认为这是您可以做到的最好的。 - Vinney Kelly
@VinneyKelly 我想你指的是单例模式,以避免下载重复的脚本。如果您动态下载它们(请参见http://ajaxpatterns.org/On-Demand_Javascript)并将它们存储在单例中,则可以拥有一个按需JavaScript系统,解决所有问题。在我的架构中,解析器通过“data-”属性决定所需的脚本,并检查它是否在单例中可用。如果不可用,则进行下载。最后,运行该脚本。当脚本运行时,它可以从其他附加的“data-”属性中获取额外的信息。 - JotaBe
你能否在回答中添加一些示例代码,展示脚本在下载后如何执行?我认为我理解了使用Ajax下载脚本的方法,但是不确定下载完脚本后应该做什么。是一个简单的“eval”调用吗? - BlueMonkMN
太容易了!你的脚本附加到单例上。就像定义jQuery插件或函数时一样... $.myPlugin = function(){} --> MySingleton.MyScript = { MyFunction: function() {}, ExtraData: ... } 如果你看到如何下载脚本,在“按需JavaScript”链接中,你会发现不一定要使用ajax。Ajax可以简单地加载一个<script ...>标签并将其附加到DOM。 - JotaBe
显示剩余5条评论

1

以下是我如何构建视图模型和视图的方法:

// ~/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
        };
    }        
}());

在我的视图中,我的主模板中有一个脚本部分。因此,我的视图看起来像这样:
@section scripts {
    <script src="~/scripts/app/viewModels/....js"></script>
    $(function() {
        var vm = primaryViewModel.init(@Html.Raw(Json.Encode(Model)); 
    });
}

事实上,我写这些MVVM应用程序的次数越多,我就越倾向于使用ajax来加载数据,而不是将模型数据传递到init函数中。这使我能够将init调用移动到工厂中。因此,你会得到像下面这样的东西:
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);
    }
}());

这将我的视图脚本简化为一个简单的脚本标签:
@section scripts {
    <script src="~/scripts/app/viewModels/primaryViewModel.js"></script>        
}

最后,我喜欢为像这样的局部视图中的vm组件创建脚本模板: 局部视图位于~/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>

这里有几件事情需要注意。首先,导入了相关的脚本。这样可以确保在渲染局部视图时包含必要的视图模型工厂脚本。这使得主视图不必考虑子组件(可能有多个)所需的脚本。此外,通过在局部视图中定义模板而不是在脚本文件中定义,我们还能够利用非常有用的HtmlHelper和UrlHelper以及其他任何您选择的服务器端实用程序。

最后,在主视图中呈现模板:

@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>

这是一大段代码,而且全部都是在SO上编写的,可能存在一些错误。我过去几年一直在演进这种MVVM+MVC架构风格,它真的让我的开发周期得到了改善。希望对你也有所裨益。如果有任何问题,我很乐意回答。


感谢您的编辑!您觉得这有帮助吗?我注意到您最近发现了ko模板。对我来说,那是让所有这些东西结合在一起的关键点。 - Vinney Kelly
还在努力理解它,才能决定它有多大帮助。这里有很多东西! - BlueMonkMN
我正在使用它在我的当前项目中,这是一个库存控制系统(非常不平凡)。正如我之前所说,我已经磨练了这个开发模型相当长的时间。我相信它还不完美,但这是我到目前为止尝试过的最好的MVVM / SPA模型。我还没有仔细研究过的一个潜在痛点是WebGrease脚本优化框架。老实说,由于我们还没有进入优化阶段,我还没有进行任何缩小。不幸的是,我还不知道足够多关于这个架构的问题。 - Vinney Kelly
由于我的原型相对较新,我已经在优化的捆绑包上运行。 我目前的想法是将所有视图模型脚本放在一个由_Layout.vbhtml(全局)模板引用的捆绑包中,然后使用类似于您的模板绑定的东西来激活部分视图中的相关部分。 然后,一些全局脚本将调用ko.applyBindings以处理模板绑定。 如果我遵循您的示例,也许我接受某些视图永远不会成为部分视图的事实,并从那里引用更具体的捆绑包。 - BlueMonkMN
让我们在聊天中继续这个讨论:http://chat.stackoverflow.com/rooms/45499/discussion-between-vinney-kelly-and-bluemonkmn - Vinney Kelly
显示剩余2条评论

1
现有的答案不够详细,所以让我提供一份带有代码的详细答案。我大部分遵循了JotaBe的建议,并且以下是具体步骤。
首先,我设计了一个自定义(“data”)属性的方案,并创建了一个辅助函数来应用它,以便使我与ASP.Net捆绑兼容。当打开捆绑优化(BundleTable.EnableOptimizations = True)时,该属性需要提供下载单个捆绑文件所需的信息,否则需要提供多个独立文件。您可以在下面的代码注释中看到我为“data-model”属性确定的格式。这段代码被放入名为Helpers.vbhtml的文件中,并添加到我的主项目中的新文件夹App_Code中。

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

然后,我可以像这样在节点上应用该属性,以指示它希望如何将knockout绑定应用于自身及其后代,并在执行之前需要哪些脚本。请注意,我的意图是能够在多个节点中引用相同的脚本包和模型,而无需重复下载或具有除非使用forceNew明确请求单独的模型实例,否则不会出现重复实例。最好将一个容器添加到一个单一位置来存放此属性,但我想演示这并非必要。

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

最后,我创建了一个JavaScript文件,它被引用在现有的bundle中,在_Layout.vbhtml中总是被加载。它包含了处理新的"data-model"属性所需的客户端代码。这个想法是在这些特定的节点上调用ko.applyBindings,并且只实例化视图模型一次,除非明确请求多个节点的不同实例。

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 https://dev59.com/TXRB5IYBdhLWcg3wNk53#691661
// 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);
    });
});

通过这个解决方案,我可以依赖现有的ASP.NET MVC4捆绑框架(无需r.js)来优化和合并JavaScript文件,同时实现按需下载以及一个不显眼的机制来定义与knockout绑定相关的脚本和视图模型。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接