65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 KnockoutJS 的股票投资组合应用程序

starIconstarIconstarIconstarIconstarIcon

5.00/5 (16投票s)

2012年7月19日

CPOL

25分钟阅读

viewsIcon

101242

downloadIcon

5183

一篇展示如何使用 KnockoutJS 和自定义控件实现 MVVM 应用的文章。

application screenshot

引言

随着平板电脑和智能手机越来越受欢迎,开发者面临着开发能够同时在这些设备和桌面电脑上运行的应用程序的日益增长的需求。HTML5 和 JavaScript 可以帮助开发者通过单一代码库实现这一目标。

直到最近,使用 JavaScript 和 HTML 编写应用程序还相当困难,因为开发者必须创建所有的业务逻辑、用户界面,然后使用 JavaScript 代码和 HTML 文档对象模型将所有这些元素连接起来。相比之下,用 Silverlight 编写 Web 应用程序要容易得多,因为逻辑和用户界面可以在很大程度上独立实现,然后通过声明性绑定连接起来。

随着 jQuery、jQuery UI 和 KnockoutJS 等 JavaScript 库的引入,这种情况开始发生变化。这些库引入了标准的方法来操作 HTML 文档(jQuery)、向 HTML 文档添加用户界面小部件(jQueryUI),以及声明性地将业务逻辑绑定到 UI(KnockoutJS)。

与这些流行的 JavaScript 库的引入同时,大多数流行的浏览器也逐渐增加了对 HTML5 功能的支持,例如本地存储、地理定位、富媒体等。这些功能也使得 HTML/JavaScript 组合成为 Web 应用程序的优秀平台。

本文描述了 **Invexplorer** 的实现,这是一个类似于 Google 财经的股票投资组合应用程序。该应用程序使用上述库,用 HTML5 和 JavaScript 编写。

要在浏览器中运行该应用程序,请点击此处

用户可以使用智能自动完成文本框将股票添加到投资组合中。例如,如果他们输入“ford mot”,文本框将显示一个包含两个与“福特汽车公司”对应的符号的列表。然后他们可以选择其中一个符号并点击“添加到投资组合”按钮。一旦项目被添加到投资组合中,用户可以通过点击复选框来绘制其价格历史,并通过直接在网格中输入来编辑交易信息。

投资组合会自动持久化到本地存储。当用户打开应用程序时,他们的投资组合会自动加载。

该应用程序遵循 MVVM 模式。视图模型类使用 **KnockoutJS** 库实现。它们可以独立于视图进行测试,并且可以在我们想要为平板电脑或手机创建新视图时重复使用。属性实现为 KnockoutJS 的“observable”和“`observableArray`”对象,这使得它们可以在 KnockoutJS 绑定中用作源和目标。

视图使用 HTML5 和两个自定义控件实现:一个来自 **jQueryUI** 库的自动搜索框和一个来自 **Wijmo** 库的图表控件。这两个库都有支持 KnockoutJS 的扩展,并且都可以通过在 HTML 页面中包含相应的链接来简单使用(无需下载/安装)。

就在本文完成前不久,CodeProject 发表了 Colin Eberhardt 的一篇精彩文章,题为“KnockoutJS vs. Silverlight”。Colin 专注于 KnockoutJS,并使用了一个不需要自定义控件的简单示例。这篇文章非常棒——强烈推荐阅读。你可以在此处找到它。

如果您想查看 Invexplorer 应用程序的 Silverlight 和 KnockoutJS 实现之间的比较,以及 JavaScript 和 MVVM 的快速介绍,您可以在此处找到。

本文的最新修订版添加了 Invexplorer 应用程序的 TypeScript 版本。TypeScript 版本将在文章的最后一节中讨论。

重要免责声明

**Invexplorer** 是一个示例应用程序,旨在展示如何使用 KnockoutJS 和自定义控件。它使用来自雅虎财经的金融数据,这不是一项免费服务。如果您想将所提供的代码用作实际应用程序的基础,则必须联系雅虎或其他金融数据提供商以获取所需的许可证。

MVVM 简介

MVVM 模式(Model/View/ViewModel)由微软引入,作为更传统的 MVC 模式(Model/View/Controller)的变体。

MVVM 将应用程序逻辑封装在一组 ViewModel 类中,这些类公开了一个对视图友好的对象模型。视图通常专注于用户界面,并依赖绑定将 UI 元素连接到 ViewModel 中的属性和方法。这种逻辑和标记分离带来了以下重要好处:

  1. **可测试性**:ViewModel 不包含任何用户界面元素,因此易于使用单元测试进行测试。
  2. **关注点分离**:业务逻辑使用 C# 或 JavaScript 等编程语言编写。用户界面使用 XAML 或 HTML 等标记语言编写。每种开发所需的技能和使用的工具根本不同。分离这些元素使团队开发更简单、更高效。
  3. **多目标应用程序**:将应用程序的业务逻辑封装到 ViewModel 类中,可以更容易地开发应用程序的多个版本,针对多个设备。例如,可以开发一个 ViewModel,并为桌面、平板电脑和手机设备开发不同的视图。

下图说明了 MVVM 应用程序中元素之间的关系(该图取自一篇优秀的 MVVM 介绍,可在此处找到)。

MVVM diagram

KnockoutJS 简介

**KnockoutJS** 通过提供两个主要元素来实现 MVVM 开发:

  • JavaScript 类,如 **observable** 和 **observableArray**,用于实现 ViewModel 变量,当其值改变时会发出通知(类似于 .NET 开发中的 `INotifyPropertyChanged`)。
  • 引用这些可观察对象并在其值改变时自动更新页面的 HTML 标记扩展。这些标记扩展非常丰富。除了显示数字和字符串等值之外,它们还可以用于自定义样式、启用或禁用 UI 元素,以及表示列表、网格或图表等集合的元素。这些标记扩展类似于 XAML 开发中的 **Binding** 对象。

要使用 KnockoutJS 开发应用程序,您首先创建包含应用程序逻辑并暴露由可观察对象构建的对象模型的 ViewModel 类。这些类可以在进入下一步(创建视图)之前进行测试。

视图使用 HTML 和 CSS 创建。唯一的区别是添加了诸如“data-bind: text”(用于显示静态内容)或“data-bind: value”(用于在文本框等元素中创建双向绑定)之类的标记扩展。

最后,ViewModel 和 View 通过调用 KnockoutJS 的 `applyBindings` 方法连接起来,该方法将对象模型作为参数并实际构建绑定。

这就是 KnockoutJS 的概况。在我看来,这种概念上的简单性是该库的主要优势之一。但它还有更多内容。KnockoutJS 官方网站有许多示例、教程和出色的文档。您可以在knockoutjs.com获取所有详细信息。

Invexplorer 示例应用程序

**Invexplorer** 应用程序大致基于 Google 财经网站。它允许用户选择股票并将其添加到投资组合中。一旦股票添加到投资组合中,应用程序会检索历史价格数据并显示价格在图表上的演变。用户可以选择应绘制哪些股票以及在哪个时期。用户还可以输入购买股票的价格和购买数量。如果提供此信息,应用程序会计算每个项目的回报。

投资组合信息是持久化的,所以当用户退出应用程序时,他们的编辑不会丢失,并且当应用程序再次运行时可以自动重新加载。

**Invexplorer** 应用程序的原始版本是使用 MVVM 模式在 Silverlight 中编写的(您可以在此处查看 Silverlight 版本)。当 KnockoutJS 推出时,我们认为它将是移植到 HTML5/JavaScript 的理想选择。

移植应用程序只花了几天时间。大部分工作都是用 JavaScript 重写视图模型类,使用 KnockoutJS 库将属性实现为可观察对象。编写视图非常容易,因为我们需要的控件很容易找到:用于选择新股票的自动搜索框是 jQueryUI 库的一部分,我们使用的图表是 Wijmo 库的一部分。这两个控件库都支持 KnockoutJS,并且都稳定、强大且易于使用。

视图模型实现 (JavaScript)

下面的类图显示了实现视图模型的类

ViewModel class diagram

每个类的主要属性如下所述

`ViewModel` 类主要属性

  • `companies`:`Company` 对象数组,包含完整的股票代码列表、公司名称和历史价格数据。当模型创建时,列表会填充股票代码和名称,价格数据按需检索。
  • `portfolio`:一个 Portfolio 对象,包含投资组合项目列表。
  • `chartSeries`、`chartStyles`:两个 `observableArray` 对象,包含为在线图表控件显示准备的数据。`chartSeries` 数组包含价格变动而不是绝对值,因此可以轻松比较不同公司的系列。`chartStyles` 数组包含用于指定每个数据系列颜色的 CSS 样式定义。
  • `minDate`:一个可观察的日期对象,定义图表的起始日期。这允许用户在特定时期(例如今年、过去 12 个月等)比较不同的股票。
  • `updating`、`chartVisible`:两个可观察对象,暴露视图模型的状态,并使视图能够在数据加载时提供用户反馈,并在图表为空时隐藏与图表相关的元素。

`Company` 类主要属性

  • `symbol`:该公司的交易代码。
  • `name`:公司全称。
  • `prices`:一个可观察对象,包含日期/价格对象数组。请注意,这不是 `observableArray` 对象;而是一个普通可观察对象,其值是一个普通数组。该数组按需填充,每个公司一次。
  • `chartSeries`:一个可观察对象,包含一个自定义对象,用于定义图表控件的单个系列。此自定义对象指定系列的 x 和 y 值以及系列标签、标记以及是否应显示在图表图例中。当 prices 数组填充时以及当父 ViewModel 的 `minDate` 属性值改变时,此数组会创建并刷新。

`Portfolio` 类主要属性

  • `items`:一个包含 `PortfolioItem` 对象的 `observableArray`。每个投资组合项目都引用一家公司,并可能包括交易数据,例如购买的股票数量和购买价格。
  • `newSymbol`:一个可观察对象,包含要添加到投资组合的股票代码。该值绑定到视图中的一个自动搜索文本框,允许用户选择要添加到投资组合的股票。
  • `canAddNewSymbol`:一个可观察对象,包含一个布尔值,用于确定 `newSymbol` 属性当前是否包含一个有效且尚未包含在当前投资组合中的符号。此变量用于启用或禁用用于向项目集合添加新项目的 UI 元素。

`PortfolioItem` 类主要属性

  • `symbol`:此项目代表的公司的交易代码。此值用作 ViewModel 的公司数组的键。
  • `company`:包含公司信息(包括名称和价格历史)的 Company 对象。
  • `lastPrice`、`change`:这些是可观察对象,最初设置为 null,并在公司价格历史可用时更新。
  • `shares`、`unitCost`:这些是用户可以编辑的可观察对象。这些值代表用户交易,并用于计算每个投资组合项目的成本。这些值属于投资组合项目本身。
  • `chart`:一个可观察的布尔变量,决定此项目是否应包含在图表中。
  • `value`、`cost`、`gain` 等:几个计算对象,提供基于其他属性计算的值。例如,value = shares * lastPrice。

`ViewModel` 类的构造函数实现如下

/***********************************************************************************
* ViewModel class.
* @constructor
*/
function ViewModel() {
  var self = this;

  // object model
  this.companies = [];
  this.updating = ko.observable(0);
  this.minDate = ko.observable(null);
  this.chartSeries = ko.observable([]);
  this.chartStyles = ko.observable([]);
  this.chartHoverStyles = ko.observable([]);
  this.chartVisible = ko.observable(false);
  this.setMinDate(6); // chart 6 months of data by default
  this.minDate.subscribe(function () { self.updateChartData() });
  this.portfolio = new Portfolio(this);

  // create color palette
  this.palette = ["#FFBE00", "#C8C800", …];

构造函数的第一部分声明了上面描述的属性。请注意,`minDate` 属性是可观察的,并且 `ViewModel` 类订阅了它。当 `minDate` 属性的值发生变化时,`ViewModel` 会调用 `updateChartData`,以便重新生成图表以反映用户请求的日期范围。

**palette** 属性包含一个数组,其中包含用于为每个项目创建图表系列的颜色。这些颜色也显示在网格中,作为图表的图例。将这种纯粹与 UI 相关的元素添加到 ViewModel 类中是相当常见的。毕竟,ViewModel 存在是为了驱动视图(这就是它们被称为“ViewModel”,而不仅仅是“Model”的原因)。

一旦属性被声明,构造函数就会填充 `companies` 数组,如下所示

// populate companies array
$.get("StockInfo.ashx", function (result) {
      var lines = result.split("\r");
      for (var i = 0; i < lines.length; i++) {
          var items = lines[i].split("\t");
          if (items.length == 2) {
              var c = new Company(self, $.trim(items[0]), $.trim(items[1]));
              self.companies.push(c);
          }
      }

      // load/initialize the portfolio after loading companies
      self.portfolio.loadItems();
});

代码使用 jQuery `get` 方法调用一个名为“_StockInfo.ashx_”的服务,该服务是 Invexplorer 应用程序的一部分。该服务异步执行,并返回一个公司代码和名称列表,该列表被解析并添加到 `companies` 数组中。

**NET 开发者:** 请注意使用“self”变量从局部函数内部访问 ViewModel 类。在此作用域中,`this` 变量指的是内部函数本身,而不是 ViewModel。这是一种常见的 JavaScript 技术。

公司数据加载后,构造函数调用投资组合的 `loadItems` 方法从本地存储加载上次保存的投资组合。为了使其正常工作,投资组合必须在用户关闭应用程序时保存。这在构造函数的最后一个块中完成:

// save portfolio when window closes
  $(window).unload(function () {
      self.portfolio.saveItems();
  });
}

此代码使用 jQuery 连接到窗口的 `unload` 事件,该事件在用户关闭应用程序时调用。此时,会调用投资组合的 `saveItems` 方法,并将当前投资组合保存到本地存储。

`saveItems` 和 `loadItems` 方法是 `Portfolio` 类的一部分。在展示它们的实现之前,这是 `Portfolio` 构造函数:

/***********************************************************************************
* Portfolio class.
* @constructor
*/
function Portfolio(viewModel) {
    var self = this;
    this.viewModel = viewModel;
    this.items = ko.observableArray([]);
    this.newSymbol = ko.observable("");
    this.newSymbol.subscribe(function () { self.newSymbolChanged() });
    this.canAddSymbol = ko.observable(false);
}

构造函数保留对父级 **ViewModel** 的引用,创建将包含投资组合项目的可观察数组,并声明一个 `newSymbol` 可观察对象。`newSymbol` 属性包含要添加到投资组合的公司的符号。

构造函数订阅 `newSymbol` 属性的变化,因此只要其值改变,就会调用 `newSymbolChanged` 方法。`newSymbolChanged` 方法反过来将 `canAddSymbol` 属性的值设置为 true,如果新符号有效且不对应于投资组合中已有的任何项目。`newSymbol` 和 `canAddSymbol` 属性由视图使用,以允许用户向投资组合添加项目。

这是从本地存储保存和加载投资组合项目的方法

// saves the portfolio to local storage
Portfolio.prototype.saveItems = function () {
  if (localStorage != null) {

    // build array with items
    var items = [];
    for (var i = 0; i < this.items().length; i++) {
      var item = this.items()[i];
      var newItem = {
        symbol: item.symbol,
        chart: item.chart(),
        shares: item.shares(), 
        unitCost: item.unitCost()
      };
      items.push(newItem);
    }

    // save array to local storage
    localStorage["items"] = JSON.stringify(items);
  }
}

**NET 开发者:** 注意使用 `Portfolio.prototype.saveItems` 语法来定义方法。“`prototype`”关键字将该方法附加到 `Portfolio` 类的每个实例。这是一种在 JavaScript 中实现对象的常见方式。

该方法首先检查 `localStorage` 对象是否已定义。这是所有现代浏览器中都可用的 HTML5 功能。然后,它构建一个数组,其中包含每个投资组合项目应持久化的信息(符号、图表、份额和单位成本)。最后,使用 `JSON.stringify` 方法将数组转换为字符串,并以“items”为键保存到存储中。

`loadItems` 方法从本地存储中读取此信息

// loads the portfolio from local storage (or initializes it with a few items)
Portfolio.prototype.loadItems = function () {

    // try loading from local storage
    var items = localStorage != null ? localStorage["items"] : null;
    if (items != null) {
        try {
            items = JSON.parse(items);
            for (var i = 0; i < items.length; i++) {
                var item = items[i];
                this.addItem(item.symbol, item.chart, item.shares, item.unitCost);
            }
        }
        catch (err) { // ignore errors while loading...
        }
    }

    // no items? add a few now
    if (this.items().length == 0) {
        this.addItem("AMZN", false, 100, 200);
        this.addItem("YHOO", false, 100, 15);
    }
}

该方法首先从 `localStorage` 中检索“items”字符串。它使用 `JSON.parse` 方法将字符串转换为 JavaScript 数组对象,然后遍历数组中的项目,对每个项目调用 `addItem` 方法。

**NET 开发者:** `JSON.stringify` 和 `JSON.parse` 方法是 JavaScript 中 .NET 序列化器的对应物。它们提供了一种简单的方法来将对象转换为字符串以及从字符串转换回来。

`addItem` 方法的实现如下:

// add a new item to the porfolio
Portfolio.prototype.addItem = function (symbol, chart, shares, unitCost) {
    var item = new PortfolioItem(this, symbol, chart, shares, unitCost);
    this.items.push(item);
}

`PortfolioItem` 类代表投资组合中的项目。这是构造函数:

/***********************************************************************************
* PortfolioItem class.
* @constructor
*/
function PortfolioItem(portfolio, symbol, chart, shares, unitCost) {
  var self = this;
  this.portfolio = portfolio;
  this.symbol = symbol;

  // observables
  this.lastPrice = ko.observable(null);
  this.change = ko.observable(null);

  // editable values
  this.shares = ko.observable(shares == null ? 0 : shares);
  this.unitCost = ko.observable(unitCost == null ? 0 : unitCost);
  this.chart = ko.observable(chart == null ? false : chart);
  this.shares.subscribe(function () { self.parametersChanged() });
  this.unitCost.subscribe(function () { self.parametersChanged() });
  this.chart.subscribe(function () { self.updateChartData() });

  // find company
  this.company = portfolio.viewModel.findCompany(symbol);
  if (this.company != null) {
    this.company.prices.subscribe(function () { self.pricesChanged() });
    this.pricesChanged();
    this.company.updatePrices();
  }

  // computed observables
  this.name = ko.computed(function() {…}, this);
  this.value = ko.computed(function () {…}, this);
  this.cost = ko.computed(function () {…}, this);
  this.gain = ko.computed(function () {…}, this);
  this.color = ko.computed(this.getColor, this);

  // finish initialization
  this.updateChartData();
  this.parametersChanged();
}

构造函数首先存储对父投资组合和公司股票代码的引用。然后它声明 `lastPrice` 和 `change` 属性,这两个可观察对象稍后将从 Web 服务获取。

接下来,构造函数声明可以由用户更改的属性:`chart`、`unitCost` 和 `shares`。请注意,构造函数也订阅了这些可观察对象,因此当用户编辑这些值中的任何一个时,投资组合项目会调用所需的方法来更新项目参数或图表数据。

当项目公司的价格历史可用时调用的 `pricesChanged` 方法实现如下:

PortfolioItem.prototype.pricesChanged = function () {
  var prices = this.company.prices();
  if (prices.length > 1) {
    this.lastPrice(prices[0].price);
    this.change(prices[0].price - prices[1].price);
    if (this.chart()) {
      this.updateChartData();
    }
  }
}

该方法从 `company.prices` 属性中检索价格历史记录。如果价格历史记录已可用,则代码会更新 `lastPrice` 和 `change` 属性的值。如果该项目当前配置为显示在图表上,则该方法会调用 `updateChartData`,该方法会调用 `ViewModel` 类上的 `updateChartData` 方法。

PortfolioItem.prototype.updateChartData = function () {
  var vm = this.portfolio.viewModel;
  vm.updateChartData();
}

这是 `updateChartData` 方法的实现:

// update chart data when min date changes
ViewModel.prototype.updateChartData = function () {

  // start with empty lists
  var seriesList = [], stylesList = [], hoverStylesList = [];

  // add series and styles to lists
  var items = this.portfolio.items();
  for (var i = 0; i < items.length; i++) {
      var item = items[i];
      if (item.chart()) {

          var series = item.company.updateChartData();
          seriesList.push(series);

          var style = { stroke: item.getColor(), 'stroke-width': 2 };
          stylesList.push(style);

          var hoverStyle = { stroke: item.getColor(), 'stroke-width': 4 };
          hoverStylesList.push(hoverStyle);
      }
  }

  // update chartVisible property
  this.chartVisible(seriesList.length > 0);

  // update chartSeries and styles
  this.chartStyles(stylesList);
  this.chartHoverStyles(hoverStylesList);
  this.chartSeries(seriesList);
}

`updateChartData` 数据方法遍历投资组合项目。对于每个 `chart` 属性设置为 true 的项目,代码会创建一个新的图表系列、一个新的图表样式和一个新的图表“悬停样式”。所有这些对象都被添加到数组中。

循环完成后,这些数组用于更新 `chartStyles`、`chartHoverStyles` 和 `chartSeries` 属性。这些都是绑定到视图中图表控件的可观察属性。

`series` 变量包含要绘制在图表上的实际数据。它由 `Company` 类的 `updateChartData` 方法计算。

// update data to chart for this company
Company.prototype.updateChartData = function () {
  var xData = [], yData = [];

  // loop through prices array
  var prices = this.prices();
  for (var i = 0; i < prices.length; i++) {

    // honor min date
    if (prices[i].day < this.viewModel.minDate()) {
        break; 
    }

    // add this point
    xData.push(prices[i].day);
    yData.push(prices[i].price);
  }

  // convert to percentage change from first value
  var baseValue = yData[yData.length - 1];
  for (var i = 0; i < yData.length; i++) {
    yData[i] = yData[i] / baseValue - 1;
  }

  // return series object with x and y values
  var series = {
    data: { x: xData, y: yData },
    label: this.symbol,
    legendEntry: false,
    markers: { visible: false }
  };
  return series;
}

代码将历史价格转换为百分比变化,使其更容易比较同一图表上显示的多家公司的表现。计算出的值被封装在一个“series”对象中,该对象包含 Wijmo 图表控件所需的属性,该控件将用于显示数据。

我们 `ViewModel` 类中最后一个有趣的方法是加载历史价格数据的方法。此方法如下所示:

// get historical prices for this company
Company.prototype.updatePrices = function () {
  var self = this;

  // don't have prices yet? go get them now
  if (self.prices().length == 0) {

    // go get prices
    var vm = self.viewModel;
    vm.updating(vm.updating() + 1);
    $.get("StockInfo.ashx?symbol=" + self.symbol, function (result) {

      // got them
      vm.updating(vm.updating() - 1);

      // parse result
      var newPrices = [];
      var lines = result.split("\r");
      for (var i = 0; i < lines.length; i++) {
        var items = lines[i].split("\t");
        if (items.length == 2) {
          var day = new Date($.trim(items[0]));
          var price = $.trim(items[1]) * 1;
          var item = { day: day, price: price };
          newPrices.push(item);
        }
      }

      // update properties
      self.prices(newPrices);

      // update chart series data
      self.updateChartData();
    });

  } else {

    // same data, different min date
    self.updateChartData();
  }
}

该方法首先检查该公司是否已加载价格。如果尚未加载,该方法会增加 `updating` 属性(以便 UI 可以显示正在进行下载活动)。

然后该方法再次调用“_StockInfo.ashx_”服务,这次将股票代码作为参数传递。当服务返回时,`updating` 属性会递减,并且返回的值被解析为 `newPrices` 数组。最后,该方法更新 prices 属性的值并更新图表数据。

视图实现 (HTML/CSS)

视图在 _default.html_ 文件中实现。该文件以一组包含语句开头,这些语句加载应用程序使用的库。这类似于在 .NET 项目中添加引用。**Invexplorer** 应用程序使用以下库:

  • **KnockoutJS**:JavaScript 应用程序的数据绑定。
  • **jQuery**:用于操作 DOM 和调用 Web 服务的实用程序。
  • **jQueryUI**:UI 控件,包括自动搜索框。
  • **Wijmo**:UI 控件,包括线图控件。
  • **Knockout-jQuery** 和 **Knockout-Wijmo**:为 jQuery 和 Wijmo 控件库添加 KnockoutJS 支持的库。
  • **ViewModel**:上面描述的 **Invexplorer** 视图模型类。

本项目中使用的 Knockout-jQuery 库由 Mike Edmunds 编写,可从 github 获取。Knockout-Wijmo 库包含在 Wijmo 中,可以直接从 Wijmo CDN 引入。

除了这些包含之外,视图还包含一个小型脚本块,它执行两件事:

  1. 实例化 `ViewModel` 并应用绑定(使用 KnockoutJS `applyBindings` 方法)
  2. 配置 jQueryUI `autoComplete` 控件,使其在下拉列表中显示 HTML 而不是纯文本。这是一个可选步骤,与 **Invexplorer** 应用程序无关。它允许我们突出显示自动完成列表中的部分匹配项。
<script type="text/javascript">

    // initialize application on page load
    $(function () {

        // create ViewModel and apply bindings
        var vm = new ViewModel();
        ko.applyBindings(vm);

        // configure auto-complete control to render html instead of plain text
        // http://stackoverflow.com/questions/3488016/using-html-in-jquery-ui-autocomplete
        $("#autoComplete").autocomplete().data("autocomplete")._renderItem =
    function (ul, item) {
        return $("<li></li>")
        .data("item.autocomplete", item)
        .append("<a>" + item.label + "</a>")
        .appendTo(ul);
    };
    });
</script>

_default.html_ 页面的主体以一个标题开头,显示应用程序的标题和一些信息。标题下方是投资组合表,这是一个标准 HTML 表格元素,定义如下:

<!-- portfolio table -->
<table>

  <!-- table header -->
  <thead>
    <tr>
      <th class="left">Name</th>
      <th class="left">Symbol</th>
      <th class="left">Chart</th>
      <th class="right">Last Price</th>
      <th class="right">Change</th>
      <th class="right">Change %</th>
      <th class="right">Shares</th>
      <th class="right">Unit Cost</th>
      <th class="right">Value</th>
      <th class="right">Gain</th>
      <th class="right">Gain %</th>
      <th class="center">Delete</th>
    </tr>
  </thead>

表头只指定了表中每一列的标题。有趣的部分是表体,它使用 KnockoutJS 绑定,如下所示:

<!-- table body-->
  <tbody data-bind="foreach: portfolio.items">
    <tr>
      <td>
        <span data-bind="style: { backgroundColor: color }">
              </span>
         <span data-bind="text: name"></span></td>
      <td data-bind="text: symbol"></td>
      <td class="center">
        <input data-bind="checked: chart" type="checkbox" /></td>
      <td class="right" data-bind="text: Globalize.format(lastPrice(), 'n2')"></td>
      <td class="right" data-bind="text: Globalize.format(change(), 'n2'),
          style: { color: $root.getAmountColor(change()) }"></td>
      <td class="right" data-bind="text: Globalize.format(changePercent(), 'p2'),
          style: { color: $root.getAmountColor(changePercent()) }"></td>
      <td><input class="numeric" data-bind="value: shares" /></td>
      <td><input class="numeric" data-bind="value: unitCost" /></td>
      <td class="right" data-bind="text: Globalize.format(value(), 'n2')"></td>
      <td class="right" data-bind="text: Globalize.format(gain(), 'n2'),
          style: { color: $root.getAmountColor(gain()) }"></td>
      <td class="right" data-bind="text: Globalize.format(gainPercent(), 'p2'),
          style: { color: $root.getAmountColor(gainPercent()) }"></td>
      <td class="center">
          <a class="hlink" data-bind="click: $root.portfolio.removeItem">x</a></td>
    </tr>
  </tbody>
</table>

`tbody` 元素指定了表格的数据源。在这种情况下,源是投资组合项目属性。在 `tbody` 元素下方,我们指定一行(`tr` 元素)和多个单元格(`td` 元素)。每个单元格都包含一个 `data-bind` 属性,该属性指定它将显示的数据,在某些情况下还指定用于显示单元格的格式和颜色。

最常见的绑定是“data-bind: text”,它指定单元格的内容。内容可以是任何 JavaScript 表达式,包括对 **Globalize** 库的调用。类似地,“`data-bind: value`”绑定用于在文本框中显示可编辑的值。

另一个常见的绑定是“data-bind: style”,它允许您指定渲染单元格时使用的 CSS 元素。上表使用样式绑定将正数显示为绿色,负数显示为红色。这是通过调用 `getAmountColor` 方法完成的,该方法在 XAML 中扮演绑定转换器的角色。

最后,“data-bind: click”用于创建一个带按钮的列,可用于从投资组合中移除项目。click 事件绑定到 `portfolio.removeItem` 方法,该方法被调用并自动接收一个指定被点击项目的参数。

使用 KnockoutJS 构建 HTML 表格与在 XAML 中构建数据网格非常相似。

在投资组合表格下方是允许用户向投资组合添加项目的部分。这通过一个 jQueryUI 自动完成控件和一个普通的 HTML 按钮实现:

<!-- add symbol -->
<div class="addSymbol">
  Add Symbol: 

  <!-- jQueryUI autocomplete -->
  <input id="autoComplete" type="text" data-bind="
    value: portfolio.newSymbol,
      jqueryui: {
        widget: 'autocomplete',
        options: {

          /* require two characters to start matching */
          minLength: 2, 

          /* use ViewModel's getSymbolMatches to populate drop-down */
          source: function(request, response) {
            response(getSymbolMatches(request)) 
          },

          /* update current portfolio's newSymbol property when drop-down closes */
          close: function() {
            portfolio.newSymbol(this.value)
          }
        }
      }" />

  <!-- add the selected symbol to the portfolio -->
  <button data-bind="
    click: function() { portfolio.addNewSymbol()},
    enable: portfolio.canAddNewSymbol">
    Add to Portfolio
  </button>

  <!-- progress indicator (visible when ViewModel.updating != 0) -->
  <span class="floatRight" data-bind="visible: updating">
    <i> getting data...</i>
  </span>
</div>

input 元素从 jQueryUI 库获取自动完成行为。我们使用数据绑定来指定有效选项列表和用户做出选择时要采取的操作。

`source` 选项指定有效选择列表将由 `ViewModel` 类的 `getSymbolMatches` 方法提供。此方法获取用户提供的输入(例如“gen mot”),并返回在名称或符号中包含这些术语的公司列表(在这种情况下,匹配将是“通用汽车”)。返回的值是 HTML,因此匹配项会在自动完成下拉列表中突出显示。

`close` 选项指定当用户从列表中选择一个项目时调用的方法。在这种情况下,该方法设置投资组合的 `newSymbol` 属性的值。回想一下,设置此值将自动更新 `canAddNewSymbol` 属性的值,该属性用于下一个绑定。

绑定控件涉及在 HTML 中设置选项和属性值。这类似于在 XAML 中设置属性值。

输入元素旁边有一个带有两个绑定的按钮:click 绑定调用投资组合类中的 `addNewSymbol` 方法;enable 绑定确保按钮仅在 `canAddNewSymbol` 属性设置为 true 时启用(当选择了一个符号且该符号尚未包含在投资组合中时发生)。这些绑定扮演了 XAML 中 `ICommand` 接口的角色。

此部分的最后一个元素是“正在获取数据”消息,其可见绑定确保该消息仅在 `ViewModel` 正在下载某些数据时可见。

下一节包含用于选择图表上显示的时间跨度的命令

<!-- links to select time span to be charted -->
<div data-bind="visible: chartVisible">
  <a class="hlink" data-bind="click: function() { setMinDate(6) }">6m</a> 
  <a class="hlink" data-bind="click: function() { setMinDate(0) }">YTD</a> 
  <a class="hlink" data-bind="click: function() { setMinDate(12) }">1y</a> 
  <a class="hlink" data-bind="click: function() { setMinDate(24) }">2y</a> 
  <a class="hlink" data-bind="click: function() { setMinDate(36) }">3y</a> 
  <a class="hlink" data-bind="click: function() { setMinDate(1000) }">All</a> 
</div>

整个部分都有一个可见绑定,确保它仅在图表当前可见时才显示。在该部分中,有带有点击绑定的链接,这些链接调用 `ViewModel` 中的 `setMinDate` 方法,并将所需的时间跨度作为参数传递。

视图的最后一部分是图表控件,实现如下:

<!-- portfolio chart -->
<div id="chart" data-bind="
  wijlinechart: { 

    /* bind series, styles */
    seriesList: chartSeries,
    seriesStyles: chartStyles,
    seriesHoverStyles: chartHoverStyles,

    /* axis label formats */
    axis: { 
      y: { annoFormatString : 'p0' },
      x: { annoFormatString : 'dd-MMM-yy' } 
    },

    /* series tooltip */
    hint: {
      content: function() {
        return this.label + ' on ' + 
               Globalize.format(this.x, 'dd-MMM-yy') + ':\n' + 
               Globalize.format(this.y, 'p2');
      }
    },

    /* other properties */
    animation: { enabled: false },
    seriesTransition: { enabled : false },
    showChartLabels: false,
    width: 800, height: 250,
  }">

data-bind 属性用于指定我们需要的图表属性。回想一下,`seriesList`、`seriesStyles` 和 `seriesHoverStyles` 是 ViewModel 实现并由 KnockoutJS 跟踪的属性。每当这些属性中的任何一个发生变化时,图表都会自动刷新。

data-bind 属性还初始化未绑定的图表属性,就像在 XAML 中设置控件属性一样。在这种情况下,代码设置了轴注释的格式,添加了一个工具提示,当用户将鼠标移到图表上时显示当前的符号、日期和值,禁用动画等等。

Web 服务实现 (C#)

回想一下,我们的视图模型类使用“_StockInfo.ashx_”服务来检索公司名称和历史价格数据。此服务是 **Invexplorer** 应用程序的一部分。它是一个“通用处理程序”,实现如下:

// StockInfo returns two types of information:
// 
// Stock Prices:
// If the request contains a 'symbol' parameter, StockInfo returns a string
// with a list where each line contains a date and the closing value for the
// stock on that day. Dates are between 1/1/2008 and today.
// Values are obtained from the Yahoo finance service.
// 
// Company Names and Symbols:
// If the request does not contain a 'symbol' parameter, StockInfo returns
// a string with a list where each line contains company symbols and names.
// Values are loaded from resource file 'resources/symbolnames.txt'.
public class StockInfo : IHttpHandler
{
  public void ProcessRequest(HttpContext context)
  {
    string symbol = context.Request["symbol"];
    string content = string.IsNullOrEmpty(symbol)
      ? GetSymbols()
      : GetPrices(symbol);

    context.Response.ContentType = "text/plain";
    context.Response.Write(content);
  }
  // implementations of GetSymbols and GetPrices methods follow…

该服务检查请求是否包含“symbol”参数。如果包含,则通过调用 `GetPrices` 方法获取内容。否则,通过调用 `GetSymbols` 获取内容。这两个方法都返回包含所请求信息的字符串,项目之间有换行符,值之间有制表符。

`GetSymbols` 方法只是读取一个包含公司名称和符号的资源文件。`GetPrices` 方法调用雅虎财经服务,就像 Silverlight 应用程序一样。我们不会在此处展示这些方法的实现,但它们包含在源代码中,以防您想查看。请记住,雅虎金融数据不是免费的;如果您想在商业应用程序中使用它,您需要联系雅虎获取许可证。

服务返回制表符分隔的项目而不是 JSON,以减少下载大小。这些调用中的每一个都返回数千个项目,并且在这种情况下解析它们非常容易。

结论

在过去的几年里,HTML5 和 JavaScript 已经取得了长足的进步。首先,jQuery 带来了浏览器独立性和易于操作 DOM。同时,新浏览器开始支持 HTML5 功能,如地理定位、隔离存储和灵活的 canvas 元素。然后 KnockoutJS 和其他类似的库使得将 HTML(视图)与 JavaScript(视图模型)分离变得容易。这种分离使得创建和维护 JavaScript 应用程序变得容易得多。

最后,一些流行的控件库增加了对 KnockoutJS 的支持,使得 HTML5 和 JavaScript 的开发变得和 Silverlight 一样简单。

在我看来,HTML5/JavaScript 技术栈仍然缺少的主要部分是:

  1. 一个业务就绪的数据层。Silverlight 长期以来一直以 RIA 服务的形式拥有此功能。JavaScript 仍然没有,但希望这种情况在不久的将来会改变。
  2. 更好地支持扩展和创建可重用的自定义控件(包括 XAML Grid 元素等布局元素)。
  3. 更高级的开发工具,内置错误检查、重构支持和智能感知。

所有这些意味着什么?HTML/JavaScript 平台是否准备好取代 Silverlight?在我看来,答案取决于应用程序的复杂性以及应用程序是否必须能够在平板电脑和手机上运行。

Invexplorer 应用程序相对简单。它不需要数据库更新、验证或复杂的数据类型。页面布局也很简单。这使得它成为 HTML5/JavaScript 实现的理想选择。

TypeScript 版本

Invexplorer 应用程序的第二个修订版整合了 **TypeScript** (https://typescript.net.cn/)。

TypeScript 是由 Anders Hejlsberg 领导的一个开源项目。TypeScript 为 JavaScript 添加了可选类型、类和模块。它编译成可读的、基于标准的 JavaScript。除了这些出色的扩展之外,TypeScript 编译器还与 Visual Studio 集成,提供自动编译、静态错误检查和智能感知。它在很大程度上解决了上一节第 3 项中列出的限制。

TypeScript 仍然很新,但它已经很受欢迎了。事实上,CodeProject 上至少已经有两篇非常出色地描述它的文章:《TypeScript 入门》和《TypeScript 简介》。

您可以从 CodePlex 下载并安装 TypeScript,网址是 http://typescript.codeplex.com/。

请注意,安装后,您可能需要手动运行 vsix 文件才能完成安装(我花了几小时才弄明白)。vsix 通常可以在此处找到:

c:\Program Files (x86)\Microsoft SDKs\TypeScript\0.8.0.0\TypeScriptLanguageService.vsix

安装完成后,您可以在 Visual Studio 中使用 **文件 | 新建项目** 菜单创建新的 TypeScript 项目,然后选择 Visual C# 节点并选择“HTML Application with TypeScript”选项(不是很直观)。

将原始 ViewModel 从纯 JavaScript 转换为 TypeScript 非常容易。该过程包括以下步骤:

1) 将原始的“js”文件分解为多个“ts”文件(每个类一个)

2) 在每个“ts”文件的顶部添加外部变量声明。这些声明指示编译器忽略在外部文件中定义的名称。Invexplorer 项目需要这些声明:

// declare externally defined objects (to keep TypeScript compiler happy)
declare var $; // jQuery
declare var ko; // KnockoutJS
declare var Globalize; // Globalize plug-in 

3) 添加“reference path”语句,允许 TypeScript 编译器在同一项目中的其他文件中查找定义的任何对象。例如,我们的 ViewModel 类引用 Portfolio 和 Company 类,因此它需要这些引用:

///<reference path='portfolio.ts'/>
///<reference path='company.ts'/> 

4) 向类及其元素添加类、成员、构造函数和方法声明。例如(只是为了让您了解语法):

class PortfolioItem {

  // fields (typed)
  portfolio: Portfolio;
  symbol: string;
  company: Company;
  // ...
  constructor(portfolio: Portfolio, symbol: string, chart = false, shares = 0, unitCost = 0) 
  {
    this.portfolio = portfolio;
    this.symbol = symbol;
    // ...
5) 向成员和方法签名添加类型信息。

这看起来可能工作量很大,但实际上非常容易。一旦完成,您将在 TypeScript 文件中获得静态错误检查和智能感知。事实上,很可能编译器在您完成转换后会立即发现项目中的一些错误。作为一名 C# 开发者,我在编写 JavaScript 代码时确实很怀念这种支持。

下图展示了 TypeScript 编译器如何与 Visual Studio 集成,提供静态错误检查和智能感知。

Static Error Checking
TypeScript 提供的静态错误检查 

IntelliSense
TypeScript 提供的智能感知 

运行项目会使 TypeScript 编译器生成包含纯 JavaScript 的“js”文件。最终项目将不包含 TypeScript 的任何痕迹,它仍然是和以前一样好的旧 HTML 和 JavaScript。

我已经是 TypeScript 的粉丝了,并计划在未来的 HTML/JS 项目中使用它。如果你用 JavaScript 开发,但还没有尝试过 TypeScript,那么你将大饱眼福。

参考资料和资源

  1. http://demo.componentone.com/wijmo/InvExplorer/:本文描述的 Invexplorer 应用程序的在线版本。
  2. https://codeproject.org.cn/Articles/365120/KnockoutJS-vs-Silverlight:Colin Eberhart 在 CodeProject 上发表的文章,比较了使用 KnockoutJS 和 Silverlight 进行 MVVM 开发。对于刚开始接触 HTML5 和 JavaScript 的 Silverlight 开发者来说,是极好的资源。
  3. http://publicfiles.componentone.com/Bernardo/MVVM in Silverlight and in HTML5 IX.pdf:比较 Invexplorer 应用程序的 Silverlight 和 JavaScript 实现的文章。
  4. http://knockoutjs.com/:KnockoutJS 主页。这是关于 KnockoutJS 的终极资源。它包含概念信息、文档、示例和教程。
  5. https://jqueryui.jqueryjs.cn/:jQueryUI 主页。jQueryUI 的官方资源,包括 jQueryUI 中包含的控件(小部件)、效果和实用程序的文档和示例。
  6. http://wijmo.com/:Wijmo 主页。Wijmo 的官方资源,一个包含支持 KnockoutJS 的网格和图表控件的控件库。
  7. http://typescript.codeplex.com/:TypeScript 主页。TypeScript 编译器为 JavaScript 添加类型信息、静态错误检查和智能感知。
© . All rights reserved.