BikeInCity 2 - KnockoutJS, JQuery, Google Maps
本文介绍如何使用KnockoutJS、JQuery和Google Maps来创建响应式的Web应用程序GUI。MVVM的使用有助于保持JavaScript代码的组织性,并使其易于演进和维护。
介绍
BikeInCity是我业余时间的项目。我维护这个项目的原因之一是有一个可以用来测试和学习新框架和技术的试验场。本文将介绍如何使用KnockoutJS和MVVM模式。
让我简要介绍一下这个应用程序。这个Web和移动应用程序作为一个自行车共享聚合器。越来越多的城市提供自行车共享系统,允许用户以少量费用在特殊站点租借自行车。通常,系统会为用户提供Web和移动应用程序,允许客户获取站点位置和可用自行车的数量。我构建了一个简单的应用程序,它聚合了所有的自行车共享系统,并提供路线规划功能。
该应用程序托管在AppHarbor上(http://bikeincity.apphb.com/)。 AppHarbor是一个.NET云平台,支持使用GIT快速部署,如果您不了解它,一定要去看看。我决定开源这个项目。该项目托管在GitHub上。
最近我决定添加一个新功能(称之为“特色”)。**用户可以在地图上添加有趣的地点(我称之为“提示”),写一些描述,并为标记添加图片,这些图片将被添加到卡片中。**然后应用程序将显示该有趣地点附近的5个最近的自行车站点。所以基本上我需要一些CRUD操作,以及维护页面后端一致模型的能力。到目前为止,该网站一直是纯JavaScript客户端,与后端的WCF服务通信。我不想打破这个模式。但我需要一种稍微组织一下事情的方法。因此,KnockoutJS成为了一个合理的解决方案。
我以前在处理Silverlight/WPF时使用过MVVM。我非常喜欢这种模式,我更倾向于声明式地定义我的UI,并将其绑定到ViewModel中的数据或功能。我查看了KnockoutJS,发现它允许我做很多我习惯的事情。
那么结果看起来怎么样?这里是页面的截图,本文将描述我们如何达到这个效果。
源代码
该项目是开源的,所以您当然可以从GitHub存储库中获取所有代码。但是,我创建了一个**示例项目,其中仅包含本文所述的确切Web部分**。后端和数据库访问通过存储在App_Data文件夹中的数据进行“模拟”。因此,您可以在VS 2010中轻松打开它,按F5键并进行调试。
背景:MVVM和KnockoutJS
Model-View-ViewModel(模型-视图-视图模型)是一种设计模式,有助于组织、架构和设计用户界面。如果明智地实现,它能很好地分离关注点并增强应用程序的可测试性。我认为,第一个正式化该模式的文章是由Josh Smith于2009年在MSDN杂志上发表的。该模式是为WPF和Silverlight应用程序“构建”的,因为它利用了这两个平台的强大绑定功能。您也可以在这里CodeProject上搜索以获取更多信息。
KnockoutJS是一种尝试(非常酷的尝试)将MVVM带入JavaScript应用程序。HTML5和JavaScript开箱即用并不提供出色的绑定功能。KnockoutJS实现了完成任务所需的底层功能。在本文中,我假设您对MVVM和KnockoutJS有一定的通用知识。请务必查看文档页面,其中提供了该框架的详细描述。
服务器端模型
简单来说:我们有一个城市集合。每个城市可以包含多个自行车站点以及多个提示。提示和站点都有纬度和经度。站点还包含有关可用自行车的信息。提示包含一些图片和地点描述。城市也按国家分组。我使用NHibernate作为ORM框架,配置是通过FluentNHibernate实现的。但这并不是本文的主题。不过,您随时可以在GitHub存储库中查看具体实现。以下是模型类。
我使用数据传输对象(DTO),在这种情况下它们非常相似。CityDto只是不包含提示和站点的集合。取而代之的是StationDto持有City的Id。所以实际上,DTO与数据库中的行非常相似。
Restful Web服务
在深入研究前端之前,必须在后端构建必要的基础设施。稍后您将看到将ViewModel连接到本文档介绍的RESTful Web服务的简便性。后端使用四个服务
- 返回可用国家列表的服务
- 返回所有提示的服务
- 允许用户创建“提示”的服务
- 返回城市中所有自行车站点数量的服务
- 添加提示时调用的用于上传图片的服务器
前三个服务都通过WCF暴露。用于上传图片的第四个服务实际上并不是WCF服务。相反,它是老式的HttpHandler,负责处理上传。
暴露这些服务的代码非常简单。查询服务只需从数据库检索对象,并可选地将它们传输到DTO。我不会详细介绍使用NHibernate存储值的我的存储库或服务类。如果服务接受整数输入(例如城市的ID),我们需要将其从字符串值转换为整数。只有在使用直接从URL映射的参数时才需要字符串值。如果这些参数通过查询传递,则可以直接使用整数值。
[OperationContract]
[WebGet(UriTemplate = "countries/{countryId}/cities", ResponseFormat = WebMessageFormat.Json)]
public List<CityDto> GetCities(String countryId)
{
int countryID = Convert.ToInt32(countryId);
var cities = _repository.Find<City>(x => x.Country.Id == countryID);
return Mapper.Map<List<CityDto>>(cities);
}
[WebGet(UriTemplate = "countries", ResponseFormat = WebMessageFormat.Json)]
public List<CountryDto> GetCountries()
{
var list = _repository.GetAll<Country>();
var clearedList = list.Select(x => new CountryDto{ Name = x.Name, Id = x.Id }).ToList();
return clearedList;
}
[OperationContract]
[WebGet(UriTemplate = "city/{cityID}/stations", ResponseFormat = WebMessageFormat.Json)]
public List<StationDto> GetStations(String cityID)
{
int id = Int32.Parse(cityID);
var stations = _repository.Find<Station>(x => x.City.Id == id);
var dtos = Mapper.Map<List<StationDto>>(stations);
return dtos;
}
[OperationContract]
[WebGet(UriTemplate = "city/{cityId}/tips", ResponseFormat = WebMessageFormat.Json)]
public List<InformationTipDto> InformationTips(String cityId)
{
var id = Convert.ToInt32(cityId);
var list = Mapper.Map<List<InformationTipDto>>(_repository.Find<InformationTip>(x => x.City.Id == id));
return list;
}
[OperationContract]
[WebInvoke(Method = "POST", BodyStyle = WebMessageBodyStyle.Bare,
RequestFormat = WebMessageFormat.Json, UriTemplate = "tips/add")]
public void AddTip(InformationTipDto tip)
{
var infoTip = Mapper.Map(tip);
var city = _repository.Find<City>(x => x.Id == tip.CityId).SingleOrDefault();
infoTip.City = city;
_repository.Save<InformationTip>(infoTip);
}
现在让我们看看配置。所有三个方法都位于同一个类中,因此只有一个服务和一个端点需要暴露。服务配置为使用JSONP格式以实现跨站点脚本。自.NET 4发布以来,您可以使用**webHttpBinding**上的**crossDomainScriptAccessEnabled**来启用JSONP行为。
<system.serviceModel>
<services>
<service behaviorConfiguration="JsonServicesBehavior" name="CPKnockout.Web.Services.Bike">
<endpoint address="json" behaviorConfiguration="JsonServicesBehavior" binding="webHttpBinding" bindingConfiguration="jsonpBinding" contract="CPKnockout.Web.Services.Bike" />
</service>
</services>
<bindings>
<webHttpBinding>
<binding name="jsonpBinding" crossDomainScriptAccessEnabled="true" />
</webHttpBinding>
</bindings>
<behaviors>
<endpointBehaviors>
<behavior name="JsonServicesBehavior">
<webHttp />
</behavior>
</endpointBehaviors>
<serviceBehaviors>
<behavior name="JsonServicesBehavior">
<serviceMetadata httpGetEnabled="true" />
<serviceDebug includeExceptionDetailInFaults="true" />
</behavior>
</serviceBehaviors>
</behaviors>
<serviceHostingEnvironment aspNetCompatibilityEnabled="true" multipleSiteBindingsEnabled="true" />
</system.serviceModel>
如前所述,HttpHandler用于处理图片的上传。我没有将其作为REST方法暴露。没有理由使用WCF——它不会简化任务,只会增加一些复杂性。
public void ProcessRequest(HttpContext context)
{
System.Web.HttpPostedFile file = context.Request.Files[0];
var fileName = RandomString(20) + "." + extension;
var folderName = "Uploads";
var filePath = System.Web.HttpContext.Current.Server.MapPath("~") + folderName + "\\" + fileName;
var link = String.Format("/{0}/{1}", folderName, fileName);
using (var img = Image.FromStream(file.InputStream, true, false ) )
{
Size sz = adaptProportionalSize(new Size(250,250), img.Size);
var smallImage = img.GetThumbnailImage(sz.Width,sz.Height,null,IntPtr.Zero );
smallImage.Save(filePath);
}
context.Response.StatusCode = 200;
context.Response.Write(link);
}
该处理程序返回上传文件的链接。响应格式是纯文本——这次没有JSON。 ViewModels
客户端模型在逻辑上复制了服务器端模型。所以我们对服务器上的每个模型类都有一个ViewModel。此外,我们还有一个聚合ViewModel。这不是我第一次在使用MVVM模式时有遵循聚合模式思想的印象。我想知道是否有人已经以某种方式发现了并正式化了这种关系。
回到工作。聚合ViewModel称为CountryListViewModel——因为它基本上是一个国家列表,并包含一些额外的逻辑。每个国家包含一个城市列表,每个城市包含站点和提示列表。这是ViewModels的层次结构:
CountryListViewModel
|-- List<CountryViewModel>
|-- List<CityViewModel>
|-- List<StationViewModel>
|-- List<TipViewModel>
function TipViewModel(cityId, parent) {
var self = this;
self.parent = parent;
self.cityId = cityId;
self.title = ko.observable();
self.description = ko.observable();
self.imageUrl = ko.observable();
self.saving = ko.observable(false);
self.lat = ko.observable();
self.lng = ko.observable();
self.selected = ko.observable(false);
self.select = function () {
if (self.parent.selectedTip() != null) {
self.parent.oldTip = self.parent.selectedTip();
self.parent.oldTip.selected(false);
}
self.parent.selectedTip(self);
self.selected(true);
};
self.save = function () {
self.saving(true);
var data = JSON.stringify(self.toDTO());
$.ajax("/Services/Bike.svc/json/tips/add", {
data: data,
type: "post", contentType: "application/json",
success: function (result) {
self.saving(false);
}
});
};
self.toDTO = function () {
var dto = new Object();
dto.CityId = self.cityId;
dto.Description = self.description();
dto.Title = self.title();
dto.ImageUrl = self.imageUrl();
dto.Lat = self.lat();
dto.Lng = self.lng();
return dto;
};
}
**save** 方法执行对服务器端暴露的Web服务的Ajax调用。当没有错误时,success标志设置为TRUE。请注意,还有一个**toDto**方法,我用它来创建服务器端暴露的DTO结构的JSON。添加Tip的服务将TipDto作为参数。
下一个ViewModel是station。station甚至更简单——没有“save”方法。此ViewModel仅用于可视化。
function StationViewModel(cityId, parent) {
var self = this;
self.parent = parent;
self.cityId = cityId;
self.address = ko.observable();
self.lat = ko.observable();
self.lng = ko.observable();
}
让我们继续**CityViewModel**。这里我们增加了一点复杂性,以加载站点和提示。但还有其他事情要注意,请先看代码。
function CityViewModel(data) {
var self = this;
self.name = ko.observable(data.Name);
self.id = data.Id;
self.tips = ko.observableArray([]);
self.stations = ko.observableArray([]);
self.newTip = ko.observable(new TipViewModel(self.id, self));
self.saving = ko.observable();
self.selectedTip = ko.observable();
self.oldTip = null;
self.lat = data.Lat;
self.lng = data.Lng;
self.description = ko.observable();
self.stationImageUrl = ko.observable();
self.bikeImageUrl = ko.observable();
$.getJSON("/Services/Bike.svc/json/city/" + this.id + "/tips", function (allData) {
var mappedTips = $.map(allData, function (item) {
var tip = new TipViewModel(self.id, self);
tip.title(item.Title);
tip.description(item.Description);
tip.imageUrl(item.ImageUrl);
tip.lng(item.Lng);
tip.lat(item.Lat);
return tip;
});
self.tips(mappedTips);
});
self.getStations = function () {
$.getJSON("/Services/Bike.svc/json/city/" + this.id + "/stations", function (allData) {
var mappedStations = $.map(allData, function (item) {
var station = new StationViewModel(self.id, self);
station.address(item.Address);
station.lng(item.Lng);
station.lat(item.Lat);
return station;
});
self.stations(mappedStations);
});
};
// Operations
self.addTip = function () {
self.newTip().save();
self.tips.push(self.newTip());
self.newTip(new TipViewModel(self.id, self));
};
}
在这里,我们需要某种方式来处理用户正在添加的“新提示”。因此,**NewTip**属性保存新提示的数据,并在每次保存发生时被新的空提示替换。接下来是CountryViewModel。Country包含一个城市列表,仅此而已。
function CountryViewModel(data) {
// Data
var self = this;
self.name = data.Name;
self.id = data.Id;
self.cities = ko.observableArray([]);
self.selectedCity = ko.observable();
self.oldCity = null;
// Load initial state from server, convert it to Task instances, then populate self.tasks
$.getJSON("Services/Bike.svc/json/countries/" + self.id + "/cities", function (allData) {
var mappedCities = $.map(allData, function (item) { return new CityViewModel(item) });
self.cities(mappedCities);
});
}
最后一个聚合所有ViewModel的是CountryListViewModel。正如您所料,CountryListViewModel包含国家列表。这里有一小段有趣的代码。**afterCountriesRendered**方法在菜单渲染后立即执行,并隐藏菜单项。稍后在讨论菜单是如何渲染时,我将重点介绍这一点。
function CountryListViewModel() {
var self = this;
self.countries = ko.observableArray([]);
self.selectedCity = ko.observable();
self.oldCity = null;
self.setSelected = function (city) {
city.getStations();
self.oldCity = self.selectedCity();
self.selectedCity(city);
};
$.getJSON("Services/Bike.svc/json/countries", function (allData) {
var mappedCountries = $.map(allData, function (item) { return new CountryViewModel(item) });
self.countries(mappedCountries);
});
self.afterCountriesRendered = function (elements) {
$(elements[1]).mouseenter(function () {
$(this).find("ul").slideToggle("fast");
});
$(elements[1]).mouseleave(function () {
$(this).find("ul").hide();
});
$(elements[1]).find("city").hide();
};
}
现在让我们看看使用所有这些ViewModel的网页。
View
在进行任何操作之前,必须初始化主ViewModel(在本例中是CountryListViewModel),并将其作为ViewModel传递给Knockout,用于绑定页面上的元素。
countryList = new CountryListViewModel();
ko.applyBindings(countryList);
页面由4个有趣的区域组成:菜单、提示列、新提示对话框和地图。让我们从包含**提示表格**的**div**元素开始。
提示表格
此元素的主要部分是一个使用Knockouts **foreach绑定**的表格。
<div class="tips_column">
<table style="background-color: #F7B54A;">
<tr>
<td>
<div data-bind="text: selectedCity()? selectedCity().name : 'Select the city'" />
</td>
<td>
<div data-bind="visible: selectedCity()" id="dialog_link">
<span style="margin: 5px">add new</span>
</div>
</td>
</tr>
</table>
<!-- ko if:selectedCity() -->
<!-- map points -->
<div data-bind="foreach: selectedCity().tips">
<div data-bind="latitude: lat, longitude:lng, map:map, selected:selected">
</div>
</div>
<table data-bind="foreach: selectedCity().tips" style="width: 100%">
<tr style="cursor: pointer;">
<td data-bind="click: select,style: { backgroundColor: selected() ? '#66C547' : '#808080'}">
<img data-bind='attr: { src: imageUrl }' height="60px" width="60px" style="float: left" />
<div style="float: left; color: White; margin: 4px">
<div data-bind='text: title' style="font-weight: bold">
</div>
<div data-bind="text: description">
</div>
</div>
</td>
</tr>
</table>
您可以看到,每一行的背景色都绑定到InformationTipViewModel的**selected**属性。您还可以看到,为了渲染图像,**使用了属性绑定**。整个区域使用此指令:
<!--ko if:selectedCity() -->
这基本上告诉Knockout在未选择任何城市时忽略整个区域。
新提示对话框
JQuery UI用于将网页的以下部分渲染为对话框。
<div id="newTip" style="background-color: Gray;" title="New bike tip" data-bind="with:selectedCity()"> <table> <tr> <td> Title: </td> <td> <input data-bind="value: $data ? newTip().title : '' " /> </td> </tr> <tr> <td> Description: </td> <td> <textarea data-bind="value: $data? newTip().description : ''" ></textarea> </td> </tr> <tr style="height:100px;margin-bottom:4px"> <td> Image: </td> <td> <input type="radio" name="uploadVSURL" id="enableUpload" onchange="imageUrlUploadSwitch()" checked="checked" /><label>Upload image</label> <input type="radio" name="uploadVSURL" onchange="imageUrlUploadSwitch()" /><label>Image from web</label> <input data-bind="value: $data? newTip().imageUrl : ''" id="providedUrl"/> <form id="uploadForm" action="/Services/Upload.ashx" method="POST" enctype="multipart/form-data"> <table> <tr> <td><input type="file" name="file" /></td> <td><input type="hidden" name="MAX_FILE_SIZE" value="100000" /></td> <td><input type="submit" value="Submit" /></td> </tr> </table> </form> </td> </tr> <tr> <td> Latitude/Longitude: </td> <td> <input data-bind="value: $data? newTip().lat : ''" /> <input data-bind="value: $data? newTip().lng : '' " /> </td> </tr> </table> <button data-bind='click: $data? addTip : new function(){}'> Add a tip</button> </div>
我在这里使用**with绑定**。此绑定允许我们在其中一个区域定义“**$data**”通配符将被传递给**with绑定**的可观察对象替换。这使开发人员能够减少在表单或区域中为每个绑定元素重复复制可观察对象的名称。我不能在这里使用**Knockouts if指令**。为了将div转换为对话框,必须在div上调用JQuery的dialog方法。Knockout在页面首次加载时看到没有选定的城市,它只会忽略div元素,并且会在不存在的div标签上调用dialog初始化。因此,我必须使用条件绑定。
value: $data ? NewTip().title : ' '这被解释为:如果有一个城市,则显示新提示值,否则显示一个空字符串。
菜单
正如您在屏幕截图中看到的,菜单由国家列表组成,每个国家有几个城市。在Knockout中,双重foreach绑定将产生所需的效果。但是,我决定让外部foreach绑定渲染一个模板。模板的优点是为渲染过程提供了更多的灵活性。特别是**‘afterRender’**回调非常有用。
<nav id="menu">
<a href="#" class="trigger">choose city</a>
<ul data-bind="template: {name:'country-menu-template', foreach: countries,afterRender: afterCountriesRendered}" style="background-color:#808080"></ul>
</nav>
<script type="text/html" id="country-menu-template">
<li class="li_country"><span data-bind="text: name"></span>
<ul id="cityList" data-bind="foreach:cities" class="ul_city" style="display:none">
<li data-bind="click: $root.setSelected" style="cursor:pointer"><span data-bind="text: name"></span></li>
</ul>
</li>
</script>
在这里,**CountryListViewModel**中定义的**afterRender**操作发挥作用。我想为菜单的鼠标事件(即鼠标进入和鼠标离开事件)定义一些回调,这些回调负责“悬停”效果。这可以使用JQuery的**mouseenter**函数并传递回调来实现。但是,当页面加载时,没有渲染任何元素。菜单会在所有数据从REST服务获取后渲染。因此,我决定使用**afterRender**回调。此回调必须设置为一个函数,该函数接受一组元素作为参数。这组元素将包含模板渲染的HTML元素。一旦我们有了渲染的元素,我们就可以在它们上面调用任何JQuery函数,定义它们的行为。请注意,**这是ViewModels包含或更确切地说显示视图知识的唯一地方**。这并不理想,但我还没有找到解决办法。
通过**数据绑定LI元素**的**click操作**来更改城市。操作非常简单:**$root.setSelected**。**$root**是根ViewModel(或者说聚合器)的通配符,由于没有为**setSelected**方法指定参数,绑定了click操作的元素将被作为参数传递。结果是调用CountryListViewModel的setSelected方法,并将当前城市作为参数传递,并处理更改。如前所述,提示表的绑定到**selectedCity**的提示,通过更改**selectedCity**,提示会自动更改。现在,必须绑定到当前城市变化的第二件事当然是地图。
地图
地图初始化的任何特殊之处都不会偏离Google Maps API中描述的标准过程。为了实现地图上的点与ViewModels之间的绑定,我决定定义一个新的绑定。我已经在我的博客上解释了我的做法,并且我还提供了一个包含**最小运行示例**的JsFiddle片段。因此,如果您以前见过,请原谅我重复。这个绑定的灵感来自Silverlight和Bing Maps。因为Silverlight对MVVM和数据到UI的绑定提供了出色的支持,所以它也被用于地图也就不足为奇了。有一种添加项目集合到地图的不错方法,类似于这样:
<map>
<itemscollections datasource="itemsVM" style="wayToRenderMarker"/>
</map>
这与声明式UI定义的原则非常吻合:我们不必编写命令式代码来定义“事物应该如何显示”。我必须说,当我使用JS和JQuery时,我非常怀念这个原则。哲学就到此为止——让我们专注于完成工作。这是声明性部分:
<div id="map_canvas">
</div>
<div data-bind="foreach: selectedCity().tips">
<div data-bind="latitude: lat, longitude:lng, map:map, selected:selected">
</div>
</div>
您可以看到,我正在使用一个**新的绑定,它接收纬度、经度、地图和一个魔术selected**回调。现在我们可以看看这个代码所需的底层功能。新绑定的声明是通过在Knockout的**bindingHandlers**属性上声明一个新对象来完成的。
ko.bindingHandlers.map = {
init: function (element, valueAccessor, allBindingsAccessor, viewModel) {
var position = new google.maps.LatLng(allBindingsAccessor().latitude(), allBindingsAccessor().longitude());
var marker = new google.maps.Marker({
map: allBindingsAccessor().map,
position: position,
icon: 'Icons/star.png',
title: name
});
google.maps.event.addListener(marker, 'click', function () {
viewModel.select()
});
markers.push(marker);
viewModel._mapMarker = marker;
},
update: function (element, valueAccessor, allBindingsAccessor, viewModel) {
var latlng = new google.maps.LatLng(allBindingsAccessor().latitude(), allBindingsAccessor().longitude());
viewModel._mapMarker.setPosition(latlng);
if (viewModel.selected()) {
//item is selected - do what you want with the marker
} else {
//deselected...
}
}
};
在绑定声明中定义了两个函数。**init**函数在Knockout首次使用ViewModel进行渲染时执行。**update**函数在ViewModel发生变化时执行。Knockout向这两个函数传递了一些非常有用的参数:
- **element** – 用于渲染ViewModel的HTML元素
- **valueAccessor** – 传递给绑定的值。这里我们定义了一个MAP绑定,所以它将持有传递给map参数的值。
- **allBindingsAccessor** – 这个对象可以用来发现所有绑定上传递的对象。这很有用,因为我们可以检索传递给其他绑定参数(纬度、经度和selected)的所有值。
- **ViewModel** – 显然是ViewModel对象——在我们的例子中,它对应于要在地图上显示的单个提示。
地图绑定初始化
init函数实际获取所有值:纬度、经度、地图以及单击标记时应执行的回调。创建标记并将其放置在地图上。现在必须将标记保存在某个地方,以便将来进行更改。保存标记的最佳位置是ViewModel本身。
viewModel._mapMarker = marker;
这里我们显然假设ViewModel上有一个名为**_mapMarker**的属性。
地图绑定更新
更新函数在ViewModel(Information Tip)的任何内容发生变化时执行。在我们的例子中,可能是位置或**selected**属性的值。通过将标记保存在ViewModel中,我们可以轻松地更改标记的位置。
viewModel._mapMarker.setPosition(latlng);
订阅城市更改事件
还有一件事需要处理。每次更改城市时,都需要对地图进行一些清理。我们定义的标记会保留在地图上,直到对其调用**setMap(null)**技巧(Google Maps API的行为方式)。幸运的是,Knockout提供了一种订阅任何可观察对象变化的方法。另一方面,如果我们无法实际观察到一个可观察对象,那它将是一个奇怪的可观察对象,不是吗?
countryList.selectedCity.subscribe(function (newValue) {
var oldCityTips = countryList.oldCity.tips
for (var i = 0; i < markers.length; i++) {
var marker = markers[i];
marker.setMap(null);
}
markers = [];
//other stuff needed to be performed on city change
);
关注点
需要指出的一点是,KnockoutJS功能非常强大。我认为从Silverlight/WPF过渡到HTML/JavaScript会更加痛苦。但实际上,每次我遇到问题时,我都在KnockoutsJS的一个特性(自定义绑定、模板、渲染后回调、订阅可观察对象)中找到了解决方案。
您还必须注意,JavaScript是一种非常灵活的语言,但用JavaScript编写的代码很难维护和组织。KnockoutJS至少为我们提供了一种可以用来保持代码组织的模式。
更多关于项目的信息
该项目已在AppHarbor上运行。我一年前描述的**为Windows Phone 7编写的移动应用程序**被下架了(实际上是我自己 )。我无法支持向后兼容性,并随时更改应用程序,因为Web服务接口已经发生了一些变化(自那时以来发生了一些变化)。我实际上并不拥有WP7手机,并且在模拟器上进行测试有点麻烦。我越来越忙于其他项目,所以现在我想知道是否有感兴趣的人帮助我维护这个开源项目。如果您愿意,请告诉我。
历史
- 2012年4月20日 - 文章初次发布