KnockoutJS-基础知识-全-

KnockoutJS 基础知识(全)

原文:zh.annas-archive.org/md5/2823CCFFDCBA26955DFD8A04E5A226C2

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

当我们构建用户界面时,解决的最困难的问题之一是同步开发人员在代码中管理的数据和向用户显示的数据。开发人员采取的第一步是将演示和逻辑分开。这种分离使开发人员能够更好地分别管理两侧。但这两个层之间的通信仍然很困难。那是因为 JavaScript 被认为是一种不重要的语言,我们过去只是用它进行验证。然后 jQuery 给了我们一个线索,说明这种语言有多强大。但是数据仍然在服务器上管理,我们只是显示静态演示。这使得用户体验差和缓慢。

在过去的几年中,一种新型的架构模式出现了。它被称为 MVVM 模式。使用这种模式的库和框架使开发人员能够轻松地同步视图和数据。其中一个库就是 Knockout,使用 Knockout 的框架名为 Durandal。

Knockout 是一个快速且跨浏览器兼容的库,可以帮助我们开发具有更好用户体验的客户端应用程序。

开发人员不再需要担心数据同步的问题。Knockout 将我们的代码绑定到 HTML 元素,实时向用户显示我们代码的状态。

这种动态绑定使我们忘记了编码同步,我们可以将精力集中在编写应用程序的重要功能上。

如今,管理这些框架对前端开发人员来说是必不可少的。在本书中,你将学习 Knockout 和 Durandal 的基础知识,并且我们将深入探讨 JavaScript 的最佳设计实践和模式。

如果你想改进应用程序的用户体验并创建完全操作的前端应用程序,Knockout 和 Durandal 应该是你的选择。

本书涵盖内容

第一章,使用 KnockoutJS 自动刷新 UI,教你关于 Knockout 库。你将创建可观察对象并使你的模板对变化具有反应性。

第二章,KnockoutJS 模板,展示了如何创建模板以减少 HTML 代码。模板将帮助您保持设计的可维护性,并且它们可以根据您的数据进行调整。

第三章,自定义绑定和组件,展示了如何扩展 Knockout 库以使您的代码更易维护和可移植。

第四章, 管理 KnockoutJS 事件,教你如何使用 jQuery 事件与隔离的模块和库进行通信。事件将帮助你在不同组件或模块之间发送消息。

第五章,从服务器获取数据,展示了如何使用 jQuery AJAX 调用从客户端与服务器通信。您还将学习如何使用模拟技术在没有服务器的情况下开发客户端。

第六章,模块模式 – RequireJS,教您如何使用模块模式和 AMD 模式编写良好形式的模块以管理库之间的依赖关系。

第七章,Durandal – The KnockoutJS Framework,教您最好的 Knockout 框架是如何工作的。您将了解框架的每个部分,从而能够用更少的代码制作大型应用程序。

第八章,Durandal – The Cart Project,将本书中构建的应用程序迁移到 Durandal。你将用几行代码开发同样的应用程序,并能够添加新功能。

本书所需内容

下面是在不同阶段需要的软件应用程序列表:

  • 要开始:

    • Twitter Bootstrap 3.2.0

    • jQuery 2.2.1

    • KnockoutJS 3.2.0

  • 为了管理高级模板:

    • Knockout 外部模板引擎 2.0.5
  • 用于从浏览器执行 AJAX 调用的服务器:

    • Mongoose 服务器 5.5
  • 为了模拟数据和服务器调用:

    • Mockjax 1.6.1

    • MockJSON

  • 要验证数据:

    • Knockout 验证 2.0.0
  • 使用浏览器进行调试:

    • Chrome Knockout 调试器扩展
  • 为了管理文件依赖关系:

    • RequireJS

    • Require 文本插件

    • Knockout 和 helpers

  • KnockoutJS 框架:

    • Durandal 2.1.0 Starter Kit
  • 其他:

    • iCheck 插件 1.0.2

本书适合谁

如果您是一名 JavaScript 开发人员,一直在使用 DOM 操作库(如 jQuery、MooTools 或 Scriptaculous),并且希望在现代 JavaScript 开发中进一步使用简单、轻量级和文档完善的库,那么这项技术和本书就适合您。

学习 Knockout 将是构建响应用户交互的 JavaScript 应用程序的下一个完美步骤。

约定

在本书中,您会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例以及它们的含义解释。

文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄如下所示:“例如,background-color 会抛出错误,因此您应该写成 'background-color'。”

代码块如下所示:

var cart = ko.observableArray([]);
var showCartDetails = function () {
  if (cart().length > 0) {
    $("#cartContainer").removeClass("hidden");
  }
};
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

当我们希望引起您对代码块中特定部分的注意时,相关行或项目将以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
<button class="btn btn-primary btn-sm" data-bind="click: showCartDetails, enable: cart().length  > 0">
  Show Cart Details
</button>

<button class="btn btn-primary btn-sm" data-bind="click: showCartDetails, disable: cart().length  < 1">
  Show Cart Details
</button>

任何命令行输入或输出如下所示:

# cp /usr/src/asterisk-addons/configs/cdr_mysql.conf.sample
 /etc/asterisk/cdr_mysql.conf

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:"一旦我们点击了确认订单按钮,订单应该显示给我们以审查,并确认我们是否同意。"

注意

警告或重要提示会以这样的框中出现。

提示

小贴士和技巧会出现在这样的格式中。

第一章:自动刷新 UI,使用 KnockoutJS

如果你正在阅读这本书,那是因为你已经发现管理 web 用户界面是相当复杂的。 DOM(Document Object Model 的缩写)仅使用本地 JavaScript 进行操作是非常困难的。这是因为每个浏览器都有自己的 JavaScript 实现。为了解决这个问题,过去几年中诞生了不同的 DOM 操作库。最常用于操作 DOM 的库是 jQuery。

越来越常见的是找到帮助开发人员在客户端管理越来越多功能的库。正如我们所说,开发人员已经获得了轻松操作 DOM 的可能性,因此可以管理模板和格式化数据。此外,这些库为开发人员提供了轻松的 API 来发送和接收来自服务器的数据。

然而,DOM 操作库并不为我们提供同步输入数据与代码中模型的机制。我们需要编写代码来捕捉用户操作并更新我们的模型。

当一个问题在大多数项目中经常出现时,在几乎所有情况下,它肯定可以以类似的方式解决。然后,开始出现了管理 HTML 文件与 JavaScript 代码之间连接的库。这些库实现的模式被命名为 MV*(Model-View-Whatever)。星号可以被更改为:

  • 控制器,MVC(例如,AngularJS)

  • ViewModel,MVVM(例如,KnockoutJS)

  • Presenter(MVP)(例如,ASP.NET)

在这本书中我们要使用的库是 Knockout。它使用视图模型将数据和 HTML 进行绑定,因此它使用 MVVM 模式来管理数据绑定问题。

在本章中,你将学习这个库的基本概念,并开始在一个真实项目中使用 Knockout 的任务。

KnockoutJS 和 MVVM 模式

KnockoutJS 是一个非常轻量级的库(仅 20 KB 经过压缩),它赋予对象成为视图和模型之间的纽带的能力。这意味着你可以使用清晰的底层数据模型创建丰富的界面。

为此,它使用声明性绑定来轻松将 DOM 元素与模型数据关联起来。数据与表示层(HTML)之间的这种链接允许 DOM 自动刷新显示的值。

Knockout 建立了模型数据之间的关系链,隐式地转换和组合它。Knockout 也是非常容易扩展的。可以将自定义行为实现为新的声明性绑定。这允许程序员在几行代码中重用它们。

使用 KnockoutJS 的优点有很多:

  • 它是免费且开源的。

  • 它是使用纯 JavaScript 构建的。

  • 它可以与其他框架一起使用。

  • 它没有依赖关系。

  • 它支持所有主流浏览器,甚至包括古老的 IE 6+、Firefox 3.5+、Chrome、Opera 和 Safari(桌面/移动)。

  • 它完全有 API 文档、实时示例和交互式教程。

Knockout 的功能很明确:连接视图和模型。它不管理 DOM 或处理 AJAX 请求。为了这些目的,我建议使用 jQuery。 Knockout 给了我们自由发展我们自己想要的代码。

KnockoutJS 和 MVVM 模式

MVVM 模式图

一个真实的应用程序—koCart

为了演示如何在实际应用中使用 Knockout,我们将构建一个名为 koCart 的简单购物车。

首先,我们将定义用户故事。我们只需要几句话来知道我们想要实现的目标,如下所示:

  • 用户应该能够查看目录

  • 我们应该有能力搜索目录

  • 用户可以点击按钮将物品添加到目录中

  • 应用程序将允许我们从目录中添加、更新和删除物品

  • 用户应该能够向购物车中添加、更新和删除物品

  • 我们将允许用户更新他的个人信息。

  • 应用程序应该能够计算购物车中的总金额

  • 用户应该能够完成订单

通过用户故事,我们可以看到我们的应用程序有以下三个部分:

  • 目录,包含和管理店内所有的商品。

  • 购物车负责计算每行的价格和订单总额。

  • 订单,用户可以在其中更新他的个人信息并确认订单。

安装组件

为了开发我们的真实项目,我们需要安装一些组件并设置我们的第一个布局。

这些都是你需要下载的组件:

由于我们在前几章只在客户端工作,我们可以在客户端模拟数据,现在不需要服务器端。 所以我们可以选择我们通常用于项目的任何地方来开始我们的项目。 我建议您使用您通常用来做项目的环境。

首先,我们创建一个名为ko-cart的文件夹,然后在其中创建三个文件夹和一个文件:

  1. css文件夹中,我们将放置所有的 css。

  2. js文件夹中,我们将放置所有的 JavaScript。

  3. fonts文件夹中,我们会放置 Twitter Bootstrap 框架所需的所有字体文件。

  4. 创建一个index.html文件。

现在你应该设置你的文件,就像以下截图所示:

安装组件

初始文件夹结构

然后我们应该设置index.html文件的内容。记得使用<script><link>标签设置所有我们需要的文件的链接:

<!DOCTYPE html>
<html>
<head>
  <title>KO Shopping Cart</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" type="text/css" href="css/bootstrap.min.css">
</head>
<body>
  <script type="text/javascript" src="img/jquery.min.js">
  </script>
  <script type="text/javascript" src="img/bootstrap.min.js">
  </script>
  <script type="text/javascript" src="img/knockout.debug.js">
  </script>
</body>
</html>

有了这些行代码,我们就有了开始应用程序所需的一切。

视图-模型

视图模型是 UI 上的数据和操作的纯代码表示。它不是 UI 本身。它没有任何按钮或显示样式的概念。它也不是持久化的数据模型。它保存用户正在处理的未保存数据。视图模型是纯 JavaScript 对象,不了解 HTML。以这种方式将视图模型保持抽象,让它保持简单,这样您就可以管理更复杂的行为而不会迷失。

要创建一个视图模型,我们只需要定义一个简单的 JavaScript 对象:

var vm = {};

然后要激活 Knockout,我们将调用以下行:

ko.applyBindings(vm);

第一个参数指定我们要与视图一起使用的视图模型对象。可选地,我们可以传递第二个参数来定义我们想要搜索data-bind属性的文档的哪个部分。

ko.applyBindings(vm, document.getElementById('elementID'));

这将限制激活到具有elementID及其后代的元素,这在我们想要有多个视图模型并将每个视图模型与页面的不同区域关联时非常有用。

视图

视图是表示视图模型状态的可见、交互式 UI。它显示来自视图模型的信息,向视图模型发送命令(例如,当用户点击按钮时),并在视图模型状态更改时更新。在我们的项目中,视图由 HTML 标记表示。

为了定义我们的第一个视图,我们将构建一个 HTML 来显示一个产品。将这个新内容添加到容器中:

<div class="container-fluid">
  <div class="row">
    <div class="col-md-12">
      <!-- our app goes here →
      <h1>Product</h1>
      <div>
        <strong>ID:</strong>
        <span data-bind="text:product.id"></span><br/>
        <strong>Name:</strong>
        <span data-bind="text:product.name"></span><br/>
        <strong>Price:</strong>
        <span data-bind="text:product.price"></span><br/>
        <strong>Stock:</strong>
        <span data-bind="text:product.stock"></span>
      </div> 
    </div>
  </div>
</div>

查看data-bind属性。这被称为声明性绑定。尽管这个属性对 HTML 来说并不是本机的,但它是完全正确的。但由于浏览器不知道它的含义,您需要激活 Knockout(ko.applyBindings方法)才能使其生效。

要显示来自产品的数据,我们需要在视图模型内定义一个产品:

var vm = {
  product: {
    id:1,
    name:'T-Shirt',
    price:10,
    stock: 20
  }
};
ko.applyBindings(vm);//This how knockout is activated

在脚本标签的末尾添加视图模型:

<script type="text/javascript" src="img/viewmodel.js"></script>

提示

下载示例代码

您可以从您在www.packtpub.com的帐户中购买的所有 Packt 图书中下载示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册以直接通过电子邮件将文件发送给您。

这将是我们应用的结果:

视图

数据绑定的结果

模型

此数据表示业务域内的对象和操作(例如,产品)和任何 UI 无关。使用 Knockout 时,您通常会调用一些服务器端代码来读取和写入此存储的模型数据。

模型和视图模型应该彼此分离。为了定义我们的产品模型,我们将按照一些步骤进行:

  1. 在我们的js文件夹内创建一个文件夹。

  2. 将其命名为models

  3. models文件夹内,创建一个名为product.js的 JavaScript 文件。

product.js文件的代码如下:

var Product = function (id,name,price,stock) {
  "use strict";
  var
    _id = id,
    _name = name,
    _price = price,
    _stock = stock
  ;

  return {
    id:_id,
    name:_name,
    price:_price,
    stock:_stock
  };
};

此函数创建一个包含产品接口的简单 JavaScript 对象。使用这种模式定义对象,称为揭示模块模式,允许我们清晰地将公共元素与私有元素分开。

要了解更多关于揭示模块模式的信息,请访问链接 carldanley.com/js-revealing-module-pattern/

将此文件与您的index.html文件链接,并将其设置在所有脚本标签的底部。

<script type="text/javascript" src="img/product.js">
</script>

现在我们可以使用产品模型定义视图模型中的产品:

var vm = {
  product: Product(1,'T-Shirt',10,20);
};
ko.applyBindings(vm);

如果我们再次运行代码,将看到相同的结果,但我们的代码现在更易读了。视图模型用于存储和处理大量信息,因此视图模型通常被视为模块,并且在其上应用了揭示模块模式。此模式允许我们清晰地公开视图模型的 API(公共元素)并隐藏私有元素。

var vm = (function(){
  var product = Product(1,'T-Shirt', 10, 20);
  return {
    product: product
  };
})();

当我们的视图模型开始增长时使用此模式可以帮助我们清晰地看到哪些元素属于对象的公共部分,哪些是私有的。

可观察对象自动刷新 UI

最后一个示例向我们展示了 Knockout 如何绑定数据和用户界面,但它没有展示自动 UI 刷新的魔法。为了执行此任务,Knockout 使用可观察对象。

可观察对象是 Knockout 的主要概念。这些是特殊的 JavaScript 对象,可以通知订阅者有关更改,并且可以自动检测依赖关系。为了兼容性,ko.observable对象实际上是函数。

要读取可观察对象的当前值,只需调用可观察对象而不带参数。在这个例子中,product.price()将返回产品的价格,product.name()将返回产品的名称。

var product = Product(1,"T-Shirt", 10.00, 20);
product.price();//returns 10.00
product.name();//returns "T-Shirt"

要将新值写入可观察对象,请调用可观察对象并将新值作为参数传递。例如,调用product.name('Jeans')将把name值更改为'Jeans'

var product = Product(1,"T-Shirt", 10.00, 20);
product.name();//returns "T-Shirt"
product.name("Jeans");//sets name to "Jeans"
product.name();//returns "Jeans"

有关可观察对象的完整文档在官方 Knockout 网站上 knockoutjs.com/documentation/observables.html

为了展示可观察对象的工作原理,我们将在模板中添加一些输入数据。

在包含产品信息的div上添加这些 HTML 标签。

<div>
  <strong>ID:</strong>
  <input class="form-control" type="text" data-bind="value:product.id"/><br/>
  <strong>Name:</strong>
  <input class="form-control" type="text" data-bind="value:product.name"><br/>
  <strong>Price:</strong>
  <input class="form-control" type="text" data-bind="value:product.price"/><br/>
  <strong>Stock:</strong>
  <input class="form-control" type="text" data-bind="value:product.stock"><br/>
</div>

我们已经使用value属性将输入与视图模型链接起来。运行代码并尝试更改输入中的值。发生了什么?什么都没有。这是因为变量不是可观察对象。更新您的product.js文件,为每个变量添加ko.observable方法:

"use strict";
function Product(id, name, price, stock) {
  "use strict";
  var
    _id = ko.observable(id),
    _name = ko.observable(name),
    _price = ko.observable(price),
    _stock = ko.observable(stock)
  ;

  return {
    id:_id,
    name:_name,
    price:_price,
    stock:_stock
  };
}

请注意,当我们更新输入中的数据时,我们的产品值会自动更新。当您将name值更改为Jeans时,文本绑定将自动更新关联的 DOM 元素的文本内容。这就是视图模型的更改如何自动传播到视图的方式。

可观察对象自动刷新 UI

可观察模型会自动更新

使用 observables 管理集合

如果你想检测并响应一个对象的变化,你会使用 observables。如果你想检测并响应一组东西的变化,请使用observableArray。这在许多情况下都很有用,比如显示或编辑多个值,并且需要在添加和删除项时重复出现和消失 UI 的部分。

要在我们的应用程序中显示一组产品,我们将按照一些简单的步骤进行:

  1. 打开index.html文件,删除<body>标签内的代码,然后添加一个表格,我们将列出我们的目录:

    <h1>Catalog</h1>
    <table class="table">
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
          <th>Stock</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td></td>
          <td></td>
          <td></td>
        </tr>
      </tbody>
    </table>
    
  2. 在视图模型内定义一个产品数组:

    "use strict";
    var vm = (function () {
    
      var catalog = [
        Product(1, "T-Shirt", 10.00, 20),
        Product(2, "Trousers", 20.00, 10),
        Product(3, "Shirt", 15.00, 20),
        Product(4, "Shorts", 5.00, 10)
      ];
    
      return {
        catalog: catalog
      };
    })();
    ko.applyBindings(vm);
    
  3. Knockout 中有一个绑定,用于在集合中的每个元素上重复执行一段代码。更新表格中的tbody元素:

    <tbody data-bind="foreach:catalog">
      <tr>
        <td data-bind="text:name"></td>
        <td data-bind="text:price"></td>
        <td data-bind="text:stock"></td>
      </tr>
    </tbody>
    

我们使用foreach属性来指出该标记内的所有内容都应该针对集合中的每个项目进行重复。在该标记内部,我们处于每个元素的上下文中,所以你可以直接绑定属性。在浏览器中观察结果。

我们想知道目录中有多少个项目,所以在表格上方添加这行代码:

<strong>Items:</strong>
<span data-bind="text:catalog.length"></span>

在集合中插入元素

要向产品数组中插入元素,应该发生一个事件。在这种情况下,用户将点击一个按钮,这个动作将触发一个操作,将一个新产品插入集合中。

在未来的章节中,你将会了解更多关于事件的内容。现在我们只需要知道有一个名为click的绑定属性。它接收一个函数作为参数,当用户点击元素时,该函数会被触发。

要插入一个元素,我们需要一个表单来插入新产品的值。将此 HTML 代码写在<h1>标签的下方:

<form class="form-horizontal" role="form" data-bind="with:newProduct">
  <div class="form-group">
    <div class="col-sm-12">
      <input type="text" class="form-control" placeholder="Name" data-bind="textInput:name">
      </div>
    </div>
    <div class="form-group">
      <div class="col-sm-12">
      <input type="password" class="form-control" placeholder="Price" data-bind="textInput:price">
      </div>
    </div>
    <div class="form-group">
      <div class="col-sm-12">
      <input type="password" class="form-control" placeholder="Stock" data-bind="textInput:stock">
      </div>
    </div>
    <div class="form-group">
      <div class="col-sm-12">
      <button type="submit" class="btn btn-default" data-bind="{click:$parent.addProduct}">
        <i class="glyphicon glyphicon-plus-sign">
        </i> Add Product
      </button>
    </div>
  </div>
</form>

在这个模板中,我们找到了一些新的绑定:

  • with 绑定:它创建一个新的绑定上下文,以便后代元素在指定对象的上下文中绑定,本例中为newProduct

    knockoutjs.com/documentation/with-binding.html

  • textInput 绑定:textInput 绑定将文本框(<input>)或文本区域(<textarea>)与视图模型属性连接起来,提供视图模型属性和元素值之间的双向更新。与value绑定属性不同,textInput 提供了对于所有类型的用户输入,包括自动完成、拖放和剪贴板事件的 DOM 的即时更新。它从 Knockout 的 3.2 版本开始提供。

    knockoutjs.com/documentation/textinput-binding.html

  • click 绑定:click 绑定添加了一个事件处理程序,使得当关联的 DOM 元素被点击时,您选择的 JavaScript 函数被调用。在调用处理程序时,Knockout 将当前模型值作为第一个参数提供。这在为集合中的每个项目渲染 UI,并且您需要知道哪个项目的 UI 被点击时特别有用。

    knockoutjs.com/documentation/click-binding.html

  • $parent 对象:这是一个绑定上下文属性。我们用它来引用foreach循环外的数据。

欲了解有关绑定上下文属性的更多信息,请阅读 Knockout 文档:knockoutjs.com/documentation/binding-context.html

在集合中插入元素

使用 with 设置上下文和 parent 通过它们导航

现在是时候向我们的视图模型添加 newProduct 对象了。首先,我们应该定义一个带有空数据的新产品:

var newProduct = Product("","","","");

我们已经定义了一个字面对象,将包含我们要放入新产品的信息。此外,我们已经定义了一个清除或重置对象的方法,一旦插入完成就会进行。现在我们定义我们的addProduct 方法:

var addProduct = function (context) {
  var id = new Date().valueOf();//random id from time
  var newProduct = Product(
    id,
    context.name(),
    context.price(),
    context.stock()
  );
  catalog.push(newProduct);
  newProduct.clear();
};

此方法创建一个从点击事件接收到的数据的新产品。

点击事件始终将上下文作为第一个参数发送。还要注意,您可以在可观察数组中使用push等数组方法。请查看 Knockout 文档 (knockoutjs.com/documentation/observableArrays.html) 以查看数组中可用的所有方法。

我们应该实现一个私有方法,一旦将新产品添加到集合中,就会清除新产品的数据:

var clearNewProduct = function () {
  newProduct.name("");
  newProduct.price("");
  newProduct.stock("");
};

更新视图模型:

return {
    catalog: catalog,
    newProduct: newProduct,
    addProduct: addProduct
};

如果您运行代码,您将注意到当您尝试添加新产品时什么也不会发生。这是因为,尽管我们的产品具有可观察属性,但我们的数组不是一个可观察的数组。因此,Knockout 不会监听更改。我们应该将数组转换为observableArray可观察的数组。

var catalog = ko.observableArray([
  Product(1, "T-Shirt", 10.00, 20),
  Product(2, "Trousers", 20.00, 10),
  Product(3, "Shirt", 15.00, 20),
  Product(4, "Shorts", 5.00, 10)
]);

现在 Knockout 正在监听该数组的变化,但不会监听每个元素内部发生的事情。Knockout 只告诉我们在数组中插入或删除元素的情况,但不告诉我们修改元素的情况。如果您想知道元素内发生了什么,那么对象应具有可观察的属性。

observableArray 只会跟踪它所持有的对象,并在添加或删除对象时通知监听者。

在幕后,observableArray 实际上是一个值为数组的可观察属性。因此,您可以像调用任何其他可观察属性一样,以无参数的方式将observableArray可观察属性作为函数进行调用,从而获取底层的 JavaScript 数组。然后您可以从那个底层数组中读取信息。

<strong>Items:</strong>
<span data-bind="text:catalog().length"></span>

计算可观察属性

想要思考一下我们在界面中显示的某些值是否取决于 Knockout 已经观察到的其他值并不奇怪。例如,如果我们想要按名称搜索我们目录中的产品,显然我们在列表中显示的目录产品与我们在搜索框中输入的术语相关联。在这些情况下,Knockout 为我们提供了计算可观察对象

您可以在 Knockout 文档中详细了解计算可观察对象

要开发搜索功能,请定义一个文本框,我们可以在其中写入要搜索的术语。我们将把它绑定到searchTerm属性。要在编写时更新值,我们应该使用textInput绑定。如果我们使用值绑定,当元素失去焦点时,值将被更新。将此代码放在产品表上方:

<div class="input-group">
  <span class="input-group-addon">
    <i class="glyphicon glyphicon-search"></i> Search</span>
  <input type="text" class="form-control" data-bind="textInput: searchTerm">
</div>

要创建一个过滤目录,我们将检查所有项目,并测试searchTerm是否在项目的name属性中。

var searchTerm = ko.observable(''); 
var filteredCatalog = ko.computed(function () {
  //if catalog is empty return empty array
  if (!catalog()) {
    return [];
  }
  var filter = searchTerm().toLowerCase();
  //if filter is empty return all the catalog
  if (!filter) {
    return catalog();
  }
  //filter data
  var filtered = ko.utils.arrayFilter(catalog(), function (item) {
    var fields = ["name"]; //we can filter several properties
    var i = fields.length;
    while (i--) {
      var prop = fields[i];
      var strProp = ko.unwrap(item[prop]).toLocaleLowerCase();
      if (strProp.indexOf(filter) !== -1){
        return true;
      };
    }
    Return false;
  });
  return filtered;
});

ko.utils对象在 Knockout 中没有文档。它是库内部使用的对象。它具有公共访问权限,并具有一些可以帮助我们处理可观察对象的函数。互联网上有很多关于它的非官方示例。

它的一个有用函数是ko.utils.arrayFilter。如果您看一下第 13 行,我们已经使用了此方法来获取过滤后的数组。

此函数以数组作为第一个参数。请注意,我们调用catalog数组可观察对象以获取元素。我们不传递可观察对象本身,而是传递可观察对象的内容。

第二个参数是决定项目是否在过滤数组中的函数。如果项目符合过滤数组的条件,它将返回true。否则返回false

在此片段的第 14 行,我们可以找到一个名为fields的数组。此参数将包含应符合条件的字段。在这种情况下,我们只检查过滤值是否在name值中。如果我们非常确定只会检查name字段,我们可以简化过滤函数:

var filtered = ko.utils.arrayFilter(catalog(), function (item) {
  var strProp = ko.unwrap(item["name"]).toLocaleLowerCase();
  return (strProp.indexOf(filter) > -1);
});

ko.unwrap函数返回包含可观察对象的值。当我们不确定变量是否包含可观察对象时,我们使用ko.unwrap,例如:

var notObservable = 'hello';
console.log(notObservable()) //this will throw an error.
console.log(ko.unwrap(notObservable)) //this will display 'hello');

将过滤后的目录暴露到公共 API 中。请注意,现在我们需要使用过滤后的目录而不是原始产品目录。因为我们正在应用揭示模块模式,我们可以保持原始 API 接口,只需使用过滤后的目录更新目录的值即可。只要我们始终保持相同的公共接口,就不需要通知视图我们将使用不同的目录或其他元素:

return {
  searchTerm: searchTerm,
  catalog: filteredCatalog,
  newProduct: newProduct,
  addProduct: addProduct
};

现在,尝试在搜索框中键入一些字符,并在浏览器中查看目录如何自动更新数据。

太棒了!我们已经完成了我们的前三个用户故事:

  • 用户应能够查看目录

  • 用户应能够搜索目录

  • 用户应能够向目录添加项目

让我们看看最终结果:

计算观察对象

总结

在本章中,你学会了 Knockout 库的基础知识。我们创建了一个简单的表单来将产品添加到我们的目录中。你还学会了如何管理 observable 集合并将其显示在表中。最后,我们使用计算观察对象开发了搜索功能。

你已经学会了三个重要的 Knockout 概念:

  • 视图模型:这包含代表视图状态的数据。它是一个纯 JavaScript 对象。

  • 模型:这包含了来自业务领域的数据。

  • 视图:这显示了我们在视图模型中存储的数据在某一时刻的情况。

为构建响应式 UI,Knockout 库为我们提供了一些重要的方法:

  • ko.observable:用于管理变量。

  • ko.observableArray:用于管理数组。

  • ko.computed:它们对其内部的 observable 的更改作出响应。

要迭代数组的元素,我们使用foreach绑定。当我们使用foreach绑定时,我们会创建一个新的上下文。这个上下文是相对于每个项目的。如果我们想要访问超出此上下文的内容,我们应该使用$parent对象。

当我们想要为变量创建一个新的上下文时,我们可以将with绑定附加到任何 DOM 元素。

我们使用click绑定将点击事件附加到元素上。点击事件函数始终将上下文作为第一个参数。

要从我们不确定是否为 observable 的变量中获取值,我们可以使用ko.unwrap函数。

我们可以使用ko.utils.arrayFilter函数来筛选集合。

在下一章中,我们将使用模板来保持我们的代码易维护和干净。模板引擎帮助我们保持代码整洁,且方便我们以简单的方式更新视图。

本章开发的代码副本在此处:

github.com/jorgeferrando/knockout-cart/archive/chapter1.zip

第二章:KnockoutJS 模板

一旦我们建立了我们的目录,就是时候给我们的应用程序添加一个购物车了。当我们的代码开始增长时,将其拆分成几个部分以保持可维护性是必要的。当我们拆分 JavaScript 代码时,我们谈论的是模块、类、函数、库等。当我们谈论 HTML 时,我们称这些部分为模板。

KnockoutJS 有一个原生模板引擎,我们可以用它来管理我们的 HTML。它非常简单,但也有一个很大的不便之处:模板应该在当前 HTML 页面中加载。如果我们的应用程序很小,这不是问题,但如果我们的应用程序开始需要越来越多的模板,这可能会成为一个问题。

在本章中,我们将使用原生引擎设计我们的模板,然后我们将讨论可以用来改进 Knockout 模板引擎的机制和外部库。

准备项目

我们可以从我们在第一章中完成的项目开始,使用 KnockoutJS 自动刷新 UI。首先,我们将为页面添加一些样式。将一个名为style.css的文件添加到css文件夹中。在index.html文件中添加一个引用,就在bootstrap引用下面。以下是文件的内容:

.container-fluid {
  margin-top: 20px;
}
.row {
  margin-bottom: 20px;
}
.cart-unit {
  width: 80px;
}
.btn-xs {
  font-size:8px;
}
.list-group-item {
  overflow: hidden;
}
.list-group-item h4 {
  float:left;
  width: 100px;
}
.list-group-item .input-group-addon {
  padding: 0;
}
.btn-group-vertical > .btn-default {
  border-color: transparent;
}
.form-control[disabled], .form-control[readonly] {
  background-color: transparent !important;
}

现在从 body 标签中删除所有内容,除了脚本标签,然后粘贴下面这些行:

<div class="container-fluid">
  <div class="row" id="catalogContainer">
    <div class="col-xs-12" data-bind="template:{name:'header'}"></div>
    <div class="col-xs-6" data-bind="template:{name:'catalog'}"></div>
    <div id="cartContainer" class="col-xs-6 well hidden" data-bind="template:{name:'cart'}"></div>
  </div>
  <div class="row hidden" id="orderContainer" data-bind="template:{name:'order'}">
  </div>
  <div data-bind="template: {name:'add-to-catalog-modal'}"></div>
  <div data-bind="template: {name:'finish-order-modal'}"></div>
</div>

让我们来审查一下这段代码。

我们有两个 row 类。它们将是我们的容器。

第一个容器的名称为catalogContainer,它将包含目录视图和购物车。第二个引用为orderContainer的容器,我们将在那里设置我们的最终订单。

我们还有两个更多的<div>标签在底部,将包含模态对话框,显示向我们的目录中添加产品的表单(我们在第一章中构建的表单),另一个将包含一个模态消息,告诉用户我们的订单已经完成。

除了这段代码,你还可以看到data-bind属性中的一个模板绑定。这是 Knockout 用来将模板绑定到元素的绑定。它包含一个name参数,表示模板的 ID。

<div class="col-xs-12" data-bind="template:{name:'header'}"></div>

在这个例子中,这个<div>元素将包含位于 ID 为header<script>标签内的 HTML。

创建模板

模板元素通常在 body 底部声明,就在具有对我们外部库引用的<script>标签上面。我们将定义一些模板,然后我们将讨论每一个模板:

<!-- templates -->
<script type="text/html" id="header"></script>
<script type="text/html" id="catalog"></script>
<script type="text/html" id="add-to-catalog-modal"></script>
<script type="text/html" id="cart-widget"></script>
<script type="text/html" id="cart-item"></script>
<script type="text/html" id="cart"></script>
<script type="text/html" id="order"></script>
<script type="text/html" id="finish-order-modal"></script>

每个模板的名称本身就足够描述性了,所以很容易知道我们将在其中设置什么。

让我们看一个图表,展示我们在屏幕上放置每个模板的位置:

创建模板

请注意,cart-item模板将针对购物车集合中的每个项目重复出现。模态模板只会在显示模态对话框时出现。最后,order模板在我们点击确认订单之前是隐藏的。

header模板中,我们将有页面的标题和菜单。catalog模板将包含我们在第一章中编写的产品表格,使用 KnockoutJS 自动刷新 UIadd-to-catalog-modal模板将包含显示向我们的目录添加产品的表单的模态框。cart-widget模板将显示我们购物车的摘要。cart-item模板将包含购物车中每个项目的模板。cart模板将具有购物车的布局。order模板将显示我们想购买的最终产品列表和确认订单的按钮。

头部模板

让我们从应该包含header模板的 HTML 标记开始:

<script type="text/html" id="header">
  <h1>
    Catalog
  </h1>

  <button class="btn btn-primary btn-sm" data-toggle="modal" data-target="#addToCatalogModal">
    Add New Product
  </button>
  <button class="btn btn-primary btn-sm" data-bind="click: showCartDetails, css:{ disabled: cart().length  < 1}">
    Show Cart Details
  </button>
  <hr/>
</script>

我们定义了一个<h1>标签和两个<button>标签。

第一个按钮标签附加到具有 ID#addToCatalogModal的模态框。由于我们使用的是 Bootstrap 作为 CSS 框架,我们可以使用data-target属性按 ID 附加模态,并使用data-toggle属性激活模态。

第二个按钮将显示完整的购物车视图,只有在购物车有商品时才可用。为了实现这一点,有许多不同的方法。

第一个方法是使用 Twitter Bootstrap 提供的 CSS-disabled 类。这是我们在示例中使用的方式。CSS 绑定允许我们根据附加到类的表达式的结果来激活或停用元素中的类。

另一种方法是使用enable绑定。如果表达式评估为true,此绑定将启用元素。我们可以使用相反的绑定,称为disable。Knockout 网站上有完整的文档knockoutjs.com/documentation/enable-binding.html

<button class="btn btn-primary btn-sm" data-bind="click: showCartDetails, enable: cart().length  > 0"> 
  Show Cart Details
</button>

<button class="btn btn-primary btn-sm" data-bind="click: showCartDetails, disable: cart().length  < 1"> 
  Show Cart Details
</button>

第一种方法使用 CSS 类来启用和禁用按钮。第二种方法使用 HTML 属性disabled

我们可以使用第三个选项,即使用计算可观察值。我们可以在视图模型中创建一个计算可观察变量,根据购物车的长度返回truefalse

//in the viewmodel. Remember to expose it
var cartHasProducts = ko.computed(function(){
  return (cart().length > 0);
});
//HTML
<button class="btn btn-primary btn-sm" data-bind="click: showCartDetails, enable: cartHasProducts"> 
  Show Cart Details
</button>

要显示购物车,我们将以与上一章中相同的方式使用click绑定。

现在我们应该转到我们的viewmodel.js文件,并添加所有我们需要使此模板工作的信息:

var cart = ko.observableArray([]);
var showCartDetails = function () {
  if (cart().length > 0) {
    $("#cartContainer").removeClass("hidden");
  }
};

并且你应该在视图模型中公开这两个对象:

  return {
  //first chapter
    searchTerm: searchTerm,
    catalog: filteredCatalog,
    newProduct: newProduct,
    totalItems:totalItems,
    addProduct: addProduct,
  //second chapter
    cart: cart,
    showCartDetails: showCartDetails,
  };

目录模板

下一步是在header模板下方定义catalog模板:

<script type="text/html" id="catalog">
  <div class="input-group">
    <span class="input-group-addon">
      <i class="glyphicon glyphicon-search"></i> Search
    </span>
    <input type="text" class="form-control" data-bind="textInput: searchTerm">
  </div>
  <table class="table">
    <thead>
    <tr>
      <th>Name</th>
      <th>Price</th>
      <th>Stock</th>
      <th></th>
    </tr>
    </thead>
    <tbody data-bind="foreach:catalog">
    <tr data-bind="style:color:stock() < 5?'red':'black'">
      <td data-bind="text:name"></td>
      <td data-bind="text:price"></td>
      <td data-bind="text:stock"></td>
      <td>
        <button class="btn btn-primary" data-bind="click:$parent.addToCart">
          <i class="glyphicon glyphicon-plus-sign"></i> Add
        </button>
      </td>
    </tr>
    </tbody>
    <tfoot>
    <tr>
      <td colspan="3">
        <strong>Items:</strong><span data-bind="text:catalog().length"></span>
      </td>
      <td colspan="1">
        <span data-bind="template:{name:'cart-widget'}"></span>
      </td>
    </tr>
    </tfoot>
  </table>
</script>

这是我们在上一章中构建的相同表格。我们只是添加了一些新东西:

<tr data-bind="style:{color: stock() < 5?'red':'black'}">...</tr>

现在,每行使用 style 绑定来提醒用户,当他们购物时,库存达到最大限制。style 绑定与 CSS 绑定类似。它允许我们根据表达式的值添加样式属性。在这种情况下,如果库存高于五,行中的文本颜色必须是黑色,如果库存是四或更少,则为红色。我们可以使用其他 CSS 属性,所以随时尝试其他行为。例如,如果元素在购物车内部,将目录的行设置为绿色。我们应记住,如果属性有连字符,你应该用单引号括起来。例如,background-color 会抛出错误,所以你应该写成 'background-color'

当我们使用根据视图模型的值激活的绑定时,最好使用计算观察值。因此,我们可以在我们的产品模型中创建一个计算值,该值返回应显示的颜色值:

//In the Product.js
var _lineColor = ko.computed(function(){
  return (_stock() < 5)? 'red' : 'black';
});
return {
  lineColor:_lineColor
};
//In the template
<tr data-bind="style:lineColor"> ... </tr>

如果我们在 style.css 文件中创建一个名为 stock-alert 的类,并使用 CSS 绑定,效果会更好。

//In the style file
.stock-alert {
  color: #f00;
}
//In the Product.js
var _hasStock = ko.computed(function(){
  return (_stock() < 5);   
});
return {
  hasStock: _hasStock
};
//In the template
<tr data-bind="css: hasStock"> ... </tr>

现在,看一下 <tfoot> 标签内部。

<td colspan="1">
  <span data-bind="template:{name:'cart-widget'}"></span>
</td>

正如你所见,我们可以有嵌套模板。在这种情况下,我们在 catalog 模板内部有一个 cart-widget 模板。这使我们可以拥有非常复杂的模板,将它们分割成非常小的片段,并组合它们,以保持我们的代码整洁和可维护性。

最后,看一下每行的最后一个单元格:

<td>
  <button class="btn btn-primary" data-bind="click:$parent.addToCart">
    <i class="glyphicon glyphicon-plus-sign"></i> Add
  </button>
</td>

看看我们如何使用魔术变量 $parent 调用 addToCart 方法。Knockout 给了我们一些魔术词来浏览我们应用程序中的不同上下文。在这种情况下,我们在 catalog 上下文中,想要调用一个位于一级上的方法。我们可以使用名为 $parent 的魔术变量。

在 Knockout 上下文中,还有其他变量可供使用。Knockout 网站上有完整的文档 knockoutjs.com/documentation/binding-context.html

在这个项目中,我们不会使用所有这些绑定上下文变量。但我们会快速解释这些绑定上下文变量,只是为了更好地理解它们。

如果我们不知道我们有多少级别深入,我们可以使用魔术词 $root 导航到视图模型的顶部。

当我们有许多父级时,我们可以获得魔术数组 $parents 并使用索引访问每个父级,例如 $parents[0]$parents[1]。想象一下,你有一个类别列表,每个类别包含一个产品列表。这些产品是一个 ID 列表,而类别有一个获取其产品名称的方法。我们可以使用 $parents 数组来获取对类别的引用:

<ul data-bind="foreach: {data: categories}">
  <li data-bind="text: $data.name"></li>
  <ul data-bind="foreach: {data: $data.products, as: 'prod'}>
    <li data-bind="text: $parents[0].getProductName(prod.ID)"></li>
  </ul>
</ul>

看看foreach绑定内部的as属性有多有用。它使代码更易读。但是,如果你在foreach循环内部,你也可以使用$data魔术变量访问每个项目,并且可以使用$index魔术变量访问集合中每个元素的位置索引。例如,如果我们有一个产品列表,我们可以这样做:

<ul data-bind="foreach: cart">
  <li><span data-bind="text:$index">
    </span> - <span data-bind="text:$data.name"></span>
</ul>

这应该显示:

0 – 产品 1

1 – 产品 2

2 – 产品 3

...

目录模板

KnockoutJS 魔术变量用于导航上下文

现在我们更多地了解了绑定变量是什么,让我们回到我们的代码。我们现在将编写addToCart方法。

我们将在我们的js/models文件夹中定义购物车项目。创建一个名为CartProduct.js的文件,并插入以下代码:

//js/models/CartProduct.js
var CartProduct = function (product, units) {
  "use strict";

  var _product = product,
    _units = ko.observable(units);

  var subtotal = ko.computed(function(){
    return _product.price() * _units();
  });

  var addUnit = function () {
    var u = _units();
    var _stock = _product.stock();
    if (_stock === 0) {
      return;
    }
  _units(u+1);
    _product.stock(--_stock);
  };

  var removeUnit = function () {
    var u = _units();
    var _stock = _product.stock();
    if (u === 0) {
      return;
    }
    _units(u-1);
    _product.stock(++_stock);
  };

  return {
    product: _product,
    units: _units,
    subtotal: subtotal,
    addUnit : addUnit,
    removeUnit: removeUnit,
  };
};

每个购物车产品由产品本身和我们想购买的产品的单位组成。我们还将有一个计算字段,其中包含该行的小计。我们应该让对象负责管理其单位和产品的库存。因此,我们已经添加了addUnitremoveUnit方法。如果调用了这些方法,它们将增加一个产品单位或删除一个产品单位。

我们应该在我们的index.html文件中与其他<script>标签一起引用这个 JavaScript 文件。

在视图模型中,我们应该创建一个购物车数组,并在返回语句中公开它,就像我们之前做的那样:

var cart = ko.observableArray([]);

是时候编写addToCart方法了:

var addToCart = function(data) {
  var item = null;
  var tmpCart = cart();
  var n = tmpCart.length;
  while(n--) {
    if (tmpCart[n].product.id() === data.id()) {
      item = tmpCart[n];
    }
  }
  if (item) {
    item.addUnit();
  } else {
    item = new CartProduct(data,0);
    item.addUnit();
    tmpCart.push(item);        
  }
  cart(tmpCart);
};

此方法在购物车中搜索产品。如果存在,则更新其单位,如果不存在,则创建一个新的。由于购物车是一个可观察数组,我们需要获取它,操作它,并覆盖它,因为我们需要访问产品对象以了解产品是否在购物车中。请记住,可观察数组不会观察它们包含的对象,只会观察数组属性。

添加到购物车模态框模板

这是一个非常简单的模板。我们只需将我们在第一章中创建的代码包装在一起,使用 KnockoutJS 自动刷新 UI,以将产品添加到 Bootstrap 模态框中:

<script type="text/html" id="add-to-catalog-modal">
  <div class="modal fade" id="addToCatalogModal">
    <div class="modal-dialog">
      <div class="modal-content">
        <form class="form-horizontal" role="form" data-bind="with:newProduct">
          <div class="modal-header">
            <button type="button" class="close" data-dismiss="modal">
              <span aria-hidden="true">&times;</span>
              <span class="sr-only">Close</span>
            </button><h3>Add New Product to the Catalog</h3>
          </div>
          <div class="modal-body">
            <div class="form-group">
              <div class="col-sm-12">
                <input type="text" class="form-control" placeholder="Name" data-bind="textInput:name">
              </div>
            </div>
            <div class="form-group">
              <div class="col-sm-12">
                <input type="text" class="form-control" placeholder="Price" data-bind="textInput:price">
              </div>
            </div>
            <div class="form-group">
              <div class="col-sm-12">
                <input type="text" class="form-control" placeholder="Stock" data-bind="textInput:stock">
              </div>
            </div>
          </div>
          <div class="modal-footer">
            <div class="form-group">
              <div class="col-sm-12">
                <button type="submit" class="btn btn-default" data-bind="{click:$parent.addProduct}">
                  <i class="glyphicon glyphicon-plus-sign">
                  </i> Add Product
                </button>
              </div>
            </div>
          </div>
        </form>
      </div><!-- /.modal-content -->
    </div><!-- /.modal-dialog -->
  </div><!-- /.modal -->
</script>

购物车小部件模板

此模板可以快速向用户提供有关购物车中有多少件商品以及它们的总成本的信息:

<script type="text/html" id="cart-widget">
  Total Items: <span data-bind="text:totalItems"></span>
  Price: <span data-bind="text:grandTotal"></span>
</script>

我们应该在我们的视图模型中定义totalItemsgrandTotal

var totalItems = ko.computed(function(){
  var tmpCart = cart();
  var total = 0;
  tmpCart.forEach(function(item){
    total += parseInt(item.units(),10);
  });
  return total;
});
var grandTotal = ko.computed(function(){
  var tmpCart = cart();
  var total = 0;
  tmpCart.forEach(function(item){
    total += (item.units() * item.product.price());
  });
  return total;
});

现在你应该像我们一直做的那样在返回语句中公开它们。现在不要担心格式,你将在未来学习如何格式化货币或任何类型的数据。现在你必须专注于学习如何管理信息以及如何向用户显示信息。

购物车项目模板

cart-item模板显示购物车中的每一行:

<script type="text/html" id="cart-item">
  <div class="list-group-item" style="overflow: hidden">
    <button type="button" class="close pull-right" data-bind="click:$root.removeFromCart"><span>&times;</span></button>
    <h4 class="" data-bind="text:product.name"></h4>
    <div class="input-group cart-unit">
      <input type="text" class="form-control" data-bind="textInput:units" readonly/>
        <span class="input-group-addon">
          <div class="btn-group-vertical">
            <button class="btn btn-default btn-xs" data-bind="click:addUnit">
              <i class="glyphicon glyphicon-chevron-up"></i>
            </button>
            <button class="btn btn-default btn-xs" data-bind="click:removeUnit">
              <i class="glyphicon glyphicon-chevron-down"></i>
            </button>
          </div>
        </span>
    </div>
  </div>
</script>

我们在每条线的右上角设置了一个x按钮,方便从购物车中移除一条线。正如您所见,我们使用了$root魔术变量来导航到顶级上下文,因为我们将在foreach循环中使用此模板,这意味着该模板将处于循环上下文中。如果我们把这个模板视为一个独立的元素,我们无法确定我们在上下文导航中有多深。为了确保,我们要到正确的上下文中调用removeFormCart方法。在这种情况下最好使用$root而不是$parent

removeFromCart的代码应该在 view-model 上下文中,代码应该如下所示:

var removeFromCart = function (data) {
  var units = data.units();
  var stock = data.product.stock();
  data.product.stock(units+stock);
  cart.remove(data);
};

注意,在addToCart方法中,我们获得了 observable 内部的数组。我们这样做是因为我们需要导航到数组的元素内部。在这种情况下,Knockout 可观察数组有一个叫做remove的方法,允许我们移除作为参数传递的对象。如果对象在数组中,则会被移除。

记住,数据环境始终作为我们在单击事件中使用的函数的第一个参数传递。

购物车模板

cart模板应显示购物车的布局:

<script type="text/html" id="cart">
  <button type="button" class="close pull-right" data-bind="click:hideCartDetails">
    <span>&times;</span>
  </button>
  <h1>Cart</h1>
  <div data-bind="template: {name: 'cart-item', foreach:cart}" class="list-group"></div>
  <div data-bind="template:{name:'cart-widget'}"></div>
  <button class="btn btn-primary btn-sm" data-bind="click:showOrder">
    Confirm Order
  </button>
</script>

重要的是,您注意到我们

购物车

下面正好绑定了模板。我们使用foreach参数将模板与数组绑定。通过这种绑定,Knockout 会为购物车中的每个元素渲染cart-item模板。这样可以大大减少我们在每个模板中编写的代码,而且使它们更易读。

我们再次使用cart-widget模板显示总商品数量和总金额。这是模板的一个很好的特点,我们可以反复使用内容。

请注意,我们在购物车的右上方放置了一个按钮,当我们不需要查看购物车的详细信息时,可以关闭购物车,并且另一个按钮是在完成时确认订单。我们的 view-model 中的代码应该如下:

var hideCartDetails = function () {
  $("#cartContainer").addClass("hidden");
};
var showOrder = function () {
  $("#catalogContainer").addClass("hidden");
  $("#orderContainer").removeClass("hidden");
};

正如您所见,我们使用 jQuery 和 Bootstrap 框架的 CSS 类来显示和隐藏元素。隐藏类只是给元素添加了display: none样式。我们只需要切换这个类来在视图中显示或隐藏元素。将这两个方法暴露在您的 view-model 的return语句中。

当需要显示order模板时我们将回来。

这就是我们有了我们的目录和购物车后的结果:

购物车模板

订单模板

一旦我们单击确认订单按钮,订单应该显示给我们,以便审查和确认我们是否同意。

<script type="text/html" id="order">
  <div class="col-xs-12">
    <button class="btn btn-sm btn-primary" data-bind="click:showCatalog">
      Back to catalog
    </button>
    <button class="btn btn-sm btn-primary" data-bind="click:finishOrder">
      Buy & finish
    </button>
  </div>
  <div class="col-xs-6">
    <table class="table">
      <thead>
      <tr>
        <th>Name</th>
        <th>Price</th>
        <th>Units</th>
        <th>Subtotal</th>
      </tr>
      </thead>
      <tbody data-bind="foreach:cart">
      <tr>
        <td data-bind="text:product.name"></td>
        <td data-bind="text:product.price"></td>
        <td data-bind="text:units"></td>
        <td data-bind="text:subtotal"></td>
      </tr>
      </tbody>
      <tfoot>
      <tr>
        <td colspan="3"></td>
        <td>Total:<span data-bind="text:grandTotal"></span></td>
      </tr>
      </tfoot>
    </table>
  </div>
</script>

这里有一个只读表格,显示所有购物车条目和两个按钮。其中一个是确认按钮,将显示模态对话框,显示订单完成,另一个让我们有选择返回目录继续购物。有些代码需要添加到我们的 view-model 中并向用户公开:

var showCatalog = function () {
  $("#catalogContainer").removeClass("hidden");
  $("#orderContainer").addClass("hidden");
};
var finishOrder = function() {
  cart([]);
  hideCartDetails();
  showCatalog();
  $("#finishOrderModal").modal('show');
};

正如我们在先前的方法中所做的,我们给想要显示和隐藏的元素添加和删除隐藏类。finishOrder方法移除购物车中的所有商品,因为我们的订单已完成;隐藏购物车并显示目录。它还显示一个模态框,向用户确认订单已完成。

订单模板

订单详情模板

finish-order-modal模板

最后一个模板是告诉用户订单已完成的模态框:

<script type="text/html" id="finish-order-modal">
  <div class="modal fade" id="finishOrderModal">
    <div class="modal-dialog">
            <div class="modal-content">
        <div class="modal-body">
        <h2>Your order has been completed!</h2>
        </div>
        <div class="modal-footer">
          <div class="form-group">
            <div class="col-sm-12">
              <button type="submit" class="btn btn-success" data-dismiss="modal">Continue Shopping
              </button>
            </div>
          </div>
        </div>
      </div><!-- /.modal-content -->
    </div><!-- /.modal-dialog -->
  </div><!-- /.modal -->
</script>

以下截图显示了输出:

完成订单模板

用 if 和 ifnot 绑定处理模板

你已经学会如何使用 jQuery 和 Bootstrap 的强大功能来显示和隐藏模板。这非常好,因为你可以在任何你想要的框架中使用这个技术。这种类型的代码的问题在于,由于 jQuery 是一个 DOM 操作库,你需要引用要操作的元素。这意味着你需要知道想要应用操作的元素。Knockout 给我们一些绑定来根据我们视图模型的值来隐藏和显示元素。让我们更新showhide方法以及模板。

将两个控制变量添加到你的视图模型中,并在return语句中公开它们。

var visibleCatalog = ko.observable(true);
var visibleCart = ko.observable(false);

现在更新showhide方法:

var showCartDetails = function () {
  if (cart().length > 0) {
    visibleCart(true);
  }
};

var hideCartDetails = function () {
  visibleCart(false);
};

var showOrder = function () {
  visibleCatalog(false);
};

var showCatalog = function () {
  visibleCatalog(true);
};

我们可以欣赏到代码变得更易读和有意义。现在,更新cart模板、catalog模板和order模板。

index.html中,考虑这一行:

<div class="row" id="catalogContainer">

用以下行替换它:

<div class="row" data-bind="if: visibleCatalog">

然后考虑以下行:

<div id="cartContainer" class="col-xs-6 well hidden" data-bind="template:{name:'cart'}"></div>

用这个来替换它:

<div class="col-xs-6" data-bind="if: visibleCart">
  <div class="well" data-bind="template:{name:'cart'}"></div>
</div>

重要的是要知道,if 绑定和模板绑定不能共享相同的data-bind属性。这就是为什么在这个模板中我们从一个元素转向两个嵌套元素。换句话说,这个例子是不允许的:

<div class="col-xs-6" data-bind="if:visibleCart, template:{name:'cart'}"></div>

最后,考虑这一行:

<div class="row hidden" id="orderContainer" data-bind="template:{name:'order'}">

用这个来替换它:

<div class="row" data-bind="ifnot: visibleCatalog">
  <div data-bind="template:{name:'order'}"></div>
</div>

通过我们所做的更改,显示或隐藏元素现在取决于我们的数据而不是我们的 CSS。这样做要好得多,因为现在我们可以使用ififnot绑定来显示和隐藏任何我们想要的元素。

让我们粗略地回顾一下我们现在的文件:

我们有我们的index.html文件,其中包含主容器、模板和库:

<!DOCTYPE html>
<html>
<head>
  <title>KO Shopping Cart</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" type="text/css" href="css/bootstrap.min.css">
  <link rel="stylesheet" type="text/css" href="css/style.css">
</head>
<body>

<div class="container-fluid">
  <div class="row" data-bind="if: visibleCatalog">
    <div class="col-xs-12" data-bind="template:{name:'header'}"></div>
    <div class="col-xs-6" data-bind="template:{name:'catalog'}"></div>
    <div class="col-xs-6" data-bind="if: visibleCart">
      <div class="well" data-bind="template:{name:'cart'}"></div>
    </div>
  </div>
  <div class="row" data-bind="ifnot: visibleCatalog">
    <div data-bind="template:{name:'order'}"></div>
  </div>
  <div data-bind="template: {name:'add-to-catalog-modal'}"></div>
  <div data-bind="template: {name:'finish-order-modal'}"></div>
</div>

<!-- templates -->
<script type="text/html" id="header"> ... </script>
<script type="text/html" id="catalog"> ... </script>
<script type="text/html" id="add-to-catalog-modal"> ... </script>
<script type="text/html" id="cart-widget"> ... </script>
<script type="text/html" id="cart-item"> ... </script>
<script type="text/html" id="cart"> ... </script>
<script type="text/html" id="order"> ... </script>
<script type="text/html" id="finish-order-modal"> ... </script>
<!-- libraries -->
<script type="text/javascript" src="img/jquery.min.js"></script>
<script type="text/javascript" src="img/bootstrap.min.js"></script>
<script type="text/javascript" src="img/knockout.debug.js"></script>
<script type="text/javascript" src="img/product.js"></script>
<script type="text/javascript" src="img/cartProduct.js"></script>
<script type="text/javascript" src="img/viewmodel.js"></script>
</body>
</html>

我们还有我们的viewmodel.js文件:

var vm = (function () {
  "use strict";
  var visibleCatalog = ko.observable(true);
  var visibleCart = ko.observable(false);
  var catalog = ko.observableArray([...]);
  var cart = ko.observableArray([]);
  var newProduct = {...};
  var totalItems = ko.computed(function(){...});
  var grandTotal = ko.computed(function(){...});
  var searchTerm = ko.observable("");
  var filteredCatalog = ko.computed(function () {...});
  var addProduct = function (data) {...};
  var addToCart = function(data) {...};
  var removeFromCart = function (data) {...};
  var showCartDetails = function () {...};
  var hideCartDetails = function () {...};
  var showOrder = function () {...};
  var showCatalog = function () {...};
  var finishOrder = function() {...};
  return {
    searchTerm: searchTerm,
    catalog: filteredCatalog,
    cart: cart,
    newProduct: newProduct,
    totalItems:totalItems,
    grandTotal:grandTotal,
    addProduct: addProduct,
    addToCart: addToCart,
    removeFromCart:removeFromCart,
    visibleCatalog: visibleCatalog,
    visibleCart: visibleCart,
    showCartDetails: showCartDetails,
    hideCartDetails: hideCartDetails,
    showOrder: showOrder,
    showCatalog: showCatalog,
    finishOrder: finishOrder
  };
})();
ko.applyBindings(vm);

在调试时将视图模型全局化是很有用的。在生产环境中这样做并不是好的实践,但在调试应用程序时是很好的。

Window.vm = vm;

现在你可以从浏览器调试器或 IDE 调试器轻松访问你的视图模型。

除了在第一章中编写的产品模型之外,我们还创建了一个名为CartProduct的新模型:

var CartProduct = function (product, units) {
  "use strict";
  var _product = product,
    _units = ko.observable(units);
  var subtotal = ko.computed(function(){...});
  var addUnit = function () {...};
  var removeUnit = function () {...};
  return {
    product: _product,
    units: _units,
    subtotal: subtotal,
    addUnit : addUnit,
    removeUnit: removeUnit
  };
};

你已经学会了如何使用 Knockout 管理模板,但也许你已经注意到,在index.html文件中拥有所有模板并不是最佳的方法。我们将讨论两种机制。第一种更像是自制的,而第二种是许多 Knockout 开发者使用的外部库,由 Jim Cowart 创建,名为Knockout.js-External-Template-Enginegithub.com/ifandelse/Knockout.js-External-Template-Engine)。

使用 jQuery 管理模板

由于我们希望从不同的文件加载模板,让我们将所有的模板移到一个名为views的文件夹中,并且每个模板都用一个文件表示。每个文件的名称将与模板的 ID 相同。因此,如果模板的 ID 是cart-item,那么文件应该被称为cart-item.html,并且将包含完整的cart-item模板:

<script type="text/html" id="cart-item"></script>

使用 jQuery 管理模板

包含所有模板的 views 文件夹

现在在viewmodel.js文件中,删除最后一行(ko.applyBindings(vm))并添加此代码:

var templates = [
  'header',
  'catalog',
  'cart',
  'cart-item',
  'cart-widget',
  'order',
  'add-to-catalog-modal',
  'finish-order-modal'
];

var busy = templates.length;
templates.forEach(function(tpl){
  "use strict";
  $.get('views/'+ tpl + '.html').then(function(data){
    $('body').append(data);
    busy--;
    if (!busy) {
      ko.applyBindings(vm);
    }
  });
});

此代码获取我们需要的所有模板并将它们附加到 body。一旦所有模板都加载完成,我们就调用applyBindings方法。我们应该这样做,因为我们是异步加载模板,我们需要确保当所有模板加载完成时绑定我们的视图模型。

这样做已足以使我们的代码更易维护和易读,但如果我们需要处理大量的模板,仍然存在问题。而且,如果我们有嵌套文件夹,列出所有模板就会变成一个头疼的事情。应该有更好的方法。

使用koExternalTemplateEngine管理模板

我们已经看到了两种加载模板的方式,它们都足以管理少量的模板,但当代码行数开始增长时,我们需要一些允许我们忘记模板管理的东西。我们只想调用一个模板并获取内容。

为此目的,Jim Cowart 的库koExternalTemplateEngine非常完美。这个项目在 2014 年被作者放弃,但它仍然是一个我们在开发简单项目时可以使用的好库。在接下来的章节中,您将学习更多关于异步加载和模块模式的知识,我们将看到其他目前正在维护的库。

我们只需要在js/vendors文件夹中下载库,然后在我们的index.html文件中链接它,放在 Knockout 库的下面即可。

<script type="text/javascript" src="img/knockout.debug.js"></script>
<script type="text/javascript" src="img/koExternalTemplateEngine_all.min.js"></script>

现在你应该在viewmodel.js文件中进行配置。删除模板数组和foreach语句,并添加以下三行代码:

infuser.defaults.templateSuffix = ".html";
infuser.defaults.templateUrl = "views";
ko.applyBindings(vm);

这里,infuser是一个我们用来配置模板引擎的全局变量。我们应该指示我们的模板将具有哪个后缀名,以及它们将在哪个文件夹中。

我们不再需要<script type="text/html" id="template-id"></script>标签,所以我们应该从每个文件中删除它们。

现在一切应该都正常了,我们成功所需的代码并不多。

KnockoutJS 有自己的模板引擎,但是您可以看到添加新的引擎并不困难。如果您有其他模板引擎的经验,如 jQuery Templates、Underscore 或 Handlebars,只需将它们加载到您的index.html文件中并使用它们,没有任何问题。这就是 Knockout 的美丽之处,您可以使用任何您喜欢的工具。

你在本章学到了很多东西,对吧?

  • Knockout 给了我们 CSS 绑定,根据表达式激活和停用 CSS 类。

  • 我们可以使用 style 绑定向元素添加 CSS 规则。

  • 模板绑定帮助我们管理已在 DOM 中加载的模板。

  • 使用foreach绑定可以在集合上进行迭代。

  • foreach内部,Knockout 给了我们一些魔术变量,如$parent$parents$index$data$root

  • 我们可以在foreach绑定中使用as绑定来为每个元素获取别名。

  • 我们可以只使用 jQuery 和 CSS 来显示和隐藏内容。

  • 我们可以使用ififnotvisible绑定来显示和隐藏内容。

  • jQuery 帮助我们异步加载 Knockout 模板。

  • 您可以使用koExternalTemplateEngine插件以更有效的方式管理模板。这个项目已经被放弃了,但它仍然是一个很好的解决方案。

摘要

在本章中,您已经学会了如何使用共享相同视图模型的模板来拆分应用程序。现在我们知道了基础知识,扩展应用程序会很有趣。也许我们可以尝试创建产品的详细视图,或者给用户选择订单发送位置的选项。您将在接下来的章节中学习如何做这些事情,但是只使用我们现在拥有的知识进行实验会很有趣。

在下一章中,我们将学习如何扩展 Knockout 行为。这将有助于格式化数据并创建可重用的代码。您将学习自定义绑定和组件是什么,以及它们如何帮助我们编写可重用和优雅的代码。

本章的代码在 GitHub 上:

github.com/jorgeferrando/knockout-cart/archive/chapter2.zip

第三章:自定义绑定和组件

通过前两章学到的所有概念,你可以构建出大部分真实世界中遇到的应用程序。当然,如果只凭借这两章的知识编写代码,你应该非常整洁,因为你的代码会变得越来越庞大,维护起来会很困难。

有一次一个谷歌工程师被问及如何构建大型应用程序。他的回答既简短又雄辩:。不要编写大型应用程序。相反,编写小型应用程序,小型的隔离代码片段互相交互,并用它们构建一个大系统。

我们如何编写小型、可重用和独立的代码片段来扩展 Knockout 的功能?答案是使用自定义绑定和组件。

自定义绑定

我们知道什么是绑定,它是我们写在data-bind属性中的一切。我们有一些内置的绑定。点击和值是其中的两个。但我们可以编写我们自己的自定义绑定,以整洁的方式扩展我们应用程序的功能。

编写自定义绑定非常简单。它有一个基本结构,我们应该始终遵循:

ko.bindingHandlers.yourBindingName = {
  init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
    // This will be called when the binding is first applied to an element
    // Set up any initial state, event handlers, etc. here
  },
  update: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
    // This will be called once when the binding is first applied to an element,
    // and again whenever any observables/computeds that are accessed change
    // Update the DOM element based on the supplied values here.
  }
};

Knockout 有一个内部对象叫做bindingHandlers。我们可以用自定义绑定扩展这个对象。我们的绑定应该有一个名称,在bindingHandlers对象内用来引用它。我们的自定义绑定是一个具有两个函数initupdate的对象。有时你应该只使用其中一个,有时两个都要用。

init方法中,我们应该初始化绑定的状态。在update方法中,我们应该设置代码以在其模型或值更新时更新绑定。这些方法给了我们一些参数来执行这个任务:

  • element:这是与绑定有关的 DOM 元素。

  • valueAccessor:这是绑定的值。通常是一个函数或可观察对象。使用ko.unwrap来获取值更安全,比如var value = ko.unwrap(valueAccessor());

  • allBindings:这是一个对象,你可以用它来访问其他绑定。你可以使用allBindings.get('name')来获取一个绑定,或使用allBindings.has('name')来查询绑定是否存在。

  • viewModel:在 Knockout 3.x 中已弃用。你应该使用bindingContext.$databindigContext.$rawData代替。

  • bindingContext:使用绑定上下文,我们可以访问熟悉的上下文对象,如$root$parents$parent$data$index来在不同的上下文中导航。

我们可以为许多事物使用自定义绑定。例如,我们可以自动格式化数据(货币或日期是明显的例子),或增加其他绑定的语义含义。给绑定起名叫toggle比仅仅设置clickvisible绑定来显示和隐藏元素更加描述性。

自定义绑定

新的文件夹结构与自定义绑定和组件

这个 toggle 绑定

要向我们的应用程序添加新的自定义绑定,我们将创建一个名为custom的新文件夹,放在我们的js文件夹中。然后,我们将创建一个名为koBindings.js的文件,并将其链接到我们的index.html文件中,放在我们的模板引擎的下方:

<script type="text/javascript" src="img/koExternalTemplateEngine_all.min.js"></script>
<script type="text/javascript" src="img/koBindings.js"></script>

我们的第一个自定义绑定将被称为toggle。我们将使用此自定义绑定来更改布尔变量的值。通过这种行为,我们可以显示和隐藏元素,即我们的购物车。只需在koBindings.js文件的开头编写以下代码。

ko.bindingHandlers.toggle = {
  init: function (element, valueAccessor) {
    var value = valueAccessor();
    ko.applyBindingsToNode(element, {
      click: function () {
          value(!value());
      }
    });
  }
};

在这种情况下,我们不需要使用update方法,因为我们在初始化绑定时设置了所有行为。我们使用ko.applyBingidsToNode方法将click函数链接到元素上。applyBindingsToNode方法具有与applyBindings相同的行为,但我们设置了一个上下文,一个从 DOM 中获取的节点,其中应用了绑定。我们可以说applyBindingsapplyBindingsToNode($('body'), viewmodel)的别名。

现在我们可以在我们的应用程序中使用这个绑定。更新views/header.html模板中的showCartDetails按钮。删除以下代码:

<button class="btn btn-primary btn-sm" data-bind="click:showCartDetails, css:{disabled:cart().length  < 1}">Show Cart Details
</button>

更新以下按钮的代码:

<button class="btn btn-primary btn-sm" data-bind="toggle:visibleCart, css:{disabled:cart().length  < 1}">
  <span data-bind="text: visibleCart()?'Hide':'Show'">
  </span> Cart Details
</button>

现在我们不再需要showCartDetailshideCartDetails方法了,我们可以直接使用toggle绑定攻击visibleCart变量。

通过这个简单的绑定,我们已经删除了代码中的两个方法,并创建了一个可重用的代码,不依赖于我们的购物车视图模型。因此,您可以在任何想要的项目中重用 toggle 绑定,因为它没有任何外部依赖项。

我们还应该更新cart.html模板:

<button type="button" class="close pull-right" data-bind="toggle:visibleCart"><span>&times;</span></button>

一旦我们进行了此更新,我们意识到不再需要使用hideCartDetails。要彻底删除它,请按照以下步骤操作:

  1. finishOrder函数中,删除以下行:

    hideCartDetails();
    
  2. 添加以下行:

    visibleCart(false);
    

没有必要保留只管理一行代码的函数。

货币绑定

自定义绑定提供的另一个有用的工具是格式化应用于节点数据的选项。例如,我们可以格式化购物车的货币字段。

在 toggle 绑定的下方添加以下绑定:

ko.bindingHandlers.currency = {
  symbol: ko.observable('$'),
  update: function(element, valueAccessor, allBindingsAccessor){
    return ko.bindingHandlers.text.update(element,function(){
      var value = +(ko.unwrap(valueAccessor()) || 0),
        symbol = ko.unwrap(allBindingsAccessor().symbol !== undefined? allBindingsAccessor().symbol: ko.bindingHandlers.currency.symbol);
      return symbol + value.toFixed(2).replace(/(\d)(?=(\d{3})+\.)/g, "$1,");
    });
  }
};

在这里,我们不需要初始化任何内容,因为初始状态和更新行为是相同的。必须要知道,当initupdate方法执行相同的操作时,只需使用update方法。

在这种情况下,我们将返回我们想要的格式的数字。首先,我们使用内置绑定称为 text 来更新我们元素的值。这个绑定获取元素和一个函数,指示如何更新此元素内部的文本。在本地变量 value 中,我们将写入 valueAccessor 内部的值。记住 valueAccessor 可以是一个 observable;这就是为什么我们使用 unwrap 方法。我们应该对 symbol 绑定执行相同的操作。symbol 是我们用来设置货币符号的另一个绑定。我们不需要定义它,因为此绑定没有行为,只是一个写/读绑定。我们可以使用 allBindingsAccesor 访问它。最后,我们返回连接两个变量的值,并设置一个正则表达式将值转换为格式化的货币。

我们可以更新 catalogcart 模板中的价格绑定。

<td data-bind="currency:price, symbol:'€'"></td>

我们可以设置我们想要的符号,价格将被格式化为:€100,或者如果我们设置符号为 $ 或空,则将看到 $100(如果价格值为 100)。

货币绑定

货币自定义绑定

注意观察如何轻松地添加越来越多有用的绑定以增强 Knockout 的功能。

货币绑定

使用 $root 上下文显示的容器进行调试。

创建一个调试绑定 – toJSON 绑定。

当我们开发我们的项目时,我们会犯错误并发现意外的行为。Knockout 视图模型很难阅读,因为我们没有普通对象,而是 observables。因此,也许在开发过程中,拥有一个显示视图模型状态的方法和容器可能很有用。这就是为什么我们要构建一个 toJSON 绑定,将我们的视图模型转换为一个普通的 JSON 对象,我们可以在屏幕上或控制台中显示。

ko.bindingHandlers.toJSON = {
  update: function(element, valueAccessor){
    return ko.bindingHandlers.text.update(element,function(){
      return ko.toJSON(valueAccessor(), null, 2);
    });
  }
};

我们已经使用 ko.toJSON 对象将我们获取的值转换为 JSON 对象。

此函数具有与原生 JSON.stringify 函数相同的接口。它将三个参数作为参数:

第一个参数是我们想要转换为普通 JSON 对象的对象。

第二个是替换参数。它可以是一个函数或一个数组。它应该返回应添加到 JSON 字符串中的值。有关替换参数的更多信息,请参阅以下链接:

developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_native_JSON#The_replacer_parameter

最后一个表示应该应用于格式化结果的空格。因此,在这种情况下,我们说我们将使用 valueAccesor() 方法中包含的对象,不使用替换函数,并且将缩进两个空格。

要看到它的作用,我们应该将此行放在具有 container-fluid 类的元素的末尾:

<pre class="well well-lg" data-bind="toJSON: $root"></pre>

现在在这个<div>标签里,我们可以将$root上下文视为一个 JSON 对象。$root上下文是我们整个 Knockout 上下文的顶部,所以我们可以在这个框中看到我们所有的视图模型。

为了让这在没有原生 JSON 序列化程序的老浏览器上工作(例如,IE 7 或更早版本),你还必须引用 json2.js 库。

github.com/douglascrockford/JSON-js/blob/master/json2.js

你可以在这个链接中了解更多关于 Knockout 如何将 observables 转换为普通 JSON:knockoutjs.com/documentation/json-data.html

通过我们的绑定语义化

有时候,我们写的代码对我们来说似乎很简单,但当我们仔细看时,我们意识到它并不简单。例如,在 Knockout 中,我们有内置的 visible 绑定。很容易认为如果我们想要隐藏某些东西,我们只需写:data-bind="visible:!isVisible",并且每次我们想要隐藏某些东西时都写这个。这并不够清晰。我们想要表达什么?这个元素应该默认隐藏吗?当变量不可见时它应该可见吗?

最好的方法是写一个名为hidden的绑定。如果你有一个hidden绑定,你可以写data-bind="hidden: isHidden;",这听起来更清晰,不是吗?这个绑定很简单,让我们看看以下的代码:

ko.bindingHandlers.hidden = {
  update: function (element, valueAccessor) {
    var value = ! ko.unwrap(valueAccessor());
    ko.bindingHandlers.visible.update(element, function () { 
      return value; 
    });
  }
};

我们只是使用visible类型的bindingHandler来改变valueAccessor方法的值。所以我们创建了一个更加有含义的绑定。

看看 Knockout 有多么强大和可扩展。我们可以构建越来越多的行为。例如,如果我们想要练习自定义绑定,我们可以创建一个接收照片数组而不仅仅是一张照片的自定义图像绑定,然后我们可以创建一个轮播。我们可以创建我们自己的链接绑定,帮助我们在我们的应用程序中导航。可能性是无限的。

现在,让我们看看如何将一个 jQuery 插件集成到我们的绑定中。

将一个 jQuery 插件包装成自定义绑定

Knockout 和 jQuery 兼容。实际上,没有必要将一个 jQuery 插件包装成一个绑定。它会工作,因为 Knockout 和 jQuery 是兼容的。然而,正如我们之前提到的,jQuery 是一个 DOM 操作库,所以我们需要设置一个 ID 来定位我们想要应用插件的元素,这将创建一个依赖关系。如果我们将插件包装在一个自定义绑定中,我们可以通过元素和valueAccessor参数访问元素和它的值,并且我们可以通过allBindings对象传递我们需要的一切。

我们将集成一个简单的插件叫做iCheck,这将为我们的复选框提供一个很酷的主题。

首先下载iCheck插件并将iCheck.js文件放入js文件夹中。然后将skins文件夹保存到css文件夹中。iCheck插件的下载链接如下:

github.com/fronteed/iCheck/archive/2.x.zip

使用index.html文件链接cssjavascript文件:

<link rel="stylesheet" type="text/css" href="css/iCheck/skins/all.css"><!-- set it just below bootstap -->
<script type="text/javascript" src="img/icheck.js">
</script><!-- set it just below jquery -->

现在我们需要初始化插件并更新元素的值。在这种情况下,initupdate方法是不同的。因此,我们需要编写当绑定开始工作时发生的情况以及当值更新时发生的情况。

.

将 jQuery 插件封装到自定义绑定中

将 iCheck 添加到我们的项目中

iCheck插件仅通过给我们的复选框提供样式来工作。现在的问题是我们需要将这个插件与我们的元素链接起来。

iCheck的基本行为是$('input [type=checkbox]').icheck(config)。当复选框的值更改时,我们需要更新我们绑定的值。幸运的是,iCheck有事件来检测值何时更改。

这个绑定只会管理iCheck的行为。这意味着可观察值的值将由另一个绑定处理。

使用checked绑定是有道理的。分别使用这两个绑定,以便iCheck绑定管理呈现,而checked绑定管理值行为。

将来,我们可以移除icheck绑定或者使用另一个绑定来管理呈现,复选框仍将正常工作。

按照我们在本章第一部分看到的init约定,我们将初始化插件并在init方法中设置事件。在update方法中,我们将在由checked绑定处理的可观察值更改时更新复选框的值。

注意我们使用allBindingsAccesor对象来获取已检查绑定的值:

ko.bindingHandlers.icheck = {
  init: function (element, valueAccessor, allBindingsAccessor) {
    var checkedBinding = allBindingsAccessor().checked;
    $(element).iCheck({
      checkboxClass: 'icheckbox_minimal-blue',
      increaseArea: '10%'
    });
    $(element).on('ifChanged', function (event) {
      checkedBinding(event.target.checked);
    });
  },
  update: function (element,valueAccessor, allBindings) {
    var checkedBinding = allBindingsAccessor().checked;
    var status = checked?'check':'uncheck';
    $(element).iCheck(status);
  }
};

现在我们可以使用这个来以隔离的方式在我们的应用程序中创建酷炫的复选框。我们将使用这个插件来隐藏和显示我们的搜索框。

将此添加到header.html模板中显示购物车详情 / 隐藏购物车详情按钮的下方:

<input type="checkbox" data-bind="icheck, checked:showSearchBar"/> Show Search options

然后转到catalog.html文件,在搜索栏中添加一个可见的绑定,如下所示:

<div class="input-group" data-bind="visible:showSearchBar">
  <span class="input-group-addon">
    <i class="glyphicon glyphicon-search"></i> Search
  </span>
  <input type="text" class="form-control" data-bind="textInput:searchTerm">
</div>

将变量添加到视图模型中,并在return语句中设置它,就像我们对所有其他变量所做的那样:

var showSearchBar = ko.observable(true);

现在你可以看到一个酷炫的复选框,允许用户显示和隐藏搜索栏:

将 jQuery 插件封装到自定义绑定中

组件 - 隔离的视图模型

自定义绑定非常强大,但有时我们需要更强大的行为。我们想要创建一个对应用程序的其余部分表现为黑匣子的隔离元素。这些类型的元素被称为组件。组件有自己的视图模型和模板。它还有自己的方法和事件,我们也可以说它本身就是一个应用程序。当然,我们可以使用依赖注入将我们的组件与我们的主应用程序视图模型链接起来,但是组件可以与给它正确数据的每个应用程序一起工作。

我们可以构建诸如表格、图表和您能想象到的一切复杂组件。要学习如何构建一个组件,您可以构建一个简单的组件。我们将创建一个add-to-cart按钮。这是一个连接我们的目录和购物车的组件,所以通过这个组件我们可以隔离我们的目录和我们的购物车。它们将通过这个组件连接,这个组件只是一个按钮,接收购物车和目录中的商品,并且将有将商品插入到购物车的所有逻辑。这是非常有用的,因为购物车不需要关心插入的商品,目录也不需要。另外,如果您需要在插入商品之前或之后执行一些逻辑,您可以在一个隔离范围内执行。

组件-隔离视图模型

组件有与主应用程序交互的隔离视图模型

一个组件的基本结构如下:

ko.components.register('component-name', {
  viewModel: function(params) {
    // Data: values you want to initilaize
    this.chosenValue = params.value;
    this.localVariable = ko.observable(true);
    // Behaviors: functions
    this.externalBehaviour = params.externalFunction;
    this.behaviour = function () { ... }
  },
  template:
    '<div>All html you want</div>'
});

使用这个模式的帮助,我们将构建我们的add-to-cart按钮。在custom文件夹内创建一个名为components.js的文件,并写入以下内容:

ko.components.register('add-to-cart-button', {
  viewModel: function(params) {
    this.item = params.item;
    this.cart = params.cart;

    this.addToCart = function() {
      var data = this.item;
      var tmpCart = this.cart();
      var n = tmpCart.length;
      var item = null;

      while(n--) {
        if (tmpCart[n].product.id() === data.id()) {
          item = tmpCart[n];
        }
      }

      if (item) {
        item.addUnit();
      } else {
        item = new CartProduct(data,1);
        tmpCart.push(item);
        item.product.decreaseStock(1);
      }

      this.cart(tmpCart);
    };
  },
  template:
    '<button class="btn btn-primary" data-bind="click:addToCart">
       <i class="glyphicon glyphicon-plus-sign"></i> Add
    </button>'
});

我们将要添加到购物车的商品和购物车本身作为参数发送,并定义addToCart方法。这个方法是我们在视图模型中使用的,但现在被隔离在这个组件内部,所以我们的代码变得更清晰了。模板是我们在目录中拥有的用于添加商品的按钮。

现在我们可以将我们的目录行更新如下:

<tbody data-bind="{foreach:catalog}">
  <tr data-bind="style:{color:stock() < 5?'red':'black'}">
    <td data-bind="{text:name}"></td>
    <td data-bind="{currency:price, symbol:''}"></td>
    <td data-bind="{text:stock}"></td>
    <td>
      <add-to-cart-button params= "{cart: $parent.cart, item: $data}">
      </add-to-cart-button>
    </td>
  </tr>
</tbody>

高级技术

在这一部分,我们将讨论一些高级技术。我们并不打算将它们添加到我们的项目中,因为没有必要,但知道如果我们的应用程序需要时可以使用这些方法是很好的。

控制后代绑定

如果我们的自定义绑定有嵌套绑定,我们可以告诉我们的绑定是否 Knockout 应该应用绑定,或者我们应该控制这些绑定如何被应用。 我们只需要在init方法中返回{ controlsDescendantBindings: true }

ko.bindingHandlers.allowBindings = {
  init: function(elem, valueAccessor) {
    return { controlsDescendantBindings: true };
  }
};

这段代码告诉 Knockout,名为allowBindings的绑定将处理所有后代绑定:

<div data-bind="allowBindings: true">
  <!-- This will display 'New content' -->
  <div data-bind="text: 'New content'">Original content</div>
</div>
<div data-bind="allowBindings: false">
  <!-- This will display 'Original content' -->
  <div data-bind="text: 'New content'">Original content</div>
</div>

如果我们想用新属性扩展上下文,我们可以用新值扩展bindingContext属性。然后我们只需要使用ko.applyBindingsToDescendants来更新其子项的视图模型。当然我们应该告诉绑定它应该控制后代绑定。如果我们不这样做,它们将被更新两次。

ko.bindingHandlers.withProperties = {
  init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
    var myVM = { parentValues: valueAccessor, myVar: 'myValue'};
    var innerBindingContext = bindingContext.extend(myVM);
    ko.applyBindingsToDescendants(innerBindingContext, element);
    return { controlsDescendantBindings: true };
  }
};

这里我们并不创建一个子上下文。我们只是扩展父上下文。如果我们想创建子上下文来管理后代节点,并且能够使用$parentContext魔术变量来访问我们的父上下文,我们需要使用createChildContext方法来创建一个新上下文。

var childBindingContext = bindingContext.createChildContext(
  bindingContext.$rawData,
  null, //alias of descendant item ($data magic variable)
  function(context) {
    //manage your context variables
    ko.utils.extend(context, valueAccessor());
  });
ko.applyBindingsToDescendants(childBindingContext, element);
return { controlsDescendantBindings: true }; //Important to not bind twice

现在我们可以在子节点内部使用这些魔术变量:

<div data-bind="withProperties: { displayMode: 'twoColumn' }">
  The outer display mode is <span data-bind="text: displayMode"></span>.
  <div data-bind="withProperties: { displayMode: 'doubleWidth' }">
    The inner display mode is <span data-bind="text: displayMode"></span>, but I haven't forgotten that the outer display mode is <span data-bind="text: $parentContext.displayMode"></span>.
  </div>
</div>

通过修改绑定上下文和控制后代绑定,您将拥有一个强大而先进的工具来创建自己的自定义绑定机制。

使用虚拟元素

虚拟元素是允许使用 Knockout 注释的自定义绑定。您只需要告诉 Knockout 我们的绑定是允许虚拟的。

ko.virtualElements.allowedBindings.myBinding = true;
ko.bindingHandlers.myBinding = {
  init: function () { ... },
  update: function () { ... }
};

要将我们的绑定添加到允许的虚拟元素中,我们写下这个:

<!-- ko myBinding:param -->
<div></div>
<!-- /ko

虚拟元素具有操作 DOM 的 API。您可以使用 jQuery 操作虚拟元素,因为 Knockout 的一个优点是它与 DOM 库完全兼容,但是我们在 Knockout 文档中有完整的虚拟元素 API。这个 API 允许我们执行在实现控制流绑定时所需的类型的转换。有关虚拟元素的自定义绑定的更多信息,请参考以下链接:

knockoutjs.com/documentation/custom-bindings-for-virtual-elements.html

在绑定之前预处理数据

我们能够在绑定应用之前预处理数据或节点。这在显示数据之前格式化数据或向我们的节点添加新类或行为时非常有用。您也可以设置默认值,例如。我们只需要使用preprocesspreproccessNode方法。使用第一个方法,我们可以操纵我们绑定的值。使用第二个方法,我们可以操纵我们绑定的 DOM 元素(模板),如下所示:

ko.bindingHandlers.yourBindingHandler.preprocess = function(value) {
  ...
};

我们可以使用钩子preprocessNode操纵 DOM 节点。每当我们用 Knockout 处理 DOM 元素时,都会触发此钩子。它不绑定到一个具体的绑定。它对所有已处理的节点触发,因此您需要一种机制来定位要操纵的节点。

ko.bindingProvider.instance.preprocessNode = function(node) { 
  ...
};

摘要

在本章中,您已经学习了如何使用自定义绑定和组件扩展 Knockout。自定义绑定扩展了我们可以在data-bind属性中使用的选项,并赋予了我们使代码更可读的能力,将 DOM 和数据操作隔离在其中。另一方面,我们有组件。组件有它们自己的视图模型。它们本身就是一个孤立的应用程序。它们帮助我们通过彼此交互的小代码片段构建复杂的应用程序。

现在您已经知道如何将应用程序拆分成小代码片段,在下一章中,您将学习如何以不显眼的方式使用事件以及如何扩展可观察对象以增加 Knockout 的性能和功能。

要下载本章的代码,请转到 GitHub 存储库github.com/jorgeferrando/knockout-cart/tree/chapter3

第四章:管理 KnockoutJS 事件

我们的应用程序与用户之间的交互是我们需要解决的最重要问题。在过去的三章中,我们一直专注于业务需求,现在是时候考虑如何使最终用户更容易使用我们的应用程序了。

事件驱动编程是一种强大的范式,它能让我们更好地隔离我们的代码。KnockoutJS 给了我们几种处理事件的方式。如果我们想使用声明范式,可以使用点击绑定或者事件绑定。

有两种不同的方式来声明事件。声明范式说我们可以在我们的 HTML 中写 JavaScript 和自定义标签。另一方面,命令范式告诉我们应该将 JavaScript 代码与 HMTL 标记分开。为此,我们可以使用 jQuery 来编写不显眼的事件,也可以编写自定义事件。我们可以使用 bindingHandlers 来包装自定义事件,以便在我们的应用程序中重复使用它们。

事件驱动编程

当我们使用顺序编程来编写我们的应用程序时,我们会准确地知道我们的应用程序将会如何行为。通常情况下,我们在我们的应用程序与外部代理没有交互时使用这种编程范式。在网页开发中,我们需要使用事件驱动的编程范式,因为最终用户会主导应用程序的流程。

即使我们之前还没谈论过事件,我们知道它们是什么,因为我们一直在使用网页开发中最重要的事件之一,即点击事件。

用户可以触发许多事件。正如我们之前提到的,点击事件是用户可以在键盘上按键的地方;我们还可以从计算机那里接收事件,比如就绪事件,以通知我们 DOM 元素都已加载完毕。现在,如果我们的屏幕是可以触摸的,我们也有触摸事件。

我们还可以定义我们自定义的事件。如果我们想要通知实体但又不想创建它们之间的依赖关系,这就很有用。例如,假设我们想向购物车中添加物品。现在添加物品到购物车的责任在于视图模型。我们可以创建一个购物车实体,它封装了所有的购物车行为:添加、编辑、删除、显示、隐藏等等。如果我们开始在我们的代码中写: cart.add, cart.deletecart.show,那么我们的应用程序将依赖于 cart 对象。如果我们在我们的应用程序中创建事件,那么我们只需要触发它们,然后忘记接下来会发生什么,因为事件处理程序将为我们处理。

事件驱动编程能够减少耦合,但也降低内聚。我们应该选择在多大程度上要保持你的代码可读。事件驱动编程有时候是一个好的解决方案,但有一条规则我们应该始终遵循:KISS(保持简单,傻瓜)。所以,如果一个事件是一个简单的解决方案,就采用它。如果事件只是增加了代码行数,却没有给我们带来更好的结果,也许你应该考虑依赖注入作为更好的方法。

事件驱动的编程

事件驱动的编程工作流程

点击事件

在过去的三章中,我们一直在使用点击绑定。在这一章中,您将学习更多关于这个事件。点击事件是用户与应用程序进行交互的基本事件,因为鼠标一直是外设的首选(也是键盘)。

您可能已经了解到,如果将函数附加到点击绑定上,那么这个函数将会随着点击事件触发。问题在于,在 Knockout 中,点击事件不接受参数。据我们所知,我们点击函数的参数是预定义的。

传递更多参数

如我们所提到的,我们绑定到点击事件的函数具有预定义的签名:function functionName(data, event){...},并且这两个参数已经被分配:data 是绑定到元素的数据,event 是点击事件对象。那么如果我们想传递更多的参数会发生什么呢?我们有三种解决方案,如下所示:

  • 第一种是在视图模型中绑定参数:

    function clickEventFunctionWithParams(p1, p2, data, event) {
      //manageEvent
    }
    
    function clickEventFunction(data, event) {
      clickEventFunctionWithParams('param1', 'param2', data, event);
    }
    
  • 第二种选择是内联编写函数。如果我们想直接从模板中的上下文对象传递参数,那么这是一个有趣的选择。

    <button data-bind="click: function(data, event) {
      clickEventFunctionWithParams($parent.someVariable, $root.otherVariable, data, event);
    }">Click me</button>
    
  • 我们的第三个和最终的解决方案是第二个的变体,但更加优雅:

    <button data-bind="
      click: clickEventFunctionWithParams.bind($data, 'param1', 'param2')"
    >Click me</button>
    

我们可以使用最接近我们需求的那个。例如,如果我们想要传递的参数是视图模型中的常量或可观察对象,我们可以使用第一个。但是,如果我们需要传递上下文变量,比如$parent,我们可以使用最后一个。

bind函数是 JavaScript 原生的。它使用$data作为上下文创建另一个函数,然后将其余的参数应用到自身。您可以在developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind找到更多信息。

允许默认点击操作

默认情况下,KnockoutJS 阻止了点击时的默认操作。这意味着如果您在锚标签(<a>)中使用了点击操作,浏览器将执行我们已经链接的操作,而不会导航到链接的href。这种默认行为非常有用,因为如果您使用点击绑定,通常是因为您想执行不同的操作。如果您想允许浏览器执行默认操作,只需在函数末尾返回true

function clickEventFunction(data, event) {
  //run your code...

  //it allows to run the default behavior.
  //In anchor tags navigates to href value.
  return true;
}

事件冒泡

默认情况下,Knockout 允许点击事件继续冒泡到任何更高级别的事件处理程序。如果您的元素有一个也处理点击事件的父级,那么您将触发两个函数。为了避免冒泡事件,您需要包含一个名为clickBubble的附加绑定,并将其设置为false

<button data-bind="{
  click: clickEventFunction,
  clickBubble: false
}">Click me</button>

事件冒泡

事件冒泡的工作流程

事件类型

浏览器可以抛出许多类型的事件。 您可以在developer.mozilla.org/en-US/docs/Web/Events找到完整的参考资料。

正如我们所知,每个浏览器都有自己的一套指令; 因此,我们可以将事件分类为以下几组:

  • 标准事件:这些事件在官方 Web 规范中定义,应该在各种浏览器中普遍存在。

  • 非标准事件:这些事件是为每个浏览器引擎专门定义的。

  • Mozilla 特定事件:这些事件用于插件开发,包括以下内容:

    • 插件特定事件

    • XUL 事件

事件绑定

为了捕获和处理所有这些不同的事件,Knockout 有event绑定。 我们将使用它在文本上方和离开时显示和隐藏调试面板,借助以下代码的帮助:

  1. index.html 模板的第一个更新如下。 用这个新的 HTML 替换调试 div:

    <div data-bind="event: {
      mouseover:showDebug,
      mouseout:hideDebug
    }">
      <h3 style="cursor:pointer">
        Place the mouse over to display debug
      </h3>
      <pre class="well well-lg" data-bind="visible:debug, toJSON: $root"></pre>
    </div>
    

    该代码表示,当我们将鼠标悬停在div元素上时,我们将显示调试面板。 最初,只显示h3标签内容。

  2. 当我们将鼠标悬停在h3标签上时,我们将更新调试变量的值,并显示调试面板。 为了实现这一点,我们需要使用以下代码更新我们的视图模型:

    var debug = ko.observable(false);
    
    var showDebug = function () {
      debug(true);
    };
    
    var hideDebug = function () {
      debug(false);

      };
    
  3. 然后我们需要更新我们的接口(视图模型的返回值)。

    return {
      debug: debug,
      showDebug:showDebug,
      hideDebug:hideDebug,
      searchTerm: searchTerm,
      catalog: filteredCatalog,
      cart: cart,
      newProduct: newProduct,
      totalItems:totalItems,
      grandTotal:grandTotal,
      addProduct: addProduct,
      addToCart: addToCart,
      removeFromCart:removeFromCart,
      visibleCatalog: visibleCatalog,
      visibleCart: visibleCart,
      showSearchBar: showSearchBar,
      showCartDetails: showCartDetails,
      hideCartDetails: hideCartDetails,
      showOrder: showOrder,
      showCatalog: showCatalog,
      finishOrder: finishOrder
    };
    

现在当鼠标悬停在h3标签上时,调试面板将显示。 试试吧!

无侵入 jQuery 事件

在过去几年里,从 HTML 模板中删除所有 JavaScript 代码已经成为一个良好的做法。 如果我们从 HTML 模板中删除所有 JavaScript 代码并将其封装在 JavaScript 文件中,我们就是在进行命令式编程。 另一方面,如果我们在 HTML 文件中编写 JavaScript 代码或使用组件和绑定,我们就是在使用声明式编程。 许多程序员不喜欢使用声明式编程。 他们认为这使得设计人员更难以处理模板。 我们应该注意,设计人员不是程序员,他们可能不理解 JavaScript 语法。 此外,声明式编程将相关代码拆分为不同的文件,可能使人们难以理解整个应用程序的工作方式。 此外,他们指出,双向绑定使模型不一致,因为它们在没有任何验证的情况下即时更新。 另一方面,有人认为声明式编程使代码更易于维护,模块化和可读性强,并且说如果您使用命令式编程,您需要在标记中填充不必要的 ID 和类。

没有绝对的真理。你应该在两种范式之间找到平衡。声明式本质在消除常用功能并使其变得简单方面表现得很出色。foreach 绑定及其兄弟,以及语义 HTML(组件),使代码易于阅读并消除了复杂性。我们必须自己用 JavaScript 编写,使用选择器与 DOM 交互,并为团队提供一个共同的平台,使他们可以专注于应用程序的工作原理,而不是模板和模型之间的通信方式。

还有其他框架,如 Ember、React 或 AngularJS,它们成功地使用了声明式范式,因此这并不是一个坏主意。但是,如果你感觉更舒适地使用 jQuery 定义事件,你将学会如何做。我们将以不引人注目的方式编写 确认订单 按钮。

首先,删除 data-bind 属性并添加一个 ID 来定位按钮:

<button id="confirmOrderBtn" class="btn btn-primary btn-sm">
  Confirm Order
</button>

现在,在 applyBindings 方法的上方写入这段 JavaScript 代码:

$(document).on('click', '#confirmOrderBtn').click(function() {
  vm.showOrder();
});
ko.applyBindings(vm);

这两种方法都是正确的;决定选择哪种范式是程序员的决定。

如果我们选择以 jQuery 的方式编写我们的事件,将所有事件合并到文件中也是一个好习惯。如果你没有很多事件,你可以有一个名为 events.js 的文件,或者如果你有很多事件,你可以有几个文件,比如 catalog.events.jscart.events.js

使用 jQuery 实现不引人注目的事件

命令式范式与声明式范式

委托模式

当我们处理大量数据时,普通的事件处理会影响性能。有一种技术可以提高事件的响应时间。

当我们直接将事件链接到项目时,浏览器为每个项目创建一个事件。然而,我们可以将事件委托给其他元素。通常,这个元素可以是文档或元素的父级。在这种情况下,我们将其委托给文档,即添加或移除产品中的一个单位的事件。问题在于,如果我们只为所有产品定义一个事件管理器,那么我们如何设置我们正在管理的产品?KnockoutJS 为我们提供了一些有用的方法来成功实现这一点,ko.dataForko.contextFor

  1. 我们应该通过分别添加 add-unitremove-unit 类来更新 cart-item.html 文件中的添加和移除按钮:

    <span class="input-group-addon">
      <div class="btn-group-vertical">
        <button class="btn btn-default btn-xs add-unit">
          <i class="glyphicon glyphicon-chevron-up"></i>
        </button>
        <button class="btn btn-default btn-xs remove-unit">
          <i class="glyphicon glyphicon-chevron-down"></i>
        </button>
      </div>
    </span>
    
  2. 然后,我们应该在 确认订单 事件的下方添加两个新事件:

     $(document).on("click", ".add-unit", function() {
      var data = ko.dataFor(this);
      data.addUnit();
    });
    
    $(document).on("click", ".remove-unit", function() {
      var data = ko.dataFor(this);
      data.removeUnit();
    });
    
  3. 使用 ko.dataFor 方法,我们可以获得与我们在 KnockoutJS 上下文中使用 $data 获得的相同内容。有关不引人注目的事件处理的更多信息,请访问knockoutjs.com/documentation/unobtrusive-event-handling.html

  4. 如果我们想要访问上下文,我们应该使用 ko.contextFor;就像这个例子一样:

    $(document).on("click", ".add-unit", function() {
      var ctx = ko.contextFor(this);
      var data = ctx.$data;
      data.addUnit();
    });
    

因此,如果我们有数千种产品,我们仍然只有两个事件处理程序,而不是数千个。以下图表显示了代理模式如何提高性能:

代理模式

代理模式提高了性能。

构建自定义事件

有时,我们需要使应用程序中的两个或多个实体进行通信,这些实体彼此不相关。例如,我们希望将我们的购物车保持独立于应用程序。我们可以创建自定义事件来从外部更新它,购物车将对此事件做出反应;应用所需的业务逻辑。

我们可以将事件拆分为两个不同的事件:点击和动作。因此,当我们点击上箭头添加产品时,我们触发一个新的自定义事件来处理添加新单位的操作,删除产品时同样如此。这为我们提供了关于应用程序中正在发生的事情的更多信息,我们意识到一个通用含义的事件,比如点击,只是获取数据并将其发送到更专业的事件处理程序,该处理程序知道该怎么做。这意味着我们可以将事件数量减少到只有一个。

  1. viewmodel.js文件末尾创建一个click事件处理程序,抛出一个自定义事件:

    $(document).on("click", ".add-unit", function() {
      var data = ko.dataFor(this);
      $(document).trigger("addUnit",[data]);
    });
    
    $(document).on("click", ".remove-unit", function() {
      var data = ko.dataFor(this);
      $(document).trigger("removeUnit, [data]);
    });
    
    $(document).on("addUnit",function(event, data){
      data.addUnit();
    });
    $(document).on("removeUnit",function(event, data){
      data.removeUnit();
    });
    

    粗体行展示了我们如何使用 jQuery 触发方法来发出自定义事件。与关注触发动作的元素不同,自定义事件将焦点放在被操作的元素上。这给了我们一些好处,比如代码清晰,因为自定义事件在其名称中有关于其行为的含义(当然我们可以称事件为event1,但我们不喜欢这种做法,对吧?)。

    您可以在 jQuery 文档中阅读更多关于自定义事件的内容,并查看一些示例,网址为learn.jquery.com/events/introduction-to-custom-events/

  2. 现在我们已经定义了我们的事件,是时候将它们全部移到一个隔离的文件中了。我们将这个文件称为cart/events.js。这个文件将包含我们应用程序的所有事件。

    //Event handling
    (function() {
      "use strict";
      //Classic event handler
      $(document).on('click','#confirmOrder', function() {
        vm.showOrder();
      });
      //Delegated events
      $(document).on("click", ".add-unit", function() {
        var data = ko.dataFor(this);
        $(document).trigger("addUnit",[data]);
      });
      $(document).on("click", ".remove-unit", function() {
        var data = ko.dataFor(this);
        $(document).trigger("removeUnit, [data]);
      })
      $(document).on("addUnit",function(event, data){
       data.addUnit();
      });
      $(document).on("removeUnit",function(event, data){
       data.removeUnit();
      });
    })();
    
  3. 最后,将文件添加到脚本部分的末尾,就在viewmodel.js脚本的下方:

    <script type="text/javascript" src="img/events.js"></script>
    

我们应该注意到现在与购物车的通信是使用事件完成的,并且我们没有证据表明有一个名为cart的对象。我们只知道我们要与之通信的对象具有两个方法的接口,即addUnitremoveUnit。我们可以更改接口中的对象(HTML),如果我们遵守接口,它将按照我们的期望工作。

事件和绑定

我们可以将事件和自定义事件包装在bindingHandlers中。假设我们希望仅在按下Enter键时过滤产品。这使我们能够减少对过滤方法的调用,并且如果我们正在对服务器进行调用,这种做法可以帮助我们减少流量。

custom/koBindings.js文件中定义自定义绑定处理程序:

ko.bindingHandlers.executeOnEnter = {
  init: function (element, valueAccessor, allBindingsAccessor, viewModel) {
    var allBindings = allBindingsAccessor();
    $(element).keypress(function (event) {
      var keyCode = (event.which ? event.which : event.keyCode);
      if (keyCode === 13) {
        allBindings.executeOnEnter.call(viewModel);
        return false;
      }
      return true;
    });
  }
};

由于这是一个事件,我们应该记住事件初始化可以在init方法本身中设置。我们用 jQuery 捕获keypress事件并跟踪被按下的键。Enter键的键码是 13。如果我们按下Enter键,我们将在视图模型的上下文中调用executeOnEnter绑定值。这就是allBindings.executeOnEnter.call(viewModel);所做的。

然后,我们需要更新我们的视图模型,因为我们的过滤目录是一个计算的可观察数组,每当按下键时都会更新自身。现在我们需要将这个计算的可观察数组转换为一个简单的可观察数组。因此,请根据以下方式更新您的filteredCatalog变量:

//we set a new copy from the initial catalog
var filteredCatalog = ko.observableArray(catalog());

意识到以下更改的后果:

var filteredCatalog = catalog();

我们不是在制作副本,而是在创建一个引用。如果我们这样做,当我们过滤目录时,我们将丢失项目,而且我们将无法再次获取它们。

现在我们应该创建一个过滤目录项目的方法。这个函数的代码与我们在上一个版本中拥有的计算值类似:

var filterCatalog = function () {
  if (!catalog()) {
    filteredCatalog([]);
  }
  if (!filter) {
    filteredCatalog(catalog());
  }
  var filter = searchTerm().toLowerCase();
  //filter data
  var filtered = ko.utils.arrayFilter(catalog(), function(item){
    var strProp = ko.unwrap(item["name"]).toLocaleLowerCase();
    if (strProp && (strProp.indexOf(filter) !== -1)) {
      return true;
    }
    return false;
  });
  filteredCatalog(filtered);
};

现在将其添加到return语句中:

return {
  debug: debug,
  showDebug:showDebug,
  hideDebug:hideDebug,
  searchTerm: searchTerm,
  catalog: filteredCatalog,
  filterCatalog:filterCatalog,
  cart: cart,
  newProduct: newProduct,
  totalItems:totalItems,
  grandTotal:grandTotal,
  addProduct: addProduct,
  addToCart: addToCart,
  removeFromCart:removeFromCart,
  visibleCatalog: visibleCatalog,
  visibleCart: visibleCart,
  showSearchBar: showSearchBar,
  showCartDetails: showCartDetails,
  hideCartDetails: hideCartDetails,
  showOrder: showOrder,
  showCatalog: showCatalog,
  finishOrder: finishOrder
};

最后一步是更新catalog.html模板中的搜索元素:

<div class="input-group" data-bind="visible:showSearchBar">
  <span class="input-group-addon">
    <i class="glyphicon glyphicon-search"></i> Search
  </span>
  <input type="text" class="form-control"
  data-bind="
    textInput: searchTerm,
    executeOnEnter: filterCatalog"
  placeholder="Press enter to search...">
</div>

现在,如果您在搜索框中输入内容,输入项目将不会更新;然而,当您按下Enter键时,过滤器会应用。

这是在插入新代码后我们的文件夹结构的样子:

事件和绑定

文件夹结构

摘要

在本章中,您已经学会了如何使用 Knockout 和 jQuery 管理事件。您已经学会了如何结合这两种技术,以根据项目的要求应用不同的技术。我们可以使用声明性范例来组合事件附加、bindingHandlers和 HTML 标记,或者我们可以使用 jQuery 事件将事件隔离在 JavaScript 代码中。

在下一章中,我们将解决与服务器通信的问题。您将学习如何验证用户输入,以确保我们向服务器发送干净和正确的数据。

我们还将学习模拟数据服务器端的技术。使用模拟库将帮助我们开发我们的前端应用程序,而无需一个完整的操作服务器。为了发送 AJAX 请求,我们将启动一个非常简单的服务器来运行我们的应用程序,因为浏览器默认不允许本地 AJAX 请求。

请记住,您可以在 GitHub 上检查本章的代码:

github.com/jorgeferrando/knockout-cart/tree/chapter4

第五章:从服务器获取数据

我们现在有了一个购物车应用程序。要使其像真实世界的应用程序一样工作,我们需要从服务器获取数据。然而,本书侧重于如何使用 KnockoutJS 开发项目,而不是如何配置和运行服务器。

幸运的是,这种情况在每个项目中都会发生。前端开发人员开始仅使用数据规范,而没有任何后端服务器。

本章中,我们将构建一个完全功能的前端通信层,而无需后端服务器。要成功完成这项任务,我们将使用虚假对象模拟我们的数据层。当我们移除模拟层时,我们的应用将能够使用真实数据。这将帮助我们更快、更安全地开发我们的应用程序:更快,因为我们不需要等待真实服务器的响应,更安全,因为我们的数据操作不会影响真实服务器。

REST 服务

在本章中,你将学习如何使前端层与后端层通信。

你不是在构建一个简单的网页,你正在构建一个 web 应用程序。这意味着你的项目不仅包含要显示给用户的数据,还有一些可点击的锚点和导航。这个网页还有一个逻辑和模型层,这使得它比一个简单的网页更复杂。

前端与服务器通信使用 Web 服务。W3C(代表 World Wide Web Consortium)定义 Web 服务为一种设计用于在网络上支持可互操作的机器对机器交互的软件系统。你可以使用许多协议来执行此交互:SOAP、POX、REST、RPC 等。

现在在 web 开发中,RESTful 服务被最多使用。这是因为 REST(代表 Representational State Transfer)协议具有一些特性,使其在这种应用程序中易于使用:

  • 它们是基于 URI 的

  • 通信使用互联网媒体类型(通常为 JSON,但也可以是 XML 或其他格式)

  • HTTP 方法是标准的:GETPOSTPUTDELETE

  • 可以使用超链接来引用资源的状态

要理解这些概念,我们将看一些示例。考虑到购物车场景,假设你想检索所有你的产品,那么请执行以下操作:

  1. 定义 API 的入口点。RESTful 协议是基于 URI 的,如下所示:

    http://mydomain.com/api/
    
  2. 现在你想检索所有你的产品,因此定义一个指向此资源的 URI 如下所示:

    http://mydomain.com/api/products
    
  3. 由于这是一个检索操作,因此 HTTP 头应包含如下所示的 GET 方法:

    GET /api/products HTTP/1.1
    
  4. 为了利用 HTTP 协议,你可以在头部发送元数据;例如,你要发送的数据类型以及你要接收的数据,如下所示:

    'Content-Type': 'application/json' //what we send
    Accept: 'application/json; charset=utf-8'//what we expect
    
  5. 服务器将以预期格式回应一些数据和通常包含在 HTTP 头中的一些信息,例如操作的状态:HTTP/1.1 200 OK。以下是格式:

    • 如果一切顺利,则 2xx

    • 4xx,如果前端出现错误

    • 5xx,如果服务器端出现错误

如果您想要更新或删除一个对象,请将该对象的 ID 附加到 URI 并使用相应的标头。例如,要编辑或删除一个产品,使用适当的方法调用此 URI:PUT进行编辑和DELETE进行删除。服务器将适当处理这些请求,查找 URI 和标头中的信息,例如:

http://mydomain.com/api/products/1

要了解有关 REST 和 RESTful 服务的更多信息,请参阅en.wikipedia.org/wiki/Representational_state_transfer

定义 CRUD

当您定义一个用于发送和接收数据的服务时,此对象通常应执行最低程度的行为。您可以通过缩写CRUD来识别此行为:

  • 创建(C):您需要向服务器发送一条消息,其中包含要将其持久化在数据库中的新对象。HTTP 的POST动词用于此类请求。

  • 检索(R):该服务应能够发送请求以获取对象集合或仅特定对象。用于此类请求的是GET动词。

  • 更新(U):这是一个更新对象的请求。按照惯例,用于此类请求的是PUT动词。

  • 删除(D):这是一个删除对象的请求。用于此类请求的是DELETE动词。

可以实现更多操作,有时您不需要编写所有 CRUD 方法。您应根据应用程序的要求调整代码,并仅定义应用程序需要的操作。请记住,编写比应用程序需要的更多代码意味着在代码中创造更多错误的可能性。

单例资源

在此应用程序中,我们将资源称为与 API 服务器中包含的 URI 相关的对象。这意味着要管理/productsURI,我们将拥有一个名为ProductResource的对象,该对象将管理此 URI 的 CRUD 操作。

我们将创建此对象作为单例,以确保我们在应用程序中只有一个对象管理每个 URI。有关单例的更多信息,请参阅en.wikipedia.org/wiki/Singleton_pattern

在资源中设置 CRUD 操作

我们将定义一些服务来为我们的产品和订单定义 CRUD 操作。一些开发人员常犯的一个常见错误是在模型类中设置 CRUD 操作。最佳实践表明,最好将模型和通信层分开。

为准备您的项目,请创建一个名为services的文件夹。在此文件夹中,存储包含 CRUD 操作的文件。执行以下步骤:

  1. 在新文件夹中创建两个文件。它们代表两个通信服务:OrderResource.jsProductResource.js

  2. 打开ProductResource.js文件,并定义基本的 CRUD 操作如下:

    var ProductResource = (function () {
      function all() {}
      function get(id) {}
      function create(product) {}
      function update(product) {}
      function remove(id) {}
      return {
        all: all,
        get: get,
        create: create,
        update: update,
        remove: remove
      };
    })();
    

    这是 CRUD 服务的骨架。你可以使用 allget 方法来定义检索操作。all 方法将返回所有产品,而 get 方法只返回传递的 ID 的产品。create 方法将创建一个产品,而 update 方法将更新一个产品。remove 方法将执行删除操作。我们称其为 remove,因为 delete 是 JavaScript 语言中的保留字。

  3. 要实现这些方法的主体,请使用 jQuery AJAX 调用 (api.jquery.com/jquery.ajax/)。这样向服务器发出的请求是异步的,并使用一个称为 promise 的概念 (api.jquery.com/promise/)。Promise 只是一个将来会包含一个值的对象。这个值通过使用回调函数来处理。

    Promise 图表:一个 promise 执行异步代码

  4. 要定义 retrieve 方法,你需要定义 AJAX 请求的配置。调用此方法将返回一个 promise。你可以按照以下方式在视图模型中处理此 promise 中包含的数据:

    function all() {
      return $.ajax({
        dataType:'json',
        type: 'GET',
        url: '/products'
      });
    }
    function get(id) {
      return $.ajax({
        dataType:'json',
        type: 'GET',
        url: '/products/'+id
      });
    }
    
  5. 注意,你只需要定义服务器可用于获取数据的响应类型和端点。此外,完成 CREATEUPDATEDELETE 方法。记住要尊重动词 (POSTPUTDELETE)。

    function create(product) {
      return $.ajax({
        datatype:'json',
        type: 'POST',
        url: '/products',
        data: product
      });
    }
    function update(product) {
      return $.ajax({
        datatype:'json',
        type: 'PUT',
        url: '/products/'+product.id,
        data: product
      });
    }
    function remove(id) {
      return $.ajax({
        datatype:'json',
        type: 'DELETE',
        url: '/products/'+id
      });
    }
    

记住你正在构建一个 REST API,所以要遵循架构的约定。这意味着实体的 URL 应该以复数形式命名。

要获取所有产品,使用 /products URL。要获取一个产品,仍然使用 /products URL,但也将产品的 ID 添加到 URI 中。例如,/products/7 将返回 ID 为 7 的产品。如果关系更深入,例如,“客户 5 有消息”,则将路由定义为 /customers/5/messages。如果要从用户 5 中读取消息 ID 为 1 的消息,则使用 /customers/5/message/1

有些情况下,你可以使用单数名称,比如 /customers/5/configuration/,因为一个用户通常只有一个配置。何时使用复数形式取决于你。唯一的要求是保持一致性。如果你更喜欢使用所有名称的单数形式,也可以,没有问题。将名称变为复数只是一种约定,而不是规则。

在视图模型中使用资源

现在我们已经创建了我们的产品资源,我们将在我们的视图模型中使用它来通过以下步骤获取我们的数据:

  1. 首先,在 index.html 文件中链接 ProductResource.js 文件,如下所示:

    <script type='text/javascript' src='js/resources/ProductResource.js'></script>
    

    由于资源是异步工作的,所以不能在文件末尾应用绑定,因为数据可能还没有准备好。因此,应在数据到达时应用绑定。

    要做到这一点,请创建一个名为activate的方法。此方法将在文件末尾触发,在我们之前调用ko.applyBindings的同一行上,方式如下:

    1. 获取此行代码:

      ko.applyBindings(vm);
      
    2. 用这个替换它:

      vm.activate();
      
  2. 现在在视图模型中定义activate方法:

    var activate = function () {
      ProductResource.all().done(allCallbackSuccess);
    };
    

    当您调用all方法时,将返回一个 jQuery 承诺。为了管理承诺的结果,jQuery 提供了一个承诺 API:

    • .done(callback):当承诺以成功解决时触发此方法。这意味着收到了与 5xx 或 4xx 不同的状态。

    • .fail(callback):您可以使用此方法来处理被拒绝的承诺。它由 5xx 和 4xx 头触发。

    • .then(successCb, errorCb):此方法以两个回调作为参数。第一个在承诺解决时调用,第二个在承诺被拒绝时调用。

    • .always(callback):传递给此方法的回调在两种情况下运行。

    通过使用 HTTP 头,您可以避免在响应主体中发送额外的信息以了解您是否收到了错误。了解您正在使用的协议(在本例中为 HTTP)并尝试使用它的所有优势是很重要的,比如在本例中,可以在其标头中发送信息的可能性。

  3. 现在是定义allCallbackSuccess方法的时候了:

    var allCallbackSuccess = function(response){
      catalog([]);
      response.data.forEach(function(item){
        catalog.push( 
          Product (item.id, item.name, item.price, item.stock)
        );
      });
      filteredCatalog(catalog());
      ko.applyBindings(vm);
    };
    

    一个 jQuery AJAX 回调总是将响应作为第一个参数。在这种情况下,您会收到一个 JSON 响应,其中包含目录中的所有项目。

    第一步是将目录初始化为空数组。一旦目录初始化完成,就可以对项目集合进行迭代。该集合存储在一个数据对象中。将数据隔离在其他变量中是一个好习惯。这只是为了以防您想向响应添加元数据。一旦目录准备就绪,请将其链接到filteredCatalog方法。

    当我们准备好初始数据时,这就是您可以调用ko.applyBindings方法的时刻。如果您在回调范围之外调用它,您不能确定目录是否已经包含了所有项目。这是因为资源执行操作是异步的,这意味着代码不是按顺序执行的。当资源返回的承诺有数据可用时,它才被执行。

  4. 最后一步是在文件末尾运行activate方法,如下所示:

    //ko External Template Settings
    infuser.defaults.templateSuffix = '.html';
    infuser.defaults.templateUrl = 'views';
    vm.activate();
    
    

运行我们的应用程序,它将无法工作,因为没有服务器来处理我们的请求。我们会得到一个 404 错误。为了解决这个问题,我们将模拟我们的 AJAX 调用和数据。

在视图模型中使用资源

在没有服务器支持的情况下进行 AJAX 调用会引发 404 错误

使用 Mockjax 模拟 HTTP 请求

Mocking data意味着用另一个模拟其行为的函数替换$.ajax调用。在遵循测试驱动开发范 paradigm 时,模拟是一种常用的技术。

要模拟 jQuery AJAX 调用,我们将使用一个名为 Mockjax 的库。要在应用程序中安装 Mockjax,请按照以下步骤操作:

  1. github.com/jakerella/jquery-mockjax下载该库。

  2. 将其保存到vendors文件夹中。

  3. index.html页面中添加一个引用,就在 jQuery 库后面。为此,使用<script>标签,如下所示:

    <script type='text/javascript' src='vendors/jquery.mockjax.js'></script>
    
  4. 创建一个名为mocks的文件夹,并在其中创建一个名为product.js的文件。

  5. product.js文件中,定义一个调用$.mockjax函数的模拟,如下所示:

    $.mockjax({
      url: '/products',
      type: 'GET',
      dataType: 'json',
      responseTime: 750,
      responseText: []
    });
    

    在这个定义中,你正在模拟ProducResource.all()方法内部调用的请求。要定义模拟,你只需要定义这些参数:

    • url:你想要模拟的 URL

    • type:请求的类型

    • dataType:你期望的数据类型

    • responseTime:响应所需的持续时间

    • responseText:响应体

使用 MockJSON 生成模拟数据

一旦你模拟了 HTTP 调用,你需要在响应中发送一些数据。你有不同的可能性:

  • 你可以手写数据到$.mockjax调用的responseText属性中:

    $.mockjax({
      url: '/products',
      type: 'GET',
      dataType: 'json',
      responseTime: 750,
      responseText: ['Here I can fake the response']
    });
    
  • 你可以使用一个函数来生成模拟数据:

    $.mockjax({
      url: '/products',
      type: 'GET',
      dataType: 'json',
      responseTime: 750,
      response: function(settings) {
        var fake = 'We fake the url:'+settings.url;
        this.responseText = fake;
      }
    });
    
  • 你可以使用一个在响应中生成复杂和随机数据的库。

    这第三个选项可以通过一个叫做mockJSON的库来执行。你可以从 GitHub 仓库github.com/mennovanslooten/mockJSON下载它。

    这个库允许你生成数据模板来创建随机数据。这有助于使你的虚假数据更加真实。你可以在屏幕上看到许多不同类型的数据。这将帮助你检查更多的数据显示可能性,比如文字是否溢出容器或者文字过长或过短在屏幕上看起来很难看。

    • 要生成一个随机元素,定义一个模拟模板如下:

      $.mockJSON.generateFromTemplate({
        'data|5-10': [{
          'id|1-100': 0,
          'name': '@PRODUCTNAME',
          'price|10-500': 0,
          'stock|1-9': 0
        }]
      });
      

      这个模板表示你想要生成 5 到 10 个具有以下结构的元素:

      • ID 将是介于 1 到 100 之间的数字。

      • 产品名称将是存储在PRODUCTNAME数组中的值。

      • 价格将是介于 10 到 500 之间的数字。

      • 股票价格将是介于 1 到 9 之间的数字。

      • 要生成产品名称数组,你只需要将一个数组或一个函数添加到$.mockJSON.data对象中,如下所示:

        $.mockJSON.data.PRODUCTNAME = [
          'T-SHIRT', 'SHIRT', 'TROUSERS', 'JEANS', 'SHORTS', 'GLOVES', 'TIE'
        ];
        

    你可以生成任何你能想象到的数据。只需创建一个函数,返回一个你想要生成的值的数组,或者定义一个生成随机结果、数字、唯一 ID 等的函数。

    • 要将其作为响应返回,请将此模板附加到响应文本。你的代码应该如下所示:

      $.mockJSON.data.PRODUCTNAME = [
        'T-SHIRT', 'SHIRT', 'TROUSERS', 'JEANS', 'SHORTS', 'GLOVES', 'TIE'
      ];
      $.mockjax({
        url: '/products',
        type: 'GET',
        dataType: 'json',
        responseTime: 750,
        status:200,
        responseText: $.mockJSON.generateFromTemplate({
          'data|5-5': [{
            'id|1-100': 0,
            'name': '@PRODUCTNAME',
            'price|10-500': 0,
            'stock|1-9': 0
          }]
        })
      });
      

index.html文件的末尾使用<script>标签添加mocks/product.js文件,然后查看每次刷新网页时如何获得新的随机数据。

使用 MockJSON 生成模拟数据

当进行模拟调用时,我们会在控制台中看到这条消息

通过 ID 检索产品

要从我们的 API 获取一个产品,我们将伪造 ProductResourceget 方法。 即当我们在目录列表中点击产品名称时,ProductResource.get 方法将被激活。

此 URI 在 URI 的最后一段包含产品的 ID。 这意味着 ID=1 的产品将生成类似 /products/1 的 URI。 ID=2 的产品将生成类似 /products/2 的 URI。

因此,这意味着我们无法将 URL 设置为固定字符串。 我们需要使用正则表达式。

如果您需要更多关于正则表达式的信息,请查看此链接:

developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions

为了完成代码以检索产品,请按照以下步骤进行:

  1. 添加一个 mockjax 调用来模拟 URI。它应该使用 GET HTTP 方法。将正则表达式附加到 url 属性,如下所示:

    $.mockjax({
      url: /^\/products\/([\d]+)$/,
      type: 'GET',
      dataType: 'json',
      responseTime: 750,
      responseText: ''
    });
    
  2. 创建一个返回单个产品对象的模板。要生成随机描述,您可以使用 @LOREM_IPSUM 魔术变量,它会返回随机文本。它的使用方式与构建 @PRODUCTNAME 变量的方式相同。让我们使用以下代码创建一个模板:

    $.mockJSON.generateFromTemplate({
      'data': {
        'id|1-100': 0,
        'name': '@PRODUCTNAME',
        'price|10-500': 0,
        'stock|1-9': 0,
        'description': '@LOREM_IPSUM'
      }
    })
    
  3. 将以下模板附加到 responseText 变量:

    //URI: /products/:id
    $.mockjax({
      url: /^\/products\/([\d]+)$/,
      type: 'GET',
      dataType: 'json',
      responseTime: 750,
      responseText: $.mockJSON.generateFromTemplate({
        'data': {
          'id|1-100': 0,
          'name': '@PRODUCTNAME',
          'price|10-500': 0,
          'stock|1-9': 0,
          'description': '@LOREM_IPSUM'
        }
      })
    });
    
  4. viewmodel.js 文件中,创建一个方法,该方法使用 ProductResource 对象检索产品。 该方法在数据可用时将显示一个警告框。

    var showDescription = function (data) {
      ProductResource.get(data.id())
      .done(function(response){
        alert(response.data.description);
      });
    };
    
  5. showDescription 方法绑定到 catalog.html 模板上:

    <td><a href data-bind='click:$parent.showDescription, text: name'></a></td>
    
  6. 在视图模型接口中公开 showDescription 方法:

    return {
      …
      showDescription: showDescription,
      …
    };
    
  7. 测试如何在警告框中获取描述。按 ID 检索产品

    点击产品名称将显示产品描述

创建一个新产品

要创建一个产品,请按照前一节中的相同步骤进行:

  1. mocks/product.js 文件中添加一个 AJAX 模拟调用:

    $.mockjax({
      url: '/products',
      type:'POST',
      dataType: 'json',
      responseTime: 750,
      status:200,
      responseText: {
        'data': {
          text: 'Product created'
        }
      }
    });
    

    您应该记住一些注意事项:

    • 您应该使用 POST 动词来创建对象。实际上,您可以使用任何您想要的动词,但根据 RESTful API 的约定,POST 动词是您应该用来创建新对象的一个。

    • 响应文本是提供有关结果的一些信息的消息。

    • 结果本身由标头管理:

    • 如果在状态中获得 2xx 值,则会触发 done 方法。

    • 如果收到 4xx5xx 错误,则调用 fail 方法。

  2. 转到 modelview.js 文件并更新 addProduct 函数:

    var addProduct = function (data) {
      var id = new Date().valueOf();
      var product = new Product(
        id,
        data.name(),
        data.price(),
        data.stock()
      );
    
      ProductResource.create(ko.toJS(data))
      .done(function (response){
        catalog.push(product);
        filteredCatalog(catalog());
        newProduct = Product(new Date().valueOf(),'',0,0);
        $('#addToCatalogModal').modal('hide');
      });
    };
    

显而易见,您不能将 Knockout observables 发送到服务器。 要将包含 observables 的对象转换为普通 JSON 对象,请使用 ko.to JS 函数。 此函数会遍历对象并提取每个 observable 的值。

您可以在 knockoutjs.com/documentation/json-data.html 上找到关于 ko.to JS 和其他方法的信息。

也许你已经注意到了,当你添加一个新产品时,库存会减少一个。这是因为当你在产品中使用ko.toJS函数时,它会执行所有的函数。因此,为了使用它,你应该避免那些会操作对象并可能在内部改变其值的方法。我们将在下一节中解决这个问题。

测试应用程序在调用addProduct方法时是否发送数据。

创建新产品

添加新产品时使用 AJAX 调用;注意 URL 和类型字段

关注关注点分离 - 行为和数据

我们在应用程序中发现了一个问题。当我们使用ko.toJS函数时,结果与预期不符。这是软件开发中常见的情况。

我们在模型中设置了一些逻辑,这是一个错误的选择,我们需要修复它。为了解决这个问题,我们将数据和这些行为分开。我们将使用一些我们称之为服务的类。

服务将管理我们模型的逻辑。这意味着每个模型都会有一个相关的服务来管理其状态。

创建产品服务

如果你查看models/product.js文件,你会发现该模型包含一些逻辑:

var hasStock = function () {
  return _product.stock() > 0;
};
var decreaseStock = function () {
  var s = _product.stock();
  if (s > 0) {
    s--;
  }
  _product.stock(s);
};

我们将使用以下步骤将此逻辑和更多内容移动到一个服务中:

  1. 创建一个名为services的文件夹。

  2. 在其中,创建一个名为ProductService的文件。

  3. 创建一个单例对象,并添加hasStockdecreaseStock函数,如下所示:

    var ProductService = (function() {
      var hasStock = function (product) {
        return product.stock() > 0;
      };
    
      var decreaseStock = function (product) {
        var s = product.stock();
        if (s > 0) {
          s--;
        }
        product.stock(s);
      };
    
      return {
        hasStock:hasStock,
        decreaseStock:decreaseStock
      };
    })();
    
  4. 更新add-to-cart-button组件:

    this.addToCart = function() {
      ...
      if (item) {
        CartProductService.addUnit(item);
      } else {
        item = CartItem(data,1);
        tmpCart.push(item);
        ProductService.decreaseStock(item.product);
      }
      this.cart(tmpCart);
    };
    

注意,你还需要创建一个服务来管理购物车商品的逻辑。

创建CartProduct服务

购物车商品服务还提取了CartProduct模型的逻辑。要创建此服务,请按照以下步骤操作:

  1. service文件夹中创建一个名为CartProductService.js的文件。

  2. CartProduct模型中删除addUnitremoveUnit方法。

  3. 使用以下方法更新服务:

    var CartProductService = (function() {
    
      var addUnit = function (cartItem) {
        var u = cartItem.units();
        var _stock =  cartItem.product.stock();
        if (_stock === 0) {
          return;
        }
        cartItem.units(u+1);
        cartItem.product.stock(--_stock);
      };
    
      var removeUnit = function (cartItem) {
        var u =  cartItem.units();
        var _stock =  cartItem.product.stock();
        if (u === 0) {
          return;
        }
        cartItem.units(u-1);
        cartItem.product.stock(++_stock);
      };
    
      return {
        addUnit:addUnit,
        removeUnit:removeUnit
      };
    })();
    

更新产品

在我们的目录中,我们将希望更新我们产品的价值。要完成此操作,请按照以下步骤操作:

  1. 首先,要更新一个产品,你需要模拟处理该操作的 URI:

    $.mockjax({
        url: /^\/products\/([\d]+)$/,
        type:'PUT',
        dataType: 'json',
        responseTime: 750,
        status:200,
        responseText: {
            'data': {
                text: 'Product saved'
            }
        }
    });
    
  2. catalog.html视图的每一行中添加一个按钮,在您有add-to-cart-button组件的相同单元格中:

    <button class='btn btn-info' data-bind='click: $parent.openEditModal'>
      <i class='glyphicon glyphicon-pencil'></i>
    </button>
    
  3. 现在,使用这个产品的数据打开一个模态框:

    var openEditModal = function (product) {
      tmpProduct = ProductService.clone(product);
      selectedProduct(product);
      $('#editProductModal').modal('show');
    };
    
  4. tmpProduct将包含您要编辑的对象的副本:

    Var tmpProduct = null;
    
  5. selectedProduct将包含您要编辑的原始产品:

    Var selectedProduct = ko.observable();
    
  6. ProductService资源中创建clone函数:

    var clone = function (product) {
      return Product(product.id(), product.name(), product.price(), product.stock());
    };
    
  7. ProductService资源中创建refresh函数。此方法允许服务在不丢失对购物车中产品的引用的情况下刷新产品。

    var refresh = function (product,newProduct) {
      product.name(newProduct.name());
      product.stock(newProduct.stock());
      product.price(newProduct.price());
    };
    
  8. 将这两个方法添加到服务接口中:

    return {
      hasStock:hasStock,
      decreaseStock:decreaseStock,
      clone:clone,
      refresh: refresh
    };
    
  9. 创建edit-product-modal.html模板以显示编辑模态框。此模板是create-product-modal.html模板的副本。你只需要更新 form 标签行,如下所示:

    <form class='form-horizontal' role='form' data-bind='with:selectedProduct'>
    
  10. 你还需要更新button绑定:

    <button type='submit' class='btn btn-default' data-bind='click: $parent.cancelEdition'>
      <i class='glyphicon glyphicon-remove-circle'></i> Cancel
    </button>
    <button type='submit' class='btn btn-default' data-bind='click: $parent.updateProduct'>
      <i class='glyphicon glyphicon-plus-sign'></i> Save
    </button>
    
  11. 现在,定义cancelEditonsaveProduct方法:

    var cancelEdition = function (product) {
      $('#editProductModal').modal('hide');
    };
    var saveProduct = function (product) {
      ProductResource.save(ko.toJS(product)).done( function(response){
        var tmpCatalog = catalog();
        var i = tmpCatalog.length;
        while(i--){
          if(tmpCatalog[i].id() === product.id()){
            ProductService.refresh(tmpCatalog[i],product);
          }
        }
        catalog(tmpCatalog);
        filterCatalog();
        $('#editProductModal').modal('hide');
      });
    };
    
  12. 最后,将这些方法添加到视图模型 API 中。

现在,您可以测试如何更新产品的不同值。

删除产品

要删除产品,按照与CREATEUPDATE方法相同的简单步骤进行操作。

  1. 第一步是在mocks/products.js文件中创建模拟内容,如下所示:

    $.mockjax({
      url: /^\/products\/([\d]+)$/,
      type:'DELETE',
      dataType: 'json',
      responseTime: 750,
      status:200,
      responseText: {
        'data': {
          text: 'Product deleted'
        }
      }
    });
    
  2. 这种方法非常简单。只需添加一个类似编辑按钮的按钮,然后删除它。

    var deleteProduct = function (product){
      ProductResource.remove(product.id())
      .done(function(response){
        catalog.remove(product);
        filteredCatalog(catalog());
        removeFromCartByProduct(product);
      });
    };
    
  3. 创建一个函数来从购物车中移除产品。此函数遍历购物车项目并找到与移除产品相关的购物车项目。一旦找到该项目,就可以使用removeFromCart函数将其删除为普通项目:

    var removeFromCartByProduct = function (product) {
      var tmpCart = cart();
      var i = tmpCart.length;
      var item;
      while(i--){
        if (tmpCart[i].product.id() === product.id()){
          item = tmpCart[i];
        }
      }
      removeFromCart(item);
    }
    
  4. 在目录模板中添加一个按钮,位于编辑按钮旁边:

    <button class='btn btn-danger' data-bind='click: $parent.deleteProduct'>
      <i class='glyphicon glyphicon-remove'></i>
    </button>
    

    删除产品

    编辑和删除按钮

将订单发送到服务器

一旦您可以与服务器通信来管理我们的产品,就是时候发送订单了。为此,请按照以下说明进行:

  1. 创建一个名为resources/OrderResource.js的文件,并添加以下内容:

    'use strict';
    var OrderResource = (function () {
      function create(order) {
        return $.ajax({
          type: 'PUT',
          url: '/order',
          data: order
        });
      }
      return {
        create: create
      };
    })();
    
  2. 通过创建名为mocks/order.js的文件并添加以下代码来模拟调用:

    $.mockjax({
      type: 'POST',
      url: '/order',
      status: 200,
      responseTime: 750,
      responseText: {
        'data': {
          text: 'Order created'
        }
      }
    });
    
  3. 更新viewmodel.js文件中的finishOrder方法:

    var finishOrder = function() {
      OrderResource.create().done(function(response){
        cart([]);
        visibleCart(false);
        showCatalog();
        $('#finishOrderModal').modal('show');
      });
    };
    

我们应用程序的要求之一是,用户可以更新个人数据的选项。我们将允许用户将个人数据附加到订单中。这很重要,因为当我们发送订单时,我们需要知道谁将收到订单。

  1. models文件夹中创建一个名为Customer.js的新文件。它将包含以下函数,用于生成客户:

    var Customer = function () {
      var firstName = ko.observable('');
      var lastName = ko.observable('');
      var fullName = ko.computed(function(){
        return firstName() + ' ' + lastName();
      });
      var address = ko.observable('');
      var email = ko.observable('');
      var zipCode = ko.observable('');
      var country = ko.observable('');
      var fullAddress = ko.computed(function(){
        return address() + ' ' + zipCode() + ', ' + country();
      });
      return {
        firstName:firstName,
        lastName: lastName,
        fullName: fullName,
        address: address,
        email: email,
        zipCode: zipCode,
        country: country,
        fullAddress: fullAddress,
      };
    };
    
  2. 链接到视图模型:

    var customer = Customer();
    
  3. 还要创建一个用于存储可销售国家的可观察数组:

    var countries = ko.observableArray(['United States', 'United Kingdom']);
    
  4. 在订单模板中创建一个表单,以显示一个完成客户数据的表单:

    <div class='col-xs-12 col-sm-6'>
      <form class='form-horizontal' role='form' data-bind='with:customer'>
        <div class='modal-header'>
          <h3>Customer Information</h3>
        </div>
        <div class='modal-body'>
          <div class='form-group'>
            <div class='col-sm-12'>
              <input type='text' class='form-control' placeholder='First Name' data-bind='textInput:firstName'>
            </div>
          </div>
          <div class='form-group'>
            <div class='col-sm-12'>
              <input type='text' class='form-control' placeholder='Last Name' data-bind='textInput:lastName'>
            </div>
          </div>
          <div class='form-group'>
            <div class='col-sm-12'>
              <input type='text' class='form-control' placeholder='Address' data-bind='textInput:address'>
            </div>
          </div>
          <div class='form-group'>
            <div class='col-sm-12'>
              <input type='text' class='form-control' placeholder='Zip code' data-bind='textInput:zipCode'>
            </div>
          </div>
          <div class='form-group'>
            <div class='col-sm-12'>
              <input type='text' class='form-control' placeholder='Email' data-bind='textInput:email'>
            </div>
          </div>
          <div class='form-group'>
            <div class='col-sm-12'>
              <select class='form-control' data-bind='options: $parent.countries,value:country'></select>
            </div>
          </div>
        </div>
      </form>
    </div>
    
  5. 使用finishOrder方法将此信息与订单请求一起发送:

    var finishOrder = function() {
      var order = {
        cart: ko.toJS(cart),
        customer: ko.toJS(customer)
      };
      OrderResource.create(order).done(function(response){
        cart([]);
        hideCartDetails();
        showCatalog();
        $('#finishOrderModal').modal('show');
      });
    };
    

我们的 AJAX 通讯已经完成。现在,您可以在项目中添加和移除mocks/*.js文件,以获取虚假数据或真实数据。在使用此方法时,当您开发前端问题时,无需在应用程序后面运行服务器。

将订单发送到服务器

一旦提供了个人数据,您就可以关闭订单

处理 AJAX 错误

我们构建了应用程序的正常路径。但在现实世界中,在与服务器的通讯过程中可能会发生错误。要处理这种情况有两种方法:

  • AJAX 承诺的fail方法:

    ProductResource.remove()
    .done(function(){...})
    .fail(function(response){
      console.error(response);
      alert("Error in the communication. Check the console!");
    });
    
  • 一个全局的 AJAX 错误处理程序:

    $(document).ajaxError(function(event,response) {
      console.error(response);
      alert("Error in the communication. Check the console!");
    });
    

如果您有一致的错误格式,全局处理程序是处理错误的非常好的选择。

要测试错误,请将一个模拟的状态属性从 200 更新为 404 或 501:

$.mockjax({
  url: /^\/products\/([\d]+)$/,
  type:"DELETE",
  dataType: "json",
  responseTime: 750,
  status:404,
  responseText: {
    "data": {
      text: "Product deleted"
    }
  }
});

验证数据

现在您可以发送和接收数据了,但是如果用户设置了服务器不允许的一些数据会发生什么?您无法控制用户输入。如果某些值不允许,重要的是要提醒用户。要验证 Knockout 数据,有一个名为 Knockout Validation 的库(可在github.com/Knockout-Contrib/Knockout-Validation找到),可以使这变得非常容易。

此库通过为可观察对象添加一些值来扩展可观察对象,以使您在数据更改时能够验证数据。我们现在将更新我们的模型以添加某种验证。

扩展产品模型

为了使用 Knockout Validation 库验证我们的模型,我们将扩展我们模型的属性。扩展器是 Knockout 的基本功能。使用扩展器,我们可以向我们的可观察对象添加一些属性以增强其行为。有关扩展器的更多信息,请参阅以下链接:

knockoutjs.com/documentation/extenders.html

我们将通过以下步骤扩展我们的产品模型以添加一些属性,以允许我们验证数据:

  1. 转到models/Product.js文件。

  2. 更新name字段。它应至少包含三个字母,并且应仅包含字母、数字和破折号:

    _name = ko.observable(name).extend({
      required: true,
      minLength: 3,
      pattern: {
        message: 'Hey this doesn\'t match my pattern',
        params: '^[A-Za-z0-9 \-]+$'
      }
    })
    
  3. 更新price以仅允许数字,并为其设置范围(最大和最小值):

    _price = ko.observable(price).extend({
      required: true,
      number:true,
      min: 1
    }),
    
  4. stock也执行同样的操作:

    _stock = ko.observable(stock).extend({
      required: true,
      min: 0,
      max: 99,
      number: true
    })
    
  5. 创建一个验证组以确定何时整个对象是有效的:

    var errors = ko.validation.group([_name, _price, _stock]);
    

    此错误变量将包含一个可观察数组。当此数组没有元素时,所有可观察对象均具有正确的值。

  6. add-to-catalog-modal.html模板中,仅在产品中的所有值都有效时才启用创建按钮:

    <button type='submit' class='btn btn-default' data-bind='click:$parent.addProduct, enable:!errors().length'>
      <i class='glyphicon glyphicon-plus-sign'></i> Add Product
    </button>
    
  7. edit-product-modal.html模板中添加相同的按钮:

    <button type='submit' class='btn btn-default' data-bind='enable:!errors().length, click: $parent.saveProduct'>
      <i class='glyphicon glyphicon-plus-sign'></i> Save
    </button>
    
  8. 如果要为错误消息设置样式,只需为validationMessage类定义 CSS 规则,如下所示。将显示一个span元素,显示在与验证的可观察对象绑定的元素旁边:

    .validationMessage { color: Red; }
    

扩展客户模型

您还需要验证客户数据。以下是验证规则:

  • 名字是必需的

  • 姓是必需的,且至少需要三个字符

  • 地址是必需的,且至少需要五个字符

  • 电子邮件地址是必需的,并且必须与内置的电子邮件模式匹配

  • 邮政编码是必需的,且必须包含五个数字

要完成此任务,请按照以下方式更新代码:

  1. models/Customer.js文件中扩展客户对象:

    var firstName = ko.observable('').extend({
      required: true
    });
    var lastName = ko.observable('').extend({
      required: true,
      minLength: 3
    });
    var fullName = ko.computed(function(){
      return firstName() + ' ' + lastName();
    });
    var address = ko.observable('').extend({
      required: true,
      minLength: 5
    });
    var email = ko.observable('').extend({
      required: true,
      email: true
    });
    var zipCode = ko.observable('').extend({
      required: true,
      pattern: {
        message: 'Zip code should have 5 numbers',
        params: '^[0-9]{5}$'
      }
    });
    var country = ko.observable('');
    var fullAddress = ko.computed(function(){
        return address() + ' ' + zipCode() + ', ' + country();
    });
    var errors = ko.validation.group([firstName, lastName, address, email, zipCode]);
    
  2. 如果客户数据已完成并且有效,请在order.html模板中启用购买按钮。

    <button class='btn btn-sm btn-primary' data-bind='click:finishOrder, enable:!customer.errors().length'>
      Buy & finish
    </button>
    
  3. finish-order-modal.html模板中显示用户信息。

    <div class='modal-body'>
      <h2>Your order has been completed!</h2>
      <p>It will be sent to:</p>
      <p>
        <b>Name: </b><span data-bind='text: customer.fullName'></span><br/>
        <b>Address: </b><span data-bind='text: customer.fullAddress'></span><br/>
        <b>Email: </b><span data-bind='text: customer.email'></span><br/>
      </p>
    </div>
    

    扩展客户模型

    如果字段中的信息无效,则显示验证消息。

现在我们的模型已经验证,并且我们知道我们发送的数据具有有效的格式。

要查看应用程序的完整代码,你可以从github.com/jorgeferrando/knockout-cart/tree/chapter5下载本章的代码。

摘要

在本章中,你学会了如何使用 jQuery 与我们的应用程序进行通信以执行 AJAX 调用。 你还学会了使用 Knockout Validation 库对我们的模型应用验证有多么容易,该库使用了 Knockout 本身的extend方法来增强可观察对象的行为。

你经历了 KnockoutJS 的一个问题:你需要将对象序列化后发送到服务器,并且需要在从服务器返回时将它们包装在可观察对象中。 要解决这个问题,你可以使用ko.toJS方法,但这意味着对象没有允许它们更新值的代码。

在接下来的章节中,你将学会如何使用 RequireJS 和模块模式来管理文件之间的依赖关系。

第六章:模块模式 - RequireJS

我们现在可以说我们的应用程序具有我们在第一章中提到的所有功能,使用 KnockoutJS 自动刷新 UI。我们在过去的四章中所做的是解决小型项目中的代码设计的一个很好的方法。代码整洁,文件夹结构也是连贯的。代码易于阅读和跟踪。

然而,当项目开始增长时,这种方法是不够的。你需要保持代码的整洁,不仅是在文件和文件夹结构上,还包括逻辑上。

在这一章中,我们将把我们的代码模块化,以保持应用程序的不同部分隔离和可重用。我们还将看到如何保持我们的上下文更清晰。

现在项目开始变得更加复杂。当你发现错误时,了解帮助你调试代码的工具是很重要的。在本章的第一部分,你将学习一些可以帮助你检查你的 KnockoutJS 代码的工具。你将使用一个浏览器插件(Chrome 扩展)来分析代码。

在本章的第二部分,你将把你的文件转换成模块。这将帮助你将应用程序的每个部分与其他部分隔离开来。你将使用一种叫做“依赖注入”的模式来解决模块之间的依赖关系。在en.wikipedia.org/wiki/Dependency_injection了解更多关于这个模式的信息。

在最后一部分,你将学习如何创建遵循异步模块定义(AMD)规范的模块。为了创建遵循 AMD 规范的模块,你将使用一个叫做 RequireJS 的库。这个库将管理不同模块之间的所有依赖关系。有关 AMD 的更多信息,请参阅en.wikipedia.org/wiki/Asynchronous_module_definition

安装 Knockout 上下文调试器扩展

在前面的章节中,你创建了一个简单的调试器来显示你的视图模型的状态。这对于快速查看应用程序的状态非常有用。有了调试绑定,你不需要打开扩展工具来检查你的数据发生了什么变化。但是你经常只是隔离应用程序的一部分或查看绑定到 DOM 元素的模型发生了什么变化。

在 Google Chrome 中,你有一个非常好的扩展叫做KnockoutJS 上下文调试器,可以从chrome.google.com/webstore/detail/knockoutjs-context-debugg/oddcpmchholgcjgjdnfjmildmlielhof下载。

这个扩展允许你查看每个 DOM 节点的绑定,并通过控制台在线跟踪你的视图模型的变化。安装它并重新启动 Chrome 浏览器。

安装 Knockout 上下文调试器扩展

检查 chrome://extensions 是否已安装 KnockoutJS 上下文调试器

要检查绑定到模型的上下文,请按 F12 打开 Chrome 开发者工具 并打开 Elements 标签。您会看到两个面板。左侧面板有 DOM 结构。右侧面板有不同的标签。默认情况下,打开 Styles 标签。选择名为 Knockout 上下文 的标签。在那里,您应该看到添加到根上下文的所有绑定。

安装 Knockout 上下文调试器扩展

如何显示绑定到 DOM 元素的 KnockoutJS 上下文

如果您选择目录中的 <tr> 元素,您将深入上下文并位于目录项范围内。您将无法看到 $root 上下文;您将看到 $data 上下文。您可以通过 $parent 元素向上导航或更改 DOM 面板中的元素。

安装 Knockout 上下文调试器扩展

您可以轻松检查 foreach 绑定中的项目上下文。

您还可以看到 ko 对象。这是浏览 Knockout API 的好方法。

安装 Knockout 上下文调试器扩展

您可以访问 Knockout API 并查看方法、绑定、组件等。

现在找到 KnockoutJS 标签(它与 Elements 标签在同一集合中)。按下 启用跟踪 按钮。此功能允许您跟踪视图模型的实时更改。更改将在控制台中显示。

安装 Knockout 上下文调试器扩展

如果启用跟踪,您可以通过控制台捕获视图模型的更改。

此外,您还可以使用 Timeline 标签测量时间和性能。您可以看到应用程序在模型发生变化时用于渲染 DOM 元素的时间。

安装 Knockout 上下文调试器扩展

启用跟踪功能后,您可以记录事件并获得有用信息。

现在您已经了解了这个插件,我们可以删除(或保留,这取决于您)之前构建的调试绑定。

控制台

控制台 是开发人员最重要的工具之一。您可以使用它来检查应用程序在使用过程中的状态。

您可以定位 JavaScript 代码并设置断点,以检查特定点发生了什么。您可以在 Sources 标签中找到 JavaScript 文件。只需点击要停在的行即可。然后,您可以检查变量的值并逐步运行代码。此外,您还可以在代码中写入 debugger 以在此处停止程序。

控制台

您可以在代码中设置断点并检查变量的值。

如果您导航到控制台选项卡,您将看到控制台本身。在那里,您可以使用console.log函数显示信息,或者查看控制台对象文档以查看您可以在每个时刻使用的最佳方法(developer.mozilla.org/en-US/docs/Web/API/Console)。

如果您在控制台中写入单词window,您将看到在全局范围内的所有对象。

控制台

使用控制台,您可以访问当前和全局上下文中的变量

您可以写入单词vm(视图模型)以查看我们创建的vm对象。

控制台

所有组件都设置在全局范围内

但是您也可以写ProductProductService或我们创建的任何内容,您都会看到它。当您有大量信息时,在顶层拥有所有对象可能会很混乱。定义命名空间并保持层次结构是保持组件隔离的良好实践。您应该只保留应用程序的一个入口点。

模块模式

此模式允许我们专注于哪些代码部分暴露给类外部(公共元素),以及代码的哪些部分对最终用户隐藏(私有元素)。

此模式通常用于 JavaScript 软件开发。它应用于像 jQuery、Dojo 和 ExtJS 等流行库中。

一旦您知道如何使用它,此模式具有非常清晰的结构,并且非常容易应用。让我们在我们的应用程序中应用模块模式:

  1. 首先,定义模块的名称。如果您在不同的文件中定义模块,重要的是要应用允许其可扩展性的模式来定义和初始化它。在初始化中使用||运算符表示如果ModuleName值已经有值,则将其赋值给自身。如果它没有值,则意味着这是它第一次被创建,因此给它一个默认值—在这种情况下是一个空对象:

    var ModuleName;
    ModuleName = ModuleName || {};
    
  2. 然后,定义模块的每个组件。它可以是函数、变量或另一个模块:

    ModuleName.CustomComponent = function () {
    };
    ModuleName.CustomProperty = 10;
    ModeleName.ChildModule = OtherModule;
    
  3. 最后,使用依赖注入模式插入模块的依赖项。该模式将所有模块依赖项作为参数传递,并立即调用该函数:

    ModuleName.CustomComponent = (function (dependency){
      //Component code
    })(dependency);
    
  4. 这就是一个完整模块的样子:

    var ModuleName;
    var ModuleName = ModuleName || {};
    ModuleName.CustomComponent = (function(dependency){
      //Component code
    })(dependency);
    
  5. 要定义组件,请返回component对象。定义组件的第一个模式是使用揭示模块模式。它包含在函数末尾返回一个仅包含公共接口的对象。这些是单例对象:

    ModuleName.CustomComponent = (function(dependency){
      var somePrivateProperty = 1;
      var method1 = function(){
        dependency.methodFromDependency();
      };
      return {
        method1:method1,
        method2:method2
      }
    })(dependency);
    You can also define objects that can be instantiated using the new operator: 
ModuleName.CustomComponent = (function(dependency){
      var component = function (a,b,c) {
        var somePrivateProperty=1;
        this.someMethod = function(){
          dependency.methodFromDependency()
        }
        this.otherMethod(){
          return a+b*c; 
        }
        return this;
      }    
    
      return component;
    })(dependency);
    //We can instantiate the component as an object
    //var instance = new ModuleName.CustomComponent(x,y,z);
    

创建 Shop 模块

为了使我们的应用程序模块化,我们将创建一个名为Shop的模块,该模块将包含我们的整个应用程序。此模块将包含其他子模块和组件。此分层结构将帮助您保持代码的一致性。

作为第一种方法,按文件和类型分组你的组件。这意味着模块的每个组件都将在一个文件中,并且文件将在一个文件夹中按类型分组。例如,有一个名为services的文件夹。这意味着所有服务都将在这个文件夹中,并且每个服务将在一个文件中完全定义。按照惯例,组件将与它们所在的文件具有相同的名称,当然不包括扩展名。

实际上,文件已经按类型分组了,所以这是一个你不需要再做的工作。我们将把精力集中在将我们的文件转换为模块上。

视图模型模块

我们的应用程序中只有一个视图模型。这是一个可以应用单例模块方法的组件。

我们将小心翼翼地逐步创建我们的第一个模块:

  1. 打开viewmodel.js文件。

  2. 定义Shop模块,这是我们应用程序的顶级模块:

    var Shop;
    
  3. 通过应用扩展模式初始化Shop模块:

    Shop = Shop || {};
    
  4. 定义ViewModel组件:

    Shop.ViewModel = (function(){})();
    
  5. 将未模块化的视图模型版本的代码放入模块中:

    Shop.ViewModel = (function(){
      var debug = ko.observable(false);
      var showDebug = function () {
        debug(true);
      };
    
      var hideDebug = function () {
        debug(false);
      };
      var visibleCatalog = ko.observable(true);
      // ... the rest of the code
      return {
        debug: debug,
        showDebug:showDebug,
        hideDebug:hideDebug,
        searchTerm: searchTerm,
        catalog: filteredCatalog,
    ....
      };
    })();
    
  6. 您还没有将其他文件转换为模块,但现在您将向模块添加依赖项:

    Shop.ViewModel = (function (ko, Models, Services, Resources){
      //code of the module
    })(ko, Shop.Models, Shop.Services, Shop.Resources);
    
  7. 在文件末尾,模块外部,初始化模板、验证和对象:

    $(document).ajaxError(function(event,response) {
      console.error(response);
      alert("Error in the communication. Check the console!");
    });
    
    //ko External Template Settings
    infuser.defaults.templateSuffix = ".html";
    infuser.defaults.templateUrl = "views";
    
    ko.validation.init({
      registerExtenders: true,
      messagesOnModified: true,
      insertMessages: true,
      parseInputAttributes: true
    });
    var vm = Shop.ViewModel;
    vm.activate();
    

您需要更新我们的视图模型中的两个方法:activate方法和allCallbackSuccess方法。您需要更新这些方法的原因是因为在allCallbackSuccess方法中,您需要运行ko.applyBindings方法,而allCallbackSuccess无法访问此对象,因为它超出了范围。

要解决这个问题,我们将使用与点击绑定相同的技术来附加更多参数。我们将使用bind JavaScript 方法将allCallbackSuccess方法绑定到这个对象上。因此,我们将能够像下面的代码一样使用此对象运行ko.applyBindings

var allCallbackSuccess = function(response){
  catalog([]);
  response.data.forEach(function(item){
    catalog.push(Product( item.id,item.name,item.price,item.stock));
  });
  filteredCatalog(catalog());
  if (catalog().length) {
    selectedProduct(catalog()[0]);
  }
  ko.applyBindings(this);
};

var activate = function () {
  ProductResource.all()
  .done(allCallbackSuccess.bind(this));
};

使用这种模式,您可以将任何代码片段转换为一个隔离的、可移植的模块。下一步是创建Models模块、Services模块和Resources模块。

模型模块

就像我们对视图模型所做的一样,我们将每个模型转换为一个组件,并将其包装在一个名为Models的模块中,具体步骤如下:

  1. 打开models/product.js文件。

  2. 定义我们的顶层模块,Shop,并初始化它:

    var Shop;
    Shop = Shop || {};
    
  3. 然后创建Models命名空间。它将是一个对象,或者如果存在的话,它将是它之前的值:

    Shop.Models = Shop.Models || {};
    
  4. 用其依赖项定义产品模型。请记住,第一个值是产品本身。这样可以允许我们在使用多个文件定义它的情况下扩展模型。因此,我们将产品模型定义如下:

    Shop.Models.Product = (function(){
    })()
    
  5. 传递依赖项。这次你只需要使用 Knockout 依赖项来使用 observables。Knockout 是一个全局对象,不需要将其添加到依赖项中,但最好像下面的代码那样做。

    Shop.Models.Product = (function (ko){
    }(ko)
    
  6. 最后,在先前的models/Product.js文件中设置我们之前拥有的代码:

    var Shop;
    Shop = Shop || {};
    Shop.Models = Shop.Models || {};
    Shop.Models.Product  = (function (ko){
      return function (id, name, price, stock) {
        _id = ko.observable(id).extend(...);
        _name = ko.observable(name).extend(...);
        _price = ko.observable(price).extend(...);
        _stock = ko.observable(error).extend(...);
        var errors = ko.validation.group([_name, _price, _stock]);
        return {
          id: _id,
          name: _name,
          price: _price,
          stock: _stock,
          errors: errors
        };
      };
    })(ko);
    

models/CartProduct.jsmodels/Customer.js文件执行相同的步骤以将其转换为模块。模型是应用我们用于生成可实例化对象的模式的完美候选对象。

重要的是要保持组件和文件名之间的一致性。确保你的文件名称与其包含的组件名称并带有.js扩展名。

这是将models/CartProduct.js文件转换为最终结果的步骤:

var Shop;
Shop = Shop || {};
Shop.Models = Shop.Models || {};
Shop.Models.CartProduct = (function(ko){

  return function (product,units){
    var
    _product = product,
    _units = ko.observable(units)
    ;

    var subtotal = ko.computed(function(){
      return _product.price() * _units();
    });

    return {
      product: _product,
      units: _units,
      subtotal: subtotal
    };
  }
})(ko);

同样,查看models/Customer.js文件的结果:

var Shop;
Shop = Shop || {};
Shop.Models = Shop.Models || {};
Shop.Models.Customer = (function(ko){
  return function() {
    var firstName = ko.observable("John").extend({
      required: true
    });
    var lastName = ko.observable("Doe").extend({
      required: true,
      minLength: 3
    });
    var fullName = ko.computed(function(){
      return firstName() + " " + lastName();
    });
    var address = ko.observable("Baker Street").extend({
      required: true,
      minLength: 5
    });
    var email = ko.observable("john@doe.com").extend({
      required: true,
      email: true
    });
    var zipCode = ko.observable("12345").extend({
      required: true,
      minLength: 3,
      pattern: {
        message: 'Zip code should have 5 numbers',
        params: '^[0-9]{5}$'
      }
    });
    var country = ko.observable("");
    var fullAddress = ko.computed(function(){
      return address() + " " + zipCode() + ", " + country();
    });
    var errors = ko.validation.group([firstName, lastName, address, email, zipCode]);
    return {
      firstName:firstName,
      lastName: lastName,
      fullName: fullName,
      address: address,
      email: email,
      zipCode: zipCode,
      country: country,
      fullAddress: fullAddress,
      errors: errors
    };
  };
})(ko);

资源模块

从编码角度来看,构建包含模型的模块和构建包含资源的模块并没有太大的不同。应用的模块模式是相同的。然而,你不需要创建资源的实例。要对模型应用 CRUD 操作,你只需要一个处理此责任的对象。因此,资源将是单例的,就像以下步骤中所做的那样:

  1. 打开resources/ProductResource.js文件。

  2. 创建顶层层次模块:

    var Shop;
    Shop = Shop || {};
    
  3. 创建Resources命名空间:

    Shop.Resources = Shop.Resources || {};
    
  4. 使用模块模式定义ProductResource

    Shop.Resources.ProductResource = (function(){
    })()
    
  5. 设置依赖关系。在这种情况下,jQuery 是你需要的依赖项。jQuery 是一个全局对象,不需要将其作为依赖项传递,但这样做是一个很好的实践。

    Shop.Resources.ProductResource = (function($){
    }(jQuery);
    
  6. 最后,在resources/ProductResource.js文件中设置以下代码。由于在我们的应用程序中资源是单例的,将资源与以下代码中使用的方法扩展起来:

    var Shop;
    Shop = Shop || {};
    Shop.Resources = Shop.Resources || {};
    Shop.Resources.ProductResource = (function($){
      function all() {
        return $.ajax({
          type: 'GET',
          url: '/products'
        });
      }
      function get(id) {
        return $.ajax({
          type: 'GET',
          url: '/products/'+id
        });
      }
      function create(product) {
        return $.ajax({
          type: 'POST',
          url: '/products',
          data: product
        });
      }
      function save(product) {
        return $.ajax({
          type: 'PUT',
          url: '/products/'+product.id,
          data: product
        });
      }
      function remove(id) {
        return $.ajax({
          type: 'DELETE',
          url: '/products/'+id
        });
      }
      return {
        all:all,
        get: get,
        create: create,
        save: save,
        remove: remove
      };
    }(jQuery);
    

现在对OrderResouce组件应用相同的步骤。你可以在这段代码中看到最终结果:

var Shop;
Shop = Shop || {};
Shop.Resources = Shop.Resources || {};
Shop.Resources.OrderResource = (function ($) {
  function save(order) {
    return $.ajax({
      type: 'PUT',
      url: '/order',
      data: order
    });
  }
  return {
    save: save
  };
})(jQuery);

服务模块

服务也是单例的,和资源一样,所以按照与资源模块相同的步骤进行操作:

  1. 打开services/ProductService.js文件。

  2. 创建顶层层次模块:

    var Shop;
    Shop = Shop || {};
    
  3. 创建Resources命名空间:

    Shop.Services = Shop.Services || {};
    
  4. 定义ProductService

    Shop.Services.ProductService = (function(){
    })();
    
  5. 在这种情况下,服务没有依赖关系。

  6. 最后,在services/ProductService.js文件中设置以下代码。由于在应用程序中资源是单例的,将资源与以下代码中使用的方法扩展起来:

    var Shop;
    Shop = Shop || {};
    Shop.Services = Shop.Services || {};
    Shop.Services.ProductService = (function(Product) {
      var hasStock = function (product) {
        return product.stock() > 0;
      };
    
      var decreaseStock = function (product) {
        var s = product.stock();
        if (s > 0) {
          s--;
        }
        product.stock(s);
      };
    
      var clone = function (product) {
        return Product(product.id(), product.name(), product.price(), product.stock());
      };
    
      var refresh = function (product,newProduct) {
        product.name(newProduct.name());
        product.stock(newProduct.stock());
        product.price(newProduct.price());
      };
    
      return {
        hasStock:hasStock,
        decreaseStock:decreaseStock,
        clone:clone,
        refresh: refresh
      };
    })(Shop.Models.Product);
    

事件、绑定和 Knockout 组件

我们不打算模块化事件,因为它们是特定于此应用程序的。孤立非可移植的东西是没有意义的。我们也不会将绑定或组件模块化,因为它们被注入到 Knockout 对象中作为库的一部分,所以它们已经足够孤立,它们不是模块的一部分,而是 Knockout 对象的一部分。但我们需要更新所有这些文件中的依赖关系,因为应用程序的不同部分现在都隔离在Shop模块及其子模块中。

更新 add-to-cart-button 组件

要使用新命名空间更新组件,更新(覆盖)对依赖项的引用,如下所示:

ko.components.register('add-to-cart-button', {
  viewModel: function(params) {
    this.item = params.item;
    this.cart = params.cart;
    this.addToCart = function() {
      var CartProduct = Shop.Models.CartProduct;
      var CartProductService = Shop.Services.CartProductService;
      var ProductService = Shop.Services.ProductService;

      var data = this.item;
      var tmpCart = this.cart();
      var n = tmpCart.length;
      var item = null;

      if(data.stock()<1) {
        return;
      }
      while(n--) {
        if (tmpCart[n].product.id() === data.id()) {
          item = tmpCart[n];
        }
      }
      if (item) {
        CartProductService.addUnit(item);
      } else {
        item = CartProduct(data,1);
        tmpCart.push(item);
        ProductService.decreaseStock(item.product);
      }
      this.cart(tmpCart);
    };
  },
  template:
    '<button class="btn btn-primary" data-bind="click:addToCart">
      <i class="glyphicon glyphicon-plus-sign"></i> Add
    </button>'
});

更新事件

按照以下方式更新那些具有新模块依赖关系的代码行:

(function() {
  "use strict";
  $(document).on("click","#confirmOrderBtn", function() {
    vm.showOrder();
  });
  $(document).on("click", ".add-unit", function() {
    var data = ko.dataFor(this);
    $(document).trigger("addUnit",[data]);
  });
  $(document).on("click", ".remove-unit", function() {
    var data = ko.dataFor(this);
    $(document).trigger("removeUnit",[data]);
  });
  $(document).on("addUnit",function(event, data){
    Shop.Services.CartProductService.addUnit(data);
  });
  $(document).on("removeUnit",function(event, data){
    Shop.Services.CartProductService.removeUnit(data);
  });
})();

您已经学会了一种非常好的模式,可以在没有任何外部工具的情况下管理依赖关系。您几乎可以在所有项目中使用它。如果您将所有文件合并到一个文件中,则其效果会更好。

本书不会涵盖如何合并和缩小文件以在生产环境中使用它们。合并和缩小文件可以提高应用程序的性能,因为缩小可以减少文件的大小,而合并可以减少 HTTP 调用的次数至一个。

要做到这一点,您可以使用 Node.js (nodejs.org/) 和一个构建模块,如 Grunt (gruntjs.com/) 或 Gulp (gulpjs.com/)。如果您有兴趣了解诸如缩小、文件组合等部署实践,互联网上有大量关于 Node.js 和部署工具的参考文献。

要访问本章节代码的这一部分,请访问 GitHub 仓库:

github.com/jorgeferrando/knockout-cart/tree/chapter6Part1

使用 RequireJS 来管理依赖关系

在上一节中,您学会了如何隔离代码的不同部分。您还按类型和组件名称对文件进行了分组,这遵循了一致的模式。但是,您还没有解决一个随着项目规模增大而增长的重要问题。为了给您一个关于这个问题是什么的提示,让我们来看看我们的index.html文件。查看<script>标签部分的部分:

<script type="text/javascript" src="img/jquery.min.js"></script>
<script type="text/javascript" src="img/jquery.mockjax.js"></script>
<script type="text/javascript" src="img/jquery.mockjson.js"></script>
<script type="text/javascript" src="img/icheck.js"></script>
<script type="text/javascript" src="img/bootstrap.min.js"></script>
<script type="text/javascript" src="img/knockout.debug.js"></script>
...
...
...
<script type="text/javascript" src="img/ProductResource.js"></script>
<script type="text/javascript" src="img/OrderResource.js"></script>
<script type="text/javascript" src="img/viewmodel.js"></script>
<script type="text/javascript" src="img/cart.js"></script>

您需要手动维护所有这些文件之间的依赖关系。随着项目的增长,这样做的复杂性也在增加。因此,当您需要知道所有文件的依赖关系时,就会出现问题。这在小型项目中很容易处理,但在处理大型项目时,这可能是一场噩梦。此外,如果您在开始时加载所有文件,启动应用程序可能会受到惩罚。

要解决这个问题,有多个库可以帮助。我们将使用 RequireJS(有关更多信息,请参阅 requirejs.org/),它专注于异步加载脚本和管理依赖关系。它遵循 AMD 来编写不同的模块。这意味着它使用definerequire语句来定义和加载不同的模块。AMD 库专注于应用程序的客户端,并在需要时帮助加载 JavaScript 模块。有关 AMD 的更多信息,请访问以下链接:

en.wikipedia.org/wiki/Asynchronous_module_definition

这非常有帮助,因为它优化了所发出请求的数量。这使得应用程序可以更快地启动,并且仅加载用户需要的模块。

还有另一种定义异步模块的模式,称为 CommonJS(在 requirejs.org/docs/commonjs.html 中了解更多信息),它默认由 Node.js 模块使用。你可以在客户端应用程序中使用这个定义,使用 Node.js 和一个叫做 browserify 的库(在 browserify.org/ 中了解更多信息)。

在本书中,我们将专注于 RequireJS,因为它不需要 Node.js 或任何编译,并且在客户端应用程序中经常使用。

更新模板引擎

不幸的是,我们到目前为止使用的 ExternalTemplateEngine 不兼容 AMD。这就是为什么你应该使用其他解决方案。有一个叫做 amd-helpers 的 KnockoutJS 扩展。你可以从 github.com/rniemeyer/knockout-amd-helpers 下载它。Ryan Niemeyer 是这个扩展的作者。他是一个非常有名的 Knockout 开发者,在 Knockout 社区拥有很多粉丝。他有一个名为 Knockmeout 的博客 (knockmeout.net),上面有大量关于 Knockout 的文章以及如何使用 amd-helpers 库的良好示例。在本书中,我们只会使用模板引擎。但这个扩展有很多其他功能。

RequireJS 只是原生加载 JavaScript 文件。要异步加载 HTML 文件,请从 github.com/requirejs/text 下载 text 扩展,并将其添加到 vendors 文件夹中。有了这个扩展,你可以加载任何类型的文件作为文本。

现在,当我们需要加载文本文件时,只需在文件路径前加上前缀 text!

配置 RequireJS

要配置 RequireJS,请在与 viewmodel.js 文件位于相同级别的位置创建一个文件。你可以称之为 main.js,并按照以下步骤操作:

  1. 定义基本的 config 方法:

    require.config({
    
    });
    
  2. 然后,定义脚本的基本 URL。这是 RequireJS 将查找脚本的地方:

    Require.config({
    baseUrl:'js'
    });
    
  3. 现在,在 paths 属性中为供应商库的路径定义别名。这样可以帮助你避免在模块依赖项中编写长路径。你不需要定义扩展名。RequireJS 会为你添加扩展名:

    require.config({
      baseUrl:'js',
      paths: {
        bootstrap:'vendors/bootstrap.min',
        icheck: 'vendors/icheck',
        jquery: 'vendors/jquery.min',
        mockjax: 'vendors/jquery.mockjax',
        mockjson: 'vendors/jquery.mockjson',
        knockout  : 'vendors/knockout.debug',
        'ko.validation':'vendors/ko.validation',
        'ko-amd-helpers': 'vendors/knockout-amd-helpers',
        text: 'vendors/require.text'
      }
    });
    
  4. 还要在 shim 属性内定义依赖项。这告诉 RequireJS 必须在加载库之前加载哪些文件:

    require.config({
      baseUrl:'js',
      paths: {
        ...
      },
      shim: {
        'jquery': {
          exports: '$'
        },
        bootstrap: {
          deps:['jquery']
        },
        mockjax: {
          deps:['jquery']
        },
        mockjson: {
          deps:['jquery']
        },
        knockout: {
          exports: 'ko',
          deps:['jquery']
        },
        'ko.validation':{
          deps:['knockout']
        },
        'ko.templateEngine': {
            deps:['knockout']
        }
      },
    });
    
  5. 定义配置完成后应调用的文件。在本例中,文件是 app.js。此文件将是应用程序的入口点,并触发项目启动时加载的所有依赖项:

    //write this inside main.js file
    require.config({
      baseUrl:'js',
      paths: {...},
      shim: {...},
      deps: ['app']
    });
    
  6. 现在,从 index.html 文件中删除所有 <script> 标签,并引用 vendors/require.min.js 文件。此文件使用 data-main 属性引用配置文件(main.js)。

    <script type='text/javascript' src='vendors/require.min.js' data-main='main.js'></script>
    

在我们的项目中使用 RequireJS

要将我们的模块转换为 RequireJS 兼容的模块,我们将使用 AMD 规范对它们进行定义。该规范指出,要定义一个模块,需要调用define函数。该函数接收一个包含字符串的数组。这些字符串表示每个依赖项(模块中所需的文件)的配置文件中的路径或别名。

define函数需要的第二个参数是一个将返回模块的函数。此函数将从数组中的依赖项作为参数。使用这种模式的好处是,在加载所有依赖项之前,define函数内部的代码不会被执行。以下是define函数的样子:

define(['dependency1','dependendency2'],function(dependency1,depencency2){
  //you can use depencencies here, not outside.
  var Module = //can be a literal object, a function.
  return Module; 
});

函数应该始终返回模块变量,或者模块需要返回的任何内容。如果我们没有设置return语句,模块将返回一个未定义的值。

定义 app.js 文件

当我们定义了 RequireJS 配置时,我们说入口点将是app.js文件。以下是创建app.js文件的步骤:

  1. 创建app.js文件。

  2. 设置依赖项数组。将这些依赖项映射为函数中的参数。有些文件只是执行代码,它们返回一个未定义的值。如果它们位于依赖项列表的末尾,你不需要映射这些文件。

    define([
      //LIBRARIES
      'bootstrap',
      'knockout',
      'koAmdHelpers',
      'ko.validation',
      'icheck',
    
      //VIEWMODEL
      'viewmodel',
    
      //MOCKS
      'mocks/product',
      'mocks/order',
    
      //COMPONENTS
      'custom/components',
    
      //BINDINGS
      'custom/koBindings',
    
      //EVENTS
      'events/cart'
    ], function(bs, ko, koValidation, koAmdHelpers, 'iCheck', 'ViewModel) {
    });
    
  3. 现在定义模块的主体。它将初始化全局配置和全局行为。最后,它将返回视图模型:

    define([...],function(...){
      //ko External Template Settings
      ko.amdTemplateEngine.defaultPath = "../views";
      ko.amdTemplateEngine.defaultSuffix = ".html";
      ko.amdTemplateEngine.defaultRequireTextPluginName = "text";
      ko.validation.init({
        registerExtenders: true,
        messagesOnModified: true,
        insertMessages: true,
        parseInputAttributes: true
      });
    
      $( document ).ajaxError(function(event,response) {
        console.error(response);
        alert("Error in the communication. Check the console!");
      });
    
      vm.activate();
    
      return vm;
    });
    

第一个文件有很多依赖项,我们应该保持有序。首先我们定义了库,然后是视图模型,模拟,组件,最后是事件。这些文件中的每一个也应该被定义为模块;当它们被调用时,依赖项将被加载。

注意我们如何更新了模板引擎的定义:defaultPath 值用于定义模板所在的位置,defaultSuffix 值用于定义模板的扩展名,以及用于加载模板的库(在我们的情况下是 text)。现在,我们应该将这个模式应用到其余的文件中。

将普通模块转换为 AMD 模块

要转换普通模块,我们将执行以下步骤。始终对我们所有的模块应用相同的步骤。我们需要将它们包装到define函数中,列出依赖项,并返回我们在旧模块中返回的模块。

  1. 打开viewmodel.js文件。

  2. 创建define函数:

    define([],function(){});
    
  3. 添加所有依赖项:

    define([
      'knockout',
      'models/Product',
      'models/Customer',
      'models/CartProduct',
      'services/ProductService',
      'services/CartProductService',
      'resources/ProductResource',
      'resources/OrderResource'
    ],function (ko, Product, Customer, ProductService, CartProductService, ProductResource, OrderResource) {
    });
    
  4. 导出模块到define函数中:

    define([],function(){
      var debug = ko.observable(false);
      var showDebug = function () {
        debug(true);
      } 
      ...
      var activate = function () {
        ProductResource.all()
          .done(allCallbackSuccess.bind(this));
      };
      return {
        debug: debug,
        showDebug:showDebug,
        hideDebug:hideDebug,
        ...
      };
    });
    

当我们将knockout作为依赖项时,RequireJS 将检查配置以找到别名。如果别名不存在,则它将查找我们在baseUrl属性中设置的路径。

现在我们应该更新所有使用这种模式的文件。注意,应该设置为依赖项的元素与我们使用模块模式设置的元素相同。

将 RequireJS 应用到组件

我们没有在本章的第二部分中将我们的绑定和组件模块化。但这并不意味着我们不能。

我们不仅可以使用 RequireJS 创建模块,还可以异步加载文件。在我们的情况下,绑定和组件不需要返回对象。当加载这些文件时,它们扩展了 ko 对象并完成了它们的工作。事件也是如此。我们初始化事件并完成工作。因此,这些文件只需要被包装到 define 函数中。添加依赖项并像在上一节中那样在 app.js 文件中加载它们。

对于 add-to-cart-button 组件,在文件中的代码将是以下内容:

define([
  'knockout',
  'models/CartProduct',
  'services/CartProductService',
  'services/ProductService'
],function(ko, CartProduct,CartProductService,ProductService){
  ko.components.register('add-to-cart-button', {
    ...
  });
});

将 RequireJS 应用于模拟

在模拟的情况下,我们需要如下引入 Mockjax 和 Mockjson 库:

define([
  'jquery',
  'mockjson',
  'mockjax'
], function ($, mockjson, mockjax) {
  $.mockJSON.data.PRODUCTNAME = [
    'T-SHIRT', 'SHIRT', 'TROUSERS', 'JEANS', 'SHORTS', 'GLOVES', 'TIE'
  ];
  ...
});

将 RequireJS 应用于绑定

绑定易于转换。它们只有 jQuery 和 Knockout 依赖项,如下所示:

define(['knockout','jquery'],function(ko, $){
  //toggle binding
  ko.bindingHandlers.toggle = { ... };
  ...
});

将 RequireJS 应用于事件

最后,我们需要更新 events/cart.js 文件。确认订单事件需要更新视图模型。我们可以将 viewmodel 作为依赖项并访问其公共接口:

define([
  'jquery','viewmodel','services/CartProductService'
], function(vm, CartProductService) {
  "use strict";
  $(document).on("click","#confirmOrderBtn", function() {
    vm.showOrder();
  });

  $(document).on("click", ".add-unit", function() {
    var data = ko.dataFor(this);
    $(document).trigger("addUnit",[data]);
  });

  $(document).on("click", ".remove-unit", function() {
    var data = ko.dataFor(this);
    $(document).trigger("removeUnit",[data]);
  });

  $(document).on("addUnit",function(event, data){
    CartProductService.addUnit(data);
  });

  $(document).on("removeUnit",function(event, data){
    CartProductService.removeUnit(data);
  });
});

应用程序的限制

最后我们有了一个模块化的应用程序。尽管如此,它有一些限制:

  • 浏览器的后退和前进按钮的行为是什么?如果我们尝试使用它们,我们的应用程序不会按预期工作。

  • 如果我们想将我们的应用程序分成多个页面,我们总是需要在同一个页面中显示和隐藏模板吗?

正如您所看到的,还有很多工作要做。Knockout 很好,但也许它需要与其他库合作来解决其他问题。

在本章中开发的代码副本位于 github.com/jorgeferrando/knockout-cart/tree/chapter6RequireJS

总结

在本章中,您学习了如何在我们的项目中构建模块以及如何按需加载文件。

我们谈论了模块模式和 AMD 规范来构建模块。您还学习了如何使用 Chrome 扩展程序 Knockout 上下文调试器 调试 KnockoutJS 应用程序。

最后,我们发现当应用程序变得更大时,它将需要许多库来满足所有需求。RequireJS 是一个帮助我们管理依赖关系的库。Knockout 是一个帮助我们在项目中轻松应用 MVVM 模式的库,但是大型应用程序需要 Knockout 无法提供的其他功能。

在接下来的两章中,您将学习一个称为 Durandal 的框架。这个框架使用 jQuery、Knockout 和 RequireJS 来应用 MVVM 模式。此外,Durandal 提供了更多模式来解决其他问题,如路由和导航,并通过插件和小部件实现了添加新功能的能力。我们可以说 Durandal 是 KnockoutJS 的大哥。

第七章:Durandal – KnockoutJS 框架

通过六章,我们已经使用基本库构建了一个完整的前端应用程序。

我们使用了一些库来实现我们的目标:

  • Bootstrap 3:用于在 CSS3 中拥有坚实、响应式和跨浏览器的基本样式。

  • jQuery:用于操作 DOM 并通过 AJAX 与服务器端通信。

  • Mockjax:用于模拟 AJAX 通信。

  • MockJSON:创建虚假数据。

  • KnockoutJS:用于绑定数据并轻松同步 JavaScript 数据和视图。

我们还应用了一些设计模式来提高代码质量:

  • 揭示模式:显示对象的公共接口并隐藏私有属性和方法。

  • 模块模式:用于隔离我们的代码并使其可移植。

  • 依赖注入模式:用于提高内聚性和减少耦合度。

最后,我们介绍了一个帮助我们管理项目依赖的库,RequireJS。

在小型项目中,您可以仅使用这些库。但是,当项目增长时,处理依赖关系变得更加困难。您需要的库和样式越多,维护它们就越困难。此外,维护视图模型也变得更加困难,因为它开始具有太多的代码行。拆分视图模型会导致编写更多的事件来通信,而事件会使代码更难调试。

要解决所有这些问题,Rob Eisenberg(eisenbergeffect.bluespire.com/)及其团队创建了Durandaldurandaljs.com/)。Durandal 是一个框架,它集成了你今后将学到的所有库和良好的实践。

在本章中,您将学习 Durandal 框架的基础知识,以便开始使用它。在本章中,您不会在购物车项目上工作。这将在下一章中继续。本章是关于了解 Durandal 如何工作以及它如何连接所有部件以快速轻松地创建 Web 应用程序。

需要提及的是,Durandal 一直是构建应用程序的最简单和最快的框架之一。当另一个名为 AngularJS(angularjs.org/)的良好框架宣布其 2.0 版本时,艾森伯格放弃了 Durandal 并成为 AngularJS 团队的一部分。这对 Durandal 和 KnockoutJS 社区来说是一个重大打击。但最近,艾森伯格离开了 AngularJS 2.0 项目,并宣布了 Durandal 的新版本。因此,我们可以说我们正在使用最佳框架之一来开发现代、跨浏览器且完全兼容的 Web 应用程序。

安装 Durandal

要安装 Durandal,请按照以下步骤操作:

  1. 转到 durandaljs.com/downloads.html

  2. 下载最新版本的入门套件:durandaljs.com/version/latest/HTML%20StarterKit.zip

  3. 将其解压缩到您的项目文件夹中。

  4. 将其重命名为durandal-cart

  5. 将 Mongoose 服务器添加到项目中,或者使用你感觉舒适的服务器。

起始套件将为你提供一个非常好的起点,以了解 Durandal 的工作原理。在接下来的项目中,我们可以直接使用独立的 Durandal 库开始,但在这里,我们将仔细分析这个框架的各个部分。

要深入了解 Durandal,请下载HTML Samples.zip文件(durandaljs.com/version/latest/HTML%20Samples.zip),但测试这些有趣的示例取决于你。以下是起始套件的内容:

  • 起始套件包含三个文件夹和一个 HTML index 文件。

  • app文件夹包含应用程序本身。其中包含两个文件夹:viewmodelsviews

  • viewmodels文件夹包含应用程序需要的所有视图模型——通常每个页面一个视图模型。

  • views文件夹包含绑定到每个视图模型的 HTML——通常每个视图对应一个视图模型。但是,你可以组合视图(你会发现这才是 Durandal 的实际力量)。

  • lib文件夹包含 Durandal 框架和框架所依赖的所有库。

  • durandal/js文件夹内,你会找到一个名为plugins的文件夹。你可以使用插件扩展 Durandal。你也可以使用组件和bindingHandlers扩展 KnockoutJS。

  • 还有一个名为transitions的文件夹。在其中,你可以添加在两个页面之间进行过渡时触发的动画。默认情况下,只有一个(entrance.js),但你可以从互联网下载更多,或者自己构建。

  • index.html文件将是 JavaScript 应用程序的入口点。安装 Durandal

    Durandal 的文件夹结构

Durandal 模式

在更深入了解 Durandal 之前,让我们先学习一些关于框架的模式和概念。

Durandal 是一个单页应用程序SPA)框架。这意味着:

  • 所有的 Web 应用程序都在一个页面上运行(首页)

  • 没有完整页面刷新;只更新更改的部分

  • 路由不再是服务器的责任。

  • AJAX 是与服务器端通信的基础

Durandal 遵循 Model-View-ViewModel(MVVM)模式:

  • 实际上,它被称为 MV* 模式,因为我们可以用任何我们使用的东西替换 *:View-model(MVVM),Controller(MVC)或 Presenter(MVP)。按照惯例,Durandal 使用视图模型。

  • MVVM 模式将应用程序的视图与状态(逻辑)分离。

  • 视图由 HTML 文件组成。

  • 视图模型由绑定到视图的 JavaScript 文件组成。

  • Durandal 专注于视图和视图模型。模型不是框架的一部分。我们应该决定如何构建它们。

该框架使用异步模块定义AMD)模式来管理依赖关系。它具有以下特点:

  • 它使用 RequireJS 实现这一目的。

  • 我们应该为每个文件定义一个模块。

  • 模块的名称将是没有扩展名的文件的名称。

index.html 文件

index.html 文件是应用程序的入口点。它应该有一个带有 ID applicationHost 的容器。应用程序将在此容器内运行,并且视图将被交换:

<div id="applicationHost">
  <!-- application runs inside applicationHost container -->
</div>

你可以使用 splash 类定义一个 splash 元素。当应用程序完全加载时,它会显示。

<div class="splash">
  <!-- this will be shown while application is starting -->
  <div class="message">
    Durandal Starter Kit
  </div>
  <i class="fa fa-spinner fa-spin"></i>
</div>

最后,使用 RequireJS 设置 Durandal 应用程序的入口点,就像我们在上一章中设置的一样。将 main.js 文件设置为 JavaScript 的入口点:

<script src="img/require.js" data-main="app/main"></script>

main.js 文件

main.js 文件包含 RequireJS 配置。在这里,我们可以看到 Durandal 使用哪些库来工作:

  • text: 这是一个 RequireJS 的扩展,用于加载非 JavaScript 文件。Durandal 使用 text 来加载模板。

  • durandal: 这是框架的核心。

  • plugins: 在这个文件夹中,我们可以找到并非所有应用程序都需要的框架部分。这些代码片段可以根据项目需要加载。

  • transitions: 这包含了我们可以在页面转换之间播放的不同动画。默认情况下,我们只有进入动画。

  • knockout: 这是用于绑定视图和视图模型的库。

  • bootstrap: 这是与 bootstrap.css 库相关的设计库。

  • jQuery: 这是 DOM 操作库。

你已经有了使用 RequireJS 的经验,因为你将应用程序文件转换为遵循 AMD 规范。这就是包含 RequireJS 配置的 main.js 文件应该如何看起来的:

requirejs.config
({
  paths: {
    'text': '../lib/require/text',
    'durandal':'../lib/durandal/js',
    'plugins' : '../lib/durandal/js/plugins',
    'transitions' : '../lib/durandal/js/transitions',
    'knockout': '../lib/knockout/knockout-3.1.0',
    'bootstrap': '../lib/bootstrap/js/bootstrap',
    'jquery': '../lib/jquery/jquery-1.9.1'
  },
  shim: {
    'bootstrap': {
      deps: ['jquery'],
      exports: 'jQuery'
    }
  }
});

然后定义 main 模块。以与您在购物车项目中使用 RequireJS 相同的方式定义依赖项:

define([
  'durandal/system', 
  'durandal/app', 
  'durandal/viewLocator'], function (system, app, viewLocator) {
    //main module code goes here
});

此模块是配置应用程序的地方。在入门套件项目中,有一个默认配置,可以帮助您了解在这一点上可以做什么:

  • 激活调试(或不激活):

    system.debug(true);
    
  • 设置应用程序标题。应用程序标题将默认与页面标题连接起来。

    app.title = 'Durandal Starter Kit';
    
  • 激活和配置插件:

    app.configurePlugins({
      router: true,
      dialog: true
    });
    
  • 启动应用程序:

    app.start().then(function() {
      //This code is executed when application is ready.
    
      //We can choose use framework conventions
      viewLocator.useConvention();
      app.setRoot('viewmodels/shell', 'entrance');
    });
    

当您启动应用程序时,您可以选择遵循 Durandal 的约定。如果您选择默认遵循它们,Durandal 将通过查找 views 文件夹中的视图将视图模型与视图关联起来。它们应该具有与视图模型相同的名称。这意味着如果你有一个名为 viewmodel/catalog.js 的视图模型,它的关联视图将被称为 views/catalog.js

主  文件

这是按照 Durandal 约定创建的文件结构,适用于中小型项目

这种约定适用于小型和中型项目。在大型项目中,建议不使用 Durandal 约定。如果我们选择不使用这些约定,Durandal 将在与视图模型相同的文件夹中查找视图。例如,如果视图模型称为catalog/table.js,则视图应命名为catalog/table.html。这使我们可以按功能组织视图和视图模型。

main.js 文件

通过不使用 Durandal 约定,我们按功能对文件进行分组,这对于大型和可扩展的项目是有益的

最后,指示框架哪个视图模型将启动应用程序。默认情况下,shell 视图模型会执行此操作。

shell 视图模型

Shell是入口模块。它是包装其他模块的模块。它只加载一次,并且具有一直存在的 DOM 元素。

要定义视图模型,请使用 AMD 模式定义一个简单的 JavaScript 对象,如以下步骤所示:

  1. 定义依赖关系,即路由器和 Durandal 应用程序:

    define(['plugins/router', 'durandal/app'], function (router, app) {
      return {
        //We complete this in next points
      };
    });
    
  2. 暴露router方法。router方法将给我们一个对象,使我们可以轻松显示导航栏。

    return {
      router: router
    };
    
  3. 暴露search方法。这是一个可选方法。它是入门套件应用程序的一部分。它管理全局搜索。

    return {
      router: router,
      search: function() {
        //easy way to show a message box in Durandal
        app.showMessage('Search not yet implemented...');
      },
    };
    
  4. 暴露activate方法。这是 Durandal 视图模型中的一个重要方法。activate方法在视图模型准备就绪时触发。在这里,您可以请求数据以将其绑定到视图。我们很快将看到有关 Durandal 生命周期方法的更多信息。

    define(['plugins/router', 'durandal/app'], function (router, app) {
      return {
        router: router,
        search: function() { ... },
        activate: function () {
          router.map([{ 
            route: '', 
            title:'Welcome', 
            moduleId: 'viewmodels/welcome', 
            nav: true 
          }, {
            route: 'flickr', 
            moduleId: 'viewmodels/flickr', 
            nav: true 
          }]).buildNavigationModel();   
          return router.activate();
          }
        };
    });
    

shell 视图

shell 视图包含导航栏:搜索栏和附加类称为page-host的元素。此元素将绑定到路由器,如下面的代码所示。您可以配置动画以使页面之间的过渡更加酷。

<div>
  <nav class="navbar navbar-default navbar-fixed-top" role="navigation">
    <!-- nav content we will explain then -->
  </nav>
  <div class="page-host" data-bind="router: { transition:'entrance' }"></div>
</div>

Durandal 生命周期

我们清楚地了解 Durandal 应用程序如何工作是很重要的。这是您的应用程序启动的模式图:

  1. index.html页面使用 RequireJS 请求main.js文件。

  2. main.js文件配置 require 并定义主模块,负责应用程序配置,并启动 shell 模块。

  3. shell 模块处理应用程序的全局上下文。它管理沿不同生命周期持续存在的组件。在入门套件应用程序中,它管理搜索栏。但是它也可以管理登录和注销功能,例如。shell 模块是配置所有路由的地方。

  4. 最后,路由器配置沿着应用程序拥有的所有页面的导航。Durandal 生命周期

    Durandal 初始化生命周期

激活生命周期

激活生命周期控制页面的激活和停用。Durandal 允许我们使用预定义的方法访问周期的不同部分。让我们看一下 Durandal 方法:

  • canDeactivate: 当您尝试放弃页面时,应返回 true、false 或重定向对象。如果方法的结果为 true,则可以离开页面。如果是 false,则路由过程将被中断。如果返回重定向对象,则会重定向。

  • canActivate: 当您到达新页面时,可以评估是否能够查看此页面。例如,您可以检查是否已登录到您的页面,或者是否具有足够的管理员权限来查看页面。如果返回canActivate true,则可以查看该页面。如果返回 false,则路由过程将被中断。您还可以将用户重定向到另一个页面。

  • deactivate: 如果canDeactivate返回 true 并且您可以激活下一个视图,则会触发deactivate方法。在这里,如果需要的话,清除超时和事件是一个很好的地方。

  • activate: 如果canActivate返回 true 并且您可以停用上一个视图,则会触发activate方法。这是您应该加载所有数据、绑定您的元素并初始化事件的地方。激活生命周期

    激活生命周期

还有其他方法可以在我们的生命周期中使用:

  • getView: 使用此方法,您可以构建一个 observable 来定义视图模型绑定的视图路径。

  • viewUrl: 这返回一个表示附加到视图模型的视图路径的字符串。viewUrlgetView之间的区别在于前者是一个字符串,而后者是一个 observable。

  • binding: 在视图和视图模型之间的绑定开始之前调用此方法。

  • bindingComplete: 在绑定完成后立即调用。

  • attached: 当组合引擎将视图附加到 DOM 时调用。您可以使用此钩子使用 jQuery 选择器来操作元素。

  • compositionComplete: 这是组合引擎触发的最后一个钩子。在这里,您可以测量 DOM 元素。

  • detached: 当视图从 DOM 中分离时,将触发此钩子。我们可以在这里执行清理工作。

您可以在durandaljs.com/documentation/Interacting-with-the-DOM.html了解更多关于组合生命周期的信息。

Promise 模式

Durandal 使用 promises 来管理异步行为。一个明显的例子是app.start()方法,它在main.js文件中。

Promise 是一个包含在未来可以使用的值的对象,当获得此值的先决条件时可以使用。在这种情况下,直到获得app.start()方法的结果之后,then方法才不会被触发。

在内部,Durandal 使用 jQuery 的 promise 实现以最小化第三方依赖关系。然而,你使用的其他库可能需要 Q,或者你可能需要比 jQuery 提供的更高级的异步编程能力。在这些情况下,你将希望将 Q 的 promise 机制插入到 Durandal 中,以便你可以在整个过程中拥有一个统一的 promise 实现。要集成 Q 库,请按照以下步骤操作:

  1. Q 库添加到 RequireJS 配置中。

  2. 将此代码添加到 main.js 文件中,在 app.start() 指令之前:

    system.defer = function (action) {
      var deferred = Q.defer();
      action.call(deferred, deferred);
      var promise = deferred.promise;
      deferred.promise = function() {
        return promise;
      };
      return deferred;
    };
    

如果你正在使用 HTTP Durandal 插件,则如果你想使用 Q promises,这种方法将不够。你需要将 jQuery promise 包装成 Q promise,如下所示:

http.get = function(url, query) {
  return Q.when($.ajax(url, { data: query }));
}

你可以在 durandaljs.com/documentation/Q.html 阅读更多关于 Q 库的信息。

这是我们在 Durandal 中可用的基本 jQuery promise 接口:

  • done(successFn): 如果 promise 成功解析,则将触发此事件。

  • fail(failFn): 如果 promise 被拒绝,则将触发此事件。

  • always(): 这将在成功和失败两种情况下触发。

  • then(succesFn,failFn): 这是 donefail 方法的别名。

  • when(valueOrFunction): 这将使用传递的值或函数创建一个 promise。

要了解更多关于 jQuery promises 的信息,请参考官方文档 api.jquery.com/promise/

组合

组合 是 Durandal 中最强大的部分。虽然模块帮助将应用程序分解为小部分,但组合允许我们将它们全部再次连接起来。组合有两种类型,对象组合和视觉组合。

要应用视觉组合,你需要使用 compose 绑定。你可以将 KnockoutJS observables 与 compose 绑定结合使用以实现动态组合。Compose 绑定提供了一个完整的配置界面,以增强组件的灵活性和可重用性。

对象组合

你可以通过仅使用 RequireJS 和 AMD 模式来实现对象组合。最简单的情况是你有两个模块:A 和 B。B 模块需要 A 的功能,所以你在模块 B 中使用 RequireJS 请求模块 A,如下所示:

//moduleA
define([],function(){
  var moduleA = {};

  //ModuleA code

  return moduleA;
});
//moduleB (in a different file)
define(['moduleA'],function(moduleA){
  //we can use ModuleA to extend moduleB, e.g:

  var moduleB = $.extend({}, moduleA);

  //Create moduleB unique functionality.
  return moduleB;
});

视觉组合

视觉组合 允许你将视图分解成小块并重新连接(或组合)它们,使它们可重用。这是 Durandal 中的一个核心和独特功能,并由 Composition 模块管理。组合视图的最常见方式是使用 compose 绑定处理程序。

让我们看看 shell 视图是如何组合的:

  1. 使用 RequireJS 来查找 shell 模块。按照惯例,它知道它在 shell.js 文件中。

  2. 视图定位器会为 shell 定位适当的视图:shell.html

  3. 视图引擎从 shell.html 中的标记创建视图。

  4. 使用 KnockoutJS 将 shell 模块和 shell 视图进行数据绑定。

  5. 将绑定外壳视图插入applicationHost div 中。

  6. “入口”过渡用于动画显示视图。可视化组合

    组合生命周期

现在看一下如何进行一些可视化组合。您可以将导航移动到其自己的视图,并使用导航视图组合外壳,按照以下步骤操作:

  1. 打开shell.html文件。

  2. 剪切<nav></nav>元素。

  3. 将其粘贴到名为navigation.html的新文件中。

  4. shell.html文件中添加一个<div>并绑定组合元素,如下所示:

    <div>
      <div data-bind="compose: 'navigation.html'"></div>
      <div class="page-host" data-bind="router: { transition:'entrance' }"></div>
    </div>
    

您还可以创建一个名为navigation.js的视图模型,链接到视图:

<div>
  <div data-bind="compose: 'viewmodel/navigation'"></div>
  <div class="page-host" data-bind="router: { transition:'entrance' }"></div>
</div>

您还可以选择将compose变量转换为在视图模型中生成的可观察变量:

<div>
  <div data-bind="compose: navigationObservable"></div>
  <div class="page-host" data-bind="router: { transition:'entrance' }"></div>
</div>

这是有关组合绑定工作原理的简要介绍:

  • 如果它是字符串值:

    • 如果它具有视图扩展名,则定位视图并将其注入到 DOM 中,并根据当前上下文进行绑定。

    • 如果它是模块 ID,则定位模块,定位其视图,并将它们绑定并注入到 DOM 中。

  • 如果它是一个对象,则定位其视图并将其绑定并注入到 DOM 中。

  • 如果它是一个函数,则使用新修饰符调用该函数,获取其返回值,找到该返回值的视图,并将它们绑定并注入到 DOM 中。

如果您想要自定义组合,可以直接将视图和模型数据传递给组合器绑定,如下所示:

data-bind="compose: { model:someModelProperty, view:someViewProperty }"

这允许您将具有相同数据的不同视图组合为可观察的模型或视图。

您还可以使用 Knockout 注释组合视图:

<!-- ko compose: activeItem--><!--/ko-->

您可以增加组合绑定的设置值:

  • transition:您可以在组合更改时指示过渡。

  • cacheviews:这不会从 DOM 中移除视图。

  • activate:这为此组合定义了激活函数。

  • perserveContext:如果将其设置为false,则会分离父上下文。当视图没有关联的模型时,这很有用。它提高了性能。

  • activationData:这是指附加到activate函数的数据。

  • mode:这可以是inlinetemplated。默认情况下,内联是模式。templated模式与data-part属性一起使用,通常与小部件一起使用。

  • onError:您可以绑定一个错误处理程序函数,以在组合失败时优雅地失败,如下面的代码所示:

    div data-bind="compose: { model: model, onError: errorHandlerFunction }"></div>
    

您可以在 Durandal 文档中找到有关组合的完整说明,网址为durandaljs.com/documentation/Using-Composition.html

路由器

Durandal 提供了一个路由器插件,使导航快速简便。路由器与历史插件配合工作,处理浏览器中的导航状态。

要使用路由器插件:

  1. main.js文件中激活插件:

    app.configurePlugins({
      router: true,
    });
    
  2. shell.js文件中进行配置:

    router.map([{...},{...}]).buildNavigationModel();
    return router.activate();
    

以下是我们购物车应用程序的路由器示例:

router.map([
  {route:[''/*default route*/,'catalog'], title:'catalog', moduleId:'viewmodels/catalog', nav: true},
  {route:'cart', title:'cart', moduleId:'viewmodels/cart', nav: true},
  {route:'product/:id', title:'Product detail', moduleId:'viewmodels/product-detail', nav:false},
  {route:'product/:id*action', moduleId:'viewmodels/product', nav:false, hash:'#product/:id'},
]).buildNavigationModel();
return router.activate();

看一下shell.js文件。路由器作为视图模型的一个元素传递。这使您能够根据当前路由更新导航。Durandal 提供了一个友好的界面来从router对象构建导航菜单。在 shell 激活挂钩中映射路由,然后使用路由器流畅 API 构建导航模型。

最后,返回包含来自 shell 激活挂钩的承诺的router.activate()方法。返回一个承诺意味着组合引擎将等待路由器准备好后再显示 shell。

让我们更详细地看一下路由映射。路由有不同的模式。至少,您应该提供一个路由和一个moduleId值。当 URL 哈希更改时,路由器将检测到并使用路由模式找到正确的路由。然后,它将加载具有moduleId值的模块。路由器将激活并组合视图。

有一些可选参数:

  • nav:当您调用buildNavigationModel方法时,它将只使用此属性设置为true的路由创建一个名为navigationModel的可观察数组。

  • title:这用于设置文档标题。

  • hash:使用此选项,您可以提供用于数据绑定到锚标记的自定义哈希。如果未提供哈希,则路由器将生成一个哈希。

有四种不同类型的路由:

  • 默认路由设置为空字符串:

    route.map([{route:''}]);
    
  • 静态路由没有参数:

    route.map([{route:'catalog'}]);
    
  • 参数化路由是带参数的路由:

    • 使用冒号定义参数:

      route.map([{route: 'product/:id'}]);
      
    • 可选参数在括号内:

      route.map([{route: 'product(/:id)'}]);
      
  • Splat 路由用于构建子路由。我们可以使用星号来定义它们:

    route.map({route:'product/:id*actions'});
    
  • 未知路由由方法管理:mapUnknownRoutes(module,view)

    route.mapUnknowRoutes(notfound,'not-found');
    

如果您查看navigation.html文件,您将能够看到路由器的工作方式。

注意,对于navigationModel属性路由的foreach绑定是使用buildNavigationModel方法构建的。此数组的每个元素都有一个isActive标志,当路由处于活动状态时,该标志被设置为true。最后,有一个名为isNavigating的属性,允许您向用户发出导航页面之间正在进行的警告,如下所示:

<ul class="nav navbar-nav" data-bind="foreach: router.navigationModel">
  <li data-bind="css: { active: isActive }">
    <a data-bind="attr: { href: hash }, text: title"></a>
    </li>
</ul>
<ul class="nav navbar-nav navbar-right">
  <li class="loader" data-bind="css: { active: router.isNavigating }">
    <i class="fa fa-spinner fa-spin fa-2x"></i>
  </li>
</ul>

如果你回到shell.html页面,你会看到你将路由器绑定到page-host元素。此绑定在page-host容器中显示活动路由。这只是 Durandal 组合功能的另一个演示。

路由参数

路由参数在路由中使用冒号设置。这些参数可以传递给每个模块的canActivateactivate方法。如果路由有查询字符串,则作为最后一个参数传递。

触发导航

这里列出了一些触发导航的方式:

  • 使用锚标记:

    <a data-bind="attrs:{href:'#/product/1'}">product 1</a>
    
  • 使用router.navigate(hash)方法。这将触发导航到关联的模块。

    router.navigate('#/product/1');
    
  • 如果您想要添加一个新的历史记录条目但不调用模块,只需将第二个参数设置为false

    router.navigate('#/product/1',false);
    
  • 如果您只想替换历史记录条目,请传递一个带有replacetruetriggerfalse的 JSON 对象:

    router.navigate('#/product/1',{ replace: true, trigger: false });
    

子路由器

在大型应用程序中,您必须能够处理数十个甚至数百个路由。您的应用程序可能只有一个主路由器,但也可能有多个子路由器。这为 Durandal 提供了处理深度链接场景并根据功能封装路由的方法。

通常,父级将使用星号(*)映射一个路由。子路由器将相对于该路由工作。让我们看一个例子:

  1. 需要应用程序路由器。

  2. 调用createChildRouter()。这将创建一个新的路由器。

  3. 使用makeRelative API。配置基本的moduleIdfromParent属性。该属性使路由相对于父级的路由。

这就是它的工作原理:

// product.js viewmodel
define(['plugins/router', 'knockout'], function(router, ko) {
  var childRouter = router.createChildRouter()
    .makeRelative({
      moduleId:'product',
      fromParent:true,
      dynamicHash: ':id'
    }).map([
      { route: 'create', moduleId: 'create', title: 'Create new product', type: 'intro', nav: true },
      { route: 'update', moduleId: 'update', title: 'Update product', type: 'intro', nav: true},
    ]).buildNavigationModel();
  return {
    //the property on the view model should be called router
    router: childRouter 
  };
});

首先,它捕获product/:id*动作模式。这将导致导航到product.js。应用程序路由器将检测到子路由的存在,并将控制委托给子路由。

当子路由与参数一起使用时,在makeRelative方法中激活dynamicHash属性。

事件

事件用于模块间通信。事件 API 集成到app模块中,非常简单:

  • on:订阅视图模型的事件

    app.on('product:new').then(function(product){
      ...
    });
    
  • off:取消订阅视图模型的事件

    var subscription = app.on('product:new').then(function(product){
      ...
    });
    subscription.off();
    
  • 触发器:触发事件

    app.trigger('product:new', newProduct);
    

你可以将所有事件名称传递给监听所有类型的事件:

app.on('all').then(function(payload){
  //It will listen all events
});

durandaljs.com/documentation/Leveraging-Publish-Subscribe.html阅读更多关于事件的内容。

小部件

小部件是 Durandal 组成中的另一个重要部分。它们就像视图模型,只有一个例外。视图模型可以是单例的,我们通常更喜欢它们是单例的,因为它们代表站点上的唯一页面。另一方面,小部件主要是用构造函数编写的,因此它们可以根据需要实例化多次。因此,当我们构建小部件时,我们不返回对象,就像视图模型中发生的那样。相反,我们返回一个构造函数,Durandal 实例化小部件。

将小部件保存在app/widgets/{widget_name}中。小部件应该有一个viewmodel.js文件和一个view.html文件。

我们将开发一个名为accordion的小部件来演示小部件的工作原理。此小部件将基于 Bootstrap 提供的 jQuery 折叠插件。

设置小部件

按照以下步骤创建一个插件:

  1. bootstrap库添加到项目中。要实现这一点,请将其添加到主模块的依赖项中:

    define([
      'durandal/system', 
      'durandal/app', 
      'durandal/viewLocator',
      'bootstrap'
    ],  function (system, app, viewLocator, bs) {
      //Code of main.js module
    });
    
  2. 安装插件。在main.js文件中注册小部件插件:

    app.configurePlugins({
      widget: true
    });
    
  3. app文件夹中创建一个名为 widget 的目录。

  4. 添加一个名为accordion的子目录。

  5. accordion目录下添加一个名为viewmodel.js的文件。

  6. accordion目录中添加一个名为view.html的文件。

如果你不喜欢 Durandal 的约定,可以在durandaljs.com/documentation/api#module/widget上阅读有关小部件配置的更多信息。

编写小部件视图

编写视图,请按照以下步骤进行:

  1. 打开app/widgets/expander/view.html文件。

  2. 编写此代码,按照 bootstrap3 折叠模板(getbootstrap.com/javascript/#collapse):

    <div class="panel-group" data-bind="foreach: { 
      data: settings.items }">
      <div class="panel panel-default">
        <div class="panel-heading" data-bind="">
          <h4 class="panel-title">
            <a data-toggle="collapse" data-bind="attr:{'data-target':'#'+id}">
              <span data-part="header" data-bind="html: $parent.getHeaderText($data)">
              </span>
            </a>
          </h4>
        </div>
        <div data-bind="attr:{id:id}" class="panel-collapse collapse">
          <div class="panel-body">
            <div data-part="item" data-bind="compose: $data"></div>
          </div>
        </div>
      </div>
    </div>
    

通过先编写视图,你可以确定需要在视图模型中创建哪些变量才能完成视图。在这种情况下,你将需要一个存储手风琴元素的项目数组。它将包含每个可折叠元素的 ID,在小部件内自动生成,标题文本和正文。

编写小部件视图模型

要编写小部件视图模型,请打开accordion小部件文件夹中的viewmode.js文件,并编写以下代码:

define(['durandal/composition','jquery'], function(composition, $) {
  var ctor = function() { };

  //generates a simple unique id	
  var counter = 0;

  ctor.prototype.activate = function(settings) {
    this.settings = settings;
    this.settings.items.forEach(function(item){
      item.id=counter++;
    });
  };
  ctor.prototype.getHeaderText = function(item) {
    if (this.settings.headerProperty) {
      return item[this.settings.headerProperty];
    }

    return item.toString();
  };

  return ctor;
});

正如你所见,你返回了一个小部件的构造函数,而不是像页面一样返回一个视图模型本身。

在这种情况下,要管理生命周期,你只需定义activate方法来分配值和生成 ID。请记住,如果你想用代码添加一些 DOM 修改,那么附加方法将是一个不错的地方。

注册小部件

要注册小部件,只需在主模块(main.js)中注册即可:

app.configurePlugins({
  widget: {
    kinds: ['accordion']
  }
});

使用 Durandal 构建页面

现在你已经学会了 Durandal 框架的所有基础知识,让我们创建一个包含我们的小部件和一些基本数据的新页面。

要在 Durandal 中定义新页面,始终按照相同步骤进行:

  1. 在 shell 视图模型中定义路由:

    router.map([
    { route: '', title:'Welcome', moduleId: 'viewmodels/welcome', nav: true },
    { route: 'flickr', moduleId: 'viewmodels/flickr', nav: true },
    { route: 'accordion', moduleId: 'viewmodels/accordion', nav: true }
    ]).buildNavigationModel();
    
  2. 定义views/accordion.html文件。注意,在手风琴绑定内部,你可以定义data-part模板。在这里,你正在使用 Durandal 提供的组合能力。通过添加一个add按钮,你为小部件提供了添加新元素的可能性。

    <div>
      <h2 data-bind="text:title"></h2>
      <div data-bind="accordion: {items:projects, headerProperty:'name'}">
        <div data-part="header">
          <span data-bind="text:name"></span>
        </div>
        <div data-part="item">
          <span data-bind="text:description"></span>
        </div>
      </div>
      <div class="btn btn-primary" data-bind="click:add">
        Add new project
      </div>
    </div>
    
  3. 定义viewmodels/accordion.js文件。你已经将projects设置为可观察数组,并在activate方法中进行了初始化。视图模型提供了一个add函数,触发名为accordion:add的事件。这会发送带有新标签值的消息。小部件应监听此事件并执行操作。

    define(['plugins/http', 'durandal/app', 'knockout'], function (http, app, ko) {
      return {
        title: 'Accordion',
        projects: ko.observableArray([]),
        activate: function () {
          this.projects.push(
          {name:'Project 1',description:"Description 1"});
          this.projects.push(
          {name:'Project 2',description:"Description 2"});
          this.projects.push(
          {name:'Project 3',description:"Description 3"});
        },
        add: function () {
          app.trigger('accordion:add',
          {name:'New Project',description:"New Description"});
        }
      };
    });
    
  4. widgets/accordion/viewmodel.js文件中定义事件,更新activate方法:

    ctor.prototype.activate = function(settings) {
      this.settings = settings;
    
      var _settings = this.settings;//save a reference to settings
      var items = this.settings.items();//get data from observable
    
      items.forEach(function(item){//manipulate data
        item.id=guid();
      });
    
      this.settings.items(items);//update observable with new data
    
      //listen to add event and save a reference to the listener
      this.addEvent = app.on('accordion:add').then(function(data){
        data.id = guid();
        _settings.items.push(data);
      });
    };
    
  5. 定义分离的生命周期方法,以便在小部件不在屏幕上时关闭add event

    ctor.prototype.detached = function () {
      //remove the suscription 
      this.addEvent.off();
    }
    
  6. 启动应用程序并测试小部件。

概要

在本章中,你已经了解了 Durandal。使用一个所有部件都完美连接的框架,而不是一堆库,可以帮助你避免一遍又一遍地重写相同的代码。这意味着,多亏了 Durandal,你可以轻松地遵循开发者的基本原则之一(不要重复自己 - DRY)。

你学到了一些有用的概念,比如如何安装和启动 Durandal 项目。你还了解了 Durandal 应用程序的生命周期是如何工作的。

Durandal 最强大的功能之一是组合。你可以非常轻松地组合界面,对开发者几乎是透明的。

你了解了 Durandal 如何管理承诺。默认情况下,它使用 jQuery 的承诺,但你发现很容易使用其他库,比如 Q。

最后,你开发了一个小部件,并将其集成到视图模型中。虽然视图模型是单例的,但小部件是可以多次实例化的元素。它们是 Durandal 组合的一个强大部分。

在下一章中,我们将逐步将我们的 KnockoutJS 购物车迁移到 Durandal 单页面应用程序。

第八章:使用 Durandal 开发 Web 应用程序 - 购物车项目

现在我们知道 Durandal 的工作原理,是时候将我们的旧应用程序迁移到使用我们的新框架了。在本章中,您将学习如何重用我们在书中使用的代码,并将部分代码适应新环境。

介绍

在本章中,我们将开发一个全新的应用程序。但是,我们将重用上一章中开发的大部分代码。

只使用 Knockout 的缺点之一是随着应用程序的增长,我们的应用程序需要连接到许多库。我们在本书中开发的应用程序非常小,但足够复杂,我们还没有解决一个重要的问题,即路由。我们的应用程序始终位于同一页上。我们无法在订单和目录之间或购物车和目录之间导航。我们的整个应用程序都在同一页上,显示和隐藏组件。

Durandal 连接了您在本书中学到的一些库,并且使连接到新库变得容易。

在本章中,我们将看到一些非标准 UML 符号的模式。现今,敏捷方法不建议深入使用 UML,但这些类型的图表帮助我们更全面、更清晰地了解我们功能的结构和需求。此外,为了部署视图,我们将看到一些关于 HTML 如何完成的草图和模拟:

Introduction

我们应用程序的生命周期

设置项目

要启动新项目,我们将按照一些步骤进行,这将为我们开发项目提供一个良好的起点:

  1. 创建一个与 Knockout 购物车相同的项目。

  2. 在此项目内,复制 Durandal Starter Kit 项目的内容。

  3. 现在我们的项目应该有三个文件夹:

    • app:这包含了我们的应用程序。

    • css:这包含样式表

    • lib:这包含第三方库

  4. 将以下库从 Knockout 购物车项目迁移到 Durandal 购物车项目:

    • icheck

    • kovalidation

    • mockjax

    • mockjson

  5. codeseven.github.io/toastr/ 安装一个名为 Toastr 的新库。

  6. 更新第 19 行的 ko.validation.js 文件,使用以下代码:

    define(["knockout", "exports"], factory);
    
  7. style.css 文件从 Knockout 购物车移动到 Durandal 购物车项目的 css 文件夹中。

  8. models 文件夹移动到 app 文件夹内。

  9. services 文件夹移动到 app 文件夹内。

  10. 创建一个名为 bindings.js 的文件,与 main.js 文件处于同一级别,并将所有绑定移到 koBindings.js 文件中。

  11. 创建一个名为 mocks.js 的文件,与 main.js 文件处于同一级别,并将所有模拟移到 mocks 文件夹中。

  12. 创建一个名为 components.js 的文件,与 main.js 文件处于同一级别,并将所有组件移到那里。

  13. 更新 knockout 库。Durandal 起始套件附带版本 3.1,我们将使用 3.2 版本,这是我们在 Knockout 购物车项目中使用的版本。3.2 版本允许我们使用 inputText 绑定和组件。您可以在此链接中查看所有版本之间的区别:github.com/knockout/knockout/releases

  14. 更新 main.js 文件:

    requirejs.config({
      paths: {
        'text': '../lib/require/text',
        'durandal':'../lib/durandal/js',
        'plugins' : '../lib/durandal/js/plugins',
        'transitions' : '../lib/durandal/js/transitions',
        'knockout': '../lib/knockout/knockout-3.1.0.debug',
        'bootstrap': '../lib/bootstrap/js/bootstrap.min',
        'jquery': '../lib/jquery/jquery-1.9.1',
        'toastr': '../lib/toastr/toastr.min',
        'ko.validation': '../lib/kovalidation/ko.validation',
        'mockjax': '../lib/mockjax/jquery.mockjax',
        'mockjson': '../lib/mockjson/jquery.mockjson',
        'icheck': '../lib/icheck/icheck'
      },
      shim: {
        'bootstrap': {
          deps: ['jquery'],
          exports: 'jQuery'
        },
        mockjax: {
          deps:['jquery']
        },
        mockjson: {
          deps:['jquery']
        },
        'ko.validation':{
          deps:['knockout']
        },
        'icheck': {
          deps: ['jquery']
        }
      }
    });
    
    define([
      'durandal/system',
      'durandal/app',
      'durandal/viewLocator',
      'mocks',
      'bindings',
      'components',
      'bootstrap',
      'ko.validation',
      'icheck',
    ],  function (system, app, viewLocator,mocks,bindings,components) {
      //>>excludeStart("build", true);
      system.debug(true);
      //>>excludeEnd("build");
    
      app.title = 'Durandal Shop';
    
      app.configurePlugins({
        router:true,
        dialog: true
      });
    
      app.start().then(function() {
        //Replace 'viewmodels' in the moduleId with 'views' to locate the view.
        //Look for partial views in a 'views' folder in the root.
        viewLocator.useConvention();
    
        //Show the app by setting the root view model for our application with a transition.
        app.setRoot('viewmodels/shell', 'entrance');
    
        mocks();
        bindings.init();
        components.init();
      });
    });
    
  15. 将项目设置在您喜欢的服务器上,或者将 Mongoose 可执行文件复制到 index.html 所在的文件夹中。

  16. 使用新的 css 文件更新 index.html

    <link rel="stylesheet" href="lib/toastr/toastr.min.css" />
    <link rel="stylesheet" href="lib/icheck/skins/all.css" />
    <link rel="stylesheet" href="css/style.css" />
    

现在我们的项目已经准备好了,是时候逐步迁移我们的购物车了。

项目路由 – shell 视图模型

Durandal 给了我们在项目中管理路由的可能性。我们将把项目的不同部分分割成页面。这将提供更好的用户体验,因为我们将一次只关注一个任务。

我们将将应用程序拆分为四个部分:

  • 目录

  • 购物车

  • 订单

  • 产品 CRUD

这些部分将包含我们在 Knockout 应用程序中构建的几乎相同的代码。有时,我们需要适应一些小代码片段。

要创建这些新路由,我们将打开 shell.js 文件并更新路由器:

router.map([
  { route: ['','/','catalog'], title:'Catalog', moduleId: 'viewmodels/catalog', nav: true },
  { route: 'new', title:'New product', moduleId: 'viewmodels/new', nav: true },
  { route: 'edit/:id', title:'Edit product',moduleId: 'viewmodels/edit', nav: false },
  { route: 'cart', title:'Cart', 
    moduleId: 'viewmodels/cart', nav: false },
  { route: 'order', title:'Order', moduleId: 'viewmodels/order', nav: true }
]).buildNavigationModel();

让我们回顾一下路由器的工作原理:

  • route 包含相对 URL。对于目录,有三个 URL 附加到此路由。它们是空路由 (''),斜杠 ('/') 路由和目录。为了表示这三个路由,我们将使用一个数组。

  • title 将包含在 <title> 标签中附加的标题。

  • moduleId 将包含处理此路由的视图模型。如果我们使用约定,它将在 views 文件夹中查找视图,查找与视图模型同名的视图。在这种情况下,它会查找 views/catalog.html。如果我们选择不使用约定,Durandal 将在与视图模型相同的文件夹中查找。

  • 如果 nav 为 true,则导航菜单中将显示一个链接。如果为 false,则路由器不会在导航菜单中显示链接。

导航和 shell 模板

正如我们在第七章中所做的,Durandal – The KnockoutJS Framework,我们将会将我们的 shell.html 视图分为两部分:shell.htmlnavigation.html

目录模块

在 Knockout 购物车中,我们有一个管理应用程序所有部分的视图模型。在这里,我们将把那个大的视图模型拆分成几个部分。第一部分是目录。

这里是它应该如何工作的模式图:

目录模块

目录模块的工作流程

目录将仅包含包括搜索栏和带有其操作的表格的部分。这将使视图模型更小,因此更易于维护。

虽然文件将分成不同的文件夹,但目录本身是一个模块。它包含视图模型、视图以及一些仅在该模块内部工作的服务。其他组件将被引入,但它们将在应用程序生命周期中被更多模块共享。

  1. viewmodels 文件夹中创建一个名为 catalog.js 的文件,并定义一个基本的揭示模式骨架以开始添加功能:

    define([],function(){
      var vm = {};
      //to expose data just do: vm.myfeature = ...
      return vm;
    });
    
  2. views 文件夹中创建一个名为 catalog.html 的文件:

    <div></div>
    

仅仅通过这样做,我们的模块就已经准备好工作了。让我们完成代码。

目录视图

我们将使用组合来创建这个模板。记住,组合是 Durandal 的一个强大特性之一。为了完成这个功能,我们将创建三个包含根视图不同部分的新模板。通过这样做,我们将我们的视图更加易于维护,因为我们将模板的不同部分隔离在不同的文件中,这些文件更小且易于阅读。

目录视图

目录视图的草图

按照以下步骤创建模板:

  1. 打开 catalog.html 文件并创建基本模板:

    <div class="container-fluid">
      <div class="row">
        <div class="col-xs-12">
          <h1>Catalog</h1>
          <div data-bind="compose: 'catalog-searchbar.html'"></div>
          <div data-bind="compose: 'catalog-details.html'"></div>
          <div data-bind="compose:'catalog-table.html'"></div>
        </div>
      </div>
    </div>
    
  2. 创建一个名为 catalog-searchbar.html 的视图。我们用根视图的名称为子视图添加前缀,所以如果你的编辑器按名称对文件进行排序,它们将全部显示在一起。我们也可以将它们全部组合在一个文件夹中。我们可以选择我们感觉最舒适的方式:

    <input type="checkbox" data-bind="icheck:showSearchBar"/>
      Show Search options<br/><br/>
    <div class="input-group" data-bind="visible:showSearchBar">
      <span class="input-group-addon">
        <i class="glyphicon glyphicon-search"></i> Search
      </span>
      <input type="text" class="form-control" data-bind="value:searchTerm, valueUpdate: 'keyup', executeOnEnter:filterCatalog" placeholder="Press enter to search...">
    </div>
    <hr/>
    
  3. 现在是时候定义名为 catalog-details.html 的视图了;它将包含操作和购物车详情:

    <div class="row cart-detail">
      <div class="col-lg-2 col-md-4 col-sm-4 col-xs-4">
        <strong>
          <i class="glyphicon glyphicon-shopping-cart"></i> 
            Items in the cart:
        </strong>
        <span data-bind="text:CartService.cart().length"></span>
      </div>
      <div class="col-lg-2 col-md-4 col-sm-4 col-xs-4">
        <strong>
          <i class="glyphicon glyphicon-usd"></i> 
          Total Amount:
        </strong>
        <span data-bind="text:CartService.grandTotal"></span>
      </div>
      <div class="col-lg-8 col-md-4  col-sm-4 col-xs-4 text-right">
        <button data-bind="click:refresh" class="btn btn-primary btn-lg">
          <i class="glyphicon glyphicon-refresh"></i> Refresh
        </button>
        <a href="#/cart" class="btn btn-primary btn-lg">
          <i class="glyphicon glyphicon-shopping-cart"></i> 
          Go To Cart
        </a>
      </div>
    </div>
    
  4. 最后,我们将定义包含我们在 Knockout 购物车项目中构建的表格的 catalog-table.html。某些 data-bind 元素应该被更新,而页脚需要被移除:

    <table class="table">
      <thead>
      <tr>
        <th>Name</th>
        <th>Price</th>
        <th>Stock</th>
        <th></th>
      </tr>
      </thead>
      <tbody data-bind="{foreach:filteredCatalog}">
      <tr data-bind="style:{color:stock() < 5?'red':'black'}">
        <td data-bind="text:name"></td>
        <td data-bind="{currency:price}"></td>
        <td data-bind="{text:stock}"></td>
        <td>
          <add-to-cart-button params="{cart: $parent.CartService.cart, item: $data}">
          </add-to-cart-button>
          <button class="btn btn-info" data-bind="{click:$parent.edit}">
            <i class="glyphicon glyphicon-pencil"></i>
          </button>
          <button class="btn btn-danger" data-bind="{click:$parent.remove}">
            <i class="glyphicon glyphicon-remove"></i>
          </button>
        </td>
      </tr>
      </tbody>
      <!-- FOOTER HAS BEEN REMOVED -->
    </table>
    

目录视图模型

现在是时候定义我们可以在我们的模板中识别的所有组件了。我们应该开始定义我们可以在模板中定位到的基本数据:

vm.showSearchBar = ko.observable(true);
vm.searchTerm = ko.observable("");
vm.catalog = ko.observableArray([]);
vm.filteredCatalog = ko.observableArray([]);

一旦我们定义了这些变量,我们意识到需要 Knockout 依赖。将其添加到依赖项数组中,并且也作为 module 函数的一个参数:

define(['knockout'],function(ko){ ... })

现在我们应该定义 filterCatalog 方法。这是我们在 Knockout 项目中的视图模型中拥有的相同方法:

vm.filterCatalog = function () {
  if (!vm.catalog()) {
    vm.filteredCatalog([]);
  }
  var filter = vm.searchTerm().toLowerCase();
  if (!filter) {
    vm.filteredCatalog(vm.catalog());
  }
  //filter data
  var filtered = ko.utils.arrayFilter(vm.catalog(), function (item) {
    var fields = ["name"]; //we can filter several properties
    var i = fields.length;
    while (i--) {
      var prop = fields[i];
      if (item.hasOwnProperty(prop) && ko.isObservable(item[prop])) {
        var strProp = ko.utils.unwrapObservable( item[prop]).toLocaleLowerCase();
        if (item[prop]() && (strProp.indexOf(filter) !== -1)) {
          return true;
        }
      }
    }
    return false;
  });
  vm.filteredCatalog(filtered);
};

add-to-cart-button 组件在 Knockout 项目中被定义,我们不需要触碰该组件的任何代码。这是一个很好的组件及其潜力的明确例证。

要编辑目录中的产品,我们需要导航到编辑路由。这会创建与路由插件的依赖关系。我们应该在我们的模块中添加 plugins/router 依赖关系。

vm.edit = function(item) {
  router.navigate('#/edit/'+item.id());
}

要从目录中移除产品,我们需要从服务器和购物车中将其移除。要与服务器通信,我们将使用 services/product.js 文件,而要与购物车通信,我们将在一个名为 services/cart 的文件中创建一个新服务。定义 remove 方法:

vm.remove = function(item) {
  app
    .showMessage(
      'Are you sure you want to delete this item?',
      'Delete Item',
      ['Yes', 'No']
    ).then(function(answer){
      if(answer === "Yes") {
        ProductService.remove(item.id()).then(function(response){
          vm.refresh();
            CartService.remove(item);
        })
      }
    });
}

首先我们使用 Durandal 的消息组件。它非常有用于处理模态对话框。我们将询问用户是否应删除产品。如果是,则我们将从服务器中删除它,然后刷新我们的视图模型,并且从购物车中删除产品,因为它不再可用。

我们应该添加一个依赖项到durandal/app,并且依赖于ProductServiceCartService

ProductService在 Knockout 项目中被定义。如果我们保持模型和服务非常简单,它们将变得可移植,并且非常适应不同的项目。

现在是实现refresh方法的时候了。我们将调用ProductService.all()方法,并显示一条消息,让用户知道产品已加载。我们将返回此方法生成的承诺。

vm.refresh = function () {
  return ProductService.all().then(function(response){
    vm.catalog([]);
    response.data.forEach(function(item){
      vm.catalog.push(new Product(item.id,item.name,item.price,item.stock));
    });
    var catalog = vm.catalog();
    CartService.update(catalog);
    vm.catalog(catalog);
    vm.filteredCatalog(vm.catalog());
    LogService.success("Downloaded "+vm.catalog().length+" products", "Catalog loaded");
  });
};

在这里,我们使用了在 Knockout 项目中使用的相同模型来表示产品。我们看到了很多代码,但大部分是在书中较早完成的,所以我们只需要将它们从一个项目移到另一个项目中。

最后一步是激活我们的视图模型。什么时候应该激活视图模型?当我们的产品来自服务器并且准备好展示时:

vm.activate = function() {
  if(vm.catalog().length === 0) {
    app.on("catalog:refresh").then(function(){
      vm.refresh();
    });
    return vm.refresh();
  } else {
    return true;
  }
}

第一次加载应用程序时,我们会检查目录是否有产品。如果有,我们只需返回目录已准备就绪。如果目录为空,我们会创建一个事件,让其他服务通知目录它应该更新。然后我们刷新目录以获取新数据。

这是我们catalog视图模型的最终结果;当然,我们仍然需要实现日志服务和购物车服务:

define(['knockout','durandal/app','plugins/router', 'services/log','services/product','services/cart', 'models/product','models/cartproduct'
],function(ko, app, router, LogService, ProductService, CartService, Product, CartProduct){
  var vm = {};
  vm.showSearchBar=ko.observable(true);
  vm.searchTerm = ko.observable("");
  vm.catalog = ko.observableArray([]);
  vm.filteredCatalog = ko.observableArray([]);
  vm.CartService = CartService;

  vm.filterCatalog = function () {...};
  vm.edit = function(item) {...}
  vm.remove = function(item) {...}
  vm.refresh = function () {...}
  vm.activate = function() {...}
  return vm;
});

购物车服务

购物车服务将管理所有模块的购物车数据。服务在会话期间具有持久数据,因此它们可以帮助我们在视图模型之间共享数据。在这种情况下,购物车服务将与购物车共享一些页面:目录、购物车和订单。

购物车服务将对在cart可观察对象上执行的操作做出反应。add操作由add-to-cart-button组件管理,但是将这个行为集成到这里会很有趣。代码重构可以是一个很好的练习。在这个例子中,我们将保留组件,并实现其他方法。

购物车服务还将购物车的总金额存储在grandTotal可观察对象中。

购物车服务也更新购物车。这很有用,因为当目录更新时,购物车中存储的产品引用与目录中的新产品不同,所以我们需要更新这些引用。它还更新了目录,通过减少购物车中每个产品的单位来减少库存。我们之所以这样做是因为服务器发送给我们它所拥有的数据。服务器不知道我们现在正在购物。也许我们决定不购物,所以我们购物车中的产品不被注册为已售出。这就是为什么我们需要在从服务器获取产品后更新客户端中的单位的原因。这是购物车服务的代码:

define(['knockout','durandal/app' ,'models/cartproduct'],function(ko,app, CartProduct){
  var service = {};
  service.cart = ko.observableArray([]);
  service.add = function(data){
    if(!data.hasStock()) {
      LogService.error("This product has no stock available");
      return;
    }
    var item = null;
    var tmpCart = service.cart();
    var n = tmpCart.length;

    while(n--) {
      if (tmpCart[n].product.id() === data.id()) {
        item = tmpCart[n];
      }
    }

    if (item) {
      item.addUnit();
    } else {
      item = new CartProduct(data,1);
      tmpCart.push(item);
      item.product.decreaseStock(1);
    }

    service.cart(tmpCart);
  };
  service.subtract = function(data) {
    var item = service.find(data);
    item.removeUnit();
  }
  service.grandTotal = ko.computed(function(){
    var tmpCart = service.cart();
    var total = 0;
    tmpCart.forEach(function(item){
      total+= (item.units() * item.product.price());
    });
    return total;
  });
  service.find = function (data) {
    var tmp;
    service.cart().forEach(function(item){
      if (item.product.id() === data.id()) {
        tmp = item;
      }
    });
    return tmp;
  }
  service.remove = function (data) {
    var tmp = service.find(data);
    var units = tmp.product.stock()+tmp.units();
    tmp.product.stock(units);
    service.cart.remove(tmp);
  };
  service.update = function (catalog){
    var cart = service.cart();
    var newCart = [];
    for(var i =0;i<catalog.length;i++){
      for(var j=0;j<cart.length;j++){
        var catalogItem = catalog[i];
        var cartItem = cart[j];
        if(cartItem.product.id() === catalogItem.id()){
          catalogItem.stock(catalogItem.stock() - cartItem.units());
          newCart.push(new CartProduct(catalogItem,cartItem.units()));
        }
      }
    }
    service.cart(newCart);
  }
  return service;
});

日志服务

日志服务允许我们显示消息以通知用户我们的应用程序中正在发生的情况。为此,我们使用一个称为 Toastr 的库。我们可以直接在应用程序上使用 Toastr,但是一个好的做法是始终封装库以分离我们不应该触及的代码。此外,将库包装在另一个库中使其易于扩展和定制库的行为。在这种情况下,我们还添加了在控制台中记录消息的功能:

define(["toastr"],function(toastr){
  //TOASTR CONFIG
  toastr.options.positionClass = 'toast-bottom-right';

  var error = function(text,title,log) {
    toastr.error(title,text);
    if (log) {
      console.error(title,text);
    }
  };
  var success = function(text,title,log) {
    toastr.success(title,text);
    if (log) {
      console.log(title,text);
    }
  };
  var warning = function(text,title,log) {
    toastr.warning(title,text);
    if (log) {
      console.warn(title,text);
    }
  };
  var info = function(text,title,log) {
    toastr.info(atitle,text);
    if (log) {
      console.info(title,text);
    }
  };
  return {
    error:error,
    success:success,
    warning:warning,
    info:info
  }
});

将产品添加到目录

添加功能与此路由相关:

{ route: 'new', title:'New product', moduleId: 'viewmodels/new', nav: true }

要创建这个模块,我们需要创建添加视图和添加视图模型。为此,请创建两个文件,名为views/newviewmodels/new.js,并重复我们在目录模块中使用的模板。

将产品添加到目录

添加产品的工作流程

添加产品视图

创建或更新产品更多或更少是相同的。不同之处在于当我们编辑一个产品时,字段具有数据,当我们添加一个新产品时,此产品的字段为空。这可能使我们想知道也许我们可以隔离视图。

让我们将new.html文件定义如下:

<div data-bind="compose:'edit.html'"></div>

这意味着new.html文件由edit.html文件组成。我们只需要定义一个模板来管理两者。很棒,是吗?

添加产品视图

添加新产品的草图

编辑视图

我们只需要复制并粘贴我们在 Knockout 项目中使用的编辑表单。我们已经更新了布局,但是我们使用了相同的表单。

<div class="container-fluid">
  <div class="row">
    <div class="col-xs-6 col-xs-offset-3">
      <form class="form-horizontal" role="form" data-bind="with:product">
        <div class="modal-header">
          <h3 data-bind="text:$parent.title"></h3>
        </div>
        <div class="modal-body">
          <div class="form-group">
            <div class="col-sm-12">
              <input type="text" class="form-control" placeholder="Name" data-bind="textInput:name">
            </div>
          </div>
          <div class="form-group">
            <div class="col-sm-12">
              <input type="text" class="form-control" placeholder="Price" data-bind="textInput:price">
            </div>
          </div>
          <div class="form-group">
            <div class="col-sm-12">
              <input type="text" class="form-control" placeholder="Stock" data-bind="textInput:stock">
            </div>
          </div>
        </div>
        <div class="modal-footer">
          <div class="form-group">
            <div class="col-sm-12">
              <a href="#/catalog"></a>
              <button type="submit" class="btn btn-default" data-bind="{click:$parent.edit, enable:!errors().length}">
                <i class="glyphicon glyphicon-plus-sign"></i>
                <span data-bind="text:$parent.btn"></span>
              </button>
            </div>
          </div>
        </div>
      </form>
    </div>
  </div>
</div>

有一些东西应该动态创建,比如布局的标题和按钮名称。edit方法将指定产品服务的哪个方法来处理产品——ProductService.createProductService.save

添加产品视图模型

添加产品视图模型编码在viewmodels/new.js文件中。它将创建一个新产品。如果一切顺利,我们会通知用户并导航到目录。为了在目录中显示新产品,我们触发catalog:refresh事件:

define(["durandal/app","plugins/router","services/log","services/uuid","services/product","models/product"
],function(app, router,LogService,uuid, ProductService,Product){
  var vm = {};
  vm.title = "New product";
  vm.btn = "Add product";
  vm.edit = function() {
    ProductService.create(vm.product.toObj()).then(function(response){
      LogService.success("Product added","New product "+vm.product.name()+" added");
      router.navigate("#/catalog");
      app.trigger("catalog:refresh");
    });
  };
  vm.activate = function () {
    vm.product = new Product();
  };
  return vm;
});

在我们的模拟的第一个版本中,如果我们添加了一个新项目,我们的目录没有得到更新。它返回了我们一开始得到的同样五个产品。我们打算改进我们的模拟库,使其更加逼真。

使模拟变得真实

让我们来看看我们的mocks.js文件,特别是获取产品模拟的部分:

$.mockjax({
  url: "/products",
  type: "GET",
  dataType: "json",
  responseTime: 750,
  responseText: $.mockJSON.generateFromTemplate({
    "data|5-5": [{
      "id|1-100": 0,
      "name": "@PRODUCTNAME",
      "price|10-500": 0,
      "stock|1-9": 0
    }]
  })
});

让我们将其重构为:

$.mockjax({
  url: "/products",
  type: "GET",
  dataType: "json",
  responseTime: 750,
  responseText: updatedCatalog()
});

现在我们要创建updatedCatalog函数。我们在开始时生成产品数组,然后始终使用这个副本进行操作:

var catalog = $.mockJSON.generateFromTemplate({
  "data|5-5": [{
    "id|1-100": 0,
    "name": "@PRODUCTNAME",
    "price|10-500": 0,
    "stock|1-9": 0
  }]
});
var updatedCatalog = function () {
  return catalog;
}

在旧版本的模拟中,当我们得到一个产品时,我们使用模板随机生成一个产品。现在我们将回到真实的产品。我们将沿着目录进行迭代,并返回具有选定 ID 的产品。此外,我们还将更新模拟对象。我们将创建一个响应函数来查找产品并生成正确的响应,而不是编写响应文本:

function findById(id){
  var product;
  catalog.data.forEach(function(item){
    if (item.id === id) {
      product = item;
    }
  });
  return product;
};
$.mockjax({
  url: /^\/products\/([\d]+)$/,
  type: "GET",
  dataType: "json",
  responseTime: 750,
  response: function(settings){
    var parts = settings.url.split("/");
    var id = parseInt(parts[2],10);
    var p = findById(id);
    this.responseText = {
      "data": p
    }
  }
});

我们应该更新POSTPUT模拟数据以向模拟目录添加产品并更新已存在的产品:

var lastId= 101; //Max autogenarated id is 100
$.mockjax({
  url: "/products",
  type:"POST",
  dataType: "json",
  responseTime: 750,
  response: function(settings){
    settings.data.id = lastId;
    lastId++;
    catalog.data.push(settings.data);
    this.responseText = {
      "data": {
        result: "true",
          text: "Product created"
      }
    }
  }
});
$.mockjax({
  url: "/products",
  type:"PUT",
  dataType: "json",
  responseTime: 750,
  response: function (settings) {
    var p = findById(settings.data.id);
    p.name = settings.data.name;
    p.price = settings.data.price;
    p.stock = settings.data.stock;
    this.responseText = {
      "data": {
        result: "true",
        text: "Product saved"
      }
    }
  }
});

当调用DELETE方法时,我们还应该从模拟数据中移除产品:

$.mockjax({
  url: /^\/products\/([\d]+)$/,
  type:"DELETE",
  dataType: "json",
  responseTime: 750,
  response: function(settings){
    var parts = settings.url.split("/");
    var id = parseInt(parts[2],10);
    var p = findById(id);
    var index = catalog.data.indexOf(p);
    if (index > -1) {
      catalog.data.splice(index, 1);
    }
    this.responseText = {
      "data": {
        result: "true",
        text: "Product deleted"
      }
    }
  }
});

最后,我们应该将订单模拟数据移动到这个文件中,以便与目录共享。当执行订单时,目录中的库存应该更新:

$.mockjax({
  type: 'PUT',
  url: '/order',
  responseTime: 750,
  response: function (settings){
    var cart = settings.data.order();
    cart.forEach(function(item){
      var elem = findById(item.product.id());
      elem.stock -= item.units();
    });
    this.responseText = {
      "data": {
        orderId:uuid(),
        result: "true",
        text: "Order saved"
      }
    };
  }
});

订单模拟数据将生成一个用于识别订单的唯一 ID。这必须发送回给用户以便未来识别订单。在我们的应用程序中,这标志着我们项目生命周期的结束。

这是我们用于生成唯一 ID 的uuid函数:

var uuid = (function uuid() {
  function s4() {
    return Math.floor((1 + Math.random()) * 0x10000)
    .toString(16)
    .substring(1);
  }
  return function() {
    return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
  };
})();

我们可以将该函数保留在模拟模块中,或者创建一个新的服务来处理唯一 ID 的生成。

现在我们的模拟数据以更现实的方式响应应用程序。

编辑视图模型

回到我们的模块,现在我们需要创建edit.js视图模型。它将与new.js文件有相同的结构,但在这种情况下,激活将会获取要编辑的产品。然后我们将保存产品,并且模拟数据将在(假的)服务器上更新它:

define(["durandal/app","plugins/router","services/log","services/uuid","services/product","models/product"
],function(app, router,LogService,uuid,ProductService,Product){
  var vm = {};
  vm.title = "Edit Product";
  vm.btn = "Edit product";
  vm.activate = function(id) {
    return ProductService.get(id).then(function(response){
      var p = response.data;
      if (p) {
        vm.product = new Product(p.id, p.name, p.price, p.stock);
      } else {
        LogService.error("We didn't find product with id: "+id)
        router.navigate('#/catalog');
      }
    });
  };
  vm.edit = function() {
    ProductService.save(vm.product.toObj()).then( function(response){
      LogService.success("Product saved","Product "+vm.product.name()+" saved");
      router.navigate("#/catalog");
      app.trigger("catalog:refresh");
    });
  };
  return vm;
});

我们应该注意,在添加产品和编辑产品中,模型都经过了验证。我们在 Knockout 项目中已经这样做了,现在我们在这个项目中重用它。这不是很神奇吗?

购物车模块

购物车模块将管理显示购物车的部分。就像我们在 Knockout 项目中所做的那样,我们应该能够更新产品的数量。如果不再需要商品,我们将删除它们。并且只有在购物车中有商品时才激活此视图,因为如果购物车为空,去访问购物车是没有意义的。在这种情况下,我们将被重定向到目录。

购物车模块

购物车工作流

购物车视图

购物车使用与我们在 Knockout 项目中使用的相同的模板。当然,我们对它进行了一些调整,使其在屏幕上居中显示:

<div class="container-fluid">
  <div class="row">
    <div class="col-xs-8 col-xs-offset-2">
      <h1>Cart</h1>
      <div class="list-group" data-bind="foreach:cart">
        <div data-bind="compose: 'cart-item.html'"></div>
      </div>
      <button class="btn btn-primary btn-sm" 
        data-bind="enable:cart().length,click:toOrder">
		Confirm Order
      </button>
    </div>
  </div>
</div>

就像我们处理购物车商品一样,我们也在这里组合视图。cart-item.html文件拥有和 Knockout 项目中相同的代码。只需注意现在addUnitremoveUnit由父组件调用:

<div class="list-group-item" style="overflow: hidden">
  <button type="button" class="close pull-right" data-bind="click:$parent.removeProduct">
    <span>&times;</span>
  </button>
  <h4 class="" data-bind="text:product.name"></h4>
  <div class="input-group cart-unit">
    <input type="text" class="form-control" data-bind="textInput:units" readonly/>
    <span class="input-group-addon">
      <div class="btn-group-vertical">
        <button class="btn btn-default btn-xs add-unit" data-bind="click:$parent.addUnit">
          <i class="glyphicon glyphicon-chevron-up"></i>
        </button>
        <button class="btn btn-default btn-xs remove-unit" data-bind="click:$parent.removeUnit">
          <i class="glyphicon glyphicon-chevron-down"></i>
        </button>
      </div>
    </span>
  </div>
</div>

购物车视图

购物车视图模拟

购物车视图模型

购物车视图模型将与购物车服务通信,并更新购物车的状态。看看我们是如何在模块之间使用购物车服务共享信息的。这是因为我们已将服务创建为对象,并且它是一个单例。一旦加载,它将在应用程序生命周期内持续存在:

define([
  'durandal/app','plugins/router','services/log',"services/cart"
],function(app, router, LogService, CartService){
  var vm={};
  vm.cart = CartService.cart;
  vm.addUnit = function(data){
    CartService.add(data.product);
  };
  vm.removeUnit = function(data) {
    if (data.units() === 1) {
      remove(data);
    } else {
      CartService.subtract(data);
    }

  };
  vm.removeProduct = function(data) {
    remove(data);
  };
  vm.toOrder = function() {
    router.navigate('#/order');
  }
  vm.canActivate = function () {
    var result = (vm.cart().length > 0);

    if(!result) {
      LogService.error("Select some products before", "Cart is empty");
      return {redirect:'#/catalog'};
    }

    return result;
  }
  function remove(data) {
    app
    .showMessage(
      'Are you sure you want to delete this item?',
      'Delete Item',
      ['Yes', 'No']
    ).then(function(answer){
     if(answer === "Yes") {
       CartService.remove(data.product);
       LogService.success("Product removed");
     } else {
       LogService.success("Deletion canceled");
     }
   });
  }
  return vm;
});

在 Durandal 中,有两种组件之间通信的方式,服务和事件。要在视图模型之间共享信息,最佳实践是使用服务。如果要从一个服务向视图模型或视图模型之间发送消息,则应使用事件。这是因为服务可以在模块内被引用,可以显式调用它们。此外,我们无法从其他视图模型或服务中访问视图模型,这就是为什么我们需要使用事件向它们发送消息的原因。

订单模块

此模块将管理我们订单的确认。要完成订单,我们需要输入个人数据。只有在购物车中有商品时,我们才能访问订单页面。一旦我们确认订单,我们将收到服务器发送的订单 ID 消息。产品库存将更新,我们将能够继续购物。

订单模块

订单工作流程

订单视图

订单视图将是我们在 Knockout 项目中构建的相同订单视图。这次我们将使用组合使视图更简单。

order.html 文件将包含页面的结构,我们将构建一些部分来组成整个视图。这些部分将是:

  • order-cart-detail.html:这将包含只读购物车

  • order-contact-data.html:这将包含个人数据

  • order-buttons.html:这将包含页面的操作按钮

order.html 文件将包含这段代码:

<h1>Confirm order</h1>
<div class="col-xs-12 col-sm-6">
  <div class="modal-header">
    <h3>Order</h3>
  </div>
  <div data-bind="compose:'order-cart-detail.html'"></div>
</div>
<div class="col-xs-12 col-sm-6">
  <div data-bind="compose:'order-contact-data.html'"></div>
  <div data-bind="compose:'order-buttons.html'"></div>
</div>

order-cart.html 文件将包含只读购物车。这是在 Knockout 购物车项目中的 order.html 模板中找到的相同标记。

<table class="table">
  <thead>
  <tr>
    ...
  </tr>
  </thead>
  <tbody data-bind="foreach:cart">
    ...
  </tbody>
  <tfoot>
  <tr>
    <td colspan="3"></td>
    <td class="text-right">
      Total:<span data-bind="currency:grandTotal"></span>
    </td>
  </tr>
  </tfoot>
</table>

order-contact.html 文件将包含在视图 order.html Knockout 购物车项目中的表单:

<form class="form-horizontal" role="form" data-bind="with:customer">
  <div class="modal-header">
    <h3>Customer Information</h3>
  </div>
  <div class="modal-body">
    ...
  </div>
</form>

最后,order-buttons.html 文件中有确认订单的按钮。当然,你可以在我们在 Knockout 购物车项目中构建的 order.html 文件中找到它。我们尽可能地重用代码。

<div class="col-xs-12">
  <button class="btn btn-sm btn-primary" data-bind="click:finishOrder, enable:!customer.errors().length">
    Buy & finish
  </button>
  <span class="text-danger" data-bind="visible:customer.errors().length">
    Complete your personal data to receive the order.
  </span>
</div>

订单视图

订单草图

订单视图模型

订单视图将检查我们的购物车是否为空以允许激活。验证由客户模型管理。这个模型是在 Knockout 购物车项目中构建的。其余的代码部分来自我们在 Knockout 购物车项目中的大视图模型:

define(["knockout","durandal/app","plugins/router","services/log", "services/cart","models/customer","services/order" ], function(ko, app, router, LogService, CartService, Customer, OrderService){
  var vm = {};

  vm.countries = ko.observableArray(['United States','United Kingdom']);
  vm.cart = CartService.cart;
  vm.grandTotal = CartService.grandTotal;
  vm.customer = new Customer();
  vm.finishOrder = function () {
    OrderService.save({
      customer: vm.customer,
      order: vm.cart
    }).then(function(response){
      app.showMessage(
        "Your order id is: <strong>"+response.data.orderId+"</strong>",
        'Order processed successfully'
      ).then(function(){
        LogService.success("Order completed");
        CartService.cart([]);
        router.navigate("#/catalog");
        app.trigger("catalog:refresh");
      });
    });
  }

  vm.canActivate = function () {
    var result = (vm.cart().length > 0);

    if(!result) {
      LogService.error("Select some products before","Cart is empty");
    }

    return {redirect:'#/catalog'};
  }

  return vm;
});

最后,我们的项目完成了,我们重新使用了大部分旧代码。迁移项目后,我们可以看到 Durandal 给我们带来的优势。还要注意,我们并没有充分利用 Durandal 和 Knockout 的潜力。我们可以迭代这个项目,一遍又一遍地改进所有部分。我们可以创建完美的隔离组件。我们可以将目录分割成更小的部分,并添加更多功能,如订购和分页。但是,这个项目给我们提供了 Durandal 能力的快速全局概述。

按功能分组代码 - 管理大项目

正如您在 main.js 文件中所见,我们正在使用 Durandal 约定。这意味着我们所有的视图模型都位于 viewmodels 文件夹中,而所有的视图都位于 views 文件夹中。当我们有一个大项目时,将所有文件放在同一个文件夹中可能会难以管理。

在这种情况下,我们从 main.js 文件中删除了 viewLocator.useConvention(); 语句。这作为 Durandal 的一个指示,表明所有的视图都在与视图模型相同的文件夹中。

我们将按特性对项目进行分组。我们将在我们的项目中定义这些特性:

  • catalog

  • cart

  • order

  • product

  • shell

它们将包含每个特性的代码。服务、模型和其他组件将与我们使用约定时一样。看看这些文件夹是什么样子的:

按特性分组代码 - 管理大型项目

文件按特性分组

我们需要更新一些代码。第一步是更新主文件夹,设置 shell 模块的新 ID:

app.setRoot('shell/shell', 'entrance');

然后我们应该对 shell 模块内的路由器做同样的事情:

router.map([
  { route: ['','/','catalog'], title:'Catalog', moduleId: 'catalog/catalog', nav: true },
  { route: 'new', title:'New product', moduleId: 'product/new', nav: true },
  { route: 'edit/:id', title:'Edit product', moduleId: 'product/edit', nav: false },
  { route: 'cart', title:'Cart', moduleId: 'cart/cart', nav: false },
  { route: 'order', title:'Order', moduleId: 'order/order', nav: true }
]).buildNavigationModel();

最后,我们需要更新组合路径。它们应该是完整路径。这意味着当我们有以下代码时:

<div data-bind="compose:'catalog-details.html'"/></div>

现在我们将会有以下代码:

<div data-bind="compose:'catalog/catalog-details.html"/></div>

我们的代码将准备就绪。

注意,现在很容易找到我们正在工作的代码所在的位置。通常,我们会在一个特性上工作,并且将所有这个特性的代码放在同一个地方更加方便。此外,我们可以更好地看到我们是否正确地隔离了我们的特性。如果我们注意到我们在特性文件夹之外工作得太多,也许这意味着你正在做错事。

要查看本章的代码,您可以从 GitHub 下载:

摘要

最终,我们开发了一个完整的应用程序,引导我们使用 Durandal 创建单页面应用程序。

在这本书中,您已经学会了使用 JavaScript 代码的最佳实践。这些实践和模式,比如揭示模式或模块模式,在所有的框架和库中都被使用。

构建独立且小的代码片段有助于我们轻松地将代码从一个环境迁移到另一个环境。在仅仅一个章节中,我们已经将我们的应用程序从一个基本的 Knockout 应用程序迁移到了一个 Durandal 应用程序。

现在我们已经掌握了 Knockout 和 Durandal 的良好技能,我们可以尝试自己改进这个应用程序。

我们可以创建一个用户模块,使用户能够登录,只允许管理员编辑和删除目录中的项目。或者,我们可以对我们的产品进行分页,并按价格排序。我们已经掌握了成功开发所有这些功能所需的所有技能。我们只需按照您在本书中学到的步骤来完成这些开发任务。

我希望你像我一样喜欢这本书。我想告诉你,你需要努力学习更多关于 JavaScript、Knockout、Durandal 以及当今互联网上存在的所有奇妙的 JavaScript 框架。学习最佳实践,遵循最佳模式,保持你的代码简单和稳固。

posted @ 2024-05-19 20:13  绝不原创的飞龙  阅读(12)  评论(0编辑  收藏  举报