带Knockout Js的ASP.NET MVC 4






4.88/5 (37投票s)
使用Knockout Js的风格,让Asp.Net MVC4 UI开发更轻松的技巧。
介绍
在这里我将解释如何在ASP.NET MVC 4应用程序中使用Knockout Js以及一种有助于我们编写更具可维护性代码的基本JavaScript模式。我在这里使用的示例最适用于单页应用程序。但是,它不仅限于此,您可以根据需要将其用于任何ASP.NET MVC应用程序。
背景
如果我们使用ASP.NET MVC框架,那么很明显我们需要大量使用JavaScript(我可以说jQuery,因为我们几乎在每个项目中都可以看到它)。根据我的经验,对于传统的ASP.NET开发人员来说,使用JavaScript总是一个噩梦。而且,MVC应用程序中没有服务器端控件和视图状态确实令人恐惧。好吧,一旦我开始在MVC应用程序中工作,我就更深入地研究了JavaScript和jQuery,发现它对于Web开发来说是一个非常简单而坚实的框架。我最近的大部分项目都以单页应用程序的形式实现。因此,使用JavaScript、jQuery、jQuery-UI、Knockout和一些其他js库实现了所有UI内容。最初我遇到了很多问题,但现在我对这个框架感到非常满意,并且喜欢这种方法。我要非常感谢Douglas Crockford撰写的精彩书籍JavaScript Good Parts,它帮助我更好地理解了JavaScript。
使用代码
我将把实现分为两个部分,即“基本步骤”和“高级步骤”,并逐步向您介绍。如果您不是ASP.NET MVC应用程序的初学者,可以直接跳到“高级步骤”。
基本步骤
- 文件 -> 新建 -> 项目 -> 模板 -> Visual C# -> Web -> ASP.NET MVC 4 Web 应用程序 -> 输入一个友好的名称,然后点击确定。
- 从“选择模板”菜单中选择“
基本
”。将“视图引擎”选择为“Razor”,然后单击“确定”。 - 下载并添加 Knockout 映射库到您的项目。或者,您可以使用此 NuGet 命令 Install-Package Knockout.Mapping。
- 右键单击“Controllers”文件夹 -> 添加 -> 控制器,将控制器名称命名为
PersonController
并单击“添加”按钮。 - 右键点击项目 -> 添加 -> 新建文件夹,将其重命名为ViewModel。
- 右键单击ViewModel文件夹 -> 添加 -> 类,将其命名为PersonViewModel,并确保其中包含以下代码:
- 回到
PersonController
并粘贴以下代码 - 在
index
方法内右键单击并点击Add View
,它将弹出一个窗口,保留默认选项并点击Add
按钮。这将在Views
文件夹下添加一个名为Person
的文件夹,并包含一个名为 Index.cshtml 的文件。 - 打开位于 App_Start 文件夹下的 RouteConfig.cs 文件。在这里将
controller
设置为Person
。完成更改后,您的 RoutConfig 文件应如下所示: - 右键单击Content文件夹 -> 添加 -> 新建项 -> 选择样式表 -> 命名为
Person.css
。 - 在 Scripts 文件夹下添加一个名为
Application
的新文件夹,然后右键单击它 -> 添加 -> 新建项 -> 选择 Javascript 文件,将其命名为Person.js
。
public class PersonViewModel
{
public int Id { get; set; }
public string Name { get; set; }
public DateTime DateOfBirth { get; set; }
// NOTE: For demonstration purpose included both Id as well as strong reference. In real projects, we need to use either of one (Id is preferred in case of performance).
public int CountryId { get; set; }
public Country Country { get; set; }
}
public class Country
{
public int Id { get; set; }
public string Name { get; set; }
public string Abbreviation { get; set; }
}
public ActionResult Index()
{
// NOTE: We should have a wrapper ViewModel instead of ViewBag or ViewData. This is used here to keep the demonstration simple.
ViewBag.Countries = new List<Country>(){
new Country()
{
Id = 1,
Name = "India"
},
new Country()
{
Id = 2,
Name = "USA"
},
new Country()
{
Id = 3,
Name = "France"
}
};
var viewModel = new PersonViewModel()
{
Id = 1,
Name = "Naveen",
DateOfBirth = new DateTime(1990, 11, 21)
};
return View(viewModel);
}
[HttpPost]
public JsonResult SavePersonDetails(PersonViewModel viewModel)
{
// TODO: Save logic goes here.
return Json(new { });
}
此外,请确保您已包含对 View-Model 的引用。
public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Person", action = "Index", id = UrlParameter.Optional }
);
}
}
高级步骤
我们在基本步骤中所做的一切都是为了我们的旅程做准备。让我们总结一下我们目前所做的事情。我们添加了一个新项目,将Person视为一个实体。这意味着,我们创建了PersonViewModel
、PersonController
和一个用于视图的Index.cshtml文件。此外,我们出于明显的原因添加了Person.css和Person.js。我们修改了RouteConfig,将Person作为默认根目录。将knockout mapping库包含到项目中(其他库随MVC 4模板默认提供)。我们已经完成了准备工作,在继续这些步骤之前,我想先介绍一下knockout。
Knockout
Knockout 是一个 JavaScript 库,它帮助我们保持视图模型和 UI 元素之间的同步。但这并非 Knockout 的唯一功能。有关更多信息,请查看 http://knockoutjs.com/。由于本文的目的是展示**如何开始**,因此我将只关注基本的 UI 绑定。
谈到 knockout 的数据绑定概念,我们所需要做的就是给我们的 html 元素添加一个额外的 data-bind
属性。例如:如果我们的 ViewModel 对象在 Person.ViewModel
中,我们想将 Name
属性绑定到一个 textbox
,那么我们需要有以下标记:
<input data-bind="value: Person.ViewModel.Name" type="text">
同样地,我们需要为所有字段添加 **data-bind** 属性。现在,如果您更改对象中的值,它将反映在 UI 中,反之亦然。
这是Knockout Js的基本功能。随着我们继续,我们将探索更多功能。我想是时候继续我们的旅程了。所以,这里是接下来的步骤:
- 由于我们需要为每个 UI 元素添加
data-bind
属性,因此最好为此提供一个 html 辅助方法。为此,在项目中添加一个名为 *Helper* 的文件夹,并添加一个类文件 (*HtmlExtensions.cs*),其中包含以下内容: - 打开 Views/Person 文件夹下的 Index.cshtml 文件并粘贴以下代码
public static class HtmlExtensions
{
/// <summary>
/// To create an observable HTML Control.
/// </summary>
/// <typeparam name="TModel">The model object</typeparam>
/// <typeparam name="TProperty">The property name</typeparam>
/// <param name="htmlHelper">The <see cref="HtmlHelper<T>"/></param>
/// <param name="expression">The property expression</param>
/// <param name="controlType">The <see cref="ControlTypeConstants"/></param>
/// <param name="htmlAttributes">The html attributes</param>
/// <returns>Returns computed HTML string.</returns>
public static IHtmlString ObservableControlFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, string controlType = ControlTypeConstants.TextBox, object htmlAttributes = null)
{
var metaData = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData);
string jsObjectName = null;
string generalWidth = null;
// This will be useful, if the same extension has to share with multiple pages (i.e. each with different view models).
switch (metaData.ContainerType.Name)
{
case "PersonViewModel":
jsObjectName = "Person.ViewModel."; // Where Person is the Javascript object name (namespace in theory).
generalWidth = "width: 380px";
break;
default:
throw new Exception(string.Format("The container type {0} is not supported yet.", metaData.ContainerType.Name));
}
var propertyObject = jsObjectName + metaData.PropertyName;
TagBuilder controlBuilder = null;
// Various control type creation.
switch (controlType)
{
case ControlTypeConstants.TextBox:
controlBuilder = new TagBuilder("input");
controlBuilder.Attributes.Add("type", "text");
controlBuilder.Attributes.Add("style", generalWidth);
break;
case ControlTypeConstants.Html5NumberInput:
controlBuilder = new TagBuilder("input");
controlBuilder.Attributes.Add("type", "number");
controlBuilder.Attributes.Add("style", generalWidth);
break;
case ControlTypeConstants.Html5UrlInput:
controlBuilder = new TagBuilder("input");
controlBuilder.Attributes.Add("type", "url");
controlBuilder.Attributes.Add("style", generalWidth);
break;
case ControlTypeConstants.TextArea:
controlBuilder = new TagBuilder("textarea");
controlBuilder.Attributes.Add("rows", "5");
break;
case ControlTypeConstants.DropDownList:
controlBuilder = new TagBuilder("select");
controlBuilder.Attributes.Add("style", generalWidth);
break;
case ControlTypeConstants.JqueryUIDateInput:
controlBuilder = new TagBuilder("input");
controlBuilder.Attributes.Add("type", "text");
controlBuilder.Attributes.Add("style", generalWidth);
controlBuilder.Attributes.Add("class", "dateInput");
controlBuilder.Attributes.Add("data-bind", "date: " + propertyObject); // date is the customized knockout binding handler. Check PrepareKo method of Person.
break;
default:
throw new Exception(string.Format("The control type {0} is not supported yet.", controlType));
}
controlBuilder.Attributes.Add("id", metaData.PropertyName);
controlBuilder.Attributes.Add("name", metaData.PropertyName);
// Check data-bind already exists, add if not.
if (!controlBuilder.Attributes.ContainsKey("data-bind"))
{
controlBuilder.Attributes.Add("data-bind", "value: " + propertyObject);
}
// Merge provided custom html attributes. This overrides the previously defined attributes, if any.
if (htmlAttributes != null)
{
controlBuilder.MergeAttributes(HtmlExtensions.AnonymousObjectToHtmlAttributes(htmlAttributes), true);
}
return MvcHtmlString.Create(controlBuilder.ToString());
}
/// <summary>
/// To convert '_' into '-'.
/// </summary>
/// <param name="htmlAttributes">The html attributes.</param>
/// <returns>Returns converted <see cref="RouteValueDictionary"/>.</returns>
private static RouteValueDictionary AnonymousObjectToHtmlAttributes(object htmlAttributes)
{
RouteValueDictionary result = new RouteValueDictionary();
if (htmlAttributes != null)
{
foreach (System.ComponentModel.PropertyDescriptor property in System.ComponentModel.TypeDescriptor.GetProperties(htmlAttributes))
{
result.Add(property.Name.Replace('_', '-'), property.GetValue(htmlAttributes));
}
}
return result;
}
}
此外,再添加一个名为 *ViewModelConstants.cs* 的类文件,其中包含以下内容
public static class ControlTypeConstants
{
public const string TextBox = "TextBox";
public const string TextArea = "TextArea";
public const string CheckBox = "CheckBox";
public const string DropDownList = "DropDownList";
public const string Html5NumberInput = "Html5NumberInput";
public const string Html5UrlInput = "Html5UrlInput";
public const string Html5DateInput = "Html5DateInput";
public const string JqueryUIDateInput = "JqueryUIDateInput";
}
ObservableControlFor
是一个简单的泛型方法,它创建带有 data-bind
属性的相关 HTML 元素。默认情况下,它创建文本框,但我们可以传递 ControlTypeConstants
中定义的各种其他类型。如果需要,您可以随意添加自己的。在这种情况下,您需要做的就是向 ControlTypeConstants
添加另一个常量,并扩展 ObservableControlFor
方法中的 switch case。如果您不理解上面方法中的一些内容,请不要担心,随着我们继续,您会理解的。
@model Mvc4withKnockoutJsWalkThrough.ViewModel.PersonViewModel
@using Mvc4withKnockoutJsWalkThrough.Helper
@section styles{
@Styles.Render("~/Content/themes/base/css")
<link href="~/Content/Person.css" rel="stylesheet" />
}
@section scripts{
@Scripts.Render("~/bundles/jqueryui")
<script src="~/Scripts/knockout-2.1.0.js"></script>
<script src="~/Scripts/knockout.mapping-latest.js"></script>
<script src="~/Scripts/Application/Person.js"></script>
<script type="text/javascript">
Person.SaveUrl = '@Url.Action("SavePersonDetails", "Person")';
Person.ViewModel = ko.mapping.fromJS(@Html.Raw(Json.Encode(Model)));
Person.Countries = @Html.Raw(Json.Encode(ViewBag.Countries)); // This is required because, we are holding the collection in ViewBag. If it is wrapped in ViewModel, this line is not required.
</script>
}
<form>
<div class="mainWrapper">
<table>
<tr>
<td>Id :
</td>
<td>
@Html.ObservableControlFor(model => model.Id, ControlTypeConstants.Html5NumberInput)
</td>
</tr>
<tr>
<td>Name :
</td>
<td>
@Html.ObservableControlFor(model => model.Name)
</td>
</tr>
<tr>
<td>Date Of Birth :
</td>
<td>
@Html.ObservableControlFor(model => model.DateOfBirth, ControlTypeConstants.JqueryUIDateInput)
</td>
</tr>
<tr>
<td>Country (Id will be assigned):
</td>
<td>
@Html.ObservableControlFor(model => model.CountryId, ControlTypeConstants.DropDownList,
new
{
data_bind = "options: Person.Countries, optionsCaption: 'Please Choose', optionsText: 'Name', optionsValue: 'Id', value: Person.ViewModel.CountryId"
})
</td>
</tr>
<tr>
<td>Country (Object will be assigned):
</td>
<td>
@Html.ObservableControlFor(model => model.CountryId, ControlTypeConstants.DropDownList,
new
{
data_bind = "options: Person.Countries, optionsCaption: 'Please Choose', optionsText: 'Name', value: Person.ViewModel.Country"
})
</td>
</tr>
</table>
</div>
<br />
<input id="Save" type="submit" value="Save" />
</form>
你们中有些人可能会遇到以下错误
json does not exist in the current context
您可以通过遵循此stackoverflow答案中提供的步骤来解决此问题。此外,您可能需要将Mvc4withKnockoutJsWalkThrough
替换为您各自的命名空间。
如代码所示,我们正在使用我们在上一个**步骤 (12)** 中创建的 html 帮助器。另外,您可能会注意到 scripts
部分中编写的脚本。那是:
Person.SaveUrl = '@Url.Action("SavePersonDetails", "Person")';
Person.ViewModel = ko.mapping.fromJS(@Html.Raw(Json.Encode(Model)));
Person.Countries = @Html.Raw(Json.Encode(ViewBag.Countries));
这里 Person
是一个 JavaScript 对象(我们将在 Person.js 文件中创建它)。理论上我们可以称它为命名空间(因为它在这里的目的)。
- 您可能已经注意到,我们在
Index.cshtml
页面中使用了styles
部分。因此,有必要在相关的布局页面中定义它。在我们的例子中,它是_Layout.cshtml
。所以,打开这个页面(位于 Views/Shared 文件夹下),然后在head
标签结束之前添加以下行: - 现在是时候编写最期待的 JavaScript 代码了。打开 Person.js 文件(位于 Scripts/Application 文件夹下),并粘贴以下代码:
在Razor引擎中,存在一个限制,即不能在外部javascript文件中使用其语法。因此,我们将Razor评估的值分配给Person
对象的properties
。
我将在接下来的几点中解释 ko.mapping.fromJS(@Html.Raw(Json.Encode(Model)));
和 @Html.Raw(Json.Encode(ViewBag.Countries));
这两行具体做了什么。
@RenderSection("styles", required: false)
最后,你的布局页面应该像这样:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>@ViewBag.Title</title>
@Styles.Render("~/Content/css")
@Scripts.Render("~/bundles/modernizr")
@RenderSection("styles", required: false)
</head>
<body>
@RenderBody()
@Scripts.Render("~/bundles/jquery")
@RenderSection("scripts", required: false)
</body>
</html>
var Person = {
PrepareKo: function () {
ko.bindingHandlers.date = {
init: function (element, valueAccessor, allBindingsAccessor, viewModel) {
element.onchange = function () {
var observable = valueAccessor();
observable(new Date(element.value));
}
},
update: function (element, valueAccessor, allBindingsAccessor, viewModel) {
var observable = valueAccessor();
var valueUnwrapped = ko.utils.unwrapObservable(observable);
if ((typeof valueUnwrapped == 'string' || valueUnwrapped instanceof String) &&
valueUnwrapped.indexOf('/Date') === 0) {
var parsedDate = Person.ParseJsonDate(valueUnwrapped);
element.value = parsedDate.getMonth() + 1 + "/" +
parsedDate.getDate() + "/" + parsedDate.getFullYear();
observable(parsedDate);
}
}
};
},
ParseJsonDate: function (jsonDate) {
return new Date(parseInt(jsonDate.substr(6)));
},
BindUIwithViewModel: function (viewModel) {
ko.applyBindings(viewModel);
},
EvaluateJqueryUI: function () {
$('.dateInput').datepicker();
},
RegisterUIEventHandlers: function () {
$('#Save').click(function (e) {
// Check whether the form is valid. Note: Remove this check, if you are not using HTML5
if (document.forms[0].checkValidity()) {
e.preventDefault();
$.ajax({
type: "POST",
url: Person.SaveUrl,
data: ko.toJSON(Person.ViewModel),
contentType: 'application/json',
async: true,
beforeSend: function () {
// Display loading image
},
success: function (result) {
// Handle the response here.
},
complete: function () {
// Hide loading image.
},
error: function (jqXHR, textStatus, errorThrown) {
// Handle error.
}
});
}
});
},
};
$(document).ready(function () {
Person.PrepareKo();
Person.BindUIwithViewModel(Person.ViewModel);
Person.EvaluateJqueryUI();
Person.RegisterUIEventHandlers();
});
这里 Person
是一个命名空间,或者您可以称之为一个核心对象,它代表与个人相关的操作。在我解释这些方法的作用之前,我想提供一些关于 Knockout 的更多信息。
关于 Knockout 的更多信息:
到目前为止,我解释了如何将视图模型与 UI 元素绑定,但没有说明如何创建视图模型。通常,您可以像这样创建视图模型:
var myViewModel = {
Name: ko.observable('Bob'),
Age: ko.observable(123),
Report: ko.observableArray([1,5,6,7,8])
};
你可以这样激活 knockout
ko.applyBindings(myViewModel);
看到上面的例子,似乎我们需要为每个属性调用 ko.observable
。但别担心,还有另一种方法。Knockout 为此提供了一个插件,它在 knockout.mapping-*
库中。我们已经在**步骤 3** 中将此文件添加到了我们的项目中。此外,我们还在**步骤 13** 中使用过它一次,即:
Person.ViewModel = ko.mapping.fromJS(@Html.Raw(Json.Encode(Model)));
ko.mapping.fromJS
从服务器提供的 JavaScript 对象为我们创建适当的视图模型。这样,我们就有了 Person.ViewModel
中的视图模型。因此,Person.ViewModel
的所有属性都是可观察的,我们需要使用函数语法访问它。
例如:我们可以通过 Person.ViewModel.Name()
获取人名,并通过 Person.ViewModel.Name('New Name')
设置值。正如您所注意到的,Person.ViewModel
不再适合保存。这意味着,如果您直接将 Person.ViewModel
传递给服务器,它将不会将其映射到相关的 **.NET** 对象。因此,我们需要从 **ko** 中获取回纯 **JavaScript** 对象。我们可以使用 ko.toJSON
函数来完成此操作。
ko.toJSON(Person.ViewModel)
在**步骤13**中,我们还有以下一行
Person.Countries = @Html.Raw(Json.Encode(ViewBag.Countries));
此行将 ViewBag 中可用的国家/地区集合分配给我们的 JavaScript 对象。您可能会注意到分号 (;) 附近有一个红色下划线,让您觉得存在错误(在错误列表中,您也会看到警告 Syntax error
)。这是 VS-2012 中的一个错误,您可以放心地忽略它。
注意:我使用 ViewBag 是为了简化我们的演示,而不是创建复杂的 ViewModel。在实际项目中,我建议对此类数据使用 ViewModel。
是时候探索 Person
对象中定义的各个函数了。那么,我们一个接一个地来看看:
PrepareKo: 此函数的目的是设置 **ko** 或扩展其默认功能。在上面粘贴的代码中,我正在创建自己的绑定处理程序来处理日期。这是因为 **.NET** 进行的非兼容 *JSON 日期格式序列化* 所必需的。我在这里具体做什么的解释超出了本文的范围。因此我将其省略。(如果有人感兴趣,请在评论中告诉我。我很乐意解释)。
这是此绑定处理程序的示例用法:
<input data-bind="date: AnyDate" type="text">
在我们的代码中,我们可以看到在**步骤12**的以下行中使用了它:
controlBuilder.Attributes.Add("data-bind", "date: " + propertyObject);
ParseJsonDate
: 这是一个将JSON日期转换为JavaScript日期的实用函数。BindUIwithViewModel
: 顾名思义,此函数将传入的 ViewModel 绑定到 UI 元素。EvaluateJqueryUI
: 用于编写 jQuery UI 相关操作。目前,已评估日期选择器。RegisterUIEventHandlers
: 此函数用于为 UI 元素注册事件处理程序。目前,为 ID 为Save
的元素注册了点击事件。此保存函数首先验证页面,阻止其默认功能,然后向Person.SaveUrl
中指定的 URL 触发 AJAX 请求。由于 URL 是使用Url.Action
从服务器生成的,因此它是正确的,我们无需担心域名或虚拟目录。
以上就是关于 Person 对象的所有内容。所以我们已经准备好所有的配料,是时候烹饪了 也就是说,文档准备好后,我们可以按照首选顺序逐一调用相关函数。
就这样!我们完成了,运行项目,查看结果。
关注点
我们构建了一个页面,其中包含 **数百个基本 UI 元素和 7 个丰富的元素,即 Jqx 网格 x 3 (三个选项卡具有相同数量的控件)**,处理**大量 JSON 数据**。页面运行非常流畅,我们没有发现任何性能问题。至于代码,它比我这里描述的更复杂。所使用的 JavaScript 模式与此类似,尽管它又被拆分到不同的文件中(相信捆绑和压缩!)。
传统 ASP.NET 开发者想知道的最后一件事是用于 UI 操作的零服务器端代码(除了中间的 Razor)。对我来说,理论上更合理的说法是:“**让客户端做它的工作。我们只负责提供数据**”。
历史
- 2013年11月25日 - 1.1.0 - 更新了可观察的下拉列表。
- 2013年9月26日 - 1.0.1 - 附上示例项目。
- 2013年9月25日 - 1.0.0 - 初始版本。