精通-Knockout-js-全-

精通 Knockout.js(全)

原文:zh.annas-archive.org/md5/201aa4b3e7ea4ed08a52e567cfc4d7dc

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Knockout 是基于一个起源于微软的模式构建的。这个模型是模型-视图-视图模型(MVVM),我认为向新用户介绍这个模式是更广泛采用的最大障碍之一。几乎每个其他的 JavaScript 库或框架,以及大多数服务器端框架,都是围绕模型-视图-控制器(MVC)模式构建的,两者之间的区别有时甚至对经验丰富的开发者来说都令人困惑。这个问题由于一些大型框架,如 AngularJS,最终形成了一个几乎与 MVVM 相同的模式而加剧。

Knockout 的文档非常出色,其实时示例和交互式教程也是最好的之一。然而,当涉及到组织完整的应用程序时,还需要更多的解释。当我开始写这本书的时候,亚马逊上只有一本关于 Knockout 的书,而且评价并不好。似乎缺少一本完整的指南,用于使用 Knockout 作为前端堆栈的核心组件。

我现在已经使用 Knockout 3 年了,并且在过去 2 年里一直是 StackOverflow 和 GitHub 上社区的一个活跃成员。我已在几个专业应用以及大约十个个人项目中使用了 Knockout。到目前为止,它是我最喜欢的 JavaScript 库,并且我强烈倾向于使用 MVVM 而不是 MVC 来开发客户端应用程序。希望这本书能给你提供所有你需要的东西,以便在 Knockout 上取得成功。

关于 Knockout 3.2 的笔记

当这本书正在编写时,Knockout 3.2 被发布了。第四章,使用组件和模块进行应用程序开发,被重写以包含组件功能,并对其他章节进行了一些小的修改,以确保它们的准确性。然而,大多数代码示例都是针对 Knockout 3.1 编写的,因此它们没有利用 Knockout 3.2 中发布的纯计算可观察对象或其他功能。

本书涵盖的内容

第一章,Knockout 基础知识,涵盖了 Knockout 库的环境设置和基本使用。它还涵盖了数据绑定、可观察对象、绑定处理程序和扩展器,并演示了一个简单的 Knockout 联系人列表应用程序。

第二章,使用自定义绑定处理程序扩展 Knockout,为你提供了深入的知识,了解如何创建和使用自定义绑定处理程序。它包括简单的单属性绑定处理程序,以及具有模板的复杂多属性绑定处理程序。

第三章,使用预处理器和提供者扩展 Knockout,教您如何使用 node 和绑定预处理器以及绑定提供者来自定义 Knockout 的语法。它还探讨了 Knockout Punches 库。

第四章,使用组件和模块进行应用程序开发,解释了如何使用 RequireJS 异步模块定义(AMD)与 Knockout 一起创建有组织的、模块化的视图模型。它还教授您如何使用新的 Knockout 组件功能以及如何继续使用联系人列表演示应用程序。

第五章,Durandal – the Knockout Framework,探讨了基于 Knockout 的 Durandal 框架的基础知识。本章涵盖了组合、路由、模态对话框和自定义小部件。

第六章,高级 Durandal,继续探讨 Durandal 框架的使用。本章涵盖了事件、高级组合、嵌套路由、自定义对话框和可观察插件。

第七章,最佳实践,深入探讨了 Knockout 的内部工作原理。它包括依赖检测和发布/订阅实现、可观察继承、模板引擎以及完整的 Knockout 实用工具(ko.utils)参考。

第八章,插件和其他 Knockout 库,为您提供了 Knockout 开发者推荐的模式和最佳实践的概述。

第九章,内部结构,涵盖了几个流行的 Knockout 插件,包括 Knockout 验证、Knockout 映射和新的 Knockout-ES5 插件。

您需要为本书准备的内容

您需要一个 ES5 兼容的浏览器、Git 和 Node.js。本书中的代码可以在任何操作系统上运行。

本书面向的对象

如果您是一位寻求新工具来构建 Web 应用程序并了解核心元素和应用的资深 JavaScript 开发者,这本书就是为您准备的。假设您对 DOM、JavaScript 和 KnockoutJS 有基本了解。

习惯用法

在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称的显示方式如下:“每个章节开始所需的全部代码都可以在名为cp[章节编号]-[示例]的分支中找到。”

代码块设置如下:

var subtotal = ko.observable(0);
var tax = ko.observable(0.05);
var total = ko.computed(function() {
  var subtotal = parseFloat(self.subtotal()),
  tax = parseFloat(self.tax());
  return subtotal * (1 + tax);
});

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“您可能已经注意到在前一节中,当联系人页面视图模型与数据服务通信时,它并没有处理 JSON,而是处理真实的 JavaScript 对象。”

注意

警告或重要注意事项以这种方式出现在框中。

小贴士

小技巧和技巧看起来像这样。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。

要发送一般性反馈,请简单地发送一封电子邮件到 <feedback@packtpub.com>,并在邮件主题中提及书名。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南 www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 书籍的骄傲所有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

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

此外,本书的代码是 Git 仓库的一部分,可在 GitHub 上找到,网址为 github.com/tyrsius/MasteringKnockout。代码示例按分支组织在仓库中。

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata 来报告它们,选择您的书,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。任何现有勘误都可以通过从 www.packtpub.com/support 选择您的标题来查看。

侵权

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者以及为我们提供有价值内容的能力方面的帮助。

问题

如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章. Knockout 基础知识

虽然预期您对 JavaScript 和KnockoutJS都有经验,但我们仍将介绍基础知识以建立共同的基础。如果不介绍至少基础知识,这本书就不完整。之后,我们将查看构建一个简单的应用来创建和管理联系信息。这个应用将在整本书中用来探索 Knockout 的新技术,并了解它们如何融入应用开发的大过程。在本章中,您将学习如何:

  • 定义视图模型

  • 编写标准绑定

  • 使用扩展器

  • 使用模板

  • 将所有这些部分组合成一个功能应用

这涵盖了 Knockout 的大部分标准功能。在下一章中,我们将探讨创建自己的绑定来扩展 Knockout。

即使您之前使用过 Knockout 并且认为不需要复习,我也鼓励您至少阅读涵盖联系人列表应用示例的部分。在我们探索更高级概念的过程中,我们将使用这个示例。

在我们开始之前,让我们设置我们的开发环境。

环境设置

我们将使用一个简单的Node.js服务器来托管我们的应用,因为它可以在任何操作系统上运行。如果您还没有安装,请按照nodejs.org/download上的说明安装 Node.js。

我们将使用Git来管理每章的代码。如果您还没有安装,请按照git-scm.com/book/en/Getting-Started-Installing-Git上的说明安装 Git。本书的代码可以从www.packtpub.com下载。每个章节开始所需的全部代码都可以在名为cp[章节编号]-[示例]的分支中找到。例如,我们将查看的第一个示例将位于cp1-computeds分支中。

首先,从github.com/tyrsius/MasteringKnockout克隆仓库。您可以使用提供的下载链接或运行以下命令:

git clone git@github.com:tyrsius/MasteringKnockout

然后,使用以下命令检出第一个示例:

git checkout cp1

所有示例都遵循相同的模式。在根目录下是一个包含样板 Node.js 服务器的server.js文件。在客户端目录中包含应用的所有代码。要运行应用,请在命令行中运行以下命令:

node server.js

保持命令行窗口开启,否则服务器将停止运行。然后,打开您的网络浏览器并导航到http://localhost:3000。如果您正确设置了环境,您应该会看到如以下截图所示的空联系人列表应用:

环境设置

cp1 分支包含一些空白页的骨架。直到我们到达 联系人 应用程序,大部分示例将不会有 联系人设置 页面;它们将在主页上展示代码。

查看示例

书中提供了运行代码的示例。它们位于 Git 仓库的分支中。你可以通过检出分支查看它们,使用以下命令:

git checkout [BranchName]

由于仓库是一个功能应用,大部分代码与示例无关。client 目录包含 index.htmlshell.html 页面,以及 appcontentlib 目录。app 目录是我们 JavaScript 的位置。content 目录包含包含的 CSS,而 lib 包含第三方代码(Knockout、jQuery 和 Twitter Bootstrap)。

包含的 Node 服务器具有一个非常简单的视图组合,将页面的内容放置在壳的 {{ body }} 部分中。如果你使用过任何服务器端 MVC 框架,例如 Ruby on Rails 或 ASP.NET MVC,你会对这一点很熟悉。这种机制与 Knockout 无关,但它将帮助我们保持代码分离,当我们添加文件时。壳位于 shell.html 文件中。你可以查看它,但它与示例没有直接关系。示例的 HTML 代码在 client/index.html 文件中。示例的 JavaScript 代码在 client/app/sample.js 文件中。

JavaScript 的兼容性

在整本书中,我们将使用依赖于 ECMAScript 5 特性的代码,这些特性在所有现代浏览器上都得到支持。我鼓励你使用兼容的浏览器运行这些示例。如果你不能,或者如果你有兴趣在旧环境中运行它们,你可以使用 polyfill。polyfill 是一个 JavaScript 库,它向旧环境添加标准功能,以便它们能够运行现代代码。对于 ECMAScript 5 函数,我推荐 Sugar.js。对于 CSS3 媒体查询支持,我推荐 Respond.js

Knockout 概述

Knockout 是一个为 模型-视图-视图模型MVVM)开发设计的库。这种模式是 Martin Fowler 的展示模型的子模式,它鼓励将 用户界面UI)与领域模型的企业逻辑分离。为了促进这种分离,Knockout 提供了实现此模式所需的三个必要组件,即视图的声明性语法(数据绑定 HTML 属性)、从视图模型通知更改的机制(可观察对象)以及介于两者之间的数据绑定器(Knockout 的绑定处理程序)。

我们将在这里介绍数据绑定和可观察对象语法;绑定处理程序语法及其用法将在下一章介绍。

使用 MVVM 模式意味着你的视图模型使用 JavaScript 操作数据,并且你的 HTML 视图使用声明式数据绑定语法来描述。你的 JavaScript 代码不应直接访问或修改视图——数据绑定应该通过将你的可观测对象转换为 HTML 并使用绑定处理程序来处理这一点。

要考虑视图和视图模型之间的分离,最好的方式是考虑是否两个不同的视图可以使用你的视图模型。虽然这通常不会这样做,但将其牢记在心仍然很有帮助,因为它迫使你保持它们之间的分离。MVVM 允许你在不影响视图模型的情况下重新设计视图。

可观测对象

Knockout 通过发布/订阅模式在应用程序的不同部分之间保持数据同步,例如 UI 和视图模型。Knockout 中的发布者是可观测对象。如果您之前在 Windows Presentation Foundation (WPF) 开发中使用过 MVVM,那么可观测对象可以被视为 Knockout 的 INotifyPropertyChanged 实现。

要构建一个可观测对象,需要在全局 ko 对象上调用 observable 函数:

this.property = ko.observable('default value');

observable 函数返回一个新的可观测对象。如果使用值调用 ko.observable,则返回具有该值的可观测对象。

注意

Knockout 可观测对象之所以是 JavaScript 函数而不是普通属性,是为了支持旧版浏览器,如不支持属性上的获取器和设置器的 Internet Explorer 6。如果没有这种能力,设置属性将没有机制来通知订阅者关于更改的信息。

可观测对象是 JavaScript 函数,它们记录读取其值的订阅者,然后在值更改时调用这些订阅者。这是通过 Knockout 的依赖跟踪机制完成的。

通过不带任何参数调用它们来读取可观测对象。要写入可观测对象,请使用值作为第一个也是唯一的参数调用它(其他参数将被忽略):

var total = vm.total();// read value
vm.total(50);// write new value

小贴士

下载示例代码

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

可观测对象可以包含任何合法的 JavaScript 值:原始值、数组、对象、函数,甚至其他可观测对象(尽管这可能不是很有用)。值是什么并不重要;可观测对象仅仅提供了一个机制来报告该值何时被更改。

可观测数组

尽管标准可观察对象可以包含数组,但它们并不适合跟踪数组中的变化。这是因为可观察对象正在寻找数组值的更改,而不是数组本身的引用,添加或删除元素不会影响引用。由于这是大多数人期望在数组上看起来像更改通知的样子,Knockout 提供了observableArray

this.users = ko.observableArray(myUsers);

与可观察对象一样,数组可以用初始值构造。通常,您通过调用它或通过传递参数设置其值来访问可观察对象。对于可观察数组,情况略有不同。由于数组的值是其引用,设置该值将改变整个数组。相反,您通常想要通过添加或删除元素来操作数组。考虑以下操作:

this.users().push(new User("Tim"));

通过调用this.users(),在将新用户推送到它之前检索了底层数组。在这种情况下,Knockout 没有意识到数组已更改,因为更改是对数组本身而不是可观察对象进行的。为了允许 Knockout 正确跟踪更改,这些更改需要应用于可观察对象,而不是底层值。

要做到这一点,Knockout 在可观察对象上提供了标准的数组方法,即pushpopshiftunshiftsortreversesplice。调用应该如下所示:

this.users.push(new User("Tim"));

注意,我们不是从可观察对象中检索数组,而是直接在可观察对象上调用push。这将确保订阅者通过更新后的数组收到更改通知。

计算可观察对象

可观察对象是手动设置的属性,无论是通过您的代码还是通过 UI 的绑定。计算可观察对象是那些通过对其依赖项的变化自动更新其值的属性,如下面的代码所示:

var subtotal = ko.observable(0);
var tax = ko.observable(0.05);
var total  = ko.computed(function() {
  return parseFloat(subtotal()) * (1 + parseFloat(tax()));
});

在这个例子中,subtotaltaxtotal计算可观察对象的依赖项。对于第一次,计算可观察对象计算了访问过的任何其他可观察对象的记录并为它们创建了一个订阅。结果是,每当subtotaltax发生变化时,total就会重新计算并通知其订阅者。将计算可观察对象视为声明性值可能会有所帮助;您将它们的值定义为公式,它们将保持自身更新。

parseFloat调用是为了确保它们被视为数字而不是字符串,这会导致连接而不是算术运算。由于 Knockout 将数据绑定到 HTML 属性,这些属性始终是字符串,因此数据绑定的更新会产生字符串。当我们讨论扩展器时,您将看到另一种管理此问题的方法。

您可以在cp1-computeds分支上看到这个示例的样本:

计算可观察对象

尝试更改一些数字并观察计算值自动更新。您可以通过查看client/app/sample.js文件来看到 viewmodel 代码中只包含这个示例。

可写计算可观察对象

之前的total示例是一个只读计算。虽然它们不太常见,但也可以使计算可观察量可写。要做到这一点,将一个包含readwrite函数的对象传递给ko.computed

var subtotal = ko.observable(0);
var tax = ko.observable(0.05);
var total  = ko.computed({
  write: function(newValue) {
      subtotal(newValue / (1 + parseFloat(self.tax())));
  },
  read: function() {
      parseFloat(subtotal()) * (1 + parseFloat(tax()));
 }
});

当有东西尝试向当前的total计算写入时,它将通过write函数更新subtotal可观察量。这是一个非常强大的技术,但并不总是必要的。在某些情况下,无法直接写入total可能是一件好事,例如当total可能涉及对项目列表条件性地应用税费时。你应该只在有道理这样做的时候使用可写计算。

你可以在cp1-writecomputed分支中看到这个示例。现在的total计算绑定到了一个input元素,例如subtotaltax属性,值的更改将反映回subtotal可观察量。

纯计算可观察量

非纯计算可观察量在它们的任何依赖项发生变化时都会重新评估自己,即使没有订阅者接收更新的值。如果计算也有故意的副作用,这种重新评估可能是有用的,但如果没有任何副作用,它将浪费内存和处理器的周期。另一方面,纯计算可观察量在没有订阅者时不会重新评估。

纯计算可观察量有两种状态:监听睡眠。当一个纯计算有订阅者时,它将处于监听状态,并表现得就像一个正常的计算一样。当一个纯计算没有订阅者时,它将进入睡眠状态并取消所有依赖订阅。当它醒来时,纯计算将重新评估自身以确保其值正确。

当一个值可能长时间未被使用时,纯计算可观察量非常有用,因为它们不会重新评估。然而,由于纯计算在从睡眠状态访问时总是会重新评估,它有时可能表现得不如正常计算可观察量。由于正常计算只有在它们的依赖项发生变化时才会重新评估,因此频繁从睡眠状态唤醒的计算可观察量可能会更频繁地评估其依赖项。

创建纯计算有两种方式:使用ko.pureComputed或通过将{ pure: true }作为ko.computed的第三个参数传递:

var total = ko.pureComputed(function() {
  return parseFloat(subtotal()) * (1 + parseFloat(tax()));
});
//OR
var total = ko.computed(function() {
  return parseFloat(subtotal()) * (1 + parseFloat(tax()));
}, this, { pure: true });

注意

纯计算可观察量是在 Knockout 3.2 中引入的,而这本书是在那时尚未发布。没有任何代码示例利用纯计算可观察量,尽管许多示例本可以从它们中受益。

手动订阅

有时候,当可观察量发生变化时,你需要做的不仅仅是更新依赖值,例如根据你可观察量的新值发起一个网络请求以获取更多数据。可观察量提供了一个subscribe函数,允许你注册一个函数,当可观察量更新时将被调用。

订阅在 Knockout 中使用与绑定处理程序和计算属性使用的相同内部机制来接收更改。

这是在可观察对象上设置订阅的一个示例:

var locationId = ko.observable();
locationId.subscribe(function (newLocationId) {
  webService.getLocationDetails(newLocationId);
});

locationId 被更新时,无论是由 UI 绑定还是 JavaScript 中的其他地方触发,此订阅都会被调用。

subscribe 函数还允许你为订阅提供一个目标以及你想要订阅的事件名称。目标是为你提供的订阅处理程序设置的 this 的值。事件默认为更改,它接收更新后的值,但也可以是 beforeChange,它在更改发生之前调用,并带有旧值:

locationId.subscribe(function (oldValue) {
  console.log("the location " + oldValue + " is about to change");
}, self, 'beforeChange');});

最后,你可以通过捕获订阅并调用 dispose 来停止订阅继续触发。如果你想要停止处理程序或创建只触发一次的订阅,这可能会很有用:

var subscription = locationId.subscribe(function (newValue) {
  console.log("the location " + oldValue + " is about to change");
  subscription.dispose();
});

一旦订阅被销毁,它就不能重新启动。如果你需要它,你必须重新创建订阅。

cp1-subscribe 分支有一个订阅示例,它在 JavaScript 控制台中记录 subtotal 可观察对象的任何更改,以及一个停止订阅的按钮。尝试更改小计或总价值,并注意控制台消息。更改总价值会导致小计更新,这就是为什么它仍然触发订阅。记住,来自任何来源的更改都会导致可观察对象向所有订阅者报告更改。这就是为什么更新 total 计算属性会导致视图模型属性的 input 元素更新;input 元素是视图模型属性的订阅者。

定义视图模型

视图模型是视图与之绑定的属性的对象;它们形成绑定上下文。它是你视图中的数据和操作的表示(我们将在本章后面的 控制流绑定 部分详细讨论它们)。像 JavaScript 中的常规对象一样,实际上有很多方法可以创建它们,但 Knockout 引入了一些特定的挑战。

this 和 self 关键字

在 JavaScript 中,this 有一个特殊的意义;它指的是调用函数的对象。从对象中调用的函数将那个对象设置为 this。然而,对于由代码匿名调用的函数,这仅仅是对象的 内部,其行为是不同的。考虑以下视图模型:

function Invoice() {
  this.subtotal = ko.observable();
  this.total = ko.computed(function() {
  return this.subtotal() * 1.08; //Tax Rate
  });
}

计算属性中的函数不是 Invoice 对象的属性。因为它在另一个上下文中运行,所以它的 this 值将是窗口对象,而不是 Invoice 对象。它将无法找到 subtotal 属性。有两种处理方法。

第一种是通过使用 ko.computed 函数的第二个参数将函数绑定到 this

function Invoice() {
  this.subtotal = ko.observable();
  this.total = ko.computed(function() {
    return this.subtotal() * 1.08; //Tax Rate
  }, this);
}

这给计算可观察值提供了一个对最初定义它的 Invoice 的引用,这允许计算可观察值在正确的上下文中调用提供的函数。

确保计算可观察值可以引用 subtotal 的第二种方式是捕获 this 的值在一个闭包中。然后你可以使用这个闭包安全地引用父视图模型的属性。这样的闭包有几个传统名称:that_thisself

我更喜欢使用 self,因为它在视觉上与 this 区分开来,同时仍然具有相似的含义,但最终决定权在你:

function Invoice() {
  var self = this;
  self.subtotal = ko.observable();
  self.total = ko.computed(function() {
return self.subtotal() * 1.08; //Tax Rate
  });
}

我发现第二种方法更容易记住。如果你总是使用 self 来引用模型,它总是会起作用。如果你在计算属性内部有另一个匿名函数,你必须记得绑定该函数;无论你嵌套多深,self 作为一个闭包都会继续工作。self 变量作为闭包在视图模型中定义的任何函数内部工作,包括订阅。当 self 没有被使用时,这也很容易被发现,这在调试代码时非常有帮助。

原型问题

如果你正在处理将被其他视图模型继承的视图模型,你可能会认为将所有基本可观察属性放在原型上是正确的做法。在纯 JavaScript 中,如果你正在继承一个对象,尝试改变存储在原型上的属性的值;该属性将被添加到继承对象中,而原型保持不变。然而,在使用 Knockout 中的可观察值时,情况并非如此。可观察值是函数,它们的值是通过调用它们并传递单个参数来设置的,而不是通过为新值赋值。因为原型继承会导致多个对象引用同一个可观察值;可观察值不能安全地放在视图模型的原型上。非可观察函数仍然可以安全地包含在原型中。例如,考虑以下对象:

var protoVm = {
  name: ko.observable('New User')
};

var base1 = Object.create(protoVm);
var base2 = Object.create(protoVm);

base2.name("Base2");

最后一行将导致两个对象的名字都被更新,因为它引用的是同一个函数。这个例子可以在 cp1-prototype 分支中看到,该分支包括两个绑定到每个视图模型名称的输入元素。由于它们实际上是同一个可观察值,改变一个将影响另一个。

序列化视图模型

当你准备好将你的视图模型发送到服务器,或者真正需要处理它们的值而不是可观察值时,Knockout 提供了两个非常实用的实用方法:

  • ko.toJS:这个函数接受一个对象,并对其进行深拷贝,展开所有可观察值,到一个新的 JavaScript 对象中,其属性是正常的(非可观察)JavaScript 值。这个函数非常适合获取视图模型的副本。

  • ko.toJSON:这个函数使用 ko.toJS 的输出与 JSON.stringify 结合,生成一个包含提供对象的 JSON 字符串。这个函数接受与 JSON.stringify 相同的参数。

数据绑定语法

Knockout 利用 HTML5 data-*属性规范来定义其data-bind属性。尽管所有 HTML 属性都是字符串,但 Knockout 将它们解析为 name:value 对。名称指的是要使用的绑定处理程序,而值指的是绑定将使用的值:

<button data-bind="enable: canSave">Save</button>

data-bind属性也可以包含由逗号分隔的多个绑定。这允许在元素上绑定多个属性:

<input data-bind="value: firstName, enable: canEdit" />

在前面的示例中,enable绑定使用canEdit作为值。当canEditfalse时,绑定将在按钮元素上设置disabled属性,当canEdittrue时,将移除disabled属性。如果canEdit是一个可观察的值,enable绑定将在canEdit更新时更新。如果canEdit是一个字面量值,例如true,它将只使用该值来设置初始状态。

Enable是一个单向绑定;它将更新元素值,但不会用元素值更新值。这是因为当使用enable来控制元素时,Knockout 假设不会有任何程序更新元素。更新应在视图模型中发生,绑定处理程序应负责确保视图保持同步。

当用户更新数据绑定输入元素的 UI 时,这些更改需要同步到视图模型。这是通过双向绑定完成的,例如value绑定:

<input data-bind="value: firstName" />

此绑定将input元素的初始值设置为firstName属性的当前值,之后,它将确保元素值或属性的任何更改都会导致对方更新。如果用户在输入框中输入某些内容,firstName属性将接收该值。如果firstName属性被程序更新,输入框的值也将被更新。

这些都是绑定到视图模型简单属性的示例。这是最常见的情况,但 Knockout 也支持更复杂的场景。

注意

对于标准 Knockout 绑定处理程序的完整列表,请参阅 Knockout 文档(knockoutjs.com/documentation/introduction.html)。

嵌套属性的绑定

在前面的示例中,Knockout 解析了属性的绑定值并查找当前视图模型上的该属性。您也可以提供深层属性引用。考虑以下对象:

var viewmodel = {
  user: {
    firstName: ko.observable('Tim'),
    age: ko.observable(27)
  }
};

我们可以通过使用标准的点表示法直接绑定到视图模型用户的firstName属性:

<input data-bind="value: user.firstName" />

函数绑定

如果您使用clickevent绑定来绑定一些 UI 事件,绑定期望属性是一个函数。函数将接收当前模型(绑定上下文)作为其第一个参数,并将 JavaScript 事件作为第二个参数(尽管您不需要这样做非常频繁)。

在这个例子中,父视图模型通过 click 绑定接收要删除的联系人,因为 foreach 循环为每个联系人创建了一个嵌套绑定上下文。绑定中的父引用将上下文移动到父视图模型以获取对删除函数的访问:

<ul data-bind="foreach: contacts">
    <li>
      <span data-bind="text: name"></span>
      <button data-bind="click: $parent.remove">Remove</button>
    </li>
</ul>

var ViewModel = function() {
    var self = this;
    self.contacts = ko.observableArray([{ name: 'Tim' }, { name: 'Bob' }]);
    self.remove = function (contact) {
         self.contacts.remove(contact);
    };
};

使用表达式进行绑定

除了属性引用外,Knockout 还支持将 JavaScript 表达式用作绑定值。对于期望 true 或 false 值的绑定,例如 enable,我们可以使用布尔表达式来设置它们:

<button data-bind="enable: age > 18">Approve</button>

我们还可以使用三元表达式来控制表达式的结果。这在布尔值不期望的情况下很有用,例如文本绑定:

Old enough to Drink in the U.S. 
<span data-bind="text: age > 18 ? 'Yes' : 'No'"></span>

现在 span 将以 Yes 作为内容。

这两种表达式形式都将使用依赖跟踪来重新运行,如果它们在第一次运行时从可观察值中读取。如果 age 是一个可观察值,我们可以更新它,并且元素的绑定将重新评估表达式,如果结果改变,将更改文本或启用状态。

使用函数表达式进行绑定

设置绑定值的最后一种方法是使用函数。您可以通过在绑定中引用它来调用函数:

<button data-bind="enable: canApprove(age)">Approve</button>

您也可以直接在绑定中将匿名函数作为字符串写入。当为 click 绑定创建函数时,参数是绑定上下文(视图模型)和 JavaScript click 事件。如果您使用属性名绑定到视图模型函数,它将接收相同的参数:

<button data-bind="text: 
function(data) { console.log(data.age)  }">Log Age</button>

虽然这是可能的,但我不会鼓励这样做。它将逻辑直接放在视图中,而不是属于视图模型的视图模型中。您只应在非常特殊的情况下使用这种方法。将方法放在视图模型上并仅使用属性引用要好得多。

在绑定中使用括号

尝试弄清楚在绑定中使用括号以使用可观察值作为值时可能会令人困惑。Knockout 通过在简单绑定表达式中不要求括号来尝试提供帮助,如下所示:

<input data-bind="value: firstName" />

在这个例子中,firstName 属性可以是可观察的或字面值,并且它将正常工作。然而,在绑定中有两种情况下需要括号:当绑定到嵌套属性和当绑定到表达式时。考虑以下视图模型:

var viewmodel = {
  user: ko.observable({
    firstName: ko.observable('Tim'),
    age: ko.observable(27)
  })
};

这里用户对象是一个可观察属性,以及它的每个属性也是如此。如果我们现在想写相同的绑定,它需要在 user 函数上包含括号,但不在 firstName 属性上:

<input data-bind="value: user().firstName" />

在我们直接绑定到属性的情况下,该属性的括号永远不需要。这是因为 Knockout 足够智能,能够理解如何在绑定中访问它给出的可观察值的值。

然而,如果我们绑定到一个表达式,它们总是需要的:

<button data-bind="enable: user().age > 18">Approve</button>
<button data-bind="enable: user().age() > 18">Approve</button>

这两个绑定都不会导致错误,但第一个绑定不会按预期工作。这是因为第一个表达式将尝试在 age 可观察对象本身(它是一个函数,而不是一个数字)上评估,而不是在可观察对象的值上。第二个表达式正确地比较了可观察对象的值与 18,产生了预期的结果。

使用 ko.toJSON 进行调试

因为 ko.toJSON 接受 JSON.stringifyspaces 参数,所以你可以在文本绑定中使用它来获取一个格式良好、易于阅读的 viewmodel 的实时副本:

<pre data-bind="text: ko.toJSON($root, null, 2)"></pre>

cp1-databind 分支有一个每个这些绑定的交互式示例。

控制流绑定

到目前为止,我们已经查看了一向绑定和双向绑定,它们通过 HTML 元素的属性设置或同步数据。Knockout 使用一种不同的绑定来通过添加或删除节点来修改 DOM。这些是控制流绑定,包括 foreachifwithtemplate

所有的控制流绑定实际上是通过从 DOM 中移除其内容并创建一个内存中的模板来工作的。这个模板用于根据需要添加和移除内容。

控制流绑定(除了 if)还引入了一个绑定上下文层次结构。你的根绑定上下文是传递给 ko.applyBindings 的 viewmodel。data-bind 属性可以访问当前上下文中的属性。控制流绑定(除了 if)创建一个子绑定上下文,这意味着控制流绑定模板内的 data-bind 属性可以访问它们上下文中的属性,而不是根上下文。子上下文内的绑定可以访问特殊属性,以便它们可以导航上下文层次结构。最常用的有:

  • $parent:这访问了直接父级的绑定上下文。在这个例子中,group$parent.group 指的是同一个属性,因为 $parent 访问的是人之外的上下文:

    <span data-bind="text: group"></span>
    <div data-bind="with: person">
      <span data-bind="text: name"></span>
    <span data-bind="text: $parent.group"></span>
      </div>
    
  • $parents[n]:这是一个父级上下文的数组。$parents[0] 数组与 $parent 相同。

  • $root:这是根 viewmodel,在层次结构中处于最高层。

  • $data:这是当前 viewmodel,在 foreach 循环内很有用。

    注意

    对于上下文属性的完整列表,请参阅 Knockout 文档中的它们,链接为 knockoutjs.com/documentation/binding-context.html

if 绑定

if 绑定接受一个要评估的值或表达式,并且只有在值或表达式为真(在 JavaScript 的意义上)时才渲染包含的模板。如果表达式是假的,模板将从 DOM 中移除。当表达式变为真时,模板将被重新创建,并且任何包含的 data-bind 属性都将重新应用。if 绑定不会创建一个新的绑定上下文:

<div data-bind="if: isAdmin">
  <span data-bind="text: user.username"></span>
  <button data-bind="click: deleteUser">Delete</button>
</div>

isAdminfalsenull 时,这个 div 将为空。如果 isAdmin 的值被更新,绑定将重新评估并根据需要添加或删除模板。

此外,还有一个 ifnot 绑定,它只是反转了表达式。如果你想在不需要添加感叹号和括号的情况下仍然使用属性引用,这会很有用。以下两行是等价的:

<div data-bind="if: !isAdmin()" >
<div data-bind="ifnot: isAdmin">

在第一个例子中需要括号,因为它是一个表达式,而不是属性名。在第二个例子中不需要括号,因为它是一个简单的属性引用。

With 绑定

with 绑定使用提供的值创建一个新的绑定上下文,这导致绑定在绑定元素内的作用域被限制到新的上下文中。这两个代码片段在功能上是相似的:

<div>
  First Name:
<span data-bind="text: selectedPerson().firstName"></span>
  Last Name:
<span data-bind="text: selectedPerson().lastName"></span>
</div>

<div data-bind="with: selectedPerson">
  First Name:
<span data-bind="text: firstName"></span>
  Last Name:
<span data-bind="text: lastName"></span>
</div>

虽然节省一些键盘输入并使你的绑定更容易阅读是件好事,但 with 绑定的真正好处是它是一个隐式的 if 绑定。如果值是 nullundefined,HTML 元素的内容将从 DOM 中移除。在可能的情况下,这可以节省你为每个后代绑定进行空值检查的需要。

The foreach binding

foreach 绑定创建一个隐式模板,使用 HTML 元素的内容,并为数组中的每个元素重复该模板。

这个 viewmodel 包含了一个我们需要渲染的人的列表:

var viewmodel = {
  people: [{name: 'Tim'}, {name: 'Justin}, {name: 'Mark'}]
}

使用这个绑定,我们为 li 元素创建一个隐式模板:

<ul data-bind="foreach: people">
  <li data-bind="text: name"></li>
</ul>

这个绑定会生成以下 HTML:

<ul>
  <li>Tim</li>
  <li>Justin</li>
  <li>Mark</li>
</ul>

这里需要注意的是,li 元素绑定的是 name,这是人的属性。在 foreach 绑定内部,绑定上下文是子元素。如果你需要引用子元素本身,你可以使用 $data 或为 foreach 绑定提供一个别名。

当数组只包含你想要绑定的原始数据时,$data 选项很有用:

var viewmodel = {
  people: ['Tim', 'Justin, 'Mark']
}
...
<ul data-bind="foreach: people">
  <li data-bind="text: $data"></li>
</ul>

alias 选项可以清理你的代码,但它特别有用,当你有一个嵌套上下文并且想要引用父级时。参考以下代码:

<ul data-bind="foreach: { data: categories, as: 'category' }">
    <li>
        <ul data-bind="foreach: { data: items, as: 'item' }">
          <li>
            <span data-bind="text: category.name"></span>:
            <span data-bind="text: item"></span>
          </li>
         </ul>
    </li>
</ul>

这可以通过 $parent 实现,当然,但使用 alias 时更易于阅读。

模板绑定

模板绑定是一个特殊的控制流绑定。它为每个其他控制流绑定都有一个参数。可能更准确地说,其他控制流绑定都是模板绑定的 别名

  <ul data-bind="foreach: { data: categories, as: 'category' }">
  <ul data-bind="template: { foreach: categories, as: 'category' }">

这两个在功能上是等价的。模板绑定 as 有一个参数用于 ifdata(它们一起构成了 with 绑定)。

然而,与其他控制流绑定不同,它还可以使用 name 参数从命名源生成模板。默认情况下,Knockout 只查找具有与 name 参数匹配的 id 参数的 <script> 标签:

<div data-bind="template: { name: 'person-template', data: seller }"></div>
<script type="text/html" id="person-template">
    <h3 data-bind="text: name"></h3>
    <p>Credits: <span data-bind="text: credits"></span></p>
</script>

要停止 script 块作为 JavaScript 执行,你需要一个虚拟的脚本类型,例如 text/htmltext/ko。Knockout 不会将绑定应用于脚本元素,但它将它们用作模板的源。

虽然在 foreachwith 中看到的内联模板使用得更为常见,但命名模板有三个非常重要的用途。

可重用模板

由于模板可以引用外部源来生成 HTML,因此可以有多个模板绑定指向单个源:

<div>
  <div data-bind="template: { name: 'person', data: father} "></div>
  <div data-bind="template: { name: 'person', data: mother} "></div>
</div>
...
<script type="text/html" id="person">
  <h3 data-bind="text: name"></h3>
  <strong>Age: </strong>
<span data-bind="text: age"></span><br>
  <strong>Location: </strong>
<span data-bind="text: location"></span><br>
  <strong>Favorite Color: </strong>
<span data-bind="text: favoriteColor"></span><br>
</script>

cp1-reuse 分支有一个这个技巧的例子。

递归模板

由于模板本身参与数据绑定,因此模板可以绑定到自身。如果一个模板引用了自己,结果就是递归:

<div data-bind="template: { name: 'personTemplate', data: forefather} "></div>

<script type="text/html" id="personTemplate">
  <h4 data-bind="text: name"></h4>
  <ul data-bind="foreach: children">
    <li data-bind="template: 'personTemplate'"></li>
  </ul>
</script>

在上一个模板中使用的模板引用是使用缩写绑定,它直接使用模板的名称。当使用这种缩写时,当前绑定上下文用于模板的 data 参数,这在像这样的 foreach 循环中是完美的。当使用递归模板时,这是一种常见的技巧,因为信息树是最常见的地方,可以找到视觉递归。

这个递归模板的例子在 cp1-recurse 分支中。

动态模板

上一个例子中模板的名称是一个字符串,但它也可以是一个属性引用。将模板名称绑定到一个可观察的变量允许你控制要渲染哪个模板。这可能在交换视图模型的模板在显示和编辑模式之间非常有用。考虑这个模板绑定:

<div data-bind="template: { name: template, data: father} "></div>

这个模板绑定由一个像这样的视图模型属性支持:

self.template = ko.computed(function() {
  return self.editing() ? 'editTemplate' : 'viewTemplate';
});

如果我们将 editing 属性从 true 更改为 false,模板将重新渲染从 viewTemplateeditTemplate。这允许我们通过编程方式在它们之间切换。

一个动态编辑/查看模板的例子在 cp1-dynamic 分支中。

在一个高级场景中,你可以使用这种技术来在页面上创建一个通用的容器来显示完全不同的视图。同时切换模板名称和数据将模拟导航,创建一个单页应用程序SPA)。当我们到达第四章 Chapter 4,使用组件和模块进行应用程序开发时,我们将查看一个类似的技术。

无容器控制流

到目前为止,我们已经探讨了使用控制流绑定(ifwithforeachtemplate)以及 HTML 元素上的标准 data-bind 属性。还可能使用没有元素的控制流绑定,通过使用 Knockout 解析的特殊注释标签。这被称为无容器控制流。

添加一个<!— ko -->注释开始一个以<!-- /ko -->注释结束的虚拟元素。这个虚拟元素会导致控制流绑定将所有包含的元素视为子元素。以下代码块演示了如何通过虚拟注释容器对兄弟元素进行分组:

<ul>
    <li>People</li>
    <li>Locations</li>
    <!-- ko if: isAdmin -->
    <li>Users</li>
    <li>Admin</li>
    <!-- /ko -->
</ul>

列表元素只允许特定的元素作为子元素。前面的无容器语法将if绑定应用于列表中的最后两个元素,这会导致它们根据isAdmin属性添加或从 DOM 中删除:

<ul>
    <li>Nav Header</li>
    <!-- ko foreach: navigationItems -->
    <li><span data-bind="text: $data"></span></li>
    <!-- /ko -->
</ul>

前面的无容器语法允许我们有一个foreach绑定来创建一个项目列表,同时保持列表顶部的标题项。

所有的控制流绑定都可以这样使用。前面的两个例子可以在cp1-containerless分支中看到。

扩展器

最后要介绍的是扩展器(别担心,还有很多高级内容要介绍)。扩展器提供了一种修改单个可观察对象的方法。扩展器的两种常见用途如下:

  • 向可观察对象添加属性或函数

  • 在可观察对象周围添加包装器以修改写入或读取

简单扩展器

添加扩展器就像向ko.extenders对象添加一个新函数一样简单,使用你想要使用的名称。这个函数接收被扩展的可观察对象(称为目标)作为第一个参数,并且任何传递给扩展器的配置作为第二个参数接收,如下面的代码所示:

ko.extenders.recordChanges = function(target, options) {
  target.previousValues = ko.observableArray();
  target.subscribe(function(oldValue) {
    target.previousValues.push(oldValue);
  }, null, 'beforeChange');
  return target;
};

这个扩展器将在可观察对象上创建一个新的previousValues属性。这个新属性是一个可观察数组,当原始可观察对象发生变化时(当然当前值已经在可观察对象中),旧值会被推送到它里面。

扩展器必须返回目标的原因是扩展器的结果是新的可观察对象。这种需求在查看扩展器是如何被调用的时候很明显:

var amount = ko.observable(0).extend({ recordChanges: true});

发送到recordChangestrue值作为options参数被扩展器接收。这个值可以是任何 JavaScript 值,包括对象和函数。

你也可以在同一个调用中将多个扩展器添加到可观察对象中。发送到extend方法的对象将为它包含的每个属性调用一个可观察对象:

var amount = ko.observable(0).extend({ recordChanges: true,anotherExtender: { intOption: 1});

当在可观察对象上调用extend方法时,通常是在其初始创建期间,extend调用的结果才是实际存储的内容。如果目标没有返回,amount变量就不会是预期的可观察对象。

要访问扩展的值,您可以使用 JavaScript 中的 amount.previousValues(),或者如果从绑定中访问则为 amount.previousValues。注意 amount 后面没有括号;因为 previousValues 是可观察者的一个属性,而不是可观察者值的属性,它可以直接访问。这可能一开始不太明显,但只要您记住可观察者和可观察者包含的值是两个不同的 JavaScript 对象,这应该是有意义的。

这个扩展器的一个例子在 cp1-extend 分支中。

带有选项的扩展器

之前的示例没有向 recordChanges 扩展器传递任何选项,它只是使用 true,因为该属性需要一个有效的 JavaScript 值。如果您想为扩展器提供一个配置,您可以将其作为此值传递,并通过使用另一个对象作为值来实现复杂的配置。

如果我们想要提供一个不希望记录的值的列表,我们可以修改扩展器以使用选项作为数组:

ko.extenders.recordChanges = function(target, options) {
  target.previousValues = ko.observableArray();
  target.subscribe(function(oldValue) {
    if (!(options.ignore && options.ignore.indexOf(oldValue) !== -1))
      target.previousValues.push(oldValue)
  }, null, 'beforeChange');
  return target;
};

然后,我们可以使用数组调用扩展器:

var history = ko.observable(0).extend({ 
  recordChanges: { ignore: [0, null] } 
});

现在,我们的 history 可观察者不会为 0null 记录值。

替换目标的扩展器

扩展器的另一个常见用途是将可观察者包装在一个计算可观察者中,该计算可观察者修改读取或写入,在这种情况下,它会返回新的可观察者而不是原始目标。

让我们把 recordChanges 扩展器再进一步,并实际上阻止 ignore 数组中的写入(不要在意名为 recordChanges 的扩展器在现实世界中永远不应该做这样的事情!):

ko.extenders.recordChanges = function(target, options) {
  var ignore = options.ignore instanceof Array ? options.ignore : [];
  //Make sure this value is available
  var result = ko.computed({
    read: target,
    write: function(newValue) {
      if (ignore.indexOf(newValue) === -1) {
        result.previousValues.push(target());
        target(newValue);
      } else {
        target.notifySubscribers(target());
      }
    }
  }).extend({ notify: 'always'});

  result.previousValues = ko.observableArray();

  //Return the computed observable
  return result;
};

这有很多更改,所以让我们来分解它们。

首先,为了使 ignore 更容易引用,我设置了一个新变量,该变量将是 options.ignore 属性或一个空数组。默认为空数组让我们可以跳过后面的 null 检查,这使得代码更容易阅读。其次,我创建了一个可写的计算可观察者。read 函数只是将路由到目标可观察者,但 write 函数只有在 ignore 选项不包含新值时才会写入目标。否则,它将通知目标订阅者旧值。这是必要的,因为如果 UI 绑定在可观察者上触发了更改,则需要撤销非法更改。UI 元素已经更新,最容易将其更改回原来的方式是通过标准绑定通知机制,该机制已经监听更改。

最后的更改是位于 result 上的 notify: always 扩展器。这是 Knockout 的默认扩展器之一。通常,一个可观察者只有在值被修改时才会向订阅者报告更改。为了使可观察者能够拒绝更改,它需要能够通知订阅者其当前未更改的值。通知扩展器强制可观察者始终报告更改,即使它们是相同的。

最后,扩展器返回新的计算可观察值而不是目标,这样任何尝试写入值的人都会针对计算进行操作。

cp1-extendreplace分支有一个这种绑定的例子。注意,尝试输入包含在忽略选项中(0或空字符串)的值会立即被撤销。

联系人列表应用程序

是时候将这些概念组合成一个可用的应用程序了。孤立的样本只能带你走这么远。我们将详细介绍cp1-contacts分支中的应用程序。应用程序的功能全部在联系人页面上,您可以通过浏览器中的导航栏访问它。在我们开始深入代码之前,我鼓励您先玩一下这个应用程序(它确实会持久化数据)。这将有助于理解代码中的关系。

概述

该应用程序有三个主要的 JavaScript 对象:

  • 联系人模型

  • 联系人页面视图模型

  • 模拟数据服务

应用程序仅使用index.html文件中的 HTML,但这两个部分基本上是独立的。

  • 输入表单(创建和编辑)

  • 联系人列表

示例中的 JavaScript 代码遵循立即调用的函数表达式IIFE)模式(有时发音为“iffy”),以隔离代码与全局作用域,并使用一个名为app的命名空间在文件之间共享代码:

(function(app, $, ko) {
  /* CODE IN HERE */
})(window.app = window.app || {}, jQuery, ko);

这绝对不是组织 JavaScript 代码的唯一方式,你可能有一个你更喜欢的模式。如果你想更好地理解这个模式,这里有一些在线资源:

联系人模型

client/app/contacts.js文件定义了我们的基本联系人对象。让我们逐个分析它。

它从一个标准的可观察属性声明开始,并带有一些默认值。有许多理由以不同的方式组织代码,但对于较小的模型,我更喜欢将它们的所有可持久属性一起放在顶部:

app.Contact = function(init) {
  var self = this;
  self.id = ko.observable(0);
  self.firstName = ko.observable('');
  self.lastName = ko.observable('');
  self.nickname = ko.observable('');
  self.phoneNumber = ko.observable('');
  /* More below */

接下来是displayName属性,一些简单的逻辑来生成一个漂亮的“标题”用于 UI 显示。这里使用了 JavaScript 的或运算符(||),以确保我们不会尝试在nullundefined值上读取length属性,在这种情况下,会返回一个默认值。这实际上使得它在赋值时成为一个空合并运算符:

self.displayName = ko.computed(function() {
      var nickname = self.nickname() || '';
      if (nickname.length > 0)
        return nickname;
      else if ((self.firstName() || '').length > 0)
        return self.firstName() + ' ' + self.lastName();
      else
        return 'New Contact';
    });

接下来是一个更新模型的实用方法,它接受一个对象并将其属性合并进来。我通常在我的所有模型上放一个类似的方法,这样我就可以有一个标准的方式来更新它们。再一次,我们使用 || 作为安全网,以防方法在没有参数的情况下被调用(在现实世界中,你希望有一个更强的检查,确保 update 是一个对象而不是原始值或数组):

//Generic update method, merge all properties into the viewmodel
self.update = function(update) {
  data = update || {};
  Object.keys(data).forEach(function(prop) {
    if (ko.isObservable(self[prop]))
      selfprop;
  });
};

//Set the initial values using our handy-dandy update method.
self.update(init);

还要注意,在定义 update 函数之后,模型会使用构造函数参数来调用它。这使得构造函数能够从现有数据以及部分数据中创建新的模型。这在反序列化数据时非常有用,例如,从 Ajax 请求中获取 JSON。

最后,我们有 toJSON 方法。JavaScript 中的标准 JSON.stringify 方法会查找这个方法,以便对象可以控制其序列化方式。Knockout 的 ko.toJSON 在展开所有可观察对象之后会调用 JSON.stringify,这样序列化就会得到值而不是函数。

由于我们模型的序列化形式是我们将尝试持久化的形式,通常是通过使用 Ajax 将其发送到服务器,我们不希望包含诸如我们的计算显示名称之类的东西。我们的 toJSON 方法覆盖通过仅删除属性来处理这个问题:

//Remove unwanted properties from serialized data
    self.toJSON = function() {
      var copy = ko.toJS(self);
      delete copy.displayName;
      return copy;
    };

使用 ko.toJS 进行复制很重要。我们不希望从实际模型中删除 displayName;我们只想从序列化模型中移除它。如果我们用 copy = self 创建变量,我们只会得到对同一个对象的引用。ko.toJS 方法是一种简单的方式,可以获取一个纯 JavaScript 复制,我们可以安全地从中删除属性而不影响原始对象。

联系人页面视图模型

client/app/contactspage.js 文件定义了 Contacts 页面的视图模型。与我们的联系人模型不同,该页面做的不仅仅是暴露一些可观察属性,它也不是为了从现有数据中构建而设计的。它不是通过接受一个对象来控制其起始值,这对于一个页面来说没有太多意义,其构造函数的参数是为了依赖注入设计的;它的构造函数参数接受其外部依赖项。

在这个例子中,dataService 是页面视图模型使用的依赖项:

app.ContactsPageViewmodel = function(dataService)

简单来说,如果你不熟悉依赖注入,它允许我们定义我们的页面针对一个 API(有时称为合同或接口)的方法来获取和保存数据。这对我们来说特别有用,因为在本示例应用程序中,我们没有使用真实的 Ajax,而是用一个只向 DOM 的本地存储写入的对象来模拟它:

ko.applyBindings(new app.ContactsPageViewmodel(app.mockDataService));

注意

更多关于 DOM 本地存储的信息,请参阅 Mozilla 开发者网络上的页面:developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/Storage

然而,当我们稍后编写实际的 Ajax 服务时,我们的 ContactsPageViewmodel 实际上不需要任何更改。我们只需用不同的 dataService 参数来构建它。只要它们公开相同的方法(相同的 API),它就会正常工作。

构造函数内的第一个部分是用于联系列表的。我们公开一个可观察数组,并从我们的数据服务获取联系:

self.contacts = ko.observableArray();

dataService.getContacts(function(contacts) {
  self.contacts(contacts);
});

我们将回调传递给 getContacts 调用,因为我们的数据服务提供了一个异步 API。当数据服务完成获取我们的联系后,它将使用它们调用回调。我们的回调需要做的只是将它们放入 contacts 数组。

下一段代码是用来控制单个联系 CRUD创建、读取、更新、删除)操作的。首先,我们公开一个我们将用于所有编辑的可观察对象:

self.entryContact = ko.observable(null);

    self.newEntry = function() {
      self.entryContact(new app.Contact());
    };
    self.cancelEntry = function() {
      self.entryContact(null);
    };

我们的 UI 将将编辑表单绑定到 entryContact 属性。在这里,条目联系属性承担双重职责;它包含正在创建或编辑的联系,并指示正在发生编辑。如果条目联系为空,则表示我们不在编辑;如果它有一个对象,则表示我们正在编辑。UI 将使用 withif 绑定来根据此逻辑控制显示哪些内容。

newEntrycancelEntry 函数为 UI 提供了在两种状态之间切换的手段。

对于编辑现有联系,我们只需公开另一个函数,该函数接受一个联系并将其设置为条目联系:

self.editContact = function(contact) {
      self.entryContact(contact);
    };

对于真正的编辑,我们需要的最后一件事是能够持久化我们的更改。正如现实世界一样,我们有三种情况,即创建新对象、保存现有对象和删除现有对象。

创建和更新都将使用 entryContact 属性来完成,我们希望能够为两者绑定相同的表单,这意味着我们需要针对单个函数:

self.saveEntry = function() {
  if (self.entryContact().id() === 0) {
    dataService.createContact(self.entryContact(), function() {
      self.contacts.push(self.entryContact());
      self.entryContact(null);
    });
  } else {
    dataService.updateContact(self.entryContact(), function() {
      self.entryContact(null);
    });
  }
};

在内部,我们的 saveEntry 方法会检查非默认的 id 值,以确定是创建新对象还是更新现有对象。这两种情况都是调用数据服务,使用条目联系并带有回调来清除 entryContact 属性(因为我们已经完成了编辑)。在创建的情况下,我们希望在清空条目联系之前,将新创建的联系添加到我们本地的联系列表中:

self.contacts.push(self.entryContact());
self.entryContact(null);

你可能会认为联系会在第二行被置为空,但这并不是事实。entryContact 属性是一个可观察对象,其值是一个联系。第一行读取这个值并将其推入 contacts 数组。第二行将 entryContact 属性的值设置为 null;它不会影响刚刚推入的联系。这就像我们在将对象添加到数组后将其变量设置为空一样。变量是对对象的引用,将变量设置为空会移除引用,而不是对象本身。

与之相比,删除函数很简单:

self.deleteContact = function(contact) {
      dataService.removeContact(contact.id(), function() {
        self.contacts.remove(contact);
      });
    };

它将使用现有的联系人,比如editContact所做的那样,并调用数据服务。当我们删除联系人时,我们只需要id属性。回调将在服务完成后使用 Knockout 提供的remove函数从联系人列表中删除联系人。

页面上最后的功能是搜索机制。它从一个可观察的变量开始跟踪搜索,并有一个清除搜索的功能:

self.query = ko.observable('');
self.clearQuery = function() { self.query(''); };

query属性将被用来过滤掉任何没有匹配或部分匹配属性的联系人。如果我们想要尽可能灵活,我们可以对每个属性进行搜索。然而,由于我们的联系人列表只显示计算出的displayName和电话号码,返回匹配我们未显示的属性的搜索结果看起来会很奇怪。这是代码示例中从联系人列表中进行过滤的计算可观察变量:

self.displayContacts = ko.computed(function() {  
  //No query, just return everything
  if (self.query() === '')
    return self.contacts();
  var query = self.query().toLowerCase();
  //Otherwise, filter all contacts using the query
  return ko.utils.arrayFilter(self.contacts(), function(c) {
    return c.displayName().toLowerCase().indexOf(query) !== -1 
        || c.phoneNumber().toLowerCase().indexOf(query) !== -1;
  });
});

注意

如果你想过滤联系人的所有属性,它们在代码库中以注释的形式列出。它们可以通过取消注释每一行轻松重新启用。

首先,我们检查查询是否为空,因为如果为空,我们不会过滤任何东西,所以我们不想浪费周期去迭代联系人。

在开始之前,我们调用toLowerCase()函数对查询进行转换,以避免任何大小写敏感的问题。然后,我们在联系人上迭代。Knockout 在ko.utils对象上提供了几个数组相关的实用方法(以及其他功能)。arrayFilter函数接受一个数组和迭代函数,该函数会在数组的每个元素上被调用。如果函数返回truearrayFilter将包括该元素在其返回值中;否则,它将过滤掉该元素。我们迭代器需要做的只是比较我们想要保留的过滤属性(记得首先将它们转换为小写)。

现在如果 UI 绑定到displayContacts,搜索功能将过滤 UI。

然而,如果我们每次查询更新时都要遍历所有联系人,特别是如果查询在每次按键时更新,我们可能会遇到大量联系人列表的较差性能。为了解决这个问题,我们可以在我们的过滤计算上使用标准的 Knockout rateLimit 扩展器来阻止它过于频繁地更新:

self.displayContacts = ko.computed(function() {
  /* computed body */
}).extend({
  rateLimit: {
    timeout: 100,
    method: 'notifyWhenChangesStop'
  }
});

这个扩展器有两个模式:notifyAtFixedRatenotifyWhenChangesStop。这两个选项将节流或去抖动计算。

注意

如果你不太熟悉节流和去抖动函数,drupalmotion.com/article/debounce-and-throttle-visual-explanation上有很好的解释和视觉演示。

这让我们可以控制计算重新评估自身的频率。前面的例子只有在所有依赖项停止变化 100 毫秒后,计算才会重新评估一次。这将允许在查询输入稳定下来时更新 UI,同时仍然看起来像用户输入时那样进行过滤。

关于模型与视图模型的一个哲学思考

在客户端-服务器应用程序中,模型和视图模型之间的界限可能会变得模糊,即使阅读了 Knockout 的文档(knockoutjs.com/documentation/observables.html),也可能不清楚我们的联系人对象是否真的是模型或视图模型。大多数人可能会争辩说它是一个视图模型,因为它有可观察属性。我喜欢将这些较小的对象视为模型,并将视图模型视为包含操作和视图表示的对象,例如我们的联系人页面视图模型中的removeContact操作或entryContact属性。

模拟数据服务

通常,你会使用 Ajax 调用,可能配合 jQuery,来检索数据并将数据从服务器提交和检索。因为这是一本关于 Knockout 的书,而不是 Node.js,我想尽量保持服务器尽可能瘦。从“精通 Knockout”的角度来看,我们调用一个制作 Ajax 请求的 JavaScript 对象或将其存储在 DOM 中并不重要。只要我们正在处理看起来和功能像异步服务的东西,我们就可以探索 Knockout 视图模型如何与之交互。话虽如此,数据服务中确实有一些功能会在 Ajax 数据服务对象中使用,并且从 Knockout 应用程序开发的角度来看是很有趣的。

你可能在前一节中注意到,当联系人页面视图模型与数据服务通信时,它并没有处理 JSON,而是处理真实的 JavaScript 对象。实际上,甚至不是普通的 JavaScript 对象,而是我们的联系人模型。这是因为数据服务的一部分责任,无论是模拟还是真实的 Ajax 服务,都是抽象化服务机制的知识。在我们的情况下,这意味着在 JSON 和我们的 Knockout 模型之间进行转换:

createContact: function(contact, callback) {
  $.ajax({
      type: "POST",
      url: "/contacts",
      data: ko.toJS(contact)
    })
    .done(function(response) {
      contact.id(response.id);
      callback()
    });
}

如果将我们的模拟数据服务中的createContact方法重写为使用真实的 Ajax(此代码在mockDataService.js文件中作为注释),那么它看起来是这样的。数据服务是我们应用程序的一部分,因此它知道它正在处理可观察属性,并且需要将它们转换为纯 JavaScript 以便 jQuery 能够正确序列化,因此它使用ko.toJS解包它提供的联系人。然后,在done处理程序中,它从服务器的响应中获取id,并使用它更新联系人的可观察id属性。最后,它调用回调来表示已完成。

您可能会想知道为什么它没有将contact作为参数传递给回调。当然可以,但这是不必要的。原始调用者已经有了联系人,调用者唯一需要的是新的id值。我们已经更新了id,由于它是可观察的,任何订阅者都会获取那个新值。如果我们需要在设置id值之前进行一些特殊处理,那将是另一种情况,并且我们可以通过将id作为参数传递来引发回调。

视图

希望您已经稍微玩过这个应用程序。如果您还没有,现在就是时候了。我会等待。

当您添加或编辑联系人时,您可能会注意到联系人列表被移除。您可能没有注意到的是,URL 并没有改变;当我们在这两个视图之间切换时,浏览器实际上并没有进行导航。尽管它们在同一个 HTML 文件中,但这两个不同的视图基本上是独立的,并且它们通过withifnot绑定来控制。

编辑表单

这是在添加或编辑联系人时显示的内容:

<form class="form" role="form" data-bind="with: entryContact, submit: saveEntry">
      <h2 data-bind="text: displayName"></h2>
      <div class="form-group">
        <label for="firstName" class="control-label">First Name</label>
        <input type="text" class="form-control" id="firstName"placeholder="First Name" data-bind="value: firstName">
      </div>
      <div class="form-group">
        <label for="lastName" class="control-label">Last Name</label>
        <input type="text" class="form-control" id="lastName" placeholder="First Name" data-bind="value: lastName">
      </div>
      <div class="form-group">
        <label for="nickname" class="control-label">Nickname</label>
        <input type="text" class="form-control" id="nickname" placeholder="First Name" data-bind="value: nickname">
      </div>
      <div class="form-group">
        <label for="phoneNumber" class="control-label">Phone Number</label>
        <input type="tel" class="form-control" id="phoneNumber" placeholder="First Name" data-bind="value: phoneNumber">
      </div>
      <div class="form-group">
        <button type="submit" class="btn btn-primary">Save</button>
        <button data-bind="click: $parent.cancelEntry" class="btn btn-danger">Cancel</button>
      </div>
    </form>

由于with绑定也是隐式的if绑定,因此当entryContact属性为 null 或 undefined 时,整个表单会被隐藏。

表单的其余部分相当直接。使用submit绑定,以便点击保存按钮或按任何字段的回车键都会调用提交处理程序,显示显示名称的标题,每个字段的值绑定,一个带有type="submit"的保存按钮(以便它使用提交处理程序),以及一个绑定到$parent.cancelEntry的取消按钮。记住,$parent作用域是必要的,因为with绑定在entry联系人上创建了一个绑定上下文,而cancelEntryContactPageViewmodel上的一个函数。

联系人列表

列表从entryContact属性的ifnot绑定开始,确保只有在上一个表单隐藏的情况下才会显示。我们只想一次看到其中一个:

<div data-bind="ifnot: entryContact">
  <h2>Contacts</h2>
  <div class="row">
    <div class="col-xs-8">
      <input type="search" class="form-control" data-bind="value: query, valueUpdate: 'afterkeydown'" placeholder="Search Contacts">
    </div>
    <div class="col-xs-4">
      <button class="btn btn-primary" data-bind="click: newEntry">Add Contact</button>
    </div>
  </div>
  <ul class="list-unstyled" data-bind="foreach: displayContacts">
    <li>
      <h3>
        <span data-bind="text: displayName"></span> 
          <small data-bind="text: phoneNumber"></small>
        <button class="btn btn-sm btn-default" data-bind="click: $parent.editContact">Edit</button>
        <button class="btn btn-sm btn-danger" data-bind="click: $parent.deleteContact">Delete</button>
      </h3>
    </li>
  </ul>
</div>

搜索输入具有value绑定以及valueUpdate选项。值更新选项控制value绑定何时报告更改。默认情况下,更改在失去焦点时报告,但afterkeydown设置会导致在输入获得新字母后立即报告更改。这将导致搜索实时更新,但请记住,显示的联系人有一个rateLimit扩展器,它将更新延迟到 100 毫秒。

在搜索框旁边有一个按钮用于添加新的联系人。然后,当然,联系人列表通过在displayContacts属性上的foreach绑定来绑定。如果它直接绑定到contacts,则列表将不会显示过滤功能。根据您的应用程序,您甚至可能希望保留未过滤的联系人列表为私有,并且仅公开过滤后的列表。最佳选项实际上取决于您正在做什么,在大多数情况下,使用您的个人偏好是完全可以的。

在联系人列表中,每个条目都显示了电话号码的显示名称,并有一个按钮来编辑或删除联系人。由于 foreach 在单个联系人上创建了一个绑定上下文,而编辑和删除功能在父级上,因此 click 绑定使用了 $parent 上下文属性。click 绑定还将当前模型发送到每个编辑和删除函数,这样这些函数就不必通过遍历整个列表来尝试找到正确的 JavaScript 对象。

这就是应用程序的全部内容。我们有一个带有搜索功能的列表视图,它可以切换到易于重用的视图,用于编辑和创建。

摘要

在本章的大部分内容中,我们回顾了标准 Knockout 的使用。希望我没有在细节中迷失你的注意力。重要的是,在我们继续使用自定义功能扩展 Knockout 或构建更大的应用程序之前,你必须对可观察对象和数据绑定的基本使用感到舒适。这包括:

  • **扩展器**: 这包括创建扩展器和扩展可观察对象

  • ****模板:这告诉我们控制流的工作方式,什么是绑定上下文,内联模板与命名模板,以及无容器控制流

在下一章中,我们将通过创建自己的绑定处理程序来为 Knockout 添加新的功能。

第二章. 使用自定义绑定处理器扩展 Knockout

Knockout 的标准绑定很棒。它们解决了您在开发 Web 应用时可能遇到的大多数一般性问题。但总有提供特殊功能的需求,无论是您正在开发自己的库还是只是尝试为您的应用添加一些样式。当这种情况发生时,您将通过您在所有其他地方使用的相同绑定系统来提供该功能。幸运的是,Knockout 使扩展此系统变得容易。在本章中,我们将探讨如何创建我们自己的绑定处理器。我们将涵盖以下主题:

  • 绑定处理器包含的内容

  • 创建新的绑定处理器

  • 使用自定义绑定处理器与第三方库集成

  • 管理绑定上下文

  • 使用无容器控制流语法与自定义绑定

为新的和更复杂的 HTML 交互创建自定义绑定处理器是开发功能丰富应用的关键。虽然基础知识容易学习,但扩展点足够多,可以支持几乎任何用例。我们将查看许多示例,以获得绑定处理器能够做什么以及我们如何最好地利用它们的稳固概念。

数据绑定模式

本节主要涉及哲学。如果您已经对模型-视图-视图模型MVVM)模式和绑定处理器背后的是什么为什么有了稳固的理解,那么您可能想要跳到下一节,绑定处理器的组成部分

好的,让我们来谈谈模式和最佳实践。如果您之前没有使用过 WPF,那么 MVVM 模式可能是 Knockout 中最令人困惑的事情。MVVM 是微软提出的一个模式。它并没有在.NET 社区之外得到很多关注,并且由于它与更受欢迎的 MVC 模式相似,因此混淆几乎是必然的。

在 MVVM 中,视图模型应该代表视图的抽象。考虑 iOS 中的这两个消息线程列表:

数据绑定模式

它们都显示一个线程列表,每个线程都包含一个标题,显示与之相关的人员,最近消息的摘录,以及时间戳。可以选中或删除一个线程。要选择一条消息,您可以触摸它。要删除一条消息,您可以向左滑动以显示删除按钮,然后按下删除按钮来删除线程。尽管如此,您可能已经注意到了行为上的差异。左侧的列表将整个线程向左滑动以显示删除按钮,使线程部分离开屏幕。右侧的列表将按钮叠加在线程上方,隐藏了时间戳。

这些差异完全是数据展示的一部分。这两个视图都可以,并且应该由同一个视图模型支持。它们都显示相同的数据并允许相同的操作。

要使用预期的行为(幻灯片显示或幻灯片叠加)来消费这些数据,视图需要从除视图模型之外的其他东西那里获得支持。在 MVVM 模式中,这是绑定处理器的领域,尽管绑定处理器在缩写中没有字母,但它仍然是这个谜题的关键部分。由于视图模型不应该知道与视图相关的概念,如按钮、点击或手指触摸,而视图应该完全是声明性的,因此需要一个绑定处理器来将两者粘合在一起。

这里的基本原理是关注点的分离。视图关注 UI 元素和交互。视图模型关注代码对象和动作,而绑定处理器关注在视图模型之间通用地转换特定的 UI 元素或动作。

既然这些都已经说清楚,那么是时候开始创建一些自定义绑定处理器了!

绑定处理器的组件

绑定处理器通过向ko.bindingHandlers对象添加对象来定义,就像扩展器一样。它们由一个init和一个update函数组成。

当绑定首次应用于元素时,init函数会运行,无论是调用ko.applyBindings还是通过控制流绑定(如templateforeach)创建元素。它应该用于所有一次性工作,例如将事件处理器或销毁回调附加到元素上。

update函数在init之后运行,当绑定首次应用时。每当任何可观察的依赖项发生变化时,它都会再次运行。update函数确定其依赖项的方式与计算可观察项相同。如果在更新运行时访问了可观察项,它会订阅该可观察项。update函数应用于保持 UI 与视图模型的变化同步:

ko.bindingHandlers.yourBindingName = {
    init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
        // This will be called when the binding is first applied
        // 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
        // and again whenever dependant observables change.
        // Update the DOM element based on the supplied values here.
    }
};

两个函数都接收以下参数:

  • Element:这是绑定应用到的 DOM 元素。

  • valueAccessor:这是一个函数,将返回绑定表达式的结果。例如,如果绑定是value: name,则valueAccessor将返回name属性。如果name是可观察的,则仍然需要调用它或将它传递给ko.unwrap(此函数将在下一节中介绍)以获取值。如果绑定是value: name() + '!',则valueAccessor将返回结果字符串。

  • allBindings:这是一个具有gethas函数的对象,用于访问元素上的其他绑定。

  • Viewmodel:在 Knockout 的早期版本中,这提供了对视图模型的访问,但自 Knockout 3.0 以来,它已被弃用,转而使用bindingContext.$databindingContext.$rawData

  • bindingContext:这是一个对象,包含当前绑定的绑定上下文。它具有特殊的绑定上下文属性,如$parent$root。此参数是在 Knockout 3.0 中引入的。

使用自定义绑定处理器

一旦添加到ko.bindingHandler对象中,自定义绑定与普通绑定没有区别。如果你添加了一个名为flash的绑定处理程序,你可以在具有标准data-bind属性的 HTML 元素上使用它:

<p data-bind="flash: vmProperty">Flashy! (bum dum tish)</p>

简单绑定处理程序

绑定处理程序可以从非常简单到完全独立的应用程序。由于绑定处理程序的目的在于在表示层(HTML)和视图模型(JavaScript)之间进行转换,因此绑定处理程序的复杂性直接与 UI 交互和绑定数据的复杂性相关。简单的任务,如使用动画隐藏或显示元素,将具有非常薄的处理器,而数据绑定在交互式地图元素上将需要更多的逻辑。

动画绑定处理程序

由于 DOM 交互是 jQuery 的主要用途,并且鉴于其流行程度,在 Knockout 绑定处理程序中使用 jQuery 并不罕见。Knockout 文档中的典型自定义绑定处理程序示例是一个用于隐藏和显示元素的绑定,使用 jQuery 的slideUpslideDown方法,而不是使用标准的visible绑定来切换它们的开和关:

ko.bindingHandlers.slideVisible = {
    init: function(element, valueAccessor) {
        var value = ko.unwrap(valueAccessor());
        $(element).toggle(value);
    },
    update: function(element, valueAccessor, allBindings) {
        var value = ko.unwrap(valueAccessor());
        var duration = allBindings.get('slideDuration') || 400;

        if (value === true)
            $(element).slideDown(duration); //show
        else
            $(element).slideUp(duration); //hide
    }
};

此示例同时使用了initupdate函数。这里的init函数是必要的,以确保一个初始值为假的值在绑定首次应用时不会导致元素向上滑动,反之亦然。如果没有它,update函数会立即运行并尝试通过滑动隐藏元素。init函数确保元素已经处于正确的可见状态,因此当绑定首次运行时不会发生动画。

ko.unwrap是一个实用方法,如果调用时带有一个参数,它将返回一个可观察值的值;否则,它将直接返回第一个参数。如果你不确定是否有可观察值,这是一个完美的选择,因为它可以安全地用任何东西调用。大多数自定义绑定应该能够支持可观察值和非可观察值,所以你应该始终解包valueAccessor参数,除非你有充分的理由不这样做。

检查allBindings.get('slideDuration')允许使用可配置的值来设置幻灯片的计时。allBinding对象使我们能够访问同一元素上使用的其他绑定,通常用于收集可选的配置值:

<p data-bind="slideVisible: isShowing, slideDuration: 200">Quick</p>

这允许视图决定元素隐藏和显示的速度。由于动画速度是演示的一部分,因此从视图配置它是有意义的。如果你想使用视图模型的可观察值slideDuration,你可以修改该行以解包值:

var duration = ko.unwrap(allBindings.get('slideDuration')) || 400;

此绑定的一个示例在cp2-slide分支中。

与第三方控件协同工作

slideVisible绑定是一个完美的简单绑定;它有一个基本的init函数来启动绑定,并且有一个update函数,当视图模型发生变化时修改 DOM。然而,它是一个单向绑定,只监视视图模型的变化。双向绑定还需要监视 DOM 元素的变化并将其发送回视图模型。通常,这是通过在init函数中附加事件处理器来实现的;记住,update函数会在依赖项每次更改时运行,所以在那里附加事件处理器会导致事件处理器被多次附加。

绑定处理器也可以用来与第三方控件集成。尽管 HTML5 有一个原生的datepicker控件,但你可能需要一个更向后兼容的控件。jQuery 的datepicker控件是一个很好的即用型控件,但它需要调用$(element).datepicker()来将标准输入元素转换为日期选择器。绑定处理器是运行视图初始化逻辑的完美位置:

ko.bindingHandlers.datepicker = {
    init: function(element, valueAccessor, allBindingsAccessor) {
        var options = allBindingsAccessor().datepickerOptions || {},
            $el = $(element);

        //initialize datepicker with some optional options
        $el.datepicker(options);

        //handle the field changing
        ko.utils.registerEventHandler(element, "change", function() {
            var observable = valueAccessor();
            observable($el.datepicker("getDate"));
        });

        //handle disposal (if KO removes by the template binding)
        ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
            $el.datepicker("destroy");
        });

    },
    update: function(element, valueAccessor) {
        var value = ko.unwrap(valueAccessor()),
            $el = $(element),
            current = $el.datepicker("getDate");

        if (value - current !== 0) {
            $el.datepicker("setDate", value);   
        }
    }
};

要在 HTML 中使用此绑定,请将其应用于一个输入元素:

<input data-bind="datepicker: myDate, datepickerOptions: { mandate: new Date() }" />

注意

此示例来自 R. P. Niemeyer 在 Stack Overflow 上的答案,可以在stackoverflow.com/a/6400701/788260找到。

此绑定的init函数首先存储 jQuery 包装的元素,然后检查选项。jQuery UI 的 UI 日期选择器(jqueryui.com/datepicker)有很多选项,允许绑定控制配置是标准的。

接下来是使用$el.datepicker(options)对元素进行 jQuery 化。这附加了允许 jQuery 隐藏和显示弹出日期选择器控件并将其选择路由到输入元素的value的事件处理器。然后,使用 Knockout 的ko.utils.registerEventHandler,它附加了一个事件处理器,该处理器接受新的value并将其写入提供的可观察对象。

在某些情况下,我们可能想查看valueAccessor参数是否是可观察的,这样即使针对静态值,绑定也可以用来设置元素的初始值。你在这里需要使用你的最佳判断;在这种情况下,绑定的整个目的就是收集用户输入,所以在这种情况下与非可观察值一起工作是没有意义的。如果你确实想进行检查,你可以按以下方式更改事件处理器部分:

if (ko.isObservable(valueAccessor())) {
  ko.utils.registerEventHandler(element, "change", function () {
        var observable = valueAccessor();
        observable($el.datepicker("getDate"));
    });
}

ko.isObservable函数是一个实用方法,如果第一个参数是observableobservableArraycomputed可观察对象,则返回true。当valueAccessor参数不是可观察对象时,根本不需要附加更改处理器,因为我们不会对新的值做任何事情。

init函数中的最后一部分是一个销毁处理程序。当元素从 DOM 中移除时,绑定就会被销毁,这通常发生在templateforeach等控制流绑定更新自身时。jQuery 的datepicker控件期望调用$el.datepicker("destroy")来清理事件处理器(如果有的话),并从 DOM 中移除弹出元素。记住,弹出元素是由 jQuery 在这个绑定处理程序内部添加的,所以 Knockout 的模板系统并不了解它们。《ko.utils.domNodeDisposal.addDisposeCallback》注册了当模板系统从 DOM 中移除节点时将被调用的处理器。这是一个重要的步骤,任何时候你的绑定处理程序修改了 DOM。

update函数处理可观察的变化,但由于它需要在元素值和 JavaScript Dates代码之间进行转换,它必须执行自己的相等性检查。它不是查看元素值,而是使用$el.datepicker("getDate"),这返回一个真实的 JavaScript 日期。

要查看这个绑定的实际效果,你可以查看cp2-datepicker分支。我添加了一个与日期选择器绑定相同视图模型属性的span元素,这样你可以轻松地看到值的变化。

使用绑定修改 DOM

前两个绑定主要是数据与展示逻辑之间的转换器,但绑定处理程序可以做得更多。绑定也可以用来向页面添加新元素。如果你想提供一个 1-5 星级的 UI,你应该考虑使用带有optionsvalue绑定的select元素。虽然这可以工作,但更常见的方法是提供一系列用户可以点击的星星,点击会激活被点击的星星和所有之前的星星。Knockout 教程网站(learn.knockoutjs.com/#/?tutorial=custombindings)提供了一个很好的解决方案,它用一系列样式化的span元素替换了节点的内容:

ko.bindingHandlers.starRating = {
    init: function(element, valueAccessor) {
        $(element).addClass("starRating");
        for (var i = 0; i < 5; i++) {
           $("<span>").appendTo(element);
}

        // Handle mouse events on the stars
        $("span", element).each(function(index) {
            $(this).hover(
                function() { 
                  $(this).prevAll().add(this)
                    .addClass("hoverChosen");
                }, 
                function() { 
                  $(this).prevAll().add(this)
                    .removeClass("hoverChosen");
                }                
            ).click(function() { 
                var observable = valueAccessor();
                observable(index + 1); 
            });
        });            
    },
    update: function(element, valueAccessor) {
        // Give the first x stars the "chosen" class
        // where x <= rating
        var observable = valueAccessor();
        $("span", element).each(function(index) {
            $(this).toggleClass("chosen", index < observable());
        });
    }
};

你可以这样在元素上使用这个绑定:

<span data-bind="text: name"></span>
<span data-bind="starRating: rating"></span>

结果是一个看起来很不错的控件,对于之前填写过在线调查的人来说会感到熟悉:

使用绑定修改 DOM

此绑定的init函数设置了三件事。首先,它向绑定节点添加五个 span 元素作为子元素,这些元素将作为评分的星级。其次,它添加了悬停处理程序,将hoverChosen类应用到光标下的星级以及所有前面的星级。星级是累积的,所以如果我们悬停在第三个星级上,我们应该看到前三个星级填充。最后,它为每个星级添加了一个点击处理程序,该处理程序使用星级所代表的数字更新bound属性。由于它使用循环的索引,该循环从0开始,因此它将1添加到值上。再次,我们看到绑定假定所使用的属性是可观察的。如果我们想支持只读显示,我们就会修改绑定以在尝试更新之前检查属性是否可观察。

此绑定的update函数与我们之前看到的函数不同。它不是使用valueAccesor属性的新值来设置原始绑定元素的属性,而是遍历星级并使用 jQuery 的toggleClass来设置或删除所选类,仅应用于索引在或低于新值的星级。视图模型仍然只知道一个整数值,而视图只知道绑定元素正在使用starRating来展示该数字。绑定处理程序抽象出星级元素,并处理数字值与所选星级之间的转换。

此绑定假定存在应用于星级 span 的 CSS 类。您可以在cp2-stars分支中看到此绑定和 CSS 的交互式示例。

将新绑定应用到新子元素

在上一个示例中,我们查看如何创建子元素以使用一些样式来展示我们的数据。它是使用 jQuery 来管理在绑定初始化期间添加的子元素类。然而,当使用 Knockout 绑定时,有时使用内置的绑定处理程序来处理这类事情更有意义。幸运的是,我们可以在元素创建后添加 Knockout 绑定。

Knockout 提供了一个实用函数ko.applyBindingsToNode,用于手动将绑定应用到元素上。该函数接受一个绑定元素的对象。对象上的每个属性都将用于查找绑定处理程序,并将属性的值传递给绑定。它还接受一个可选的视图模型或绑定上下文作为第三个参数;如果省略,它将使用当前的绑定上下文:

init: function(element, valueAccessor) {
    var childElementToBind = document.createElement('input');
  element.appendChild(childElementToBind);

  ko.applyBindingsToNode(childElementToBind, {
    value: valueAccessor()
  });
}

这将在原始元素之后添加一个新的输入元素,并将一个value绑定应用到原始的可观察对象上。applyBindingsToNode调用接受一个新的输入元素和一个将应用value绑定的对象。valueAccessor属性返回原始属性并将其传递给绑定,本质上是将新的输入绑定到与原始绑定相同的属性。

如果我们想要创建一个添加带有新标签的输入的绑定,它可能看起来像这样:

ko.bindingHandlers.labelInput = {
      init: function(element, valueAccessor) {
          var input = document.createElement('input'),
            label = document.createElement('label'),
            labelText = valueAccessor().label,
            inputValue = valueAccessor().value;

          label.innerHTML = labelText;
          label.appendChild(input);

      element.appendChild(label);

      ko.applyBindingsToNode(input, {
        value: inputValue,
        valueUpdate: 'afterkeydown'
      });
      }

其绑定可以使用如下方式:

<div data-bind="labelInput: { label: 'Custom', value: name }"></div>

此绑定创建一个新的标签和输入,并将其作为子元素附加到原始绑定上。标签的文本设置为绑定的 label 属性,而绑定的 value 则绑定到输入节点。希望您现在可以开始看到绑定处理程序如何被用来创建不仅自己的行为,还可以创建自己的自定义元素。

此绑定的一个示例可以在 cp2-applynode 分支中看到。

应用访问器

applyBindingsToNode 方法在 Knockout 的所有版本中都可用,但如果您使用的是 Knockout 3.0 或更高版本,则还有一个方法可用。applyBindingAccessorsToNode 方法的工作方式与 applyBindingsToNode 类似,它将绑定对象作为第一个参数,并将可选的绑定上下文作为第三个参数。然而,它不是直接获取第二个参数属性的值,而是获取一个提供 valueAccessor 属性的函数。之前的 apply 调用在被转换后看起来如下:

ko. applyBindingAccessorsToNode (input, {
  value: function() { return inputValue },
  valueUpdate: function() { return 'afterkeydown' }
});

此方法实际上是 applyBindingsToNode 在将其提供的值转换为值访问器函数(如之前的那些)后内部调用的。使用 applyBindingAccessorsToNode 获得的少量间接步骤减少了性能。然而,更大的好处在于当绑定的值是一个表达式而不是一个简单的属性时。一个表达式只有在从使用它的绑定内部评估时才能建立依赖关系。值访问器函数将在稍后评估,允许它们正确地与表达式一起工作。

控制绑定处理程序的顺序

在罕见的情况下,您可能需要确保绑定处理程序按特定顺序发生。截至 Knockout 3.0,通过将绑定处理程序的 after 属性设置为必须首先处理的绑定数组,这是可能的。例如,您可以定义一个需要首先处理值和选项的绑定:

ko.bindingHandlers.valuePlus = {
    'after': ['options', 'value'],
    'init': function (element, valueAccessor, allBindings) {
        /* some code /*
     }
}

几个默认绑定都利用了这一点。value 绑定依赖于 optionsforeachchecked 绑定依赖于 valueattr

应该注意的是,如果您创建了两个具有相互 after 引用的绑定,Knockout 如果试图将这两个绑定应用到同一元素上,将会抛出一个循环依赖异常。

高级绑定处理程序

到目前为止,我们一直在查看处理一个或两个属性并导致相当简单的单一用途控制的绑定处理程序。在之前的示例中,我们开始查看创建新子元素的绑定处理程序,并且这种技术允许我们创建更复杂的绑定行为。绑定还可以与复杂元素(如图表或地图控件)交互(例如,Google 地图小部件),为 viewmodel 提供一个干净的 API,以便与之交互。

使用图表绑定复杂数据

第一次我们考虑与第三方控件集成时,是与日期选择器的单属性双向绑定。每次我们与第三方 UI 工具一起工作时,目标是通过绑定将它们从视图和视图模型中抽象出来;即使这些工具是用于复杂结构,如图表。

Charts.js (www.chartjs.org) 是一个流行的 JavaScript 库,用于显示数据,正如你所猜想的,是图形图表。不深入探讨图表工作细节,一个由绑定处理程序提出的挑战是,图表没有用于制作增量更新的 API。整个图表需要重新渲染以进行更新。这需要访问canvas元素以及画布的 2D 上下文。如果我们创建画布在init函数中,在update函数中获取该元素可能会很棘手。让我们看看一个这样的例子(这是示例代码):

ko.bindingHandlers.doughnutChart = {
    init: function(element, valueAccessor) {
        var canvas = document.createElement('canvas'),
            options = ko.utils.extend(defaultChartOptions, valueAccessor());

        element.appendChild(canvas);
    },
    update: function(element, valueAccessor) {
        var chartContext = canvas.getContext('2d')

        /* Drawing code */

        new Chart(chartContext).Doughnut(data, options);
    }
};
//HTML
<div data-bind="doughnutChart: {data: chartSeries}"></div>

你可以在init函数中看到,一个新的画布元素已经被创建并附加到绑定元素上。然而,变量(canvas)需要在update函数中用于绘图,但实际上它并不在那里可用。

Knockout 提供了两个实用方法,ko.utils.domData.set(element, key, value)ko.utils.domData.get(element, key),它们可以用于在绑定元素上设置值。它们可以存储任何 JavaScript 值,包括 DOM 节点引用,因此我们当然可以在这里使用它们:

ko.bindingHandlers.doughnutChart = {
    init: function(element, valueAccessor) {
        var canvas = document.createElement('canvas'),
            options = ko.utils.extend(defaultChartOptions, valueAccessor());

        ko.utils.domData.set(element, 'canvas', canvas);

        element.appendChild(canvas);
    },
    update: function(element, valueAccessor) {
        var canvas = ko.utils.domData.get(element, 'canvas'),
            chartContext = canvas.getContext('2d');

        /* Drawing code */

        new Chart(chartContext).Doughnut(data, options);
    }
};

这将有效。然而,这也意味着元素不仅包含画布作为子元素,还作为属性;这也意味着每次更新运行时都需要检索该元素。

另一种方法是在init函数中创建一个具有对画布或甚至上下文的闭包的计算可观察对象。这听起来像是创建了一个额外的对象,但请记住,绑定中的update函数实际上被包裹在一个计算中,以利用依赖检测的优势。使用这种方法,我们的绑定将看起来像这样:

ko.bindingHandlers.doughnutChart = {
    init: function(element, valueAccessor) {
        var canvas = document.createElement('canvas'),
            options = ko.utils.extend(defaultChartOptions, valueAccessor()),
            chartContext = canvas.getContext('2d');

        element.appendChild(canvas);        

        ko.computed(function() { 
  canvas.height = ko.unwrap(options.height);
          canvas.width = ko.unwrap(options.width);

          var data = ko.toJS(options.data).map(function(x) {
            return {
              value: parseFloat(x.value),
              color: x.color.indexOf('#') === 0 ? "#" + x.color : x.color
            }
          });

            new Chart(chartContext).Doughnut(data, options);
        }, null, {disposeWhenNodeIsRemoved: element});
    }
};

使用这种方法时需要考虑的一点是计算的可处置性。计算构造函数的第三个参数是一个options对象,我们可以通过指定元素来指定计算应该与 DOM 节点的移除一起被销毁。这个选项可以在前面的例子中看到。

在示例中需要注意的另一件事是init函数中的options变量。你应该熟悉扩展对象的概念,但以防万一,请记住,扩展(也称为合并)是指选择一个目标对象,并通过复制其所有属性来使用源对象更新它。结果是具有两者组合值的对象,在目标对象也有值的情况下,使用源对象的值。Knockout 在ko.utils.extend上提供了一个extend方法。我在这里使用它来使所有的图表options都是可选的,通过在绑定之前提供这些默认值:

var defaultChartOptions = { 
    height: 300, 
    width: 300,
    animation: false
  };

必须提供的是图表显示所需的数据。Chart.js 要求 Doughnut 图表提供一个包含值和颜色的对象数组。为了提供一个人性化的绑定,我们可以让绑定负责确保数据被清理,这包括将值解析为数字并确保我们的颜色值以哈希(#)开头,用于十六进制代码。除了高度和宽度的一些选项外,我们的最终计算结果可能如下所示:

ko.computed(function() {

  canvas.height = ko.unwrap(options.height);
  canvas.width = ko.unwrap(options.width);

  var data = ko.toJS(options.data).map(function(x) {
    return {
      value: parseFloat(x.value),
      color: x.color.indexOf('#') === 0 ? x.color : "#" + x.color
    };
  });

  new Chart(chartContext).Doughnut(data, options);
}, null, {disposeWhenNodeIsRemoved: element});

这个绑定的一个示例,包括一些用于更改数据的绑定,可以在cp2-charts分支中找到。

动态调整图表类型

Chart.js 中的三个图表——Doughnut、Pie 和 Polar Area——使用相同的值/颜色对数据结构。如果你想支持在兼容的图表之间切换,你可以添加类型作为绑定选项。我们的计算结果将如下所示:

var chart = new Chart(chartContext),
  chartType = ko.unwrap(options.type);

if (circularChartTypes.indexOf(chartType) === -1) {
  throw new Error('Chart Type ' + chartType + 'is not a Circular Chart Type');
}

chartchartType;

为了表明这种新的绑定支持多种类型,我们可以更新名称,然后像这样使用它:

<div data-bind="circularChart: { 
            data: chartSeries, 
            width: chartWidth, 
            height: chartHeight, 
            type: selectedChartType 
}"></div>

这个修改后的示例可以在cp2-charts2分支中看到。

通过绑定公开 API

Chart.js 示例展示了针对多个属性的绑定。虽然我们能够通过修改绑定可观察的高度、宽度和类型来控制图表,但它不允许我们与图表进行交互。我们无法点击或拖动图表来更新其数据的可观察值。我们将要查看的最后一种自定义绑定技术是处理复杂交互控件;这些控件绑定多个或复杂的数据并允许用户输入。通过这样做,我们可以通过 UI 或编程方式消费控件 API。我们将使用的示例是 Google Maps API 的绑定。

我们的抽象目标之一是保持 UI 如何完成任务与 UI 声明的分离。对我们来说,visible绑定通过向元素添加style="display: none;"来实现隐藏并不重要;我们只关心当绑定的属性为truthy时,元素才会可见。

抽象的另一个目标是让第三方数据结构不进入我们的 viewmodel 代码,特别是如果第三方代码仅由绑定处理程序使用。我们的 viewmodel 不关心它的纬度和经度是否被地图使用,更不用说是一个来自 Google 的地图。这是 UI 的业务。然而,我们仍然需要将数据整理成正确的格式,如果我们希望它与第三方 API 友好地交互。在这里,绑定处理程序再次救命!

Google Maps JavaScript API 功能强大且功能丰富。我们将查看一个简单的绑定,它允许我们控制地图的中心点(纬度和经度),以及地图的缩放级别。我们将在我们的绑定中隐藏 Google Maps API 的所有细节。我们的 viewmodel 将是简单的,只有这三个属性:

var BindingSample = function() {
  var self = this;

  self.zoom = ko.observable(8);
  self.latitude = ko.observable(45.51312335271636);
  self.longitude = ko.observable(-122.67063820362091);
};

希望任何合理的映射 API 都会允许我们使用这些属性,这允许我们的 viewmodel 对它们中的任何一个进行重用。我们希望我们的 HTML 也是可重用的,因此它应该使用一个与地图提供者无关的语法:

<div data-bind="map: { lat: latitude, long: longitude, zoom:zoom }"  ></div>

到目前为止一切顺利;这里没有新的内容。让我们看看那个映射绑定处理程序:

ko.bindingHandlers.map = {
   init: function(element, valueAccessor) {
      var data = valueAccessor(),
         options = ko.utils.extend(ko.maps.defaults, data),
         //just get the relevant options
         mapOptions = {
            zoom: ko.unwrap(options.zoom),
            center: new google.maps.LatLng(ko.unwrap(options.lat), 
              ko.unwrap(options.long)),
            mapTypeId: options.mapType
         },
         map = new google.maps.Map(element, mapOptions);

      ko.computed(function() {
         map.setZoom(parseFloat(ko.unwrap(options.zoom)));
      }, null, { disposeWhenNodeIsRemoved: element });

      ko.computed(function() {
         map.panTo(new google.maps.LatLng(ko.unwrap(options.lat), ko.unwrap(options.long)));
      }, null, { disposeWhenNodeIsRemoved: element });

      google.maps.event.addListener(map, 'center_changed', function() {
         var center = map.getCenter();
         if (ko.isObservable(data.lat)) {
            data.lat(center.lat());
    }
         if (ko.isObservable(data.long)) {
            data.long(center.lng());
    }
      });

      if (ko.isObservable(data.zoom)) {
         google.maps.event.addListener(map, 'zoom_changed', function() {
            data.zoom(map.getZoom());
         });
      }
   }
};

到现在为止,开始部分应该是熟悉的了;我们正在获取valueAccessor参数,使用一些默认值(参见前面的示例)并将它们扩展到绑定数据。下一行使用 Google Maps API 创建一个新的映射,并提供了元素和我们的选项。

接下来,我们设置两个计算值,以便在缩放或纬度/经度值变化时更新地图。使用计算方法而不是绑定处理程序的update方法的优势在于,update方法将在valueAccessor属性的任何部分发生变化时触发。如果只有一个值发生变化,例如缩放,我们不想更新地图位置。我们必须找出哪个值发生了变化,这意味着在绑定中跟踪它。在这里,这两个计算值只有在它们的依赖项发生变化时才会重新运行,确保我们不会进行不必要的调用以更新地图。

最后,我们在地图上有一对事件监听器,用于在用户与地图交互时更新我们的可观察值。这些使用 Google Maps API 的addListener来获取更新,无论地图是移动的,这可以通过鼠标拖动或使用键盘箭头完成,以及当缩放改变时。panTo函数只是一个动画的move命令;如果新位置足够接近,panTo将平滑地进入。

就这样!如果我们的代码更新了这些值,地图将会移动。如果用户移动地图,绑定的可观察值将会更新。我们在第三方 UI 控件上实现了多属性的双向绑定!

显然,如果我们想支持更多的 Google Maps API,这个绑定可以做得更大,但这应该能给你一个如何做到这一点的概念。不要害怕制作更大的绑定。这本书中的例子都是出于必要而做得很小——它们告诉我这将打印在死树上——但你应该自由地制作你完成任务所需的任何大小的绑定。我宁愿选择一个更大、更灵活的绑定,也不愿选择一个更小、更不灵活的绑定。

如果你想查看这个绑定的示例,请查看 cp2-maps 分支。它有几个输入绑定到地图上,这样你就可以看到双向更新。玩起来很有趣。

通过绑定公开 API

绑定上下文和子绑定

我们迄今为止创建的所有绑定处理程序都尊重标准绑定上下文。在本节中,我们将探讨修改绑定上下文的技术。这允许对元素如何绑定以及它们绑定的数据进行精细控制。

注意

根据 Knockout 文档的说明(knockoutjs.com/documentation/custom-bindings-controlling-descendant-bindings.html),这些方法通常不用于应用程序开发。它们可能只对构建在 Knockout 之上的库或框架开发者有用。

控制子绑定

你可以通过从绑定处理程序的 init 函数返回 controlsDescendantBindings 来告诉 Knockout 你的绑定处理程序负责所有子节点的绑定。这个典型例子是 stopBinding 处理程序:

ko.bindingHandlers.stopBinding = {
    init: function(element, valueAccessor) {
        return { controlsDescendantBindings: ko.unwrap(valueAccessor()) };
    }
};

这将阻止当前绑定上下文继续遍历这些元素的后代,除非启动另一个绑定上下文,否则它们将保持初始未绑定状态:

<div data-bind="stopBinding: true">
  <h4 data-bind="text: 'Bound'">Unbound</h4>
</div>

在应用绑定后,这个 div 元素中的标题仍然会显示为 Unbound,因为 stopBinding 停止了所有子绑定的应用。你可以在 cp2-stopbinding 分支中看到一个绑定示例。注意,如果你将 stopBinding 改为 false,标题将显示为 Bound

所以这就是基本概念,但我们能做什么呢?嗯,在打断当前绑定上下文后,我们可以用另一个上下文来替换它!

子绑定上下文

可能最常用的绑定上下文操作是创建一个子上下文,其 $parent 是当前上下文。templatewithforeach 绑定会为它们绑定的数据执行此操作。子上下文可以使用特殊属性 $parent 访问其父上下文,并且可以使用 $root 访问顶级视图模型(传递给 ko.applyBindings 的那个)。你可以通过在绑定处理程序传递的 bindingContext 参数上调用 createChildContext 来创建自己的子上下文。

这里有一个通过合并两个对象来创建子上下文的绑定示例:

ko.bindingHandlers.merge = {
    init: function(element, valueAccessor, allBindings, viewmodel, bindingContext) {

        var value = valueAccessor(),
          merge = ko.utils.extend(value.target, value.source);
          child = bindingContext.createChildContext(merge);

    ko.applyBindingsToDescendants(child, element);          

        // Don't bind the descendants
      return { controlsDescendantBindings: true };
    }
};

此绑定使用两个属性,targetsource,并使用 Knockout 实用方法extend将它们合并在一起。注意,因为我们正在将绑定应用于后代,我们必须返回controlsDescendantBindings标志。考虑以下视图模型:

var BindingSample = function() {
   var self = this;
   self.name = 'Scout Retreat';
   self.springCourse = { knots: true, woodworking: true, metalworking: true };
   self.summerCourse = { rafting: true, diving: true, tracking: false };
};

我们可以使用merge绑定将模板绑定到春季和夏季课程的组合属性上:

<div data-bind="merge: { source: springCourse, target: summerCourse }">
  <h3 data-bind="text: $parent.name"></h3>
  <div>
    <label for="knots">Knots</label>
    <input type="checkbox" id="knots" disabled data-bind="checked: knots">
  </div>
  <div>
    <label for="woodworking">Woodworking</label>
    <input type="checkbox" id="woodworking" disabled data-bind="checked: woodworking">
<div>
    <label for="tracking">Tracking</label>
    <input type="checkbox" id="tracking" disabled data-bind="checked: tracking">
  </div>
  </div>
  <!-- More inputs -->
</div> 

注意,在合并绑定中,我们可以使用$parent.name来获取视图模型的名字。因为子绑定是从合并绑定处理器内部的绑定上下文中创建的,所以原始层次结构仍然可以访问。你可以在cp2-mergecontext分支中看到一个工作示例。

扩展绑定上下文

没有在层次结构中创建新的子节点,就可以修改当前的绑定上下文。嗯,差不多吧。扩展绑定上下文会克隆当前上下文的同时添加属性。其他绑定处理器、兄弟或父节点,不会受到这种变化的影响。

如果我们稍微修改前面的示例,你可以很容易地看到扩展和创建子节点之间的区别:

ko.bindingHandlers.merge = {
    init: function(element, valueAccessor, allBindings, viewmodel, bindingContext) {

        var value = valueAccessor(),
         merge = ko.utils.extend(value.target, value.source);
         context = bindingContext.extend(merge);

      ko.applyBindingsToDescendants(context, element);         

        // Also tell KO *not* to bind the descendants itself, otherwise they will be bound twice
      return { controlsDescendantBindings: true };
    }
};

这对 HTML 绑定的唯一影响是名称不再需要首先调用$parent

<div data-bind="merge: { source: springCourse, target: summerCourse }">
  <h3 data-bind="text: name"></h3>

你可以在cp2-mergecontext2分支中看到这个示例。

扩展和创建子上下文在潜在用途方面非常相似。这完全取决于你正在做什么,以及添加层是否会有所帮助。然而,还有另一种修改绑定上下文的方法,它是一个完全不同的概念。

设置新的$root 上下文

在某些情况下,可能更希望创建一个新的绑定上下文层次结构,而不是向现有的一个添加层。这将允许绑定处理器将其自身或它管理的上下文作为$root绑定上下文提供给任何后代绑定。

这种用法的一个例子是使用递归模板的绑定处理器:

var treeTemplate = '<div>Name: <span data-bind="text:name"></span><br>'
   +'Root: <span data-bind="text: isRoot ? \'Self\' : $root.name"></span><br>'
   +'<ul data-bind="foreach: { data: children, as: \'child\' }">'
      +'<li data-bind="tree: { data: child, children: $root.__children, name: $root.__name, isRoot: false }"></li>'
   +'</ul></div>';

ko.bindingHandlers.tree = {
    init: function(element, valueAccessor, allBindings, viewmodel, bindingContext) {

      var value = valueAccessor();
      var context =  { 
         __name: value.name,
         __children: value.children,
         //Default to true since template specifies
         isRoot: value.isRoot === undefined || value.isRoot,
         name: value.data[value.name],
         children: value.data[value.children],
      };

      element.innerHTML = treeTemplate;

      if (context.isRoot) {
         ko.applyBindings(context, element.firstChild);
    }
      else {
      ko.applyBindingsToDescendants(bindingContext.extend(context), element);   
    }       

      // Also tell KO *not* to bind the descendants itself, otherwise they will be bound twice
      return { controlsDescendantBindings: true };
    }
};

此绑定使用递归模板来显示一个对象及其所有子对象,同时允许原始绑定定义用于填充此数据的属性。根节点的名字在所有使用$root绑定上下文属性的子节点上使用,而不是需要通过计数当前深度回溯树。这是通过调用ko.applyBindings完成的,与其它apply调用不同,它使用第一个参数创建一个全新的绑定上下文。通常,这个调用用于启动应用程序,并且当没有提供第二个参数时,它应用于整个窗口。第二个参数将此新上下文限制在提供的元素上。tree绑定使用当前元素的firstChild。即使controlsDescendantBindings标志阻止 Knockout 绑定后代,当前元素仍然被绑定,并且对其应用绑定将导致双重绑定错误发生。

要使用此绑定,viewmodel 可以从任何自同对象开始,例如一个有孩子的个人:

var BindingSample = function() {
   var self = this;

   self.person = { 
      fullName: 'Alexander Hamilton',
      descendants: [ /* self-same children */]
   };
};

然后,使用 tree 绑定来显示这些信息,而无需使用一个特殊的 viewmodel 来匹配属性:

<div data-bind="tree: { 
               data: person, 
               children: 'descendants', 
               name: 'fullName'
}"></div>

这允许我们的 tree 绑定处理任何递归结构。你可以在 cp2-rootcontext 分支中看到此绑定的一个示例。

带有自定义绑定的无容器语法

在第一章中,我们讨论了无容器绑定;通过注释应用,在它们的 "子" 节点周围创建虚拟容器的绑定。现在,我们已经很好地理解了如何创建自己的绑定处理器,是时候学习如何制作无容器绑定了。

首先,我们将创建一个普通绑定,然后看看我们需要做什么才能让它支持虚拟元素。假设你想要一个对其子元素进行排序的绑定。它需要遍历它们,检查一些属性,然后重新排列 DOM,使它们按顺序排列。通常,排序是通过使用 foreach 绑定对排序的 observableArray 属性进行操作来实现的,但我们将创建一个按 DOM 节点宽度排序的排序绑定,这会考虑到可能影响它的任何 CSS。viewmodel 会很难获取这些信息以确定正确的排序顺序,并且 HTML 元素和宽度不属于 viewmodel 逻辑:

ko.bindingHandlers.widthSort = {
    init: function(element, valueAccessor) {
      // Pull out each of the child elements into an array
      var children = [];
      for (var i = element.children.length - 1; i >= 0; i--) {
         var child = element.children[i];
         //Don't take empty text nodes, they are not real nodes
         if (!isWhitespaceNode(child))
            children.push(child);
      };

      //Width calc must be done while the node is still in the DOM
      children.sort(function(a, b) {
         return $(a).width() <= $(b).width() ? -1 : 1;
      });

      while(children.length) {
         //Append will remove the node if it's already in the DOM
         element.appendChild(children.shift());
      }
   }
};

此绑定将使用一个虚拟属性,因为我们实际上并没有检查它:

<ul data-bind="widthSort: true">

绑定首先会从绑定元素中获取所有真实子节点。isWhitespaceNode 检查只是寻找 HTML 中的空白,这些空白来自标签之间的换行。我们想忽略这些节点,因为它们会破坏 with 检查:

function isWhitespaceNode(node) {
  return !(/[^\t\n\r ]/.test(node.textContent)) 
          && node.nodeType == 3;
}

在从元素中获取可用的子节点后,它会根据它们的宽度按升序对它们进行排序。然后,它会遍历排序后的子节点,并将它们追加到绑定元素中。节点的删除是自动的,因为 DOM 只允许节点存在一次。这产生了我们的宽度排序列表。你可以在 cp2-sort 分支中看到这个示例。它用于对以下列表进行排序:

<ul data-bind="widthSort: true">
   <li>Jimmy Dean</li>
   <li>Sara Lee</li>
   <li>Famous Amos</li>
   <li>Orville Redenbacher</li>
   <li>Dr. Pepper</li>
</ul>

注意

由于宽度排序使用实际的像素宽度,奥维尔·雷登巴赫最终排在凯洛格兄弟之后,尽管他们的字符数相同。除非,当然,你使用的是等宽字体。

使用虚拟元素 API

如果你现在尝试将此绑定用作虚拟元素绑定,你会得到一个错误提示,告诉你这不会工作。Knockout 需要在绑定可以以这种方式使用之前设置一个标志:

ko.virtualElements.allowedBindings.widthSort = true;

这个标志告诉 Knockout,widthSort将与虚拟元素一起工作,所以 Knockout 不会阻止你尝试。尽管如此,它仍然不起作用,因为我们的绑定正在调用元素的子节点。注释节点与常规 JavaScript API 不兼容,但 Knockout 提供了一个将工作的虚拟元素 API。这些函数存在于ko.virtualElements对象上:

  • childNodes(containerElement): 这个方法返回containerElement的子节点数组。

  • emptyNode(containerElement): 这个方法从containerElement中移除所有子节点。这也会清理节点上附加的任何数据,以防止内存泄漏。

  • firstChild(containerElement): 这个方法返回第一个子元素,如果没有子节点,则返回 null。

  • insertAfter(containerElement, nodeToInsert, insertAfter): 这个方法将nodeToInsert添加到containerElementinsertAfter节点之后。

  • nextSibling(node): 这个方法返回节点的下一个兄弟节点,如果没有则返回 null。

  • prepend(containerElement, nodeToPrepend): 这个方法将nodeToPrepend作为containerElement的第一个子节点插入。

  • setDomNodeChildren(containerElement, arrayOfNodes): 这个方法在插入arrayOfNodes作为子节点之前,会从containerElement中移除任何子节点(清理附加的数据)。

所有这些函数都将虚拟元素视为一个具有子节点的真实 DOM 节点。它们也与常规 DOM 节点兼容,因此相同的函数将适用于常规和无容器绑定。

widthSort绑定处理器更新为使用此 API 的示例如下:

ko.bindingHandlers.widthSort = {
   init: function(element, valueAccessor) {
      // Pull out each of the child elements into an array
      var children = [],
         childNodes = ko.virtualElements.childNodes(element);
      for (var i = childNodes.length - 1; i >= 0; i--) {
         var child = childNodes[i];
         //Don't take empty text nodes, they are not real nodes
         if (!isWhitespaceNode(child)) {
             children.push(child);
    }
      };

      //Width calc must be done while the node is still in the DOM
      children.sort(function(a, b) {
         return $(a).width() <= $(b).width() ? -1 : 1;
      });

      ko.virtualElements.setDomNodeChildren(element, children);
   }
};

唯一的两个变化是使用childNodes来获取排序的子节点,以及使用setDomNodeChildren来设置内容而不是遍历排序后的子节点。现在,我们的绑定应该支持无容器语法。

虚拟元素版本的示例在cp2-sort2分支中。为了演示,HTML 已经被更新,使得第一个元素不在排序中,这是没有虚拟元素支持我们无法做到的:

<ul class="oddball clearfix">
   <li>Jimmy Dean</li>
   <!-- ko widthSort: true -->
   <li>Sara Lee</li>
   <li>Famous Amos</li>
   <li>Orville Redenbacher</li>
   <li>Johnny Appleseed</li>
   <li>The Kellog Brothers</li>
   <!-- /ko -->
</ul>

摘要

如果从所有这些例子中提取出一个要点,那应该是绑定处理器只负责与 DOM 的交互。在我们的第一个例子中,我们将slideVisible绑定作为一个动画替换标准visible绑定。这种从正常的“即时”隐藏和显示到“动画”隐藏和显示的改变完全由我们的视图模型解耦。这样做的好处是它将这两部分完全分离,允许它们独立发展和演变。

在本章中,我们介绍了简单和复杂的绑定处理器、绑定上下文管理以及使用虚拟元素 API 来支持无容器绑定。在下一章中,我们将探讨绑定和节点的预处理器。

第三章:使用预处理器和提供者扩展 Knockout

在上一章中,我们探讨了向 Knockout 添加自定义绑定处理器以添加功能和将它们与第三方工具集成的技术。这项功能是 Knockout 首次发布时就有的,它允许对 Knockout 的功能进行强大的扩展。在本章中,我们将探讨一些更高级的扩展或甚至改变 Knockout 绑定行为的技术。你将学习如何创建:

  • 绑定处理器预处理程序

  • 节点预处理程序

  • 绑定提供者

在我们介绍完这些之后,我们将看看 Knockout Punches 库,这是一个由 Knockout 开发者 Michael Best 提供的预处理器和扩展集合。

绑定处理器预处理

到目前为止,我们已经探讨了绑定处理器的两个属性:initupdate函数。绑定处理器还有一个可选的函数,即preprocess,它在init函数之前运行。预处理器的目的是在 Knockout 确定要应用哪些绑定之前修改data-binding属性。

预处理程序不处理元素或绑定上下文;它们只处理绑定将评估的字符串。例如,如果我们有一个将所有文本绑定转换为大写的预处理程序,那么以下span元素将被处理:

<span data-bind="text: 'That Guy'"></span>

这个span元素将被处理成这样:

<span data-bind="text: 'That Guy'.toUpperCase()"></span>

如果你在这个之后检查 HTML,你仍然会看到原始的data-bind属性。这是因为预处理程序实际上并不处理元素;它们只是在正常绑定处理器应用之前修改绑定字符串。

创建预处理程序

添加一个预处理器的操作就像给绑定处理器添加一个preprocess属性一样简单,就像我们添加了initupdate函数:

ko.bindingHandlers.thing.preprocess = function(value, name, addBinding) {
    //Do stuff
}

preprocess函数的三个参数如下:

  • value: 这是绑定处理器给出的表达式。例如,在text: name中,值是name;在text: title() + '. ' + name()中,值是"title() + '. ' + name()"。这个值始终是一个字符串。

  • name: 这是绑定处理器的名称,例如,textclick。这在多个绑定处理器使用单个preprocess函数的情况下非常有用。

  • addBinding: 这是一个回调函数,它接受namevalue字符串参数,就像之前的那些一样。它将这个对作为绑定添加到元素上。

预处理程序的返回值将是整个绑定使用的新值。

让我们看看几个例子。

大写预处理程序

Knockout 文档提供了一个关于这个预处理器的示例,在撰写本文时,它返回value + ".toUpperCase()"。完整的预处理程序将如下所示:

ko.bindingHandlers.text.preprocess = function(value) {
  return value + '.toUpperCase()';
};

上述代码在例如本节开头直接取字符串时将有效。

<span data-bind="text: 'That Guy'"></span>

我们预处理器的结果将是 text: 'That Guy'.toUpperCase(),并且文本绑定将无错误地处理这个结果。不幸的是,这将在绑定到可观察属性的正常情况下失败:

<span data-bind="text: firstName"></span>

Knockout 的正常绑定过程会展开它得到的表达式,这样可观察的属性就不需要括号。另一方面,预处理器只是输出直接由绑定处理器消费的字符串。我们的大写绑定将在这里产生一个非法的结果:

<span data-bind="text: firstName.toUpperCase()"></span>

这将失败,因为 firstName 是一个可观察的属性,而不是一个字符串,并且可观察的属性没有 toUpperCase 方法。

幸运的是,这个解决方案很简单。我们的预处理器可以通过应用一个 unwrap 函数安全地处理所有值表达式:

ko.bindingHandlers.text.preprocess = function(value) {
    return 'ko.unwrap(' + value + ').toUpperCase()';
};

这将确保任何值——无论是原始类型、可观察的属性还是内联表达式——都能被绑定处理器正确评估。

你可以在 cp3-uppercase 分支中看到这个预处理器的示例。

包装现有绑定

由于 Knockout 为大多数标准场景提供了默认绑定,因此通常希望自定义绑定能够建立在它们之上。预处理器使得包装其他绑定变得非常容易。

假设我们想要一个绑定,当属性更新时使元素闪烁,同时在它上面提供 value 绑定。通常,你可能会想要将这些分成两个单独的绑定,但如果你经常这样做,一个单独的绑定将节省时间和按键。

由于 value 绑定已经存在,我们只需使用预处理器通过 addBinding 回调添加绑定即可:

ko.bindingHandlers.valueFlash = {
  preprocess: function(value, name, addBinding) {
      addBinding('value', value);
      return value;
  },
  update: function(element, valueAccessor) {
        ko.unwrap(valueAccessor());  //unwrap to get dependency
        $(element).css({opacity: 0}).animate({opacity: 1}, 500);
    }
};

addBinding 回调负责生成 value 绑定,就像它被正常应用一样,这包括为新绑定(如果有的话)运行预处理器。

在添加 value 绑定后,我们仍然需要返回原始值是很重要的。如果 preprocess 函数没有返回任何内容,那么原始绑定将被移除。之后,绑定处理器的其余部分就像往常一样:添加所需的 initupdate 函数,并编写你的自定义行为。在 cp3-wrap 分支中有一个这种绑定的示例。

那就是创建绑定处理器预处理器所需要做的全部。由于它们允许扩展性,它们的使用简单直接。当我们在本章的最后部分查看 Knockout.Punches 时,我们将探讨一些更多关于绑定预处理器在现实世界中的可能性。

节点预处理器

绑定处理器的预处理器附加到单个绑定处理器上,通过修改绑定字符串来工作。它们仅适用于各自处理器的节点。

相反,节点预处理器在每一个 DOM 节点上都会被调用。它们在 UI 首次绑定时运行,以及当 UI 被绑定如 foreachtemplate 这样的绑定修改时运行。

节点预处理器的目的是在数据绑定发生之前修改 DOM,与仅修改 data-bind 属性的绑定预处理器相反。节点预处理器是通过向绑定提供者添加一个 preprocessNode 函数来定义的:

ko.bindingProvider.instance.preprocessNode = function(node) {
  /* DOM code */
}

预处理器为每个节点调用一次。如果不需要进行任何更改,它应该返回空值。否则,它可以使用标准的 DOM API 插入新节点或删除当前节点:

  • 应该使用以下方式在当前节点之前插入新节点:

    node.parentNode.insertBefore(newNode, node);
    
  • 替换可以通过以下方式完成:

    node.parentnode.replaceChild(newNode, node);
    
  • 可以使用以下方式删除:

    node.parentNode.removeChild(node);
    

添加的任何节点都需要从 preprocessNode 返回;否则,Knockout 不会将绑定应用于它们。由于在 preprocessNode 中没有绑定上下文(你只有当前节点),因此无法自行应用绑定,除非它们应用于常量或全局值。然而,这并不推荐,因为它会在当前上下文层次结构之外创建一个新的绑定上下文。

关闭虚拟模板节点

Knockout 文档提供了一个方便的节点预处理器,它可以自动关闭虚拟模板绑定。通常,在编写无容器的模板绑定时,你需要两个注释节点:

<!-- template: 'some-template' --><!-- /ko -->

由于模板绑定在引用外部模板时永远不会包含内容,因此闭合注释节点看起来是不必要的。一个 preprocess 函数将允许你使用模板而不需要闭合标签,这样你可以将绑定写成这样:

<!-- template: 'some-template' -->

Knockout 需要一个闭合的注释标签,即 <!-- /ko -->,用于虚拟绑定。我们可以通过预处理器自动提供这个注释节点:

ko.bindingProvider.instance.preprocessNode = function(node) {
   if (node.nodeType == node.COMMENT_NODE) {
      var match = node.nodeValue.match(/^\s*(template\s*:[\s\S]+)/);
      if (match) {
         // Create a pair of comments to replace the single comment
         var c1 = document.createComment("ko " + match[1]),
            c2 = document.createComment("/ko");
         node.parentNode.insertBefore(c1, node);
         node.parentNode.replaceChild(c2, node);

         // Tell Knockout about the new nodes so that it can apply bindings to them
         return [c1, c2];
      }
   }
};

此示例使用正则表达式来识别模板注释并从绑定中提取表达式。然后,它用虚拟模板绑定的标准开/闭注释对替换原始注释。最后,它返回新的注释节点,允许 Knockout 将它们绑定;这将把模板应用到由注释节点创建的虚拟容器中。

支持不同的语法

前面的例子应该已经让你了解了节点预处理器的运作方式。然而,节点预处理器的真正威力在于它让我们能够扩展数据绑定语法本身。

看到一系列文本绑定,如这个例子,并不罕见:

First Name: <!-- text: firstName --><!-- /ko -->
Last Name: <!-- text: lastName  --><!-- /ko -->
Birth Date: <!-- text: birthDate --><!-- /ko -->

我们想列出几个属性,但这些虚拟元素相当冗长。除了属性名外,它们还增加了 29 个字符,包括空格。当然,我们也可以使用 span 元素,但考虑到它们还需要 data-bind 属性以及绑定名称,它们的大小几乎相同。

如果你曾经使用过 AngularJS 或 Handlebars,你可能会欣赏使用花括号以字符串形式访问值的最低要求。前面的例子将看起来像这样:

First Name: {{ firstName }}
Last Name: {{ lastName }}
Birth Date: {{ birthDate }}

看看这有多简短,阅读起来有多容易!这些 Handlebars 的人有正确的方法。我相信你知道我们接下来要做什么。一个节点预处理器将允许我们用第一个示例中的 HTML 替换相同的 HTML。

这个示例很长,所以我们将把它分成几部分:

var expressionRegex = /{{([\s\S]+?)}}/g;
ko.bindingProvider.instance.preprocessNode = function(node) {
    if (node.nodeType === 3 && node.nodeValue) {
        var newNodes = //Collect new nodes by scanning "node"

        // Insert the resulting nodes into the DOM
        // remove the original unprocessed node
        if (newNodes) {
            for (var i = 0; i < newNodes.length; i++) {
                node.parentNode.insertBefore(newNodes[i], node);
            }
            node.parentNode.removeChild(node);
            return newNodes;
        }
    }
};

首先,我们有一个正则表达式模式,它可以找到这些双大括号块。由于文本节点将包含任何内容,直到它们遇到第一个真实元素,所以单个节点中可能有多个大括号块,因此它需要全局匹配。然后,preprocess 函数首先检查文本节点类型。

我现在省略了实际扫描节点以创建新节点的部分;我们稍后会回到这一点。

如果有任何需要添加的节点,它们将被插入,然后删除原始节点。最后,返回我们插入的节点,以便 Knockout 可以绑定它们。

这几乎是节点预处理器的大纲代码,并且是一个非常好的模式。检查类型,创建任何新节点,如果有,替换原始节点,并返回新节点。如果您正在创建节点预处理器,这是一个很好的模板开始。

好吧,让我们进入正题。为了分配 newNodes,我们需要检查节点中的正则表达式模式并为每个匹配项构建一对虚拟文本绑定:

var newNodes = replaceExpressionsInText(node.nodeValue, expressionRegex, function(expressionText) {
    return [
        document.createComment("ko text:" + expressionText),
        document.createComment("/ko")
    ];
});

在这里,我们调用 replaceExpressionsInText 并传递节点内容、我们的正则表达式模式和回调函数,该回调函数使用我们通过正则表达式找到的表达式构建正确的替换项。然后,我们只需要实际的搜索:

function replaceExpressionsInText(text, expressionRegex, callback) {
    var prevIndex = expressionRegex.lastIndex = 0,
        resultNodes = null,
        match;

    while (match = expressionRegex.exec(text)) {
        var leadingText = text.substring(prevIndex, match.index);
        prevIndex = expressionRegex.lastIndex;
        resultNodes = resultNodes || [];

        // Preserve leading text
        if (leadingText) {
            resultNodes.push(document.createTextNode(leadingText));
        }

        resultNodes.push.apply(resultNodes, callback(match[1]));
    }

    // Preserve trailing text
    var trailingText = text.substring(prevIndex);
    if (resultNodes && trailingText) {
        resultNodes.push(document.createTextNode(trailingText));
    }

    return resultNodes;
}

搜索函数在正则表达式模式上循环,提取第一个匹配项。它将匹配项发送到回调函数,并保留结果,包括任何前导或尾随空格。当完成匹配后,它返回它们。

就这样。现在,我们的 Handlebars 代码将被转换为虚拟文本绑定。您可以在 cp3-interpolate 分支中查看这个示例。

注意

这段代码是从 blog.stevensanderson.com/2013/07/09/ 上的 StringInterpolatingBindingProvider 示例改编的。

多种语法

如果我们想把这个示例做得更深入,我们可以支持额外的插值语法。replaceExpressionsInText 已经设置为接受正则表达式输入,并且因为它使用回调,我们可以为不同的正则表达式模式构造不同的节点。

让我们添加嵌入式 Ruby 语法插值,它使用 <%= expression %>

// Replace <%= expr %> with data bound span's
var erbNodes = replaceExpressionsInText(node.nodeValue, /\<\%=([\s\S]+?)\%\>/g, function(expressionText) {
    var span = document.createElement('span');
    span.setAttribute('data-bind', 'text: ' + expressionText);
    return [span];
});

这次,我们正在替换一个 span 元素而不是虚拟文本元素,这样我们就可以区分生成的 HTML。因为这个预处理器可以支持两种语法,所以您可以对混合语法的模板进行绑定:

First Name: {{ firstName }}
Last Name: <%= lastName %>
Birth Date: {{ birthDate }}

生成的 HTML 将看起来像这样:

First Name: <!--ko text: firstName --><!--/ko-->
Last Name: <span data-bind="text: lastName"></span>
Birth Date: <!--ko text: birthDate --><!--/ko-->

您可以在 cp3-interpolate2 分支中查看这个示例。

绑定提供者

使用绑定预处理程序,我们可以访问绑定表达式并在绑定评估之前对其进行修改。使用节点预处理程序,我们可以访问节点并在绑定应用之前修改 DOM。这两者都只是将事物转换成正常的 Knockout 语法。它们也仅限于在 DOM 上操作,并且无法访问绑定上下文。

Knockout 绑定提供程序是对象,它们接收 DOM 节点和绑定上下文,并确定将应用哪些绑定处理程序以及这些绑定接收哪些valueAccessor属性。

预期绑定提供程序提供以下函数:

  • nodeHasBindings(node): 此函数应返回一个布尔值,指示节点是否定义了任何绑定。

  • getBindingAccessors(node, bindingContext): 此函数应返回一个对象,其中每个要应用的绑定都有一个属性,其值是一个评估绑定表达式的函数。此函数用作绑定处理程序中的valueAccessor属性。

注意

如果你针对的是 2.x 版本,你需要支持getBindings函数,该函数返回一个对象,其属性值是最终的绑定值。这个函数在 Knockout 3.0 中被弃用。

默认绑定提供程序通过在元素或以ko开头的注释节点上查找data-bind属性来操作。如果存在,nodeHasBindings将返回true。当调用getBindingAccessors时,它通过评估data-bind属性并从绑定上下文中获取valueAccessors属性来返回绑定。

自定义绑定提供程序

我们已经看到了如何使用预处理程序允许使用不同的语法进行数据绑定。因此,为了更好地理解绑定提供程序的能力,我们将查看预处理程序无法做到的事情:根据数据类型选择绑定。

Knockout 插件Knockout.BindingConventions(github.com/AndersMalmgren/Knockout.BindingConventions)创建了一个绑定提供程序,该程序通过查看绑定上下文以获取要使用的绑定线索,在data-name属性上提供绑定,因此它是一个自定义提供程序的绝佳示例。由于这与 Knockout 的工作方式有很大不同,让我们将其与标准视图模型和绑定设置进行比较:

var BindingSample = function() {
   var self = this;

   self.name = ko.observable('Timothy');
   self.locations = ['Portland', 'Seattle', 'New York City'];
   self.selectedLocation = ko.observable();
   self.isAdmin = ko.observable(true););
}; 

使用标准 Knockout 绑定来绑定此内容可能看起来像这样:

<label>Name
  <input data-bind="value: name" />
</label>
<label>LocationLocationName
  <select data-bind="options: locations, value: selectedLocation"></select>
</label>
<label>Admin
  <input data-bind="checked: isAdmin" type="checkbox" />
</label>

我们有三个绑定元素和四个绑定。第一个输入是一个绑定到namevalueselect元素绑定到locations上的optionsselectedLocation上的value,最后一个输入将checked绑定到isAdmin。在需要指定输入绑定是一个值的情况下,这是一个简单的例子;在大多数情况下,输入将绑定到值,或者在这种情况下,复选框将绑定到checked

“约定优于配置”的哲学旨在消除在常规场景中指定发生什么的需求。换句话说,除非另有说明,否则执行标准操作。以下是使用BindingConventions插件查看之前 DOM 的方式:

<label>Name
  <input data-name="name" />
</label>
<label>LocationNameLocation
  <select data-name="locations"></select>
</label>
<label>Admin
  <input data-name="isAdmin" />
</label> 

在这里,BindingConventions正在做所有确定绑定的工作。name输入是我们 viewmodel 上的一个字符串可观察对象,它位于一个输入节点上,因此它获得value绑定。isAdmin输入是我们 viewmodel 上的布尔可观察对象,因此输入节点被转换成复选框,并接收checked绑定。locations属性是我们 viewmodel 上的一个数组,因此select元素获得一个options绑定。然而,这还不是全部!我们的 viewmodel 有一个selectedLocations可观察对象,BindingConventions确定它应该为select元素获得一个value绑定,因为将数组名称单数化并在前面添加selected是一个绑定约定。

最后一个可能看起来像是魔法,我个人认为它有点不明显,但它确实有一定的吸引力。如果你遵循约定,你可以真正简化你的绑定。你可以在cp3-provider分支中看到这个示例的实际效果。

现在你已经看到了这个绑定提供者正在做什么,让我们看看它是如何工作的。

注意

我们将在BindingConventions插件中查看绑定提供者的简化版本。真正的提供者支持更多的约定,并允许添加自定义约定。这个示例只是为了说明类型检测概念和创建自定义提供者的基础知识。

在创建自定义绑定提供者时,需要决定的第一件事是您是否需要扩展默认绑定提供者或替换它。BindingConventions提供者将支持data-name属性。在这种情况下,扩展默认提供者是有意义的,因为它们之间没有冲突,并且我们将需要标准的data-bind支持来处理非常规场景(例如,将我们的选择值绑定到favoriteLocation属性)。

做这件事的最简单方法是存储对原始绑定提供者的引用,并在我们的自定义提供者中调用它:

ko.bindingConventions = {};
ko.bindingConventions.ConventionBindingProvider = function () {
     this.orgBindingProvider = ko.bindingProvider.instance || new ko.bindingProvider();
 };

var nodeHasBindings = function(node) { /* check node */ };
var conventionBindings = function(node, bindingContext) { /* check node */ };

 ko.bindingConventions.ConventionBindingProvider.prototype = {
     nodeHasBindings: function (node) {
         return this.orgBindingProvider.nodeHasBindings(node) || nodeHasBindings(node);
     },
     getBindingAccessors: function (node, bindingContext) {
         return this.orgBindingProvider.getBindingAccessors(node, bindingContext)
            || conventionBindings(node, bindingContext);
     }
 };
 ko.bindingProvider.instance = new ko.bindingConventions.ConventionBindingProvider();

这基本上是一个扩展默认提供者的绑定提供者的样板代码。它存储原始提供者,并通过首先调用默认提供者来实现nodeHasBindingsgetBindingAccessors函数,如果默认提供者返回空值,则调用其自己的实现。如果您希望您的提供者在默认提供者之前检查绑定,您可以交换调用顺序。最后,您可以通过将绑定处理程序附加到默认提供者的结果来组合两者。

在设置所需的函数之后,ko.bindingProvider.instance被替换为新的自定义提供者。重要的是要注意,所有这些都必须在调用ko.applyBindings之前完成,因为绑定提供者只为根绑定上下文构建一次。

从这里,我们只需要提供检查绑定并创建它们的方法。检查绑定只需要检查data-name属性:

var getNameAttribute = function (node) {
   var name = null;
   if (node.nodeType === 1) {
      name = node.getAttribute("data-name");
   }
   return name;
};

var nodeHasBindings = function(node) { 
   return getNameAttribute(node) !== null; 
};

从绑定上下文获取值需要做更多的工作。Knockout 在ko.expressionRewriting对象下提供了实用方法,可以解析任何支持的 Knockout 绑定语法。BindingConventions插件不支持除属性引用之外的内容,但它支持深层次引用,例如person.firstName。为了简单起见,我将不涉及这一点,但如果你对这个感兴趣,可以查看插件源代码中的getDataFromComplexObjectQuery。现在,我们假设所有data-name属性都直接引用一个属性:

var conventionBindings = function(node, bindingContext) {
   var bindings = {};
   var name = getNameAttribute(node);
   if (name === null) {
      return null;
}

   var data = bindingContext[name] ? bindingContext[name] : bindingContext.$data[name];

   if (data === undefined) {
      throw "Can't resolve member: " + name;
   }

   var unwrapped = ko.utils.peekObservable(data);
   var type = typeof unwrapped;

   //Loop through convention handlers to construct bindings

   return bindings;
};

首先,我们从data-name属性中获取 viewmodel 属性的名称,然后我们执行一个合理性检查以确保它存在以进行绑定。然后,我们使用ko.utils.peekObservable获取数据并检查其类型。所有可观察对象都有一个peek函数,它返回底层值而不触发依赖检测。peekObservable函数如果第一个参数是可观察的,将调用peek;否则,它将只返回第一个参数。这是一个类似于ko.uwrap的安全实用工具。

在我们有了这两条信息之后,我们可以构建返回所需的绑定对象。记住,这个绑定对象应该有一个以要应用的绑定命名的属性,其值是绑定对应的valueAccessor对象。绑定被返回到绑定提供者的getBindingAccessors函数。为了构建绑定,我们将遍历一组约定:

for (var index in ko.bindingConventions.conventionBinders) {
   if (ko.bindingConventions.conventionBinders[index].rules !== undefined) {
      var convention = ko.bindingConventions.conventionBinders[index];
      var shouldApply = true;

      convention.rules.forEach(function (rule) {
         shouldApply = shouldApply && rule(name, node, bindings, unwrapped, type, data, bindingContext);
      });

      if (shouldApply) {
         convention.apply(name, node, bindings, unwrapped, type, function() { return data }, bindingContext);
         break;
      }
   }
}

这将遍历conventionBinders数组,并按顺序检查每个元素的规则,以找到当前节点、数据和数据类型的匹配项。如果一个约定处理程序的规则都通过了,那么我们调用该约定的apply函数并停止检查——每个节点应该只应用一个约定。apply函数获取我们迄今为止收集的所有信息,以及一个可以用于绑定的valueAccessor属性。

我们的例子只使用了两种约定,即optionsinput

ko.bindingConventions.conventionBinders.options = {
  rules: [function (name, element, bindings, unwrapped) { return element.tagName === 'SELECT' && unwrapped.push; } ],
  apply: function (name, element, bindings, unwrapped, type, valueAccessor, bindingContext) {
      bindings.options = valueAccessor;
      singularize(name, function (singularized) {
         var selectedName = 'selected' + getPascalCased(singularized);
         if (bindingContext.$data[selectedName] !== undefined) {
            bindings['value'] = function() {
               return bindingContext.$data[selectedName];
            };
         }
      });
  }
};

选项只有一个规则:元素必须是一个select元素,并且数据需要是一个数组(通过查找push函数来检查)。

apply 函数直接将选项绑定设置到 valueAccessor 属性。然后,它尝试在上下文中找到一个匹配 'selected' + getPascalCased(singularized) 约定的属性。singularizegetPascalCased 函数在此处未包含,但您可以在以下代码示例分支中看到它们。不出所料,它们找到一个单词的单数形式并将其首字母大写。如果找到匹配项,则将 value 绑定添加到传入的 bindings 对象中。

input 处理器要简单得多:

ko.bindingConventions.conventionBinders.input = {
  rules: [function (name, element) { return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA'; } ],
  apply: function (name, element, bindings, unwrapped, type, valueAccessor, bindingContext) {
      var bindingName = null;
      if (type === 'boolean') {
          element.setAttribute('type', 'checkbox');
          bindingName = 'checked';
      } else {
          bindingName = 'value';
      }
      bindings[bindingName] = valueAccessor;
  }
};

input 处理器的规则不检查数据类型;它只是节点是 inputtextarea。如果类型不是 Boolean,则 apply 函数将使用 value 绑定;否则,它将节点上的 checkbox 属性设置为 checkbox 并使用 checked 绑定。

就这些。这个绑定提供程序将允许使用 data-name 属性进行绑定,只需要一个视图模型属性作为值,并且它智能地设置了常规场景的绑定。如果我们需要更多控制,仍然可以使用常规的 data-bind 属性来应用绑定。

这个 BindingConventions 绑定提供程序的简化实现可以在 cp3-provider2 分支中看到。该分支的 client/app 目录包含这里讨论的简化实现以及从插件中获取的完整实现。

没有绑定或节点预处理器可以实现这一点,因为它依赖于绑定上下文中的数据类型。希望这能给您一个关于使用自定义绑定提供程序和整体绑定系统灵活性的良好概念。

Knockout 拳击

现在您已经熟悉了用于修改绑定语法和预处理器的通用使用的技术,我们将探讨流行的 Knockout 插件 Knockout.Punches (get it?)。Punches 由 Michael Best 编写,他是 Knockout 开发者,也是 Knockout 预处理器功能以及一些最佳预处理器的实际用例的创造者。我们将探讨其中的一些,并深入探讨它们是如何工作的。本节不会涵盖 Knockout Punches 的所有内容;如果您想了解更多,可以查看在线文档。

注意

Knockout.Punches 的文档可以在 mbest.github.io/knockout.punches 找到,其中包含 API 参考和源代码。

内嵌文本绑定

内嵌文本绑定提供了与我们在 支持替代语法 部分中创建的预处理器的相同语法——将花括号转换为虚拟文本节点:

<div>Hello {{ name }}.</div>

之前的命令变为以下内容:

<div>Hello <!--ko text:name--><!--/ko-->.</div>

Knockout Punches 使用的方法比我们之前看过的更高效,但它仍然提供了我们使用的相同可定制性。如果你想使用除了虚拟文本节点之外的东西作为插值替换,你可以提供一个返回node-array的函数作为以下内容的替换:

ko.punches.utils.interpolationMarkup.wrapExpression(expressionText)

命名空间绑定

Knockout Punches 提供了一个简写绑定语法,将x.y: value展开为x : { y: value }。默认情况下,这种命名空间语法对eventattrstylecss绑定可用。在style绑定中使用它将导致以下内容展开:

<div data-bind="style.color: textColor"></div>

这将展开为以下内容:

<div data-bind="style: { color: textColor }"></div>

这通过覆盖标准的ko.getBindingHandler函数来实现,该函数通常只返回绑定处理程序。它被替换为一个查找绑定名称中具有匹配getNamespacedHandler属性的点的函数,并返回该函数。

动态命名空间绑定

由于ko.getBindingHandler被这样覆盖,因此可以通过向绑定处理程序添加getNamespacedHandler属性来创建自己的绑定命名空间:

ko.bindingHandlers.customNamespace = {
    getNamespacedHandler: function(binding) {
        return {
           init: function(element, valueAccessor) { },
           update: function(element, valueAccessor) { }
        };
    }
};

binding参数是绑定的名称;对于style.color,它将是color。该函数返回要使用的绑定处理程序。这允许你为命名空间中的所有绑定提供一个单一的动态处理程序。

假设我们想要为 Twitter Bootstrap 提示插件创建一个绑定命名空间。我们需要提供提示文本内容和方向。通常,我们可能会编写一个绑定,将这些作为选项:

ko.bindingHandlers.tooltip = {
    update: function(element, valueAccessor) {
      //Cleanup previous tooltips
      if (element.attributes['data-original-title']) {
        $(element).tooltip('destroy');
    }
      var options = valueAccessor();
        $(element).tooltip({ 
          placement: options.placement || 'left', 
          title: ko.unwrap(options.title || 'sample') 
        });
    }
};

然后,我们可以用对象绑定到它上:

data-bind="tooltip: { placement: 'top', title: title}"

这工作得很好,但我们可以使用命名空间绑定处理程序重写这个,以便获得放置的点语法:

ko.bindingHandlers.tooltip = {
    getNamespacedHandler: function(binding) {
        return {            
            update: function(element, valueAccessor) {
              //Cleanup previous tooltips
              if (element.attributes['data-original-title']) {
                $(element).tooltip('destroy');
      }
                $(element).tooltip({ 
                  placement: binding, 
                  title: ko.unwrap(valueAccessor()) 
                });
            }
        };
    }
};

这会产生一个更短的绑定属性,我认为这更容易阅读:

data-bind="tooltip.top: title"

这个例子可以在cp3-namespace分支中看到。

绑定过滤器

在视模型属性上执行过滤操作是很常见的。通常的做法是在视模型上有一个计算属性执行过滤,但这可能会变得冗长,尤其是如果你有多个不同的过滤属性。Knockout Punches 提供了在绑定内应用过滤表达式的语法:

<span data-bind="text: name | fit:20 | uppercase"></span>

过滤器由管道分隔,多个参数由冒号分隔。例如,fit最多接受三个参数,可以用fit:20:'…':'middle'指定。

应该注意的是,在先前的例子中,name不包括可观察的括号。虽然带有过滤器的整个绑定是一个单独的表达式,通常需要括号,但 Knockout Punches 通过对其调用ko.unwrap来智能地处理每个部分。这意味着绑定值和每个过滤器都被视为它们自己的表达式。

过滤是通过一个绑定预处理器完成的,该预处理器解析表达式并将管道部分递归展开为对过滤器的调用。前面的示例最终将从preprocess函数返回以下内容:

ko.filters'uppercase')

编写自定义过滤器

添加自己的过滤器与添加绑定处理器非常相似。只需向ko.filters对象添加一个函数,该函数接受一个值和任意数量的参数,并返回一个修改后的值:

ko.filters.translate = function(value, language) {
    return SomeLanguageLibrary.translate(value, language);
}

第一个参数是要处理的当前值。所有其他参数都是在绑定表达式中传递给过滤器的参数。

过滤器可以有零个参数——就像uppercase示例中那样——或者可选参数——就像fit示例中那样。过滤器预处理器不会检查传递给它的参数数量是否合理;它只是使用绑定表达式中的所有内容调用过滤器。

过滤器预处理器很容易扩展,并且提供了相当大的功能。我认为它是绑定预处理器潜力的最佳示例之一。

其他绑定的过滤器

默认情况下,textattrhtml绑定启用了过滤器,但额外的绑定可以通过调用ko.punches.textFilter.enableForBinding(<binding>)来使用过滤器。如果你想在自定义绑定上利用过滤器,这可能很有用。

过滤器不能用于双向绑定,如绑定值,因为它们总是产生内联表达式。

添加额外的预处理器

Knockout Punches 提供了两个实用方法,用于添加额外的绑定和节点预处理器,这些方法可以通过ko.punches.utils访问:

  • addBindingPreprocessor(bindingKeyOrHandler, preprocessFn)

  • addNodePreprocessor(preprocessFn)

如果你多次调用这些函数中的任何一个,相应的预处理器将被链在一起,每个新的预处理器都在链的末尾被调用。

绑定预处理器将一直运行,直到其中一个移除绑定或直到链的末尾。这阻止了链尝试处理不再存在的绑定。

节点预处理器将一直运行,直到其中一个返回要添加的新节点或直到链的末尾。这阻止了链尝试处理已经修改的节点。新节点不会被节点预处理器遍历,因此它们应该被添加到 DOM 中,并准备好进行数据绑定。

摘要

本章主要介绍了如何扩展 Knockout 的绑定过程并修改其语法。我们介绍了三种实现方式:

  • 绑定预处理器:用于在绑定处理器运行之前修改绑定字符串

  • 节点预处理器:用于在绑定开始之前修改 DOM

  • 绑定提供者:用于控制应用于每个 DOM 节点的绑定

最后,我们研究了Knockout.Punches插件,以了解一些实际的 Knockout 扩展。

在下一章中,我们将介绍 Knockout 的 Web 组件功能,这些功能允许您将视图和 ViewModel 绑定在一起,形成可重用的控件。

第四章. 使用组件和模块进行应用程序开发

好的,现在是时候回到应用程序开发上了。我们之前在 第一章 简单提到了这一点,Knockout 基础;我们将在本章继续探讨。本章主要介绍如何在现代网页应用程序中使用 Knockout。在本章中,我们将探讨以下主题:

  • 使用 RequireJS 的模块

  • 创建可重用组件

  • 使用自定义组件加载器扩展 Knockout

  • 单页应用(SPA)路由

由于 Knockout 是一个库——它在主页上自豪地宣称这一点——它并不涵盖你在完整的网页应用程序中需要的所有内容。这使得 Knockout 能够通过专注于有限的功能集来专业化,但将决定如何构建应用程序其余部分的任务留给了你,即开发者。我们本章中介绍的方法并不是唯一可用的选项——我们没有那么多时间或空间来介绍——但它们应该提供足够的通用指导,帮助你做出自己的决定,同时考虑到 Knockout 的优势。

我们还将把 联系人列表 应用程序转换成一个单页应用(SPA)——一个使用 JavaScript 来更改当前视图模板的应用程序,模拟页面变化而不是使用浏览器导航。这种模式已经变得如此流行,以至于大多数开发者在开发新的 JavaScript 网页客户端时都认为这是理所当然的,因此了解 Knockout 如何适应这种开发模式是很重要的。

RequireJS – AMD 视图模型

RequireJS (requirejs.org/) 是一个你应该已经听说过的库,即使没有使用过。这本书仍然是关于 Knockout 的,如果你计划在应用程序中使用 RequireJS,你应该先了解它,但在这里我仍会给你一个简要概述。

RequireJS 概述

RequireJS 的目的是允许你的代码被分割成声明其依赖关系的模块,以便在运行时注入。这有几个重要的好处。当 RequireJS 加载你的 JavaScript 时,你不需要在 HTML 中使用 script 标签包含每个脚本。由于 RequireJS 根据依赖关系加载脚本,你不必担心它们加载的顺序。随着每个模块的依赖关系被注入,模块可以很容易地使用模拟进行测试。此外,RequireJS 还将加载的所有对象保持在全局作用域之外,这除了被视为良好的通用实践外,还减少了命名空间冲突的可能性。

默认情况下,RequireJS 将在运行时按需异步加载所有脚本。在某些情况下,这种延迟加载是有益的,但在生产中,你通常希望将代码打包成一个单独的文件。为此,RequireJS 提供了其优化器 r.js。RequireJS 甚至可以通过将多个文件组打包在一起,然后在运行时按需加载这些组来结合这些技术。最好的部分是,无论你处于哪种工作模式,你的代码都不需要改变!

注意

我们不会介绍 r.js,但如果你在开发 Web 应用程序,调查这一点可能值得(见 requirejs.org/docs/optimization.html)。

异步模块定义

异步模块定义(AMD)是 RequireJS 中的一个重要概念:它声明了一个函数,其返回值表示模块。在形式上,它与我们在 第一章,Knockout 基础 中看到的 立即执行函数表达式(IEFE)并没有太大的不同。这是一个典型的模块定义:

define('moduleName', ['pathto/dependency'], dependency'], function(injectedModule) {
  return //Some module code;
});

define 方法构成了文件中的第一个也是唯一的顶层语句。RequireJS 实际上通过忽略对 define 的多次调用来强制执行每个文件一个模块的限制。define 调用接受以下三个参数,前两个是可选的:

  • 模块名称:此参数通常被忽略,因为引用模块的标准方式是通过它们的路径。因此,我们不会使用此参数。

  • 依赖项:这是一个模块名称或路径的数组,该模块依赖于它们。路径不需要 .js 后缀;RequireJS 已经知道它正在加载 JavaScript。

  • 模块函数:此函数接收前一个数组中的每个依赖项作为参数,并应返回模块。

当 RequireJS 尝试加载一个模块时,它会通过路径或名称定位该模块,并运行在该文件中找到的 define 方法。首先,它会检查所有依赖项是否已加载;如果尚未加载,它会递归地异步并行加载它们。当所有依赖模块加载完成后,它会运行模块函数,并将每个依赖项作为参数传入,其顺序与它们作为依赖项声明的顺序相同。模块加载函数的返回值是传递给任何需要它作为依赖项的模块的参数值。

启动 RequireJS

实际上,有几种方法可以使用 RequireJS 启动应用程序,但到目前为止,最常见的方法是使用一个指向应用程序初始脚本的 script 标签:

<script type="text/javascript" src="img/require-2.1.js" data-main="/app/main"></script>

data-main 属性指示哪个脚本将配置 RequireJS 并启动应用程序。请注意,与正常模块路径一样,.js 后缀不是必需的。

这个script标签通常放在你的 shell(或布局)文件中,它替换了 RequireJS 负责加载的所有script标签。在许多情况下,这意味着唯一的 JavaScript script标签就是加载 RequireJS 的那个。这是 RequireJS 的一个杀手级特性:在我们开发过程中,我们不再需要在 HTML 代码中添加script标签。

注意路径以正斜杠开头,这使得它是一个绝对路径。这是必需的,因为 shell 被用于多个页面,相对路径在像/contacts/1这样的 URL 上不会工作,因为它会在/contacts/app/main.js中寻找我们的脚本。

配置

main.js文件(AMD 应用的入口点的传统名称)通常在开始之前包含一个配置部分。以下是我们将要使用的配置:

require.config({
  paths: {
    'knockout': '/lib/knockout-3.2.0',
    'bootstrap': '/lib/bootstrap-3.1.1',
    'jquery': '/lib/jquery-2.1.1.min'
  },
  shim: {
    'bootstrap': {
      deps: ['jquery'],
      exports: '$.fn.popover'
    }
  }
});

paths部分允许我们将路径映射到模块名称,以便在依赖数组中使用。这对于所有库代码来说是一个好习惯,这样我们的应用程序代码就可以使用简单、一致的名字。再次强调,使用绝对路径是很重要的。

shim部分对于加载依赖于全局对象的脚本来说是必要的。在前面的例子中,bootstrap的 shim 将 jQuery 声明为一个依赖项,并指示它导出$.fn.popover。通常,你会寻找一个新的命名空间,比如$.bootstrap,但是因为bootstrap没有创建一个单一的端点;我们正在寻找它添加的插件之一。任何导出的值都可以在这里使用;popover只是被选中的那个。

许多库开始支持以 AMD 方式加载:它们寻找 RequireJS 或其他模块加载器,并在它们可用时使用它们。尽管并非所有库都这样做,但 JavaScript 库的标准模式一直是只在全球范围内寻找依赖项。因为bootstrap需要jQuery,但没有向 RequireJS 表明这个依赖项,如果我们尝试正常加载它,它将会失败。shim 告诉 RequireJS 这个库是一个旧的全球作用域风格的脚本,并手动指示其依赖项。exports部分提供了一个对象,RequireJS 可以查找以检查脚本是否已加载完成。RequireJS 将等待指定的对象存在,然后才允许任何依赖于bootstrap的 AMD 开始。本质上,shim部分是 RequireJS 如何使用非 AMD 代码作为异步依赖项的方式。如果你需要使用 jQuery 插件或其他非 AMD 兼容的库,只需为它们创建一个 shim 即可。

RequireJS 配置有许多其他选项——太多以至于无法在这里全部涵盖。如果你想了解更多,请查看他们的文档,可在requirejs.org/docs/api.html#config找到。

启动应用

现在已经配置了 RequireJS,是时候启动应用程序了。包含我们配置的主要脚本也是 RequireJS 寻找初始模块的地方,它看起来像这样:

require.config({
  //config
});

define(['jquery', 'knockout', 'contactsPage', 'bootstrap'], function($, ko, ContactsPageViewmodel) {
    $(document).ready(function() {
      ko.applyBindings(new ContactsPageViewmodel());
    });
});

主要模块就像其他模块一样,只不过 RequireJS 会在其依赖项可用时立即运行它。这段代码就是之前在 联系人 页面的脚本中使用的启动代码。你可能注意到这个模块的依赖项名称与传入的参数名称不匹配。jQuery 被注入为 $,Knockout 为 ko联系人 页面构造函数为 ContactsPageViewModel。所有这些都是它们对应对象的常规 JavaScript 名称。模块按照依赖项数组的顺序注入;RequireJS 实际上并不查看参数的名称。这与标准函数没有区别;调用者不关心参数的名称,只关心顺序。尽管如此,这对新用户来说并不总是显而易见的。

你可能也注意到了 bootstrap 甚至没有参数。这是因为 bootstrap 没有自己的对象;它所做的只是向 jQuery 添加函数。然而,RequireJS 不会在它被依赖项要求之前加载它(或在这种情况下模拟它)。以这种方式初始化插件式依赖项是很常见的,因为我们希望它们在应用启动时就能可用。

要查看转换为 AMD 模块后的 Contacts List 应用程序,请打开 cp4-contacts 分支。代码已经位于 IEFE 块中,所以变化不大。app 对象不再需要,因为命名空间已经被依赖注入所取代。除了 RequireJS 之外的所有 script 标签都已从 HTML 代码中删除。应用程序仍然以相同的方式运行,但通过使用 RequireJS,我们不再需要担心加载脚本。现在这看起来可能是一个小的收益,但当你应用开始增长时,这将会产生很大的影响。

文本插件

管理 HTML 模板可能很棘手,因为没有原生的方式来引用或嵌入外部 HTML 文件,就像脚本那样。如果你熟悉 Knockout 社区,你可能遇到过一些旨在解决此问题的插件,例如 Knockout-External-Templates(该插件已停止开发)。RequireJS 通过文本插件干净地解决了这个问题。文本插件的工作方式与标准模块非常相似:你声明对外部文本的依赖,然后 RequireJS 就像正常 JavaScript 模块一样将其注入到模块中。

要开始使用,你应该将文本库添加到你的 RequireJS 配置中。使用如 text 这样的名称是标准的:

require.config({
  paths: {
    'text': '/lib/require-text-2.0.12',
    'knockout': '/lib/knockout-3.2.0',
    'bootstrap': '/lib/bootstrap-3.1.1',
    'jquery': '/lib/jquery-2.1.1.min'
  }
});

一旦文本插件可用,你就可以在外部文件中使用它,例如:

define(['text!some.html'], function (htmlString) {

});

如果文本插件位于你应用的根目录,并且你使用 text! 前缀为依赖项,则此配置部分是可选的。由于我们一直在不同的文件夹中放置第三方库,因此配置是必要的。

在下一节中,我们将探讨如何将这种能力与组件结合,以创建具有外部、隔离的 HTML 视图的可重用模板。

组件

在 3.2 版本中,Knockout 通过将模板(视图)与视图模型结合使用来添加组件,以创建可重用、行为驱动的 DOM 对象。Knockout 组件受到了 Web 组件的启发,这是一组新的(并且在撰写本文时是实验性的)标准,允许开发者定义与 JavaScript 配对的自定义 HTML 元素,从而创建打包的控件。与 Web 组件类似,Knockout 允许开发者使用自定义 HTML 标签在 DOM 中表示这些组件。Knockout 还允许在标准 HTML 元素上使用绑定处理器来实例化组件。Knockout 通过注入一个 HTML 模板来绑定组件,该模板绑定到其自己的视图模型。

这可能是 Knockout 一直添加到核心库中的单个最大功能。我们之所以从 RequireJS 开始,是因为组件可以可选地通过模块加载器加载和定义,包括它们的 HTML 模板!这意味着我们的整个应用程序(甚至 HTML)都可以定义在独立的模块中,而不是作为一个单一层次结构,并且异步加载。

基本组件注册

与通过仅向 Knockout 添加对象来创建的扩展器和绑定处理器不同,组件是通过调用 ko.components.register 函数来创建的:

ko.components.register('contact-list, {
  viewModel: function(params) { },
  template: //template string or object
});

这将创建一个名为 contact-list 的新组件,它使用 viewModel 函数返回的对象作为绑定上下文,并将模板作为其视图。建议您使用小写、由连字符分隔的名称来命名组件,以便它们可以轻松地在 HTML 中用作自定义元素。

要使用这个新创建的组件,您可以使用自定义元素或组件绑定。以下所有三个标签产生等效的结果:

<contact-list params="data: contacts"><contact-list>
<div data-bind="component: { name: 'contact-list', params: { data: contacts }"></div>
<!-- ko component: { name: 'contact-list', params: { data: contacts } --><!-- /ko -->

显然,自定义元素语法更干净、更容易阅读。需要注意的是,自定义元素不能是自闭合标签。这是 HTML 解析器的限制,并且不能由 Knockout 控制。

使用组件绑定的一个优点是组件的名称可以是可观察的。如果组件的名称发生变化,则之前的组件将被销毁(就像控制流绑定将其移除时一样),并且将初始化新的组件。

自定义元素的 params 属性的工作方式与 data-bound 属性类似。逗号分隔的键/值对被解析以创建一个属性包,并将其提供给组件。值可以包含 JavaScript 字面量、可观察属性或表达式。还可以在不使用视图模型的情况下注册组件,在这种情况下,由 params 创建的对象将直接用作绑定上下文。

要查看这一点,我们将将联系人列表转换为组件:

<contact-list params="contacts: displayContacts, 
  edit: editContact, 
  delete: deleteContact">
</contact-list>

列表的 HTML 代码被替换为一个带有列表参数以及两个按钮(editdelete)的回调的自定义元素:

ko.components.register('contact-list', {
  template: 
  '<ul class="list-unstyled" data-bind="foreach: contacts">'
    +'<li>'
      +'<h3>'
        +'<span data-bind="text: displayName"></span> <small data-bind="text: phoneNumber"></small> '
        +'<button class="btn btn-sm btn-default" data-bind="click: $parent.edit">Edit</button> '
        +'<button class="btn btn-sm btn-danger" data-bind="click: $parent.delete">Delete</button>'
      +'</h3>'
    +'</li>'
  +'</ul>'
});

此组件注册使用内联模板。您可以在cp4-inline-component分支中看到此组件。一切看起来和运行都相同,但生成的 HTML 现在包括我们的自定义元素。

基本组件注册

IE 8 及以上版本的自定义元素

IE 9 及更高版本以及所有其他主要浏览器在自定义元素注册之前在 DOM 中看到自定义元素没有问题。然而,较旧版本的 IE 如果没有注册,将会移除该元素。注册可以通过 Knockout,使用ko.components.register('component-name'),或者使用标准的document.createElement('component-name')表达式语句来完成。这些中的一个必须放在自定义元素之前,无论是包含它们的脚本在 DOM 中首先出现,还是自定义元素在运行时被添加。

当使用 RequireJS 时,首先在 DOM 中不会有所帮助,因为加载是异步的。如果您需要支持较旧的 IE 版本,建议您在body标签顶部或head标签中包含一个单独的脚本以注册自定义元素名称:

<!DOCTYPE html>
<html>
  <body>
    <script>
      document.createElement('my-custom-element');
    </script>
    <script src='require.js' data-main='app/startup'></script>

    <my-custom-element></my-custom-element>
  </body>
</html>

一旦完成此操作,组件将在 IE 6 及以上版本中正常工作,即使有自定义元素。

模板注册

发送到注册的配置的template属性可以采用以下任何一种格式:

ko.components.register('component-name', {
  template: [OPTION]
});

元素 ID

考虑以下代码语句:

template: { element: 'component-template' }

如果您在 DOM 中指定元素的 ID,则该元素的内部内容将用作组件的模板。尽管在 IE 中尚不支持,但模板元素是一个很好的候选者,因为浏览器不会在视觉上渲染模板元素的内容。

此方法可以在cp4-component-id分支中看到。

元素实例

考虑以下代码语句:

template: { element: instance }

您可以将实际的 DOM 元素传递给模板以供使用。这在模板是程序性构建的场景中可能很有用。与元素 ID 方法一样,只有元素的内部内容将被用作模板:

var template = document.getElementById('contact-list-template');
ko.components.register('contact-list', {
  template: { element: template }
});

此方法可以在cp4-component-instance分支中看到。

DOM 节点数组

考虑以下代码语句:

template: [nodes]

如果您将 DOM 节点数组传递给模板配置,则整个数组将用作模板,而不仅仅是子节点:

var template = document.getElementById('contact-list-template')
nodes = Array.prototype.slice.call(template.content.childNodes);
ko.components.register('contact-list', {
  template: nodes
});

这可以在cp4-component-arrray分支中看到。

文档片段

考虑以下代码语句:

template: documentFragmentInstance

如果您传递一个文档片段,则整个片段将用作模板,而不仅仅是子节点:

var template = document.getElementById('contact-list-template');
ko.components.register('contact-list', {
  template: template.content
});

此示例之所以有效,是因为模板元素将它们的内容包裹在一个文档片段中,以阻止正常的渲染。使用内容是 Knockout 在提供模板元素时内部使用的相同方法。此示例可以在cp4-component-fragment分支中看到。

HTML 字符串

我们在上一节中看到了 HTML 字符串的示例。虽然直接使用值可能不太常见,但如果您的构建系统提供了它,提供字符串将是一件简单的事情。

使用 AMD 模块注册模板

考虑以下代码语句:

template: { require: 'module/path' }

如果将require属性传递给模板的配置对象,默认模块加载器将加载该模块并将其用作模板。该模块可以返回上述任何格式。这对于 RequireJS 文本插件特别有用:

ko.components.register('contact-list', {
  template: { require: 'text!contact-list.html'}
});

使用这种方法,我们可以将 HTML 模板提取到其自己的文件中,极大地提高了其组织性。仅此一点,对开发来说就是一个巨大的好处。这可以在cp4-component-text分支中看到示例。

视图模型注册

就像模板注册一样,视图模型可以使用几种不同的格式进行注册。为了演示这一点,我们将使用我们联系列表组件的简单视图模型:

function ListViewmodel(params) {
  this.contacts = params.contacts;
  this.edit = params.edit;
  this.delete = function(contact) {
    console.log('Mock Deleting Contact', ko.toJS(contact));
  };
};

为了验证事情是否正确连接,您将想要一个交互式的东西;因此,我们使用假的delete函数。

构造函数

考虑以下代码语句:

viewModel: Constructor

如果您将函数提供给viewModel属性,它将被视为构造函数。当组件实例化时,new将在该函数上调用,params对象作为其第一个参数:

ko.components.register('contact-list', {
  template: { require: 'text!contact-list.html'},
  viewModel: ListViewmodel //Defined above
});

这种方法可以在cp4-components-constructor分支中看到。

单例对象

考虑以下代码语句:

viewModel: { instance: singleton }

如果您希望所有组件实例都由一个共享对象支持——尽管这不被推荐——您可以将其作为配置对象的instance属性传递。由于对象是共享的,因此不能使用此方法将参数传递给视图模型。

工厂函数

考虑以下代码语句:

viewModel: { createViewModel: function(params, componentInfo) {} }

这种方法很有用,因为它将组件的容器元素提供给componentInfo.element的第二个参数。它还为您提供了执行任何其他设置的机会,例如修改或扩展构造函数参数。createViewModel函数应返回一个视图模型组件的实例:

ko.components.register('contact-list', {
  template: { require: 'text!contact-list.html'},
  viewModel: { createViewModel: function(params, componentInfo) {
    console.log('Initializing component for', componentInfo.element);
    return new ListViewmodel(params);
  }}
});

这个示例可以在cp4-component-factory分支中看到。

使用 AMD 模块注册视图模型

考虑以下代码语句:

viewModel: { require: 'module-path' }

就像模板一样,视图模型可以使用返回上述任何格式的 AMD 模块进行注册。

cp4-component-module分支中,您可以看到一个示例。组件注册已移动到main.js文件。

注册 AMD

除了单独将模板和视图模型注册为 AMD 模块外,您还可以通过 require 调用将整个组件注册:

ko.components.register('contact-list', { require: 'contact-list' });

AMD 模块将返回整个组件配置:

define(['knockout', 'text!contact-list.html'], function(ko, templateString) {
  function ListViewmodel(params) {
    this.contacts = params.contacts;
    this.edit = params.edit;
    this.delete = function(contact) {
      console.log('Mock Deleting Contact', ko.toJS(contact));
    };
  }

  return { template: templateString, viewModel: ListViewmodel };
});

如 Knockout 文档所指出的,这种方法有几个优点:

  • 注册调用只是一个require路径,易于管理。

  • 组件由两部分组成:一个 JavaScript 模块和一个 HTML 模块。这既提供了简单的组织,又实现了清晰的分离。

  • RequireJS 优化器(r.js)可以使用 HTML 模块上的文本依赖来将 HTML 代码与捆绑输出捆绑在一起。这意味着你的整个应用程序,包括 HTML 模板,在生产中可以是一个单独的文件(或者如果你想利用懒加载,可以是一组捆绑包)。

你可以在cp4-component-amd分支中看到这个例子。这是组件推荐的模式,也是本章其余示例将使用的一种模式。

观察组件参数的变化

组件参数将通过以下三种方式之一通过params对象传递到组件的视图模型:

  • 不需要发生任何观察表达式评估,值被直接传递:

    <component params="name: 'Timothy Moran'"></component>
    <component params="name: nonObservableProperty"></component>
    <component params="name: observableProperty"></component>
    <component params="name: viewModel.observableSubProperty"></component>
    

    在所有这些情况下,值都是直接传递到params对象上的组件。这意味着这些值的更改将改变实例化视图模型上的属性,除了第一种情况(字面值)。观察值可以被正常订阅。

  • 需要评估一个观察表达式,因此它被包装在一个计算观察值中:

    <component params="name: name() + '!'"></component>
    

    在这种情况下,params.name不是原始属性。调用params.name()将评估计算包装器。尝试修改值将会失败,因为计算值是不可写的。值可以被正常订阅。

  • 一个观察表达式评估一个观察实例,因此它被包装在一个展开表达式结果的观察值中:

    <component params="name: isFormal() ? firstName : lastName"></component>
    

    在这个例子中,firstNamelastName都是观察属性。如果你调用params.name()返回的是观察值,你需要调用params.name()()来获取实际值,这看起来相当丑陋。相反,Knockout 会自动展开表达式,使得调用params.name()返回firstName 或lastName的实际值。

如果你需要访问实际的观察实例,例如写入它们的值,尝试写入params.name将会失败,因为它是一个计算观察值。为了获取未包装的值,你可以使用params.$raw对象,它提供了未包装的值。在这种情况下,你可以通过调用params.$raw.name('New')来更新名称。

通常情况下,应该通过从绑定表达式中移除逻辑并将其放置在视图模型中的计算观察值中来避免这种情况。

组件的生命周期

当应用组件绑定时,Knockout 会执行以下步骤。

  1. 组件加载器异步创建视图模型工厂和模板。这个结果被缓存,所以每个组件只执行一次。

  2. 模板被克隆并注入到容器中(无论是自定义元素还是具有组件绑定的元素)。

  3. 如果组件有一个视图模型,它将被实例化。这是同步完成的。

  4. 组件绑定到 viewmodel 或 params 对象。

  5. 组件保持 活动 状态,直到它被销毁。

  6. 组件被销毁。如果 viewmodel 有一个 dispose 方法,它将被调用,然后模板将从 DOM 中移除。

组件的销毁

如果组件因组件绑定名称或控制流绑定(例如,ifforeach)更改而被 Knockout 从 DOM 中移除,组件将被销毁。如果组件的 viewmodel 有一个 dispose 函数,它将被调用。组件视图中的正常 Knockout 绑定将被自动销毁,就像在正常控制流情况下一样。然而,由 viewmodel 设置的任何内容都需要手动清理。以下是一些 viewmodel 清理的示例:

  • 可以使用 clearInterval 来移除 setInterval 回调。

  • 可以通过调用它们的 dispose 方法来移除计算可观察对象。纯计算可观察对象不需要销毁。仅由绑定或其他 viewmodel 属性使用的计算可观察对象也不需要销毁,因为垃圾回收会捕获它们。

  • 可以通过调用它们的 dispose 方法来销毁可观察订阅。

  • 事件处理器可以由不属于正常 Knockout 绑定的组件创建。

你可以在 cp4-dispose 分支中看到一个简单的销毁处理器。它只是将日志记录到控制台以演示它何时会触发;尝试编辑一个联系人以使控制流从页面上删除列表。

将组件与数据绑定结合使用

在使用组件绑定的自定义元素上使用的 data-bind 属性只有一个限制:绑定处理器不能使用 controlsDescendantBindings。这不是一个新的限制;控制后代的两个绑定不能在单个元素上,因为组件控制后代绑定,不能与也控制后代的绑定处理器结合。尽管如此,这仍然值得记住,因为你可能会倾向于在组件上放置一个 ifforeach 绑定;这样做将导致错误。相反,用元素或无容器绑定包裹组件:

<ul data-bind='foreach: allProducts'>
  <product-details params='product: $data'></product-details>
</ul>

还值得注意的是,如 texthtml 这样的绑定将替换它们所在元素的内容。当与组件一起使用时,这可能会导致组件丢失,因此这不是一个好主意。

自定义组件加载器

到目前为止,我们已经介绍了默认组件加载器的行为。它非常灵活,对于许多开发者来说,它将足够满足大多数用例。然而,可以实现自定义组件加载功能。实际上,你可以同时激活多个组件加载器,每个加载器提供不同的功能。

本节将处理创建自定义组件加载器。如果你对默认加载器的功能感到满意,你可能想跳过这一节,继续到单页应用程序路由。

首先,让我们了解一下组件加载系统是如何工作的。组件加载只对每个组件执行一次。Knockout 缓存已加载的组件。此缓存提供了以下两个公共函数:

  • ko.components.get(name, callback): 此函数遍历所有加载器,直到其中一个返回一个组件。此组件被缓存,然后使用它调用回调。

  • ko.components.clearCachedDefinition(name): 此函数从注册表中删除组件。

Knockout 在 ko.components.loaders 上维护一个加载器数组。默认情况下,此数组只包含一个加载器,它也位于 ko.components.defaultLoader 上。当组件绑定请求组件或你调用 ko.components.get 时,Knockout 会遍历加载器,对每个组件调用 getConfig,直到它得到一个非空对象。然后将此配置传递给每个加载器,直到返回一个有效的组件对象。加载的组件随后被缓存。一个有效的组件对象具有以下属性:

  • template: 这是一个 DOM 节点数组

  • createViewModel(params, componentInfo): 这是一个可选的工厂方法,用于构建组件

实现组件加载器

所有方法在组件加载器上都是可选的,因为 Knockout 会遍历每个加载器上的每个方法,直到它得到一个有效响应,然后再在下一个方法上重复执行。所有组件加载器函数都是通过提供回调来异步执行的。记住,结果将被缓存,除非手动使用 ko.components.clearCachedDefinition(componentName) 清除。以下是用以实现组件加载器的常用方法:

  • getConfig(name, callback): 这返回一个组件配置对象。配置对象是任何加载器的 loadComponent 函数可以理解的内容。

  • loadComponent(name, componentConfig, callback): 这提供了类型为 { template: domNodeArray, createViewModel(params, componentInfo) } 的组件对象。

  • loadTemplate(name, templateConfig, callback): 这提供了用作模板的 DOM 节点数组。

  • loadViewModel(name, viewModelConfig, callback): 这提供了用作 createViewModel(params, componentInfo) 工厂函数的功能。

要实现一个方法,只需将其包含在你的加载器中。若要使你的加载器跳过它已实现的方法,请调用 callback(null)

最后两个方法不是由 Knockout 组件系统直接调用,而是由默认加载器的 loadComponent 方法调用。

默认加载器

要了解如何使自定义加载器上的方法成为可选的,你必须了解默认加载器的工作方式。默认加载器有一个内部注册表用于组件配置——不要与组件缓存混淆。默认加载器将以下方法添加到ko.components对象中,以便与组件配置注册表一起工作:

  • ko.components.register(name, configuration): 这在前一节中已详细说明

  • ko.components.isRegistered(name): 如果组件配置在注册表中,则返回true;否则,返回false

  • ko.unregister(name): 如果存在,则移除命名配置

当 Knockout 首次尝试加载一个组件时,它会遍历ko.components.loaders中的每个加载器,并对每个加载器调用getConfig,直到其中一个返回非空对象。然后,它将配置对象传递给每个加载器的loadComponent,直到其中一个返回非空组件对象。如果除了默认加载器之外的加载器从loadComponent返回组件,则链在此处结束。

然而,默认加载器的loadComponent方法会在每个加载器(包括自身)上调用loadTemplateloadViewModel,直到它获得模板和 viewmodel。这些调用是独立的;默认加载器将取它得到的第一个模板和第一个 viewmodel,即使它们来自不同的加载器。如果你的自定义加载器比默认加载器具有更高的优先级,或者默认加载器无法理解你的配置,你的自定义加载器将有机会通过实现loadTemplateloadViewModel来提供自己的模板和/或 viewmodel。

注册自定义加载器

ko.bindingHandlersko.extenders都是对象不同,ko.components.loaders是一个数组。一旦你创建了自定义加载器,你可以将其添加到loaders数组中。loaders数组的顺序决定了优先级;Knockout 总是从第一个到最后一个遍历加载器:

  • 对于具有较低优先级的自定义加载器,使用ko.components.loaders.push(loader)

  • 对于具有更高优先级的自定义加载器,使用ko.components.loaders.unshift(loader)

  • 对于细粒度控制的自定义加载器,使用ko.components.loaders.splice(priority, 0, loader),其中 priority 是新加载器的 0 索引排名

如果你从ko.component.loaders中移除默认加载器,那么loadTemplateloadViewModel将不再被调用(除非它们被另一个自定义加载器调用)。由于可以简单地添加一个具有更高优先级的自定义加载器,因此移除默认加载器的价值很小。

注册自定义元素

自定义元素通过包装组件绑定在 Knockout 中工作。有两种选项可以用来让 Knockout 将自定义元素视为组件:

  • 调用ko.components.register('component-name', { /* config */ }).

  • 覆盖ko.components.getComponentNameForNode(node),使其返回组件的名称。只要存在可以加载此方法返回的名称的加载器,组件就不需要注册。默认加载器只会加载使用ko.components.register注册的组件。

使用自定义配置加载组件

好的,现在是时候看看一个例子了。这个例子是从 Knockout 组件的文档中取出的。假设您正在使用我们自己的异步加载库来加载 HTML,并且希望您的自定义加载器使用它。这可能是对 JavaScript 加载器的例子,文档中提供了这个例子,但在这里它足够相似,以至于可以省略。它将使用自己的配置属性名称,以避免与默认加载器混淆:

ko.components.register('contact-list', {
  template: { fromUrl: 'contact-list.html', maxCacheAge: 100 },
  viewModel: { require: 'contact-list'  }
});

由于默认加载器将此配置传递给每个加载器的loadTemplate方法,我们只需实现该方法即可:

var templateFromUrlLoader = {
  loadTemplate: function(name, templateConfig, callback) {
    if (templateConfig.fromUrl) {
      // Uses jQuery's ajax facility to load the markup from a file
      var fullUrl = '/app/' + templateConfig.fromUrl + '?cacheAge=' + templateConfig.maxCacheAge;
      $.get(fullUrl, function(markupString) {
        callback($.parseHTML(markupString));
      });
    } else {
      // Unrecognized config format. Let another loader handle it.
      callback(null);
   }
  }
};

如果此加载器具有fromUrl属性,它将使用 jQuery 检索和解析模板;否则,它将不执行任何操作。剩下要做的就是将加载器添加到 Knockout 中:

ko.components.loaders.unshift(templateFromUrlLoader);

您可以在cp4-loader分支中看到这个自定义加载器;它在main.js文件中。

Knockout 的默认组件加载器已经足够灵活,但能够提供自己的自定义加载器,用于配置和实例化,使 Knockout 组件系统能够与您想要创建的任何格式一起工作。

单页应用程序(SPA)路由

Knockout(或任何 MV*框架)的吸引力很大一部分在于其模板引擎允许我们在不与服务器通信的情况下重新渲染页面的一部分。能够在客户端进行增量页面更新意味着更低的延迟,使应用程序感觉更加敏捷。SPA 通过让 JavaScript 客户端控制页面间的导航,将这一概念提升到了新的水平。当浏览器导航时,它必须重新渲染整个页面,这意味着重新加载 JavaScript、HTML、CSS 以及一切。当 JavaScript导航时,它只需要更改 HTML 的一部分,这在大多数情况下会更快。

Knockout 可以相对容易地提供这种虚拟页面更改功能,但 SPA 的一个重要组成部分是页面更改仍然更新 URL。这有助于用户检查是否发生了更改,但更重要的是,这意味着如果用户刷新页面或分享链接,应用程序将转到正确的页面。如果没有 URL 更新,用户将始终停留在主页。这个功能通常被称为路由。Knockout 不提供此机制。

为了探索 Knockout 如何适应 SPA 场景,我们将使用 SammyJS (sammyjs.org/)。SammyJS 是一个流行的用于路由的库;Knockout 甚至在其教程网站上使用了它。当然,还有许多其他选项,但无论您使用哪个库,概念都应该非常相似。

SammyJS 概述

SammyJS 的默认路由使用哈希更改导航,它使用 URL 哈希来存储当前状态。由于浏览器不会将哈希发送到服务器,服务器始终将 URL 视为对主页的请求。一旦页面加载,Sammy 将检查哈希并定位到匹配的路由(如果存在)。如果找到路由,它将运行该路由的回调。回调负责执行导航所需的任何应用程序逻辑。以下代码演示了这一点:

var app = Sammy('#appHost', function() {
  //Home route
   this.get('#/', function() {
    //Load home page
  });
  this.get('#/contacts/:id', function() {
    var contactId = this.params.id;
    //Load contact
  });
}).run('#/');

这是一个典型的 Sammy 应用程序配置。Sammy 对象是一个函数,它接受一个元素的 ID,它将作用域处理程序,并返回应用程序对象。在初始化处理程序内部,它为每个 HTTP 动词提供了注册路由的方法。前面的示例注册了 #/(一个标准的 主页 路由)和 #/contacts/:id 路由。路由中的 :id 部分表示一个将匹配任何内容并提供路由回调中 params 对象内值的参数。

Sammy() 返回的应用程序对象将在调用 run() 之前不会启动,run() 方法应该等待 DOM 准备就绪。run() 方法接受一个默认路由,如果没有哈希(例如,当导航到裸域名 URL 时),将加载该路由。

控制导航

SammyJS 监控 window.location.hash 属性的任何变化,并运行匹配的路由处理程序。这可能在用户点击带有包含哈希的 href 属性的 a 标签时发生,或者通过 JavaScript 设置 window.location.hash。在视图中使用 window 对象通常是不推荐的,因为它在单元测试中很难模拟。如果以后需要更改,也最好将导航逻辑集中化。为此,我们将导航封装到一个路由模块中。目前,它只需要一个方法:

define(function() {
  return {
    navigate: function(path) {
      window.location.hash = '#' + path;
    }
  };
});

一旦注入了 RequireJS,可以通过调用 router.navigate 来导航视图模型。

创建页面组件

在单页应用(SPA)和通用网页应用中组织 Knockout 视图模型有许多不同的方法。正如我们刚刚学习了如何创建组件,我们将探讨一种将每个页面结构化为组件的方法。这给我们带来了一些明显的优势:

  • 页面将相互解耦

  • 每个页面都将有自己的 HTML 和 JavaScript 文件,这感觉是自然的

  • 在外壳上绑定单个组件可以保持页面的 主体,同时为导航栏保持静态布局

在某个时候,我们需要引入一个文件夹结构来组织这些文件,所以我们不妨现在就开始。

创建页面组件

主页(它只是一个问候)已经被移除,但占位符设置页面仍然存在,这样我们至少有两个链接来测试导航。路由器和模拟数据服务已经被移动到core文件夹(我更喜欢这个名字,因为它比common更短)。其余的代码,包括联系模型和两个页面,已经被移动到contacts文件夹。main.js起始文件没有移动。

你当然可以以任何你想要的方式分组文件;到目前为止我们所涵盖的内容都不会要求任何特定的文件结构。

编辑页面

之前,两个页面都是由一个视图模型管理的,该视图模型通过使用一个空的编辑联系人在这两个页面之间切换。然而,很明显,这个组合视图模型承担了多个角色。将编辑代码拆分应该会减少一些混淆:

define(['knockout', 'text!contacts/edit.html', 'core/dataService', 'core/router', 'contacts/contact'], 
function(ko, templateString, dataService, router, Contact) {

  function ContactEditViewmodel(params) {
    self.entryContact = ko.observable(new Contact());
    if (params && params.id) {
      dataService.getContact(params.id, function(contact) {
        if (contact)
        self.entryContact(contact);
      });
    }

    self.cancelEntry = function() {
      router.navigate('/');
    };
    self.saveEntry = function() {

      var action = self.entryContact().id() === 0 
      ? dataService.createContact 
      : dataService.updateContact;

      action(self.entryContact(), function() {
        router.navigate('/');
      });
    };
    self.dispose = function() {
      self.entryContact(null);
    };
  }

  return { 
    template: templateString, 
    viewModel: ContactEditViewmodel 
  };
});

这里只有三个真正的变化:

  • 首先,而不是清除entryContact对象来表示编辑已完成,视图模型调用router.navigate('/')。由于我们不再有主页,列表页面将用作默认页面,它将绑定到/路由。

  • 其次,由于编辑将基于导航而不是直接设置entryContact对象,视图模型使用params组件来查找 ID。如果没有 ID,则假定我们正在创建一个新的联系人;如果存在 ID,则从数据服务中加载联系人。

  • 最后,添加了一个dispose方法,它将清除entryContact对象。这实际上并不是必要的,但它展示了清理将如何进行。

HTML 代码实际上并没有真正改变,只是现在它将位于自己的文件中。

列表页面

列表页面将成为新的主页。像编辑页面一样,它需要使用路由器导航到编辑页面,而不是使用entryContact对象。列表页面不需要任何参数:

define(['knockout', 'text!contacts/list.html', 'core/dataService', 'core/router'], 
function(ko, templateString, dataService, router) {

  function ContactsListViewmodel() {
    var self = this;

    self.contacts = ko.observableArray();

    dataService.getContacts(function(contacts) {
      self.contacts(contacts);
    });
    self.newEntry = function() { router.navigate('/contacts/new'); };
    self.editContact = function(contact) { router.navigate('/contacts/' + contact.id()); };

    self.deleteContact = function(contact) {
      dataService.removeContact(contact.id(), function() {
        self.contacts.remove(contact);
       });
    };

    self.query = ko.observable('');
    self.clearQuery = function() { self.query(''); };

    self.displayContacts = ko.computed(function() {
      //Same as before
    });

    self.dispose = function() {
      self.contacts.removeAll();
    };
  }

  return { 
    template: templateString, 
    viewModel: ContactsListViewmodel 
  };
});

在这里,HTML 代码也没有发生太多变化,只是contact-list组件已经被移除,因此它的视图被重新添加到列表页面。

协调页面

到目前为止,示例服务器负责通过执行字符串替换将每个页面放入我们的 shell/layout HTML 中。为了获得真实 SPA 的体验,我们将更改服务器以返回一个索引文件,而不会对其进行任何解析或渲染:

<!DOCTYPE html>
<html>
  <head>
    //Same as before
  </head>
  <body>
    <!-- Navbar -->
    <nav class="navbar navbar-default" role="navigation">
      //Same as before
    </nav>

    <!-- Main Application Body -->
    <div id="appHost" class="container" data-bind="if: name">
      <!-- ko component: { name: name, params: data } --><!-- /ko -->
    </div>
    <script type="text/javascript" src="img/require-2.1.js" data-main="/app/main"></script>
  </body>
</html>

上一段代码中的appHost元素包含一个无容器组件绑定,该绑定使用一个可观察的nameparams值。它被一个if绑定包裹,确保组件绑定在页面被选中之前不活跃。根视图模型只需要提供nameparams属性。

因此,我们的 main.js 将包含一个简单的视图模型,其中包含每个属性。当路由被激活时,SammyJS 路由处理程序将设置此视图模型。main.js 文件还将负责将页面组件注册到 Knockout。它很长,所以我们将其分成几个部分:

define(['jquery', 'knockout', 'sammy', 'bootstrap'], function($, ko, Sammy) {
  var pageVm = {
    name: ko.observable(),
    data: ko.observable(),
    setRoute: function(name, data) {
      //Set data first, otherwise component will get old data
      this.data(data);
      this.name(name);
    }
  };

  //Sammy Setup
  Var sammyConfig = /* see below */

  $(document).ready(function() {
    sammyConfig.run('#/');
    ko.applyBindings(pageVm);
  });
});

SammyJS 已被添加为依赖项并注入。RequireJS 的配置没有显示,但它不需要一个垫片。SammyJS 作为 AMD 运行得很好。pageVm 对象通过两个可观察属性和一个用于设置它们的辅助方法创建。顺序很重要,因为组件视图模型是同步实例化的,并且当组件名称更改时,绑定到 params 对象的数据需要已经就绪;否则,组件将在 params 对象设置之前初始化。

在 SammyJS 设置完成后,文档就绪处理程序使用默认路由启动它,然后使用 pageVm 对象应用绑定。

一种执行 SammyJS 配置的方法是写出每个组件注册和路由处理程序,如下所示:

ko.components.register('contact-edit', { require: 'contacts/edit' });
self.get('#/contacts/:id', function() {
  pageVm.setRoute('contact-edit', { id: this.params.id });
});

个人而言,我认为这最终会变得有点混乱。它还在 registersetRoute 中重复了组件名称。SammyJS 还不允许你在单个调用中将多个路由绑定到同一个处理程序;你必须将它们都写出来。这对于主页来说尤其令人烦恼,因为 SammyJS 将空路由和 #/ 路由视为不同的路由,尽管它们都传统上意味着 主页。为了解决这些问题,我们可以将组件和路由定义组合成一个页面对象,然后遍历它们:

var sammyConfig = Sammy('#appHost', function() {
  var self = this;
  var pages = [
    { route: ['/', '#/'], component: 'contact-list', 	module: 'contacts/list'}, { route: ['#/contacts/new', '#/contacts/:id'], component: 'contact-edit', 	module: 'contacts/edit' }, { route: '#/settings', component: 'settings-page', module: 'settings/page' }
  ];

  pages.forEach(function(page) {
    //Register the component, only needs to happen
    ko.components.register(page.component, { require: page.module });

    //Force routes to be an array
    if (!(page.route instanceof Array))
    page.route = [page.route];

    //Register routes with Sammy
    page.route.forEach(function(route) {
      self.get(route, function() {

        //Collect the parameters, if present
        var params = {};
        ko.utils.objectForEach(this.params, function(name, value) {
          params[name] = value;
        });

        //Set the page
        pageVm.setRoute(page.component, params);
      });
    });
  });
});

更好了。现在,很容易看出路由和组件之间的关系,并且为单个组件定义多个路由很简单。它还消除了重复的组件名称。

instanceof 检查使我们能够通过始终将其作为数组来使用 page.route 属性。params 部分将包括由路由处理程序捕获的任何参数,并将它们作为组件绑定用于 params 对象的数据传递。

我们刚刚讨论的所有代码都可以在 cp4-spa 分支中看到。确保使用应用中的每个页面,并注意 URL 的变化。如果你访问一个页面,例如特定的联系人页面,并刷新浏览器,SammyJS 将确保加载正确的页面而不是总是跳转到主页。这给应用程序带来了非常自然的感觉。你也应该注意到,在页面之间切换几乎没有延迟(取决于你的 CPU)。为了比较,尝试在 cp4-contacts 分支中查看 /contacts/settings 页面之间的变化。SPA 导航要快得多。

摘要

到现在为止,你应该对如何构建 Knockout 网络应用程序有一个或两个好的想法,特别是关于如何构建单页应用程序。Knockout 组件功能为你提供了一个强大的工具,帮助你创建可重用、行为驱动的 DOM 元素,编写自定义加载器允许你完全控制组件的使用方式。RequireJS AMD 模式通过将 JavaScript 和 HTML 分割成独立的模块,使得应用程序的组织变得简单。因为这些模块使用依赖注入,因此创建单元测试的模拟也是可能的。最后,你看到了 SammyJS 如何通过组件控制页面来创建快速的客户端 JavaScript 导航。

在下一章中,我们将探讨 Durandal 框架,这将使单页应用程序的开发变得更加简单。

第五章. Durandal – Knockout 框架

在上一章中,我们探讨了如何使用 RequireJS 和 SammyJS 与 Knockout 结合,通过模块定义和客户端路由来为我们的前端堆栈添加更多标准功能。这两个概念在 JavaScript 世界中已经变得非常普遍;你甚至可以将它们视为现代网络应用的标准。Knockout 是一个库而不是一个框架,因为它填补了特定的角色——数据绑定——而不是试图成为前端开发框架的全部。如果你想要创建一个现代的 JavaScript 客户端,这会留下很多决策需要做出,可能会变得繁重、耗时,如果你的团队意见分歧,可能会产生争议。Durandal 是一个框架,它试图在保持 Knockout 的 MVVM 哲学的同时,做出许多这些决策。

Durandal 是由 Blue Spire 创建的,其主要开发者 Rob Eisenberg 还创建了流行的 WPF 框架 Caliburn.Micro,另一个 MVVM 框架。在接下来的两章中,我们将探讨 Durandal 如何帮助我们轻松地构建网络应用,同时利用我们从 Knockout 获得的所有经验和自定义代码。本章将涵盖以下内容:

  • Durandal 框架概述

  • 组合系统

  • 路由器

  • 模态对话框

  • 应用程序的生命周期

  • 小部件

再次强调,我们将使用本章的示例使用 Contacts 应用程序。

Durandal 框架概述

Durandal 是建立在 Knockout、jQuery 和 RequireJS 之上的。Durandal 的核心是一组 AMD 模块,它们提供了组合、事件和激活功能,以及一些实用函数。除了核心模块之外,Durandal 还提供了一些可选激活的插件,或者可以通过社区或个人插件添加。这些插件包括路由器(每个 SPA 框架的基本要求)、对话框和小部件。

注意

Durandal 的文档可以在 durandaljs.com/docs.html 找到。

Promises

为了将这些内容整合在一起,Durandal 的内部和外部通信是通过 promises 来处理的。如果你不熟悉 JavaScript 的 promises(有时被称为 thenables,因为它们提供了一个 then 方法),那么你错过了一些东西。简要来说,promises 通过用一个表示异步工作的返回对象替换回调来改变异步操作的处理方式。它允许异步任务以简单、易于调试的方式串联,并进行错误处理。在这里我不会介绍 promises 的工作原理,但它将会是相关的。如果你还没有阅读过,你应该去了解一下。

如果你熟悉承诺,你可能已经知道 jQuery 的承诺实现并不符合 A+规范(promisesaplus.com),这是大多数其他承诺库所遵循的。为了最小化第三方依赖,Durandal 默认使用 jQuery 的承诺,但它们的文档提供了一个简单的补丁,允许使用另一个承诺实现。这个例子(使用 Q,一个非常流行的承诺库)是从 Durandal 文档中摘录的。在使用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;
};

如果你更喜欢另一个库,只需在前面代码中将Q替换即可。为了简单起见,我在本章中将使用 Durandal 的默认承诺,但我鼓励你在实际应用中使用符合 A+规范的实现。

开始使用

虽然 Durandal 对你的文件系统只有一个真正的要求,即所有核心模块都应该在同一个文件夹中,所有插件都应该在自己的文件夹中,但有一些关于组织方式的约定,如下面的截图所示:

开始使用

这应该很熟悉,因为它与我们一直在使用的没有太大区别。app目录包含我们的代码,lib目录包含第三方代码,content目录包含我们的 CSS 和其他视觉资产。Durandal 的整个源代码,其中包含一些自己的 CSS,核心模块以及标准插件目录,被放入lib中。我们的main.js配置看起来像这样:

require.config({
  paths: {
    'text': '../lib/require/text',
    'durandal':'../lib/durandal/js',
    'plugins' : '../lib/durandal/js/plugins',
    'transitions' : '../lib/durandal/js/transitions',
    'knockout': '../lib/knockout-3.1.0',
    'bootstrap': '../lib/bootstrap-3.1.1',
    'jquery': '../lib/jquery-2.1.1.min'
  },
  shim: {
    'bootstrap': {
      deps: ['jquery'],
      exports: 'jQuery'
    }
  },
  waitSeconds: 30
});

这里不应该有什么令人惊讶的地方,因为我们已经在上一章中介绍了 RequireJS 的配置。除了bootstrap之外的所有路径都是 Durandal 所必需的。配置完成后,Durandal 需要初始化;这通常放在main.js中,位于require.config下方:

define(['durandal/system', 'durandal/app'],
function(system, app, extensions) {

  system.debug(true);

  //specify which plugins to install and their configuration
  app.configurePlugins({
    //Durandal plugins
    router:true,
    dialog: true
  });

  app.title = 'Mastering Knockout';
  app.start().then(function () {
    app.setRoot('shell/shell');
  });
});

appsystem模块是 Durandal 对象。system.debug调用指示 Durandal 将所有步骤记录到控制台,这对于开发很有用。app.configurePlugins调用注册要安装的插件,尽管它们在调用app.start之前不会运行。app.start调用初始化所有 Durandal 模块并安装已注册的插件。由于app.start是一个返回承诺的函数,因此附加了一个then方法,它在完成时调用app.setRoot方法。setRoot方法将指定的模块组合到 DOM 中,作为应用程序的根视图模型。

应用程序的根被放置在一个具有applicationHost ID 的div元素中,它预期已经在 DOM 中。由于 Durandal 将负责所有的 HTML 渲染,原始 DOM 相当简单。它只需要使用的 CSS,applicationHost ID,以及 RequireJS 的script标签。这是标准的index.html文件:

<!DOCTYPE html>
<html>
  <head>
    <title>Mastering Knockout</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="content/css/bootstrap-3.1.1-darkly.css" type="text/css" media="all"  title="darkly" />
    <link rel="alternate stylesheet" href="content/css/bootstrap-3.1.1-cosmo.css" type="text/css" media="all" title="cosmo" />
    <link rel="stylesheet" href="content/css/font-awesome-4.0.3.css" type="text/css" media="all" />

    <link rel="stylesheet" href="lib/durandal/css/durandal.css" />
    <link rel="stylesheet" href="content/css/app.css" 
  </head>
  <body>
    <!-- Main Application Body -->
    <div id="applicationHost"></div>
    <script type="text/javascript" src="img/require.js" data-main="app/main"></script>
  </body>
</html>

就这样!Durandal 已经启动,从这一点开始之后的所有内容都将是你应用程序的代码。

组合系统

在上一章中,我们探讨了 Knockout 的新组件功能,它允许我们通过从 DOM 中使用自定义元素(或绑定)实例化它们来构建视图/ViewModel 对。Knockout 在 Durandal 之后发布了这个功能,所以两者之间有一些重叠。Durandal 的组合类似于组件和模板绑定的混合。

组合主要通过两种方式调用,使用setRoot来组合applicationHost ID,以及使用组合绑定来处理数据绑定值。组合通过将 viewmodel 与视图配对来工作。

注意

Durandal 的文档将 viewmodels 称为模块,我认为这有点令人困惑。在本章中,我将把可组合模块称为 viewmodels。

当组合被赋予一个 viewmodel 时,它会查找视图,使用 RequireJS 的文本加载器加载它,将其绑定到视图上,最后将其附加到 DOM 中。

组合应用程序的根

让我们看看 shell viewmodel 的根组合。我们前面的示例是将根设置为shell/shell。如果我们的app目录中有一个shell文件夹,shell.js模块将被setRoot加载并组合。组合使用 Durandal 的viewLocator模块通过替换模块的文件扩展名来查找 HTML 文件;因此对于shell.js,它会查找shell.html并将其用作视图。

你可以在cp5-shell中看到一个非常简单的例子。shell模块非常简单,只包含一个title属性,我们将将其绑定到:

define(function (ko, app) {
  return {
    title: 'Welcome!'
  };
});
<html> root element:
<div class="jumbotron">
  <h1 data-bind="text: title"></h1>
  <p>This HTML was rendered into the DOM with Durandal's composition system. Notice the data-binding on the <code>h1</code> tag with the viewmodel property <code>title</code>.</p> 
</div>

Durandal 期望视图是部分 HTML 文档。它们不应包含HTMLHEADBODY元素;它们应只包含用于 DOM 内容的模板的 HTML。

如果你运行代码,你会看到这个 HTML 被渲染到 DOM 中,标题被绑定到shell模块的title属性上。shell模块返回的对象被用作 shell 视图的绑定上下文。

组合绑定

通常,应用程序的根不会改变,而是作为 HTML 的布局或外壳。它显示每个页面上都存在的内容(如导航栏),因此它不需要改变。组合也可以通过组合绑定来调用,该绑定接受一个 viewmodel 作为绑定值。

打开cp5-composition分支。注意 shell 视图又回到了包含我们熟悉的导航栏,以及在其主要内容区域中的组合绑定:

<div>
  <nav class="…" role="banner">
    //Standard Nav Bar HTML you've seen in every other sample
  </nav>

  <div class="page-host container">
    <div data-bind="compose: currentModel"></div>
  </div>
</div>

shell viewmodel 有一个currentModel属性,以及两个函数,用于在editlist页面对象之间切换currentModel属性:

define(['knockout', 'durandal/app', 'contacts/edit', 'contacts/list'], 
function (ko, app, EditVm, ListVm) {
  var listVm = new ListVm(),
  editVm = new EditVm();
  return {
    title: app.title,
    currentModel: ko.observable(listVm),
    setEdit: function() { this.currentModel(editVm); },
    setList: function() { this.currentModel(listVm); }
  };
});

尝试按下导航栏中的按钮,看看主体内容是否在两个页面之间切换。组合绑定正在获取一个模块实例,定位其视图,并将视图绑定为 DOM 的内容。由于currentModel是可观察的,所以每当它改变时,组合都会重新运行。

由于listedit对象只构建一次并交换,你应该注意到在编辑页面上输入的值是持久的。这是因为,虽然在切换时 HTML 被丢弃并重新创建,但新的 HTML 仍然绑定到同一个对象上。

希望这个示例的简洁性不会削弱组合系统的强大功能。它们如此之小的事实应该突出组合是多么容易处理;只需交换绑定值,我们就可以在两个完全不同的页面之间切换!

你可能已经注意到,组合就像是对 Knockout 组件的映射。不是 DOM 中的自定义元素或绑定来选择要渲染的内容,而是组合渲染 JavaScript 指定的值。这最终对灵活性产生了重大影响。组件是 DOM 所说的元素,但单个组合绑定可以持有任何模块,并且它可以随时更改。它们可能看起来像是相互竞争的功能,但我认为它们服务于不同的目标。

组件就像高级的绑定处理器,允许 HTML 实例化行为驱动的模板。

组合使用我们视图模型代码创建和管理的关系,并在表示层中反映它们。

组合选项

我们已经探讨了两种组合示例——setRoot和组合绑定——它们各自接受一个要组合的对象实例。当然,Durandal 是一个深思熟虑的框架,所以组合还有几种其他的工作模式。组合绑定可以接受以下任何一种值。

模块实例

我们已经讨论过这个问题,但为了完整性,组合绑定可以接受模块的实例,并使用它来定位视图。请参阅cp5-composition分支中的示例。这是使用组合绑定组合的最常见用例。

构造函数

cp5-composition2分支中,你可以看到一个修改后的外壳,它将currentModel属性直接设置为构造函数:

define(['knockout', 'durandal/app', 'contacts/edit', 'contacts/list'], 
function (ko, app, EditVm, ListVm) {
  return {
    title: app.title,
    currentModel: ko.observable(ListVm),
    setEdit: function() { this.currentModel(EditVm); },
   setList: function() { this.currentModel(ListVm); }
  };
});

虽然这不是一个很好的用例,但它得到了支持。构造函数最常用于与路由器绑定的模块,因为导航页面之间通常希望有一个新的视图模型。与上一个示例不同,它为每个页面存储了构造视图模型的引用,而这种方法将在每次导航时重新创建视图模型。

模块 ID 字符串

使用字符串作为组合绑定值有两种方式。第一种是通过提供模块 ID。你可以在cp5-composition3分支中看到这一点:

currentModel: ko.observable(''contacts/list''),
setEdit: function() { this.currentModel(''contacts/edit''); },
setList: function() { this.currentModel('contacts/list'); }

这导致模块被组合。如果模块返回一个对象,它将被直接组合;如果模块返回一个函数,它被视为构造函数来创建对象。当然,因为它是一个字符串,所以可以直接在绑定中使用它:

<div data-bind="compose: 'contacts/list'"></div>

虽然得到了支持,但我个人认为这违反了关注点的分离原则。它将 HTML 视图直接绑定到视图模型上。

视图路径字符串

compose绑定中使用字符串的第二种方式是使用视图路径。如果字符串包含viewEngine模块能识别的扩展名,它将被用来加载该视图并将其绑定到当前绑定上下文中。这里的常见用例是部分视图:

<div class="page-host container">
    <div data-bind="compose: 'shell/sub.html'"></div>
</div>

再次,字符串可以是 HTML 或来自 viewmodel。在这种情况下,由于视图正在引用另一个视图,我认为字符串属于 HTML。否则,将发生关注点分离的逆向违反,其中 viewmodel 直接引用视图。

这个示例可以在cp5-composition4中看到。

显式模型和视图

组合绑定还可以接受一个设置对象,该对象指定一个模型、一个视图或两者。关于这些示例没有太多可说的,所以这一节直接来自 Durandal 文档:

  • data-bind="compose: { model: model }": 这使用model的值与viewLocator一起获取一个视图。然后它们被绑定,并将视图注入到 DOM 节点中。

  • data-bind="compose: { view: view }": 这将评估view的值。如果是字符串,则使用viewLocator定位视图;否则,假定它是一个视图。结果视图将被注入到 DOM 节点中。

  • data-bind="compose: { model: model, view: view }": 这将解析model的值。view的值将被解析,并按照前一点所述构建一个视图。然后,modelview都将被绑定并注入到 DOM 节点中。

  • data-bind="compose: { model: model, view:'myView.html' }": 解析model的值。然后使用viewLocator模块获取由view属性指示的视图。它们随后被绑定,并将视图注入到 DOM 节点中。

  • data-bind="compose: { model:'shell', view: view }": 使用 RequireJS 解析shell模块。view的值将被解析,并返回视图,如前所述。然后,视图被绑定到解析的模块,并注入到 DOM 节点中。

  • data-bind="compose: { model:'shell', view:'myView.html' }": 使用 RequireJS 解析shell模块。然后使用viewLocator模块获取由view指示的视图。该视图随后被绑定到解析的模块,并注入到 DOM 节点中。

无容器组合

所有的前述示例都与 Knockout 的无容器注释语法一起工作,所以以下也是有效的:

<!-- ko compose: model--><!--/ko-->

注意

组合系统具有比本章范围更广泛的功能,包括视图缓存、过渡、模板模式和自定义视图位置策略。它们将在下一章中讨论,该章将涵盖更高级的用例。

视图位置

如前所述,组合使用的viewLocator模块的默认行为是查找与模块路径相同的视图,但带有.html扩展名。这导致按文件夹分组的模块:

视图位置

在前面的示例中,shell 目录包含 shell 的视图和视图模型,而 contacts 目录包含一个联系人基模型,以及 listedit 的视图和视图模型。我认为这种组织方式非常易于理解,并且在大型应用程序中具有良好的扩展性,因为每个功能或功能组都保持在一起。

尽管如此,Durandal 提供了另一种策略,它称之为传统策略。你可以通过修改 main.js 文件来在 viewlocator 模块上调用 useConvention 来激活它:

define(['durandal/system', 'durandal/app', 'durandal/viewLocator'],
function(system, app, viewLocator) {

  //plugin configuration omitted

  viewLocator.useConvention('viewmodels', 'views');

  app.title = 'Mastering Knockout';
  app.start().then(function () {
    app.setRoot('shell/shell');
  });
});

这导致 Durandal 在 views/contactList.html 中寻找具有 viewmodels/contactList ID 的模块。虽然你可以为视图模型和视图输入任何字符串作为路径,但实际上这是默认的。调用 viewLocator.useConvention()(不带参数)会产生相同的效果。

我认为这种方法扩展性不好,我个人觉得它更难操作。我更喜欢将视图模型和视图放在文件系统中的同一位置,这样我就不必去寻找它。但这完全取决于你(或你的团队)的偏好。

这里显示的所有代码示例都将使用默认行为,而不是传统行为。

使用路由器

虽然技术上这是一个可选插件,但我无法想象任何实际的 SPA 会不使用路由器。虽然 SammyJS 将 URL 片段绑定到函数,但 Durandal 的路由器将 URL 直接绑定到模块 ID。该模块可以返回单例或构造函数,并将用于使用标准组合系统绑定视图。

配置路由器

让我们开始配置路由器:

  1. 路由配置相当直接。以下是 shell 模块中为 Contact 应用程序配置的路由器:

    define(['plugins/router', 'knockout', 'durandal/app'], 
    function (router, ko, app) {
      return {
        title: app.title,
        router: router,
        activate: function() {
    
          router.map([
            { route: '', moduleId: 'contacts/list', title: 'Contacts', nav: true },
            { route: 'contacts/new', moduleId: 'contacts/edit', title: 'New Contact', nav: true },
            { route: 'contacts/:id', moduleId: 'contacts/edit', title: 'Contact Details', nav: false }
          ])
          .buildNavigationModel()
          .mapUnknownRoutes('shell/error', 'not-found');
    
          return router.activate();
        }
      };
    });
    
  2. 路由插件在 shell 模块中是必需的,并在其 activate 方法中设置。

  3. map 方法接受一个路由数组,而 buildNavigationModel 则设置这些路由。mapUnknownRoutes 函数接受一个模块 ID 和一个用作对所有未注册路由导航尝试的通配符的路由。如果没有这个功能,导航将被取消,而不会向用户显示任何错误!

  4. 我们将在稍后详细介绍 activate 和其他生命周期钩子。现在,只需知道 activate 在组合期间被调用。如果 activate 的返回值是一个 promise,则组合将等待直到 promise 解决。

  5. 最后,router.activate,它也返回一个 promise,被返回到 shellactivate 方法,该方法将组合等待与路由器完成。

路由属性

传递给 map 函数的路由配置对象具有以下属性:

  • route:这是要映射的 URL。它可以是字符串或字符串数组。每个字符串可以采用以下形式之一:

    • 默认路由:这是 route: ''

    • 静态路由:这是 route: 'contacts'

    • 参数化路由:这是 route: 'contacts/:id'

    • 可选参数路由:这是 route: 'contacts(/:id)'

    • 通配符路由:这是 route: 'contacts*details'。它是一个 通配符,将匹配以 contacts 开头的任何 URL。

  • moduleId:这是绑定路由的模块。

  • hash:这主要用于数据绑定 <a> 标签。在大多数情况下,路由会自动生成这个值,但也可以覆盖。在具有可选参数或通配符的路由上覆盖这个属性是必要的。

  • title:将 document.title 属性设置为这个值。如果存在,则路由是活动的;如果不存在,则不更改 document.title

  • nav:如果为 true,则路由将被包含在路由的 navigationModel 中,这是一个在调用 buildNavigationModel 时创建的路由的可观察数组,可以用来轻松生成导航栏。默认值是 false

如果一个具有 activatecanActivate 函数的模块被路由激活,则路由的参数将作为参数传递给它。再次强调,激活和其他生命周期钩子将在本章的后面更详细地介绍。

查询字符串也作为对象传递给 activate/canActivate 的最后一个参数,该对象具有键/值对的查询字符串键。

绑定路由

路由引入了一个特殊的绑定,也称为 router,它使用特殊的处理逻辑包装了 compose 绑定。它具有与 compose 绑定相同的属性:

<!-- ko router: { model: router.activeItem }--> <!-- /ko -->

路由上的 activeItem 对象包含当前活动路由的模块。如果省略了路由绑定上的 model 属性,绑定将查找当前绑定上下文上的路由属性并取其 activeItem 对象。前面的例子等同于这个例子:

<!-- ko router: { }--> <!-- /ko -->

路由还有一个 navigationModel 可观察数组,这在生成导航栏时非常有用:

<ul class="nav navbar-nav" data-bind="foreach: router.navigationModel">
  <li data-bind="css: { active: isActive }">
    <a class="" data-bind="attr: { href: hash }, text: title"></a>
  </li>
</ul>

每个路由都有一个 isActive 属性,它指示路由何时处于活动状态,以及一个 hash 属性,它可以用于标签的 href 属性。

加载或导航,也作为路由上的可观察者暴露。这使得在页面上绑定加载指示器变得很容易:

<i class="fa fa-spinner fa-3x fa-spin" data-bind="visible: router.isNavigating"></i>

好的,现在是时候看看一个活生生的例子了。打开 cp5-router 分支。尝试通过编辑联系人或使用导航栏链接来移动应用程序。注意,URL 的哈希值会更新以匹配当前路由。你甚至可以使用浏览器的后退和前进按钮来控制导航,因为路由已经连接到 window.location 对象。像所有真正的单页应用(SPA)一样,导航是在应用程序内部发生的,而不是通过执行浏览器导航。

激活路由

当一个路由被激活时,相关的 viewmodel 模块会通过 RequireJS 加载,并组合到 DOM 中。通过 RequireJS 加载的模块必须是对象,它将被视为单例并绑定到视图,或者是一个函数,它将被视为构造函数并用于 new 一个对象以绑定到视图。

导航 – 哈希变化与推状态

我们刚刚看到了路由器如何通过更改 URL 的哈希来处理导航。这是默认行为,但路由器还支持推状态导航。推状态导航是使用 HTML5 历史 API 来修改当前 URL 和历史堆栈,而不引起浏览器导航。这导致在路由器导航期间出现更美观且看起来正常的 URL。我们看到 http://localhost:3000/contacts/new 而不是 http://localhost:3000/#contacts/new

通过传递 router.activate({ pushState: true }) 调用来激活这种导航模式。尽管较旧的浏览器不支持推状态,但 Durandal 会在不支持推状态时优雅地降级到哈希变化导航。

这不是默认行为的原因是因为它需要服务器支持才能正常工作。目前,我们的服务器只有在导航到根 URL 时才提供服务。如果我们尝试导航到 /contacts/new,服务器将显示一个 404 错误。由于 Durandal 应该控制路由和导航,因此将此支持逐个添加到服务器将导致大量重复。在服务器上支持推状态的建议方法是使用通配符路由将所有页面请求发送到索引页面。一旦 Durandal 加载,它将检测 URL 并激活正确的路由。

通配符路由的实现完全取决于您的服务器后端。我们的示例使用的是 Node.js 服务器,这使得它变得相当简单:

//Index Route
app.get('/*', function(req, res){
  res.sendfile(clientDir + '/index.html');
});

这将处理页面路由,但推状态路由存在一个更大的可支持性问题;HTML 中的相对路径和 RequireJS 配置。目前,我们代码中所有指向 CSS 或脚本的链接看起来像这样:

<link rel="stylesheet" href="content/css/app.css" />

如果页面尝试加载 /contacts/new,这将是一个问题,因为 content/css 是一个相对路径;它将被浏览器视为 /contacts/content/css。显然这将失败;要么服务器将显示一个 404 错误,要么更糟糕的是,通配符路由将导致返回索引页面!

为了解决这个问题,所有路径都需要是绝对路径;它们必须以正斜杠(/)开头:

<link rel="stylesheet" href="/content/css/app.css" />

这可能很棘手,因为它需要手动更新包含链接的任何代码,包括 RequireJS 配置。只要您在开始项目时意识到您想要走的路,这就不会造成太大的麻烦。如果您可以的话,我建议使用推状态路由。那些漂亮的 URL 会使很大的不同。它还释放了哈希,以便它能够正常地表示页面上的位置或状态。

您可以在 cp5-pushstate 中看到一个推状态的示例。请注意,作为一个特别的待遇,这个分支支持 IE 8,这样您可以看到对哈希变化导航的优雅降级。本章中的其余示例将使用推状态导航,但只支持 ES5 兼容的浏览器。

从 JavaScript 控制导航

可以使用路由器的navigate函数轻松进行导航,该函数接受一个 URL 字符串。路由器是一个单例,可以通过plugins/router在任何模块中引入:

define(['durandal/app', 'knockout', 'services/mock', 'plugins/router'],
function(app, ko, dataService, router) {
  return function ContactListVM() {

    //…
    self.newEntry = function() {
      router.navigate('contacts/new');
    };
    self.editContact = function(contact) {
      router.navigate('contacts/' + contact.id());
    };
  };
});

模态对话框

在 Windows 中过度使用模态对话框以及在早期浏览器应用中使用警告框之后,模态对话框给一些开发者留下了不好的印象。然而,当适当使用时,它们是简单而强大的工具。Durandal 的模态对话框实现通过使对话框返回在关闭时解决的承诺,使得从模态对话框中收集用户输入变得非常容易。Durandal 中的模态对话框有两种类型,即消息框和自定义对话框。

消息框

对于显示通知或收集单个用户输入的简单情况,Durandal 在app.showMessage上提供了一个模态对话框,它接受以下参数:

  • 消息 (string): 这包含消息框的主要内容。

  • 标题 (string, optional): 这包含消息框的标题;默认标题是app.title

  • 按钮 (array, optional): 这是一个要显示的按钮数组;默认是['Ok']。数组中的第一个按钮将是对话框的默认操作。如果数组是字符串数组,则文本既是按钮文本也是点击该按钮的返回值。要指定按钮的值,请使用对象数组,即[{ text: "One", value: 1 }, { text: "Two", value: 2 }]

  • Autoclose (boolean, optional): 如果为true,则当用户点击对话框窗口外部时,对话框将被关闭;默认是false

  • 设置 (object, optional): 请参阅即将到来的消息框设置部分。

虽然简单的调用app.showMessage('This is a message!')是将某物直接展示给用户的好方法,但我认为消息框的最佳用例是确认对话框

消息框

self.deleteContact = function(contact) {
  app.showMessage('Are you sure you want to delete ' + contact.displayName() + '?', 'Delete Contact?', ['No', 'Yes'])
    .then(function(response) {
      if (response === 'Yes') {
        dataService.removeContact(contact.id(), function() {
          self.contacts.remove(contact);
        }); 
      }
    });
};

当有人尝试删除联系人时,我们在这里显示一个消息框。消息包括联系人的姓名(以提供上下文)和标题。两个按钮的顺序,然后,确保如果用户立即按回车键,将被选中。我认为默认选择更安全的情况是好的。用户选择的任何内容都将传递给showMessage返回的承诺,我们可以在then处理程序中访问它。

根据你如何计算这些行,我们只用 2-3 行非常易读的代码就通过模态对话框双重检查了用户操作。你可以在cp5-message分支中看到这个示例。

消息框设置

showMessage函数的最后一个参数是一个对象,用于控制显示选项。它接受以下参数:

  • buttonClass: 这指定了所有按钮的类。默认是btn

  • primaryButtonClass: 这指定了第一个按钮的附加类。默认是btn-primary

  • secondaryButtonClass: 它指定了除了第一个按钮之外的其他按钮的附加类。默认没有类。

  • class: 这指定了消息框最外层 div 元素的类。默认为 "messageBox"。请注意,您必须用引号指定此属性,否则在 IE8 中会崩溃;例如,"class""myClass"

  • style: 这指定了消息框最外层 div 元素的额外样式。默认为无。

您也可以通过将相同的设置对象传递给 dialog.MessageBox.setDefaults 来控制默认设置。此函数将合并传递给它的设置与默认设置;如果您省略了设置,它们将被保留,而不是被删除。

自定义对话框

消息框非常适合单输入,如 yesno 或从列表中选择一个选项。然而,当事情需要比单个答案更复杂时,Durandal 允许我们创建自定义对话框。要显示自定义对话框,您可以使用 plugins/dialog 要求对话框对象并调用 dialog.show,或者使用别名 app.showDialog。对话框使用组合,因此传递给 show 的任何视图模型都将使用标准方法查找并绑定其视图。

要关闭自身并将结果返回给调用者,对话框承载的视图模型需要要求 plugins/dialog 并调用 dialog.close(self, result)

要了解这是如何工作的,请打开 cp5-dialog 分支。主列表页面上的 添加联系人 按钮将在对话框中打开编辑视图模型,该对话框将关闭为取消的条目 null 或为保存的条目创建新联系人。为了展示其灵活性,导航栏中的 添加联系人 链接仍然会导航到一个新页面以创建新联系人。两者,对话框和页面都由同一个视图模型运行!

define(['durandal/system', 'knockout', 'plugins/router', 'services/mock', 'contacts/contact', 'plugins/dialog'], 
function(system, ko, router, dataService, Contact, dialog) {
  return function EditContactVm(init) {
    var self = this;

    self.contact = ko.observable(new Contact());

    self.activate = function(id) {
      //Id is only present when editing
      if (id)
        dataService.getContact(id, self.contact);
    };

    self.saveEntry = function() {
      var action = self.contact().id() === 0 
        ? dataService.createContact 
        : dataService.updateContact;

      action(self.contact(), function() {
        self.close(self.contact());
      });
    };

    self.cancel = function() {
      self.close(null);
    };

    self.close = function(result) {
      if (dialog.getDialog(self))
        dialog.close(self, result);
      else
        router.navigate('');
    };
  };
});

如您所见,几乎没有什么变化。在完成时,不再总是使用路由器导航到主页,新的关闭方法会检查 dialog.getDialog(self)) 以确定它是否是对话框,并使用结果(null 或新创建的联系人)关闭自身。dialog.getDialog(self)) 方法返回对话框上下文,如果没有找到则返回未定义。

列表视图模型只需进行以下更改即可打开对话框并保留结果:

self.newEntry = function() {
  app.showDialog(new ContactVM())
  .then(function(newContact) {
    if (newContact) {
      self.contacts.push(newContact);
    }
  });
};

ContactVM 对象是编辑视图模型,它通过 contact/edit 被要求。一个新的实例被构建并传递给 app.showDialog。组合渲染视图模型并返回对话框结果的承诺。此承诺将由编辑视图模型中的 dialog.close 调用完成。then 处理程序只是确保它存在并将其添加到其联系人列表中。

自定义对话框有一些 HTML/CSS 考虑因素。与 Durandal 使用 Bootstrap 的 modal 类样式的消息框不同,自定义对话框被渲染到一个居中的空 div 元素中,该元素具有绝对定位和透明背景。如果没有一些样式,输出看起来相当糟糕:

自定义对话框

幸运的是,清理起来并不需要太多。这是我使用的 CSS:

.edit-container {
  padding: 20px;
  min-width: 600px;
  background-color: #222222;
}

上述 CSS 产生了这个看起来更舒服的结果:

自定义对话框

虽然这种需求可能令人惊讶,但我认为它比 Durandal 对所有模态应用一些默认样式的替代方案更好,这必须被强制覆盖,当它不符合你的需求时。在下一章中,我们将介绍添加自定义对话框宿主,这为控制消息框和自定义对话框的默认模态外观提供了更好的方式。

一种替代方法

为了使调用视图模型保持一定的简洁性,并且减少对对话框视图模型工作方式的了解,我更倾向于封装实际的对话框代码。通过向编辑视图模型添加一个show方法来实现这一点非常简单:

self.show = function() {
  return dialog.show(self);
};

并且像这里所示的那样调用它,而不是app.showDialog

self.newEntry = function() {
  new ContactVM().show()
  .then(function(newContact) {
    if (newContact) {
      self.contacts.push(newContact);
    }
  });
};

这隐藏了特定方法对调用者的可见性,允许编辑视图模型控制其显示方式。show方法甚至可以接受在显示对话框之前允许配置的参数。这在有多个对话框宿主可用时特别有用,我们将在下一章中介绍。你可以在cp5-dailog2分支中看到这个示例。

应用程序的生命周期

Durandal 的合成和激活器服务允许可选的回调来控制或挂钩其生命周期。当执行设置和清理,或实现阻止或重定向页面更改的逻辑时,它们可能很有用。

激活生命周期

激活器是一个特殊的计算可观察对象,其write函数强制执行激活生命周期。除非你自己在管理合成或路由,否则你将只与路由器和对话框系统使用的激活器一起工作。尽管如此,如果你感兴趣,可以通过在durandal/activator模块中引入并使用create函数来创建自己的激活器。

当活动值尝试更改时,激活器会调用以下可选属性:

  • canActivate:在新的值上调用此方法;它应该返回一个布尔值或一个解析为布尔值的承诺。如果结果是false,则激活被取消。

  • activate:在canActivate之后对新值进行调用;它用于执行任何所需的设置逻辑。如果activate返回一个承诺,则新值不会成为活动值,直到承诺解析。

  • canDeactivate:在旧值上调用;就像activate一样,它应该返回一个布尔值或一个解析为布尔值的承诺。如果结果是false,则激活被取消。

  • Deactivate:在激活成功后但在切换之前对旧值进行调用。它用于执行任何必要的清理逻辑。

使用activate准备视图模型

你已经看到了在列表和编辑视图模型中使用activate的情况,它被用来加载数据:

self.activate = function() {
  dataService.getContacts(function(contacts) {
    self.contacts(contacts);
  });
};

可能不明显的是,因为模拟数据服务正在使用本地存储,如果这个服务调用实际上花费了时间,页面会在数据返回之前渲染。这可能导致当所有联系人突然加载时出现令人震惊的变化。activate调用并不等待这个回调完成,所以 Durandal 在视图模型真正准备好之前就激活了视图模型。

要查看这看起来是什么样子,请打开cp5-timeout分支。在它们的回调被使用之前,所有模拟服务调用都添加了 1 秒的超时,这将导致更接近现实世界的响应时间场景。加载主页时,您可以看到列表在页面其余部分加载之后加载。当尝试编辑联系人时,这是一个特别有问题的情况,因为表单将显示默认值,直到联系人加载。

要停止页面加载直到检索到列表,我们可以在激活中返回一个承诺。durandal/system模块提供了一种创建承诺的方法,如果您不使用自己的库(如 Q)来做这件事:

self.activate = function() {
  return system.defer(function(defer) {
    dataService.getContacts(function(contacts) {
      self.contacts(contacts);
      defer.resolve();
    });
  }).promise();
};

在这里,我们返回一个承诺,该承诺将由回调函数解析我们的模拟数据服务。system.defer函数接受一个执行异步工作的处理程序,使用延迟对象调用它。延迟对象具有解析和拒绝函数,可以接受成功或失败的价值。您可以在cp5-activate分支中看到这一点,其中对编辑页面进行了相同的更改。由于激活正在等待这个承诺,因此激活将不会继续,直到它解析。这些页面将在数据加载之前不会激活,所以用户在页面准备好之前永远不会看到它。

虽然这种方法有效,但有一种更干净的方法来做这件事。我们不是在我们的数据服务中使用回调,在我们的视图模型中使用承诺,这实际上混淆了策略,我们可以在我们的数据服务中使用承诺。如果我们的数据服务返回一个承诺,激活方法看起来会更好:

self.activate = function() {
  return dataService.getContacts()
  .then(function(contacts) {
    self.contacts(contacts); 
  });
};

这是一个多么大的改进!实际上,我们可以更进一步。由于self.contacts是一个可观察数组,它只是一个函数,我们可以在then处理程序中删除匿名函数,使用这个简写:

self.activate = function() {
  return dataService.getContacts()
  .then(self.contacts);
};

这之所以有效,是因为self.contacts成为then处理程序,所以当服务返回联系人列表时,承诺直接解析到它。这可能不会吸引每个人,甚至可能看起来很混乱。然而,如果它不会影响您的可读性,较短的代码可能更好。

这种方法可以在cp5-activate2分支中看到,它将所有数据访问代码完全转换为承诺,例如这个:

getContacts: function() {
  return system.defer(function(defer) {
    //Return our POJO contacts as real contact objects
    var typedContacts = [];
    for (var c in contacts) {
      if (contacts.hasOwnProperty(c)) {
        typedContacts.push(new Contact(contacts[c]))
      }
    }
    setTimeout(function() {
      defer.resolve(typedContacts);
    }, 1000);
  }).promise();
}

由于 Durandal 已经将这种对承诺的理解集成到其生命周期钩子中,这使得在所有异步代码中使用承诺更具吸引力。如果您还没有这样做,我强烈建议您考虑一下。从现在开始的所有代码示例都将使用承诺。

这种异步激活是组件组合的另一个优点。组件只能同步构建和绑定,这可能会使某些组件的初始化变得非常复杂。组合允许进行异步工作,使其更加灵活。

关于路由器的 isNavigating 属性的说明

在上一节 绑定路由器 中,我们查看路由器的 isNavigating 属性,在导航期间为 true。激活生命周期是导航的一部分,因此在激活生命周期中的任何异步活动期间 isNavigating 都将是 true。这允许你在页面加载时绑定视觉指示器,使你的应用程序感觉更响应。

使用 canDeactivate 检查导航

canActivatecanDeactivate 方法也支持承诺。使用 Ajax 请求去服务器查看视图是否可以被停用可能看起来很奇怪,但 Ajax 并不是承诺的唯一来源。canDeactivate 的最佳可能用途之一是与简单消息框的承诺——你有未保存的更改,你确定要离开吗?

打开 cp5-deactivate 分支并打开一个联系人进行编辑。如果你点击 取消,你仍然会被带回到列表中,但如果你进行了更改并点击 取消,你将收到提示。如果你点击 ,导航将被取消。

你可能认为这是从 取消 按钮执行的,但用户点击浏览器的后退按钮或导航链接(基本上,除了硬浏览器导航之外的所有操作)也会发生这种情况。这是因为无论尝试停用的来源是什么,canDeactivate 都会被执行:

self.canDeactivate = function() {
  if (!self.contact().state.isDirty())
  return true;
  return app.showMessage('You have unsaved changes. Are you sure you want to leave?', 'Cancel Edit?', ['No', 'Yes'])
  .then(function(response) {
    return response === 'Yes';
  });
};

注意

此示例中的脏标志来自 Ryan Niemeyer 的博客 Knock Me Out,网址为www.knockmeout.net/2011/05/creating-smart-dirty-flag-in-knockoutjs.html。它可以在分支源代码中的 common/extensions.js 文件中看到。

在这里,我们只是显示一个标准消息框并将结果转换为布尔值用于 canDeactivate。返回这个结果的承诺,canDeactivate 将等待它解析,以确定是否可以继续激活。

实际上我们可以缩短这个,因为激活器模块将通过检查它们与它认为为真的确认和响应列表来解释字符串的响应。这是 Durandal 用来检查激活结果的代码,取自激活器模块:

affirmations: ['yes', 'ok', 'true'],
interpretResponse: function(value) {
  if(system.isObject(value)) {
    value = value.can || false;
  }

  if(system.isString(value)) {
    return ko.utils.arrayIndexOf(this.affirmations, value.toLowerCase()) !== -1;
  }

  return value;
}

这个由 truthy 字符串组成的数组可以通过访问 activator.defaults.affirmations 来更改。

带着这些知识,我们只需直接返回消息框的承诺。激活器模块将 Yes 视为真值结果,并将任何其他字符串视为 false

self.canDeactivate = function() {
  if (!self.contact().state.isDirty())
    return true;
  return app.showMessage('You have unsaved changes. Are you sure you want to leave?', 'Cancel Edit?', ['No', 'Yes']);
};

这看起来不错吗?你可以在 cp5-deactivate2 分支中看到这一点。

虽然这些例子很短,但希望它们能给你一个关于激活生命周期能够做什么的思路,尤其是在与承诺结合使用时。因为承诺可以被链式调用,当你去服务器获取一些信息时,你可以阻止去激活,然后在消息框中向用户显示它,并将结果传递给激活模块。

组合

组合生命周期还有另一组事件可以被钩入,这允许你控制 DOM 的渲染方式,或响应组合的各个阶段。同样,这些都是可选的:

  • getView(): 这是一个函数,可以返回一个视图 ID(视图文件的路径),或一个 DOM 元素。这会覆盖由组合所做的任何其他视图位置。

  • viewUrl: 这是一个视图 ID 的字符串属性,用于覆盖视图位置。只有在getView不存在时才会使用。

  • activate(): 就像激活的activate方法一样,这个函数会在组合开始时被调用。如果组合绑定指定了activationData方法,它将被作为参数传递给激活。如果返回了一个承诺,则组合将不会继续,直到它解析。

  • binding(view): 在绑定发生之前会被调用。视图作为参数传递给这个函数。如果绑定返回false{ applyBindings:false },则不会在视图中进行绑定。

  • bindingComplete(view): 当绑定完成时会被调用。视图作为参数传递。

  • attached(view, parent): 在视图被添加到 DOM 后,会调用这个方法,传递视图及其父 DOM 元素。

  • compositionComplete(view, parent): 在所有组合完成之后,包括子元素的组合,会调用这个方法,传递视图及其父 DOM 元素。

  • detached(view, parent): 在视图从 DOM 中移除后会被调用。

在组合激活和组合生命周期的案例中,例如路由的导航,激活模块的activate方法是唯一被调用的。

除了绑定可以阻止绑定发生之外,组合生命周期钩子不提供像激活钩子那样的控制或取消过程的机会。尽管在 MVVM 中通常不建议 viewmodel 直接与视图交互,但组合生命周期被设计得使得这样做变得容易。应该只遵循那些有帮助或可能的模式,如果绑定无法完成你的工作,你可能需要在 viewmodel 中与 DOM 一起工作。

小部件

Durandal 中的小部件与 Knockout 组件类似,因为它们都是从 DOM 中实例化的 viewmodel/view 对。组件使用自定义元素,而小部件使用自定义绑定。它们之间肯定有一些重叠,但 Durandal 的小部件系统是在 Knockout 的组件系统之前出现的。小部件还有一个比组件更出色的特性;它们的视图可以有可替换的部分,可以被覆盖。这个特性通常被称为转译——一个文档嵌入到另一个文档中。

不使用示例很难谈论小部件 API。当我们查看组件时,我们创建了一个联系人列表组件;那么让我们看看用小部件做同样的事情会是什么样子。它可能不是非常可重用,这使得它成为一个奇怪的小部件选择;但它将涵盖整个过程。

创建一个新的小部件

Durandal 期望小部件位于一个名为widgets的目录中,该目录位于应用的根目录下,在我们的例子中,它位于client/app/widgets下。每个小部件将存储其代码在一个文件夹中,该文件夹将用作小部件的名称。小部件的代码必须是一个名为viewmodel.js的 JavaScript 文件和一个名为view.html的 HTML 文件。因此,为了制作我们的联系人列表小部件,我们将使用以下结构:

创建一个新的小部件

对于视图,我们只是将从list.html视图中提取整个列表部分:

<ul class="list-unstyled" data-bind="foreach: contacts">
  <li>
    <h3>
    <span data-bind="text: displayName"></span> <small data-bind="text: phoneNumber"></small>
    <button class="btn btn-sm btn-default" data-bind="click: $parent.edit">Edit</button>
    <button class="btn btn-sm btn-danger" data-bind="click: $parent.delete">Delete</button>
  </h3>
  </li>
</ul>

由于我们将要绑定到一个新的 viewmodel,我已经将foreach绑定从displayContacts改为contacts。我们的 viewmodel 将非常类似于我们的正常页面 viewmodel。像由路由器实例化的页面一样,我们的小部件 viewmodel 将无法接收构造参数;通过绑定传递给小部件的数据将传递给activate函数:

define(['durandal/app', 'knockout'], function(app, ko) {
  return function ContactListWidget() {
    var self = this;

    self.activate = function(options) {
      self.contacts = options.data;
      self.edit = options.edit;
      self.delete = options.delete;
    };
  };
});

我们在这里传递了视图所需的数据,即contacts数组,以及editdelete的回调函数。

使用小部件

Durandal 提供了几种使用小部件的方法。首先,我们必须在main.js文件中激活小部件插件:

app.configurePlugins({
  //Durandal plugins
  router:true,
  dialog: true,
  widget: true
});

现在我们可以使用小部件绑定来创建小部件:

<div data-bind="widget: { kind: 'contactList', 
   data: displayContacts, 
  edit: editContact, 
  delete: deleteContact }">
</div>

我并不太喜欢这种方式;它有点冗长。有两种方式可以注册小部件,允许它像绑定一样使用:

<div data-bind="contactList: { data: displayContacts, 
  edit: editContact, 
  delete: deleteContact }">
</div>

我认为这看起来更美观。要注册小部件,你可以调用widget.registerKind('contactList'),或者修改插件配置:

app.configurePlugins({
  //Durandal plugins
  router:true,
  dialog: true,
  widget: {
    kinds: ['contactList']
  }
});

我个人更喜欢这个最后的方法;尽管如果你有很多小部件,你可能更喜欢其他方法之一。你可以在cp5-widget分支中看到这个小部件的使用。结果看起来与上一个版本相同,但现在列表是在一个单独的视图中。

使用数据-part 属性修改小部件

到目前为止,我们的这个小部件并没有什么特别之处。它没有添加 Knockout 组件无法提供的内容,而且组件有更美观的定制元素语法。

如果您的小部件视图中有一个带有 data-part 属性的元素,那么该元素可以被调用者覆盖。例如,如果我们想改变电话号码的显示方式,第一步是为小部件添加一个 data-part 属性:

<small data-bind="text: phoneNumber" data-part="phone"></small>

下一步是在调用者中使用相同的 data-part 属性:

  <div data-bind="contactList: { data: displayContacts, 
    edit: editContact, 
    delete: deleteContact }">
    <span data-part="phone" data-bind="text: phoneNumber"></span>
  </div>

结果是新的 span 元素,它取代了小部件内部的原始元素。您可以在 cp5-datapart 分支中看到这一点。

这里需要注意的一个重要事项是,新的 span 元素有一个数据绑定,它引用了联系人的 phoneNumber 属性。data-part 属性覆盖了一个绑定上下文在 widget 的 foreach 循环作用域内的元素,并且这个作用域由新元素维护。在 widget-bound 元素内部声明的 data-part 属性的绑定上下文是它所替代元素的绑定上下文。

小部件绑定上下文的特殊 $root 属性设置为声明的作用域,这对于覆盖 data-part 属性特别有用。如果我们想引用列表视图模型上的属性,我们可以这样做:

<div data-bind="contactList: { data: displayContacts, delete: deleteContact }">

  <small data-part="phone"><em data-bind="text: phoneNumber"></em></small>
  <button data-part="edit-btn" data-bind="click: $root.editContact" class="btn btn-sm btn-default">Edit</button>
</div>

这假设小部件视图中匹配的按钮添加了 data-part="edit-btn" 属性。现在,此按钮直接引用列表视图模型上的 editContact 函数,而不是小部件上的函数。您可以在 cp5-datapart2 分支中看到这一操作。

小部件可以有任意数量的 data-part 属性,并且每个 data-part 属性可以包含其他 data-part 属性。这允许在控制模板化小部件的外观和功能方面具有最大的灵活性。

摘要

这些只是使用 Durandal 的基础知识,但希望您已经能够欣赏到该框架提供的强大和简单性。在线上,Knockout 经常被与更完整的框架,如 Angular 进行比较,并且当它缺少组件,如路由器时,这些被视为对其的批评。Durandal 与这些框架的对比更加均衡,同时它仍然利用了使 Knockout 变得出色的所有事物。

在本章中,您应该已经学习了组合系统,以及路由器如何为您的应用程序带来组织和模块化。我们看到了承诺如何与模态对话框和应用程序生命周期结合,使我们能够轻松自然地响应异步事件。最后,我们看到了小部件如何将 Knockout 组件背后的概念(可重用、行为驱动的控件,由视图标记实例化)以及添加模板化的 data-part 属性来实现转义。

下一章将继续探讨 Durandal 框架如何简化 Knockout 应用程序开发。

第六章:高级 Durandal

在上一章中,我们介绍了 Durandal 框架的大部分基本用法。到现在,你应该已经能够舒适地使用它来启动应用程序。在本章中,我们将继续探讨 Durandal,通过介绍一些更高级的框架特性以及查看一些有用的模式,这些模式将帮助我们解决在 SPA 开发中遇到的常见挑战。

  • 使用事件进行发布和订阅

  • 应用程序登录场景

  • 高级组合

  • 嵌套路由

  • 自定义模态对话框

  • 绑定到纯 JavaScript 对象

发布和订阅

新开发者开始使用 Knockout 时面临的一个非常常见的问题是,如何在没有与主视图模型或视图模型对象之间的单一层次结构或任何其他形式的直接引用的情况下在视图模型之间进行通信。这类硬依赖通常被认为是不良做法,但需要在不同的视图模型之间发送消息的需求是不可避免的。

发布-订阅(pub/sub)模式是解决此问题的流行解决方案。Durandal 通过 Events 模块为你提供了一个简单的 pub/sub 实现。你可以通过两种方式使用事件系统:通过默认包含在 durandal/app 对象上的事件,或者通过将事件添加到自己的对象上。

事件模块

事件系统包括 events 模块和 subscription 类。events 模块,由 durandal/events 需要,为你提供了 includeIn 方法来向对象添加事件。当调用 Events.includeIn(obj) 时,以下函数将被添加到 obj 中:

  • on: 这用于在对象上订阅事件

  • off: 这用于取消订阅事件

  • trigger: 这用于引发事件

  • proxy: 这将返回一个可以调用来引发事件的函数

订阅事件

on 方法可以用两种不同的方式使用。要提供一个回调和一个可选的上下文(回调的 this 值),将它们作为参数传递。从 onobj 将被返回,以便可以添加链式订阅:

obj.on('contact:added', self.contacts.push, self.contacts)
.on('contact:deleted', self.contacts.remove, self.contacts);

要获取订阅对象,只需将事件名称提供给 onon 返回的内容将是一个订阅对象,它提供了一个 then 和一个 off 方法。then 方法可以用来附加一个回调:

obj.on('contact:added').then(function(newContact) {
  self.contacts.push(newContact);
});

then 方法也返回订阅,允许你存储订阅引用。

你可以使用以空格分隔的事件名称参数的名称列表同时订阅多个事件。你也可以使用 all 事件名称来订阅对象上的所有事件。

从事件中取消订阅

移除回调的方式与添加回调的方式类似,这取决于你是否使用 on 添加了回调,或者是在订阅上使用 then

如果你使用on进行了订阅,你可以通过调用带有相同事件名称和回调的off来取消订阅。要移除该事件名称(或名称)的所有回调,不要向第二个参数提供回调。要移除具有特定上下文的所有回调,请向第三个参数提供上下文:

//Remove a specific callback on an event
obj.off('contact:added', self.contacts.push);

//Remove all callbacks for a context (will remove both added and deleted from above example)
obj.off(undefined, undefined, self.contacts);

//Remove all callbacks
obj.off();

如果你使用了订阅对象,只需在订阅上调用off

var subscription = obj.on('contact:added').then(self.contacts.push);
//unsubscribe
sSubscription.off();

触发事件

在对象上触发事件类似于订阅它们。您可以使用单个事件名称、多个用空格分隔的事件名称,或者使用特殊的all事件名称来触发所有事件。

当事件被触发时,它们可以向订阅事件的回调传递参数。尽管触发的事件可以使用任何数量的参数,但当它们始终使用单个参数时,与回调一起工作会更容易:

obj.trigger('contact:added', newContact);
obj.trigger('contact:added contact:approved', newApprovedContact);
obj.trigger('all', superImportantEventData);

代理事件

事件代理是一种方法,它将引发预选事件(或事件列表),并将其参数作为事件参数传递。以下两个方法是等价的:

obj.trigger('contact:added', newContact);
//
var contactAdded = obj.proxy('contact:added');
contactAdded(newContact);

代理的优点是它们是可重用的,并且可以被存储或传递。这样做可以与其他系统共享代理,或者只是在几个地方有一个单独的事件引发函数。将一个函数表示为具有固定参数的另一个函数的这种做法被称为柯里化

代理的事件名称可以是trigger可以使用的任何字符串,包括all

应用程序事件

由于app对象是一个单例,它自带事件,因此这些事件对于应用范围内的消息传递非常有用。独立顶级组件之间的通信,例如页面视图模型,是应用范围内消息传递的良好候选者。

假设我们希望在添加新联系人时引发一个事件,以减少服务器的负载,这样列表页就可以获取新联系人而无需去服务器刷新整个列表。为了在activate方法中停止加载列表,它将被转换为一个单例,该单例重用相同的加载承诺:

function ContactListVM() {
  // ...
  var singleActivate = dataService.getContacts()
  .then(function(contacts) {
    self.contacts(contacts);
  });

  self.activate = function() {
    return singleActivate;
  };
  //...
};

return new ContactListVM();

由于返回给activate的承诺只运行一次,因此当页面多次导航时,列表不会重新加载。

列表页的视图模型现在需要创建一个事件订阅来接收新的联系人。Durandal 为事件名称的约定是指定源和事件类型,由冒号分隔。此约定是推荐的,但不是必需的;Durandal 不会将冒号视为事件名称的分隔符。例如,以下是在导航期间路由器引发的两个事件:

router:navigation:complete
router:navigation:cancelled

要订阅新联系人的事件,列表页可以使用以下订阅:

app.on('contact:added').then(function(newContact) {
  self.contacts.push(newContact);
});

然而,由于唯一采取的操作是将newContact参数发送到contacts.push,因此将其作为带有上下文的回调来写会更简洁:

app.on('contact:added', self.contacts.push, self.contacts);

这两种方法等价。需要注意的是,定义上下文的第三个参数是必要的;否则,push 函数将被调用,并且在没有处于联系人数组上下文的情况下失败。

新/编辑页面现在可以在创建联系人后使用 contact:added 事件发布此事件:

self.saveEntry = function() {
  if (self.contact().id() === 0) {
    dataService.createContact(self.contact())
    .then(function(contact) {
      app.trigger('contact:added', contact);
    });
  } else {
    //Edit
  }
};

这将发送 createContact 承诺返回的联系人作为触发事件的参数。然而,由于这是将参数发送到另一个单函数的另一个案例,它可以使用代理来编写:

var contactAdded = app.proxy('contact:added');
self.saveEntry = function() {
  if (self.contact().id() === 0) {
  dataService.createContact(self.contact())
    .then(contactAdded)
  } else {
    //edit
  }
};

你可以在 cp6-pubsub 分支中看到一个示例。

模块作用域的事件

除了应用范围内的 pub/sub,Durandal 还提供了一种简单的方法将事件方法添加到任何对象中,允许事件具有作用域。调用 Events.includeIn(obj) 将创建与 app 对象默认具有相同的事件处理方法:onofftriggerproxy

数据服务是处理与联系人添加(或修改)相关事件的理想选择,因为只有已经具有对其引用的模块才会对这些事件感兴趣。将 contact:added 事件从新/编辑页面移至数据服务也确保了如果另一个模块尝试添加联系人,事件仍然会触发:

var dataService = {};
Events.includeIn(dataService);
//other methods omitted
dataService.createContact = function(contact) {
  contact.id(UUID.generate());
  contacts[contact.id()] = ko.toJS(contact);
  saveAllContacts();
  return getTimeoutPromise(contact).then(function() {
    dataService.trigger('contact:added', contact);
    return contact;
  });
};

这将为 dataService 对象添加事件方法,并在 createContact 方法的返回承诺中引发 contact:added 事件。

列表页面视图模型的变化只是引用 dataService 对象而不是 app 进行事件订阅:

dataService.on('contact:added', self.contacts.push, self.contacts);

需要做的就这些。现在 dataService 对象正在充当联系人事件的作用域。你可以在 cp6-event 分支中看到这个示例。

处理登录

由于各种原因,处理登录可能很棘手,而且有数百种不同的技术。Web 应用程序登录通常分为两类:要么你的网站可以自由浏览而不需要登录(随时登录),要么它使用并要求用户首先登录(需要登录)。每个类别所面临的挑战不同,因此最佳解决方案也不同。

需要登录

直到最近,几乎所有需要登录的网站都使用某种重定向模式向用户展示登录页面,这通常是一种不愉快的体验。除了页面加载时间的问题之外,返回原始请求的 URL 通常意味着包含原始 URL 的查询字符串参数。如果原始 URL 本身就有查询字符串,它们要么丢失,要么附加到 URL 查询值上。

单页应用(SPAs)可以通过在当前 URL 上显示登录页面来绕过重定向问题;没有重定向意味着整个过程更快,没有查询字符串的麻烦,用户也不会因为 URL 变化而感到不适。然而,他们面临一个不同的挑战:如何处理外壳?你可以将登录表单放在外壳旁边,并通过绑定在它们之间切换,但这会使外壳充满登录标记。你可以使用模态对话框来显示登录表单,这样外壳就不会受到影响,但外壳要么是空的,要么显示应该通过登录才能访问的信息。

Durandal 的setRoot方法真正简化了这个问题。如果用户需要登录,将登录表单设置为根意味着外壳甚至不会被加载。登录完成后,外壳可以设置为根;外壳的标记保持不变,用户永远不会看到他们不应该看到的内容:

  1. 首先,我们的应用程序在main.js中的启动将使用setRoot跳转到登录或外壳,这取决于用户是否已经登录(比如,来自 cookie):

    define(['durandal/system', 'durandal/app', 'common/extensions', 'services/mock'],
    function(system, app, extensions, dataService) {
    
      ///Same as before
    
      app.title = 'Mastering Knockout';
      app.start().then(function () {
        app.setRoot(dataService.isLoggedIn() ? 'shell/shell' : 'login/page');
      });
    });
    
  2. 这依赖于dataService对象,执行同步检查以查看isLoggedIn是否为true,但它可以轻松支持一个异步的,只需将其钩入app.start承诺:

    app.start()
    .then(dataService.isLoggedIn)
    .then(function (isLoggedIn) {
      app.setRoot(isLoggedIn ? 'shell/shell' : 'login/page');
    });
    

一旦登录过程完成,登录视图模型只需调用setRoot来设置外壳。就是这样!实际上,登录视图模型中唯一的其他属性是usernamepassword和失败的登录标志。登录完成后,外壳将像之前一样启动,激活路由器,并组合正确的页面。在登录发生时,无需担心管理空状态,因为外壳直到setRoot被调用之前都不会被加载。

你可以在cp6-login分支中看到这个示例。登录模块包含一个标准的视图模型和视图。要登录,可以使用任何登录详情,其中用户名和密码相同。显然,在实际应用中,你希望创建一个服务器请求。

需要注意的一个重要问题是注销功能。在示例中,它位于外壳中,但在实际应用中,它应该重构为外部服务——可能是同一个服务,该服务包含用于获取和设置登录 cookie 的方法,以便集中管理登录行为。在单页应用(SPAs)中,由于没有发生导航,清理登录用户在应用状态中的所有数据可能是一个挑战,尤其是当你有单例时。尝试创建一个删除所有这些数据的方法容易出错;很容易遗漏重要数据,并且随着应用的扩展,需要不断维护。相反,简单地重新加载浏览器会更安全。导航,即使是刷新,也会完全重置 JavaScript 状态,确保之前登录用户的所有内容都不会留在内存中。location.reload方法是一个简单的方法来实现这一点,但如果用户在包含敏感 URL 的页面上,这可能不是最佳方法。一个更安全的方法是将位置设置为域名根:

location.href = '/';

任何时间登录

允许用户浏览和可选登录的网站与门控登录网站相比面临不同的挑战。一些允许可选登录的网站仍然有单独的登录页面,并仍然使用重定向参数将用户送回原始位置,但这对用户来说体验更加不愉快,因为它似乎是不必要的。当然,如果你允许通过 HTTP 浏览并需要重定向到 HTTPS 以执行登录,这可能就是必要的,但这更是始终要求使用 HTTPS 的理由!如果你选择重定向路径到达 HTTPS 页面,那么前面的方法对你来说也将不起作用,因为前面的方法没有使用浏览器导航来更改页面。

如果你总是需要在正常浏览时使用 HTTPS,那么你可以允许用户登录而不干扰当前页面。你可以使用与门控登录相同的技巧,但不需要隐藏登录后的信息,有更不侵入性的方法。

一种常见的方法,也是最不侵入性的方法之一,是在导航栏中包含一个内联登录表单。

任何时间登录

登录后,导航栏将与之前的导航栏相同,显示登录名和注销按钮。这个导航栏的小部分可以由一个登录视图模型支持,该模型被组合到外壳中,从而将登录实现细节分离:

<nav role="navigation" class="collapse navbar-collapse" id="navbar-collapse-group">
  <ul class="nav navbar-nav" data-bind="foreach: router.navigationModel">
    <li data-bind="css: { active: isActive }">
      <a class="" data-bind="attr: { href: hash }, text: title"></a>
    </li>
  </ul>
  <div class="nav navbar-nav navbar-right">
    <!-- ko compose: login --><!-- /ko -->
  </div>
</nav>

对于此功能,登录视图模型不需要做太多改变,但注销功能可以移动到其中,因为它不再由外壳控制。你可以在cp6-login-nav分支中看到一个例子。尝试登录并注意导航栏如何变化。

响应用户的登录变化

内联登录表单是有效的,但你的应用程序可能需要以某种方式响应当前登录的用户,例如,只允许登录用户创建、编辑或删除联系人。有两种处理方式:要么使用一些事件和 Knockout 可观察对象的组合来更新页面,要么在用户登录时重新加载页面。

可能采取页面重新加载的路线会更简单,但这实际上取决于你的应用程序。如果你使用任意登录并允许用户在不登录的情况下查看大多数页面,你可能不需要维护每个页面的两个独立版本。相反,你可能使用if/visible绑定来隐藏仅限登录用户的内容。如果是这种情况,那么更新这些可观察对象不会太费力。

然而,如果你因为登录用户和未登录用户之间的差异足够大,而维护每个页面的两个独立版本,那么页面重新加载的方法可能更好。由于重新加载路由不需要太多解释,让我们看看第一个案例。

对于隐藏编辑控件这种简单情况,Knockout 可观察对象就足够了。数据服务中的登录检查函数是一个很好的地方来放置多个视图模型将依赖的可观察对象,因为它已经是一个共享组件。在更大的应用程序中,你可能想要将数据服务分成多个服务,以服务于特定的角色,如登录和联系人 CRUD:

dataService.loginName = ko.observable(storage.get('loginToken'));
dataService.isLoggedIn = ko.computed(function() {
  return dataService.loginName() != null;
});
dataService.tryLogin = function(username, password) {
  var success = username === password;
  if (success) {
    storage.set('loginToken', username);
    dataService.loginName(username);
  }

  return getTimeoutPromise(success);
};
dataService.logout = function() {
  dataService.loginName(null);
  storage.remove('loginToken');
};

在这里,loginName决定isLoggedIn是否为true。如果存储中有保存的令牌,则初始设置loginName参数,并在用户登录或注销时更新。需要使用这些字段中的任何一个的地方有三个:列表页面、列表项和外壳。列表页面将使用它来显示用户是否可以编辑联系人:

self.canEdit = ko.computed(function() {
  return dataService.isLoggedIn();
});

这个属性被项目列表用来隐藏或显示按钮:

<!-- ko if: $parent.canEdit -->
  <button class="btn btn-sm btn-default" data-part="edit-btn" data-bind="click: $parent.editContact">Edit</button>
  <button class="btn btn-sm btn-danger" data-bind="click: $parent.deleteContact">Delete</button>
<!-- /ko -->

为了额外的安全性,这些按钮背后的方法也应该检查canEdit属性。delete按钮没有显示,但它使用与以下代码中显示的相同的检查:

self.editContact = function(contact) {
  if (!self.canEdit()) {
    return;
  }
  router.navigate('contacts/' + contact.id());
};

同样,为了确保用户不能通过手动输入 URL 来访问编辑页面,它应该使用canActivate检查来阻止匿名用户的导航:

self.canActivate = function() {
  return dataService.isLoggedIn();
};

最后,外壳希望在用户未登录时从导航栏中删除路由。一种方法是在外壳上创建一个计算的可观察数组,在用户未登录时过滤掉路由:

router.map([
  { route: '', moduleId: 'contacts/list', title: 'Contacts', nav: true },
  { route: 'contacts/new', moduleId: 'contacts/edit', title: 'New Contact', nav: true, auth: true },
  { route: 'contacts/:id', moduleId: 'contacts/edit', title: 'Contact Details', nav: false }
])
.buildNavigationModel()
.mapUnknownRoutes('shell/error', 'not-found');

this.navigationModel = ko.computed(function() {
  var navigationModel = router.navigationModel();
  if (dataService.isLoggedIn()) {
    return navigationModel;
  } else
  return navigationModel.filter(function(route) {
    return !route.auth;
  });
});

这个模型将在用户未登录时删除任何具有auth: true属性的路线,这使得将来添加需要登录的页面变得容易。

这个例子可以在cp6-login-event分支中看到。为了便于看到注销过渡,这个分支在用户注销时不会重新加载页面;相反,它只是清除存储并更新数据服务上的可观察对象。

保护路由

在上一节中,我们使用页面视图模型上的canActivate检查来确保用户只有在登录时才能访问页面。这很有效,但如果需要为多个页面设置门控,或者需要使用页面可能没有的逻辑,则可以将此逻辑添加到路由器中。

guardRoute方法是一个可选方法,路由器将使用它来筛选每个尝试的导航。它接收正在激活的模块和路由指令作为参数。如果从guardRoute返回true或对true的承诺,则导航将正常继续。如果返回一个字符串或对字符串的承诺,它将被用作重定向路由。如果返回false或对false的承诺,则导航将被取消:

router.guardRoute = function(model, instruction) {
  return !(instruction.config.auth && !dataService.isLoggedIn());
};

此路由守卫可以替换编辑页面的canActivate方法,因为它将在路由有auth:true且用户未登录时取消导航。然而,取消导航有时可能看起来像是应用程序没有响应,例如当按下后退按钮时。可以通过将当前页面重定向到错误页面来改进:

router.guardRoute = function(model, instruction) {
  return !(instruction.config.auth && !dataService.isLoggedIn()) || 'shell/error';
};

此示例可以在cp6-guard-route分支中看到。

高级组成

在第五章中,我们介绍了 Durandal 的组成系统的基本和常用用法。本节将介绍进一步的组成技术,如缓存、转换和组成模式。

视图缓存

默认情况下,当组成模块更改时,由组成绑定渲染的视图将被丢弃。这导致组成绑定的 DOM 内容始终只是当前模块的视图。组成绑定上的cacheView选项将改变此行为,使 Durandal 可以保留任何组成的视图。如果使用已绑定到视图的相同对象重新激活模块,则不会重新创建。composerouter绑定都具有此选项:

<div class="page-host">
  <!-- ko router: { cacheViews: false }--> <!-- /ko -->
</div>

你可以在cp6-cache分支中看到这个示例。如果你打开控制台,你可以看到在重新访问列表或编辑页面时,不再触发附加和绑定事件。你还可以通过调试器断点看到,视图模型只会在第一次构建。

当与缓存视图一起工作时,需要格外小心。由于模块是单例且仅构建一次,因此activate方法负责设置数据或清除旧数据。例如,以前,编辑页面仅在构建期间将其contact属性设置为新的实例。如果页面是在新条目模式下(没有 ID)加载的,则需要重置联系人:

self.activate = function(id) {
  //Id is only present when editing
  if (id) {
    return dataService.getContact(id).then(self.contact);
  }
  else
  self.contact(new Contact());
};

如果不这样做,用户在创建或编辑了之前的联系人后尝试创建新的联系人时,将不会看到空表单。

即使将 cacheViews 属性设置为 true,如果模型实例已更改,Durandal 也不会缓存 DOM 视图。在 cp6-cache2 分支中,构造函数从列表页面返回,你可以看到即使 cacheViews 已设置,仍然会构建一个新的实例并将其附加到 DOM 上。

过渡

Durandal 的 routercompose 绑定有一个钩子,允许组合视图使用动画进行过渡。要使用它,请在绑定上的 transition 属性提供值:

<!-- ko router: { 
  cacheViews: false, 
  transition: 'entrance' 
}--> <!-- /ko -->

默认提供 entrance 过渡;它通过轻微的滑动效果淡出当前视图并淡入下一个视图。你可以在 cp6-entrance 分支中看到它。请注意,为了使此动画正常工作,组合需要在具有 CSS position: relative 属性的元素中发生,因为动画使用绝对定位。

durandal/composition 模块还有一个 defaultTransitionName 属性,它将为所有未指定自己过渡的所有组合使用提供的过渡。

要创建自己的过渡,你需要一个模块,该模块返回一个 Durandal 可以调用来运行过渡的函数。过渡函数将接收组合设置并需要返回一个表示其完成的承诺。设置对象中有许多值,但最有用的两个是 activeView,它是正在过渡出的视图,以及 child,它是正在过渡到的视图。

这里是一个使用 jQuery UI 的滑动效果的定制过渡示例。它假设 jQueryUI 已经在 RequireJS 中设置好:

define(['durandal/system', 'jquery', 'jquery-ui'], function(system, $) {

  var outDuration = 400,
  outDirection = 'down'
  inDuration = 400,
  inDirection = 'up',
  easing = 'swing';

  return function slideAnimation(settings) {

    var currentView = settings.activeView,
    newView = settings.child;

    return system.defer(function(defer) {
      function endTransition() {
        defer.resolve();
      }

      function slideIn() {
        $(newView).show('slide', { direction: inDirection, easing: easing }, inDuration, endTransition);
      }

      if (currentView) {
        $(currentView).hide('slide', { direction: outDirection, easing: easing }, outDuration, newView ? slideIn : endTransition);
      } else {
        $(newView).show();
        endTransition();
      }

    }).promise();
  };
});

该模块返回动画函数,该函数本身返回一个表示动画的承诺。动画函数提取当前视图和下一个视图,然后设置回调以使用 jQuery 结束视图并滑动新视图。最后的 if 块确保只有当当前视图存在时才对其执行操作。如果不存在,则不会创建动画(因为没有东西可以滑动出去),视图将直接显示。

默认情况下,Durandal 通过在名称后附加 'transitions/' 来查找过渡,以获取 RequireJS 路径。这就是为什么标准的 Durandal RequireJS 配置中定义了过渡路径。如果你想将过渡保存在其他位置(如 app 文件夹),则可以在 RequireJS 中将路径映射到另一个文件夹,或者你可以覆盖组合模块的 convertTransitionToModuleId 函数以提供自己的查找逻辑。

此示例可以在 cp6-transition 分支中看到。此分支使用 app 目录中 transitions 文件夹的 RequireJS 路径,该文件夹包含前面的滑动动画。

模板模式

在上一章中,我们介绍了小部件,它通过使用 data-part 属性覆盖组合元素的部分来提供功能。此功能也适用于使用 mode: 'templated' 选项的 viewmodel 组合。

使用的示例小部件有些牵强,因为联系人列表并不是一个可重用的部件。对于列表,尤其是复杂项,更常见的技巧是为列表项创建一个模块,并通过foreach绑定来组合它。

将复杂列表项与其显示的页面分开,可以保持列表项特有的属性和方法。这是驱动视图模型和模块分离的相同模块化逻辑。它让页面视图模型更多地关注页面整体采取的动作,并让项目专注于自身。联系人列表项并不复杂到需要这样做,但你可以想象出这样的案例。

将联系人列表小部件替换为compose/foreach绑定很简单:

<ul class="list-unstyled" data-bind="foreach: displayContacts">
  <li data-bind="compose: $data"></li>
</ul>

这允许将项目本身移动到自己的文件中,即listItem.html

<h3 data-bind="with: contact">
  <span data-bind="text: displayName"></span>
  <small data-bind="text: phoneNumber" data-part="phone"></small>
  <div class="inline" data-part="btn-container">"
    <button class="btn btn-sm btn-default" data-part="edit-btn" data-bind="click: edit">Edit</button>
  </div>
</h3>

这是之前使用的相同模板,只是没有删除按钮。列表项的视图模型很简单,只包含一个contact对象和一个edit函数:

define(['knockout', 'plugins/router'], function(ko, router) {
  return function ListItem(contact) {
    var self = this;

    self.contact = contact;

    self.edit = function() {
      router.navigate('contacts/' + self.contact.id());
   };
  };
});

最后要做的事情是在列表页面上构建列表项,而不仅仅是使用裸露的联系人模型:

self.activate = function() {
  return dataService.getContacts()
  .then(function(contacts) {
    var listItems = contacts.map(function(contact) {
      return new ListItem(contact);
    })
    self.contacts(listItems);
  });;
};

你可以在cp6-list-item分支中看到这个示例。这只是一个设置,但真正追求的是通过data-part属性覆盖列表项视图。数据部分覆盖在组合中与在部件中工作方式相同:

<ul class="list-unstyled" data-bind="foreach: displayContacts">
  <li data-bind="compose: { model: $data, mode: 'templated' }">
    <div data-part="btn-container" class="inline">
      <button class="btn btn-sm btn-default" data-bind="click: edit">Edit</button>
      <button data-bind="click: $root.deleteContact" class="btn btn-sm btn-danger">Delete</button>
    </div>
  </li>
</ul>

在这里,整个btn-container元素被覆盖,以便添加删除按钮。记住,data-part属性的作用域是它们将被放置的视图,在这种情况下是listItemedit函数已经在这个作用域中,但deleteContact函数在listItem的父级中,可以使用模板元素的$root属性来访问。

这个示例可以在cp6-template-compose分支中看到。

子路由

另一个常见场景是需要支持路由内的路由;这有时被称为嵌套路由。例如,你可能有多个页面位于父/about路由下,这些页面由/about/author/about/publisher URL 表示,它们被显示为主/about页面的不同子部分。

要做到这一点,父路由必须捕获子路由。它可以使用通配符路由或hasChildRoutes属性来完成:

router.map([
  { route: 'about', moduleId: 'about/index', title: 'About', nav: true, hasChildRoutes: true }
  //OR
  { route: 'about*children', moduleId: 'about/index', title: 'About', nav: true }
]);

两种方式都行,但请注意,about*children通配符路由需要在星号(*)之后至少有一个字符;about*路由无法正确捕获子路由。我个人认为hasChildRoutes属性有更明确的目的。

接下来,创建子路由的视图模型创建一个子路由:

define(['plugins/router'], function(router) {
  var childRouter = router.createChildRouter()
  .makeRelative({
    moduleId: 'about',
    fromParent: true
  }).map([
  { route: ['author', ''], moduleId: 'author', title: 'Author', nav: true },
  { route: 'publisher', moduleId: 'publisher', title: 'Publisher', nav: true }
  ]).buildNavigationModel();

  return {
    router:childRouter
  };
});

createChildRouter函数返回根路由的子路由。你只能有一个根路由,但它可以有任意数量的子路由,子路由也可以有子路由。

makeRelative 函数接受一个可选对象。moduleId 选项指示所有子路由的模块都以前缀提供的模块开头,本质上使路由相对于一个文件夹。这不是必需的,但它可以使路由更短。fromParent 选项使子路由从 route 属性继承其父级的 URL。

最后,该模块将 childRouter 作为路由暴露,以便其视图可以使用与 shell 相同的语法绑定到它。这是 about 父页面的视图:

<h1>About</h1>
//Text removed for clarity
<ul class="nav nav-tabs" role="tablist" data-bind="foreach: router.navigationModel">
  <li data-bind="css: { active: isActive }">
    <a class="" data-bind="attr: { href: hash }, text: title"></a>
  </li>
</ul>
<div class="page-sub-host">
  <!-- ko router: { cacheViews: false }--> <!-- /ko -->
</div>

这个例子可以在 cp6-child-router 分支中看到。关于页面及其子路由位于 app/about 文件夹中,并且路由已经被添加到导航栏中。

动态子路由

当为具有参数的父路由创建子路由,例如 /contacts/23/bio,需要额外的配置才能允许子路由相对于 /contacts/:id 动态父路由。为了看到这个例子,我们将向我们的联系人页面添加一个传记和位置部分。

联系人编辑路由需要表明它有子路由。相同的选项可用,但对于 splat 路由有一个注意事项——你必须手动指定 hash

{ route: 'contacts/:id', moduleId: 'contacts/edit', title: 'Contact Details', nav: false, hasChildRoutes: true },
//OR
{ route: 'contacts/:id*children', moduleId: 'contacts/edit', title: 'Contact Details', nav: false, hash: 'contacts/:id' },

如果没有手动指定 hash,子路由将无法从 splat 路由创建正确的 URL。如果你使用 hasChildRoutes 标志,则不需要这样做。

子路由定义几乎相同,除了 dynamicHash 属性:

var childRouter = router.createChildRouter()
.makeRelative({
  moduleId: 'contacts/edit',
  fromParent: true,
  dynamicHash: ':id'
}).map([
  { route: ['details', ''], moduleId: 'details', title: 'Details', nav: true },
  { route: 'bio', moduleId: 'bio', title: 'Biography', nav: true },
  { route: 'location', moduleId: 'location', title: 'Location', nav: true }
]).buildNavigationModel();

dynamicHash 属性控制子路由 URL 的创建方式,因为它们需要包含 route 参数。但这就足够了!之后,这些路由就可以在参数化 URL 上使用了。

你可以在 cp6-dynamic-child-routes 分支中看到这个例子。编辑页面的子路由已经被放置在 contacts/edit 文件夹中以进行组织。此外,传记和位置页面仅包含占位文本。

自定义模态对话框

在 Durandal 中,对话上下文是控制模态对话框的 viewmodel。它有一个用于添加模态对话框宿主的方法,即模态内容将被放置在内的 DOM 节点。

Durandal 提供了两个模态对话框:消息框和默认上下文。Durandal 提供的消息框向默认上下文添加了一些简单的 DOM 元素,这对于向用户显示简短消息非常有用。默认对话框上下文可以托管任何可组合模块,包括消息框。如果你想使用自己的对话框,例如 Twitter Bootstrap 中包含的对话框,可以将其添加为对话框上下文。

对话上下文是一个对象,可以在 DOM 中创建一个对话,其中内容可以添加。自定义上下文使用以下 API:

  • addHost(dialog): 此函数负责创建对话框本身,通过将其添加到 DOM 中。它必须将参数中的 dialog.host 属性分配给此 DOM 节点,该节点将被用作组合的父节点,用于组成模块。

  • removeHost(dialog): 此函数移除对话框的 DOM 并执行任何清理操作。

  • compositionComplete(child, parent, context): 这是一个组合钩子,上下文可以使用它来执行任何设置。要获取 dialog 对象(来自其他两个函数的参数),请调用 dialog.getDialog(context.model)

当您对模态窗口有不同的需求时,自定义对话框上下文非常有用。例如,Twitter Bootstrap 对话框使用与 Bootstrap 框架其余部分相同的响应式 CSS 系统,使其非常适合需要在桌面和手机上使用的对话框。您可能还希望将一些对话框显示为圆形弹出窗口,以便与其他应用程序使用的模态框区分开来。

使用自定义对话框是通过 dialog 模块完成的,该模块使用 'plugins/dialog' 注入。您可以使用 dialog.addContext 函数添加自定义对话框上下文,该函数接受与先前 API 匹配的上下文。第一个参数是新上下文名称,第二个是上下文对象:

dialog.addContext('bootstrap', {
  addHost: function (dialogInstance) {
    //Create dialog, add to DOM
  },
  removeHost: function (dialogInstance) {
    //Remove dialog from DOM
  },
  compositionComplete: function (child, parent, context) {
    //Perform setup
  }
});

在可以使用对话框之前,需要完成此设置,因此在任何应用程序设置中都进行此操作是很好的。在即将到来的示例中,这将在 common/extensions 模块中完成。

每个上下文方法的实际设置逻辑取决于您添加的对话框。这就是 Bootstrap 模态框设置的样貌:

addHost: function (dialogInstance) {
  var body = $('body'),
  host = $('<div class="modal fade"><div class="modal-dialog"><div class="modal-content"></div></div></div>');
  host.appendTo(body);
  dialogInstance.host = host.find('.modal-content').get(0);
  dialogInstance.modalHost = host;
}

与 Durandal 模态框不同,其中内容容器和对话框元素相同,Bootstrap 模态框期望内容容器位于对话框元素内部。内容的 DOM 元素放置在 dialogInstance.host 属性中,Durandal 将使用它来组成模块。外部的模态框元素存储在 modalHost 属性中,它将仅由我们自定义的 Bootstrap 上下文中的函数使用:

compositionComplete: function (child, parent, context) {
  var dialogInstance = dialog.getDialog(context.model),
  $child = $(child);
  $(dialogInstance.modalHost).modal({ backdrop: 'static', keyboard: false, show: true });

  //Setting a short timeout is need in IE8, otherwise we could do this straight away
  setTimeout(function () {
    $child.find('.autofocus').first().focus();
  }, 1);

  if ($child.hasClass('autoclose') || context.model.autoclose) {
    $(dialogInstance.blockout).click(function () {
      dialogInstance.close();
    });
  }
}

这是从实际运行 Bootstrap $.modal() 代码的地方,因为模态框的大小和位置需要已经存在的一个完全组成的模块。它使用 modalHost 属性而不是 host 属性,因为 Bootstrap 期望模态容器。此外,处理程序被设置为支持标准的 Durandal 自动聚焦和自动关闭类:

removeHost: function (dialogInstance) {
  $(dialogInstance.modalHost).modal('hide');''''''
}

removeHost 函数负责执行隐藏模态框和背景所需的步骤。

最后,我们在编辑联系人视图模型中通过指定 dialog.show 的上下文参数来使用这个新的模态框:

self.show = function() {
  return dialog.show(self, null, ''bootstrap'');
};

如果你查看cp6-bootstrap-dialog分支,这个上下文将被添加。当按下列表页上的添加联系人按钮时,会打开的来自第五章,Durandal – Knockout 框架的模态对话框已被恢复。你可以看到这个新的对话框具有 Bootstrap 滑动进入动画,内容是响应式的。

还有一个可以用来显示自定义对话框的方法。addContext方法会自动使用上下文名称创建一个辅助方法。对于 Bootstrap 上下文,该方法为dialog.showBootrap

self.show = function() {
  return dialog.showBootstrap(self);
};

你可以在cp6-bootstrap-dialog2分支中看到这个示例。

替换默认上下文

有多个对话框上下文当然很有用,但如果你正在添加自定义对话框上下文,那么很可能你希望它成为默认的对话框上下文。拥有一个 Bootstrap 模态对话框很棒,但标准消息框仍然使用非响应的 Durandal 上下文。要更改这一点,只需将dialog.show方法替换为在未明确提供上下文时指定你的上下文的方法:

var oldShow = dialog.show;
dialog.show = function(obj, data, context) {
  return oldShow.call(dialog, obj, data, context || 'bootstrap');
};

这将导致所有常规调用对话框模块都使用此上下文,而不会影响代码手动控制用于特殊场景的对话框上下文的能力:

//Shows using the Bootstrap dialog
app.showMessage('Are you sure you want to delete ' + contact.displayName() + '?', 'Delete Contact?', ['No', 'Yes']);
//Shows using the Bootstrap dialog
self.show = function() {
  return dialog.show(self);
};
//Uses the bubble context, equivalent to calling dialog.showBubble();
self.show = function() {
  return dialog.show(self, null, 'bubble');
};

cp6-bootstrap-dailog3分支中,你可以看到删除确认消息框以及添加联系人的模态对话框都使用了 Bootstrap 对话框上下文。

如果你仍然需要访问默认上下文,考虑向对话框对象添加一个常规辅助函数,例如dialog.showDefaultdialog.showOld

var oldShow = dialog.show;
dialog.show = function(obj, data, context) {
  return oldShow.call(dialog, obj, data, context || 'bootstrap');
};
dialog.showDefault = oldShow;

使用激活器

路由器会自动使用激活生命周期,但有时你希望在不需要将工作绑定到 URL 的情况下使用它,这实际上相当简单。激活器只是一个计算可观察的,其写入函数强制执行生命周期。可以通过调用durandal/activator模块中的activator.create()来创建激活器。

在这个示例中,我们将在列表页添加一个内联快速编辑,允许在不导航到另一个页面的情况下编辑联系人。它将利用现有的编辑页面视图模型进行一些小的修改,因为它已经有一个canDeactivate方法,当存在未保存的更改时,会通过确认模态对话框提示用户。列表页的激活器将自动挂钩到相同的逻辑。

这个示例在cp6-activator分支中。在我们深入探讨其工作原理之前,你可能想先玩一玩。只需使用列表页上的快速编辑按钮,联系人就会被加载到搜索框下方的编辑表单中。

列表页需要一个激活器和设置激活器的函数:

self.editContact = activator.create();
self.quickEdit = function(listItem) {
  self.editContact(new ContactVM(listItem.contact, function() {
    self.editContact(null);
  }));
};

quickEdit 函数,它将被绑定到列表项上的按钮,将 editContact 激活器设置为编辑页面视图模型的新实例。它为新的视图模型提供要编辑的联系人,并提供一个回调来清除 editContact 对象。HTML 只需要一个按钮来调用它:

<ul class="list-unstyled" data-bind="foreach: displayContacts">
  <li data-bind="compose: { model: $data, mode: 'templated' }">
    <div data-part="btn-container" class="inline">
      <button class="btn btn-sm btn-default" data-bind="click: edit">Edit</button>
      <button class="btn btn-sm btn-default" data-bind="click: $root.quickEdit">Quick Edit</button>
      <button data-bind="click: $root.deleteContact" class="btn btn-sm btn-danger">Delete</button>
    </div>
  </li>
  </ul>

要使用此功能,编辑页面视图模型在保存或取消时需要调用关闭回调——即第二个构造函数参数——其方式与处理关闭对话框的方式相似:

function EditContactVm(initContact, closeCallback) {

  ///...

  self.close = function(result) {
    if (closeCallback) {
      closeCallback();
    } else if (dialog.getDialog(self)) {
      dialog.close(self, result);
    } else {
      router.navigate('''');
    }
  };

实际上,这就是我们利用取消激活保护器所需的一切,该保护器已经在编辑视图模型上。还有一些额外的逻辑来处理保存更改,但这与激活器的使用没有直接关系。如果你尝试使用快速编辑,进行一些更改,点击 取消,将会提示你。如果你点击 ,项目将不会被取消激活。如果你在存在未保存更改的情况下尝试使用不同的快速编辑,也会被提示。所有这些保护逻辑都由 editContact 作为激活器可观察对象为你处理。

除了可以通过调用 editContact(newValue) 使用正常可观察模式进行写入外,激活器还有一个 activateItem 方法。activateItem 的第一个参数是 newValue,第二个参数是 activationData,它允许你向新设置的 activate 方法发送属性包。这将被用作 editContact.activateItem(newValue, data)

与本书中的大多数示例相比,这个示例为了简洁而非常牵强。过度加载编辑页面视图模型,使其内部意识到它被用于三个不同的上下文,这不是一个好的设计,也不建议在实际应用中使用。

将绑定到普通 JavaScript 对象

我们将要介绍的 Durandal 的最后一部分是可观察插件,它允许数据绑定通过在底层转换它们来使用正常的视图模型属性作为可观察对象。

可观察插件使用 defineProperty 创建的 JavaScript 获取器和设置器,这是 ECMAScript 5 规范的一部分。只有现代浏览器支持这个功能,所以如果你的应用程序需要在 Internet Explorer 8 中工作,可观察插件将无法使用。

使用可观察插件消除了 Knockout 语法中最常见的抱怨之一:括号。所有属性访问都使用纯语法执行,无论是读取还是赋值:

function Contact() {
  var self = this;
  self.firstName = '';
  self.lastName = '';
  self.reset = function() {
    self.firstName = '';
    self.lastName = ''
  };
};

var viewmodel = new Contact();

//HTML
<input data-bind="value: firstName" />
<input data-bind="value: lastName" />
<button data-bind="click: reset">Reset</button>

在数据绑定过程中,一切都被可观察插件转换成了可观察对象。尽管可以使用 ko.observable 创建 Knockout 可观察对象,但通常是不必要的。

然而,这确实会影响你所有的代码,因为使用括号来访问属性将不再有效;它们不再是函数了!使用可观察插件意味着你的应用程序代码将进行彻底的转换。

可观察插件设置

使用可观察插件,就像任何插件一样,需要在调用app.start之前安装它:

app.configurePlugins({
  router:true,
  dialog: true,
  observable: true
});
app.start().then(function () {
  app.setRoot('shell/shell');
});

如果您需要手动使用插件,它需要通过plugins/observable模块导入。

订阅和扩展

当您不再手动创建可观察对象时,您将必须使用可观察插件来访问底层可观察对象以设置订阅或添加扩展器。这可以通过将可观察模块作为具有observable(object, 'property')的函数调用来完成。可观察模块通过'plugins/observable'注入:

function Contact() {
  //Same as before

  observable(self, 'firstName').subscribe(function(value){
    console.log('First name changed.');
  });

  observable(self, 'firstName').extend({
    rateLimit: {
      timeout: 100,
      method: 'notifyWhenChangesStop'
    }
  });
};

这可以在任何时候进行,即使属性尚未转换为可观察对象,因为调用可观察模块会立即将属性转换为可观察对象。

计算可观察对象

计算可观察对象使用observable.defineProperty创建:

observable.defineProperty(self, 'displayName', function() {
  var nickname = self.nickname || '';
  if (nickname.length > 0) {
    return nickname;
  } else if ((self.firstName || '').length > 0) {
    return self.firstName + ' ' + self.lastName;
  } else {
    return 'New Contact';
  }
});

defineProperty方法还返回底层的计算可观察对象,以便可以扩展或订阅它。

尽管如此,计算可观察对象有一个注意事项。如果任何东西在依赖项转换为可观察对象之前尝试访问计算值,那么计算值将无法注册这些依赖项;其值将永远不会更新:

return function Contact(init) {
  var self = this;

  self.id = 0;
  self.firstName = '';
  self.lastName = '';
  self.nickname = '';
  self.phoneNumber = '';

  observable.defineProperty(self, 'displayName', function() {
    var nickname = self.nickname || '';
    if (nickname.length > 0)
    return nickname;
    else if ((self.firstName || '').length > 0)
    return self.firstName + ' ' + self.lastName;
    else
    return 'New Contact';
  });

  //This will break the display name property
  var name = self.displayName;
}

为了防止这种情况发生,需要手动将依赖项firstNamelastNamenickname转换为可观察对象。这可以通过在可观察模块上调用convertObject来完成:

observable.convertObject(self);
observable.defineProperty(self, 'displayName', function() {
  //
});

这确保了第一次访问displayName时,它读取的是可观察属性而不是常规属性。

由于这种错误在发生时可能难以追踪,因此始终在 viewmodel 构造函数中调用convertObject是一个好的实践。它不会引起任何性能损失,因为它与可观察插件在数据绑定时使用的是同一个方法。如果您需要更细粒度的控制转换,可以使用observable.convertProperty(object, 'propertyName')逐个转换属性。

承诺

除了将常规 JavaScript 属性视为可观察对象外,可观察插件还允许通过将属性转换为可观察对象并设置一个回调来更新它在解决承诺时绑定的承诺:

self.contacts = dataService.getContacts()
.then(function(contacts) {
  return contacts.map(function(contact) {
    return new ListItem(contact);
  });
});

在转换为可观察对象后,联系人数组仍然可以正常绑定。实际上,在示例代码中,进行此更改不需要任何 HTML 更改。

样本

您可以在cp6-observable分支中看到所有这些绑定方法的示例。所有代码都已转换为使用带有可观察插件的纯 JavaScript 属性。

所有应用程序代码中的括号都已删除,包括模拟数据服务。现在应该更容易阅读。

在列表 viewmodel 上,使用前面的联系人承诺示例,它替换了activate方法。displayContacts计算值使用可观察插件创建,并且仍然应用了rateLimit扩展器。

Contact 模型使用 convertObject 方法手动转换为可观察对象,因为 state 上的脏标志将尝试读取 displayName 计算值。

编辑页面上的唯一更改是移除了括号。

摘要

Durandal 致力于通过提供一个以视图-视图模型为中心的框架来补充 Knockout 的 MVVM 哲学,该框架专注于组合。如果你喜欢 Knockout(你应该喜欢;毕竟,你在读这本书!),你可能会希望将 Durandal 视为一个自然的扩展。Durandal 提供的工具在简化单页应用(SPAs)的开发方面大有裨益。

在下一章中,我们将离开 Durandal 并深入探索 Knockout 的内部机制。

第七章. 最佳实践

到目前为止,所有的编码建议都穿插着 Knockout 技术介绍。为了更详细地介绍这些模式以及它们为什么有用,并提供一个综合参考,我们将在这章中回顾它们。由于 JavaScript 是一个非常灵活的语言,拥有最大的在线开发者社区之一,并且在业余爱好者到企业级开发的各个层面都有使用,因此不带有偏见地谈论好的或有用的模式是困难的。这些实践应被视为建议,而不应被视为教条。许多这些建议适用于一般的编程,而不仅仅是 Knockout 开发。

坚持 MVVM

Knockout 是按照 模型-视图-视图模型MVVM)模式设计的。虽然使用 Knockout 和其他设计模式开发应用程序是可能的,但坚持 MVVM 将会在 Knockout 和您的代码之间产生自然的对齐。

视图和视图模型

关注点的分离是关键。不要在您的视图模型中引入视图概念,如 DOM 元素或 CSS 类;这些属于 HTML。限制或避免在您的视图中使用业务逻辑和内联绑定函数;这些应作为属性或函数存在于视图模型中。保持这两者分离可以使工作得以分割和并行化,使视图模型可重用,并使单元测试视图模型成为可能。

视图模型杂乱

动画处理程序是视图逻辑的一个很好的例子,这些逻辑通常最终会出现在视图模型中。foreach 绑定处理程序有几个后处理钩子(如 afteraddafterrenderbeforeremove),目的是允许使用动画。使用视图模型函数似乎很自然,因为它们在绑定中指定,通常绑定视图模型属性:

<div data-bind='template: { foreach: planetsToShow,
                            beforeRemove: hidePlanetElement,
                            afterAdd: showPlanetElement }'>
    <div data-bind='attr: { "class": "planet " + type }, text: name'></div>
</div>

var PlanetsModel = function() {
    //Viewmodel properties
    this.planets = ko.observableArray();

    // Animation callbacks for the planets list
    this.showPlanetElement = function(elem) {
        if (elem.nodeType === 1) {
$(elem).hide().slideDown() ;
}
    }
    this.hidePlanetElement = function(elem) {
    	if (elem.nodeType === 1) {
$(elem).slideUp(function() { $(elem).remove(); });
}
    }
};

不幸的是,这紧密地将视图模型与视图耦合在一起,使得视图模型和动画都变得不可重用。更好的解决方案是将动画存储在全局可访问的位置,例如 ko.animations,并在绑定中引用它们:

<div data-bind='template: { foreach: planetsToShow,
                            beforeRemove: ko.animations.slideHide,
                            afterAdd: ko.animations.slideShow }'>
    <div data-bind='attr: { "class": "planet " + type }, text: name'></div>
</div>

ko.animations = {};
ko.animations.slideShow = function(elem) {
    if (elem.nodeType === 1) {
$(elem).hide().slideDown();
}
};
ko.animations.slideHide =  function(elem) {
    if (elem.nodeType === 1) {
$(elem).slideUp(function() { $(elem).remove(); });
   }
};

var PlanetsModel = function() {
    //Viewmodel properties
    this.planets = ko.observableArray();
};

现在,相同的动画可以在其他列表中重用,并且视图模型不包含控制 DOM 的逻辑。

视图杂乱

虽然保持视图模型对视图的无知通常是明确的(不要引用 HTML 类型),但将内联代码排除在视图之外通常需要更多的考虑。这部分是因为与呈现相关的逻辑可能属于视图,或者至少是一个绑定处理程序,部分是因为当需要许多小而独特属性时,存在一种平衡行为。

不属于视图的内联逻辑的例子是一个按钮禁用表达式:

<button data-bind="disable: items().length > 3, click: submitOrder">Submit</button>

考虑这种情况,这个值需要改变:你真的想在整个 HTML 中寻找控制这个规则的规则吗?当这个值是变量并由其他因素确定时呢?这绝对应该是视图模型中计算出的canSubmit(或类似名称),因为项目数量的最大值是业务逻辑,这不是视图的领域。

一个不那么明确的例子是基于类似逻辑的警告显示。假设禁用按钮不足以提供视觉提示,你还希望按钮变成红色:

<form data-bind="submit: submitOrder, css: { 'invalid-form': items().length > maxItems }">
    //Irrelevant form code…
</form>

这不是一个完美的例子,你可能会想在你的视图模型中添加一个overMaxItemLimit计算属性;而且它也没有直接表达业务逻辑。如果表单中的项目太多,突出显示表单是表示逻辑,如果你有足够的这些一次性计算属性,它们只是对单个可观察对象进行简单表达式,你的视图模型会很快变得杂乱。在这些情况下,强迫视图模型表示这种逻辑可能没有任何价值,你在决定将其放在哪里时应该谨慎行事。

使用服务模块

即使在小型应用程序中,视图模型也不应包含所有应用程序代码。当可能时,代码应拆分为非视图模型模块,这些模块封装了工作并可以重用。这些模块通常被称为服务。

例如,从服务器获取数据的视图模型不需要知道该操作是如何处理的,无论是使用 jQuery 的 AJAX 方法、WebSocket 还是其他检索方法。将此逻辑放入数据服务模块不仅使其对其他视图模型可重用,而且通过限制每个对象的作用域到其自身的工作,使单元测试更容易。这里的驱动哲学是单一职责原则。

创建小型模块

创建更小的模块可以使单元测试更简单,并减少其他人阅读代码时理解代码所需的努力。在决定是否向模块添加功能或将其拆分为新的模块时,请牢记单一职责原则。

这是一种平衡行为。如果你的 JavaScript 应用程序有一个 RESTful API,那么创建模块通过提供方法来抽象单个 URL 是个好主意。然而,包含整个应用程序所有 URL 的单个数据服务模块,即使在中等规模的应用程序中,也会导致模块非常大。另一方面,为每个单独的路由创建服务模块会产生更多的文件。这将使单元测试和维护更困难,而不是更容易。最好的做法是按功能将路由分组到模块中。在 REST URL 的情况下,按资源分组会产生非常自然的组织。

编写单元测试

如果你遵循所有之前的建议,那么你的代码将处于良好的单元测试状态。在编写可测试的单元测试代码时,主要考虑的是可模拟性:外部依赖松散耦合的代码。松散耦合的依赖可以在单元测试中用模拟、存根、mock、spy 或其他形式的替代品替换,其行为可以通过测试来控制。这个挑战通过将 DOM 和绑定代码从 viewmodel 中分离出来,保持模块小,并通过依赖注入等实践避免与其他 viewmodel 的紧密耦合来解决。

有几个 JavaScript 单元测试框架可供使用,它们都提供了类似的好处和工作流程。重要的是你使用什么工具进行单元测试,而不是你是否编写了单元测试。单元测试的价值真的无法过高估计。在像 JavaScript 这样的不提供编译时检查的动态语言中,它甚至更重要。

单例与实例

当你有一个实际被多次使用的 viewmodel 时,例如支持foreach循环的 viewmodel,使用实例是唯一的选择。当 viewmodel 只有一个实例时,例如支持表单输入或 SPA 中的页面的 viewmodel,选择可能并不简单。

一个好的经验法则是考虑对象的生命周期。如果对象的生命周期不会结束,例如始终存在的导航栏的 viewmodel,使用单例是合适的。如果对象的生命周期较短,例如 SPA 中的页面 viewmodel,那么使用单例意味着对象即使在不再被积极使用后也无法被垃圾回收。在这种情况下,建议使用可丢弃的实例。

另一个经验法则是考虑它是否有内部状态。如果没有需要管理的内部状态,那么多次使用对象或其方法导致错误的风险很小。如果一个对象没有内部状态,例如抽象 AJAX 请求或 cookie 访问的服务,即使对象有有限的生命周期,单例也是合适的。这不适用于状态重要的 viewmodel,例如支持表单输入的 viewmodel;这是因为每次使用时,它都应该有一个新的状态。即使对象有较长的生命周期,例如导航栏中的登录 viewmodel,也需要新的状态。在注销后重建 viewmodel 将确保之前使用的信息不会保留。

一次调用 ko.applyBindings(每个根)

我不知道有多少次在 Stack Overflow 上看到关于开发者多次调用ko.applyBindings导致的问题,他们认为这是同步 DOM 和可观察数据的原因。这与其说是一个最佳实践,不如说是一个警告,但如果完全忽略它,我会感到疏忽。对于 HTML 中的任何给定根元素,最多只能有一个ko.applyBindings调用。

性能问题

自从 Knockout 首次发布以来,其性能已经提高了好几次,但在包含大量操作或对象的应用中仍然可能遇到问题。虽然随着工作量的增加,性能的某些下降是可以预料的,但有一些方法可以减轻 CPU 的负担。

可观察循环

在循环中更改可观察数组会导致它们多次发布更改通知。这些更改的成本与数组的大小成比例。你可能需要向数组添加多个项目,并使用循环来完成这项工作:

var contacts = ko.observableArray();
for (var i = 0, j = newContacts.length; i < j; i++) {
    contacts.push(new Contact(newContacts[i]);
}

这里的问题是push被多次调用,导致数组发送出多次更改通知。如果所有更改一次性发送,对数组的订阅者来说会更容易。这可以通过在循环中收集所有更改,然后在最后使用push.apply将它们应用到可观察数组中来实现:

var contacts = ko.observableArray();
for (var i = 0, j = newContacts.length, newItems = []; i < j; i++) {
    newItems.push(new Contact(newContacts[i]);
}
contacts.push.apply(contacts, newItems);

前述方法确保对于可观察数组只发生一次valueHasMutated调用。对于这个常见问题的一个流行解决方案是将此添加到observableArray.fn对象上的函数中,使其对所有可观察数组都可用:

ko.observableArray.fn.pushAll = function (items) {
    this.push.apply(this, items);
};

以下方法可以用来添加一个项目数组:

var contacts = ko.observableArray();
for (var i = 0, j = newContacts.length, newItems = []; i < j; i++) {
    newItems.push(new Contact(newContacts[i]);
}

contacts.pushAll(newitems);

限制活动绑定

大量的绑定,尤其是那些注册事件处理程序(如值和点击)的绑定,可能会迅速导致浏览器性能下降。管理这一点需要仔细考虑如何最好地减少同时需要发生的变化的数量。

一种方法是使用控制流绑定来移除不必要的绑定部分。限制屏幕上的内容量有助于性能,并且还有减少用户需要解析的杂乱信息的附带好处。例如,分页技术不仅适用于长列表,还可以用来将长表单或活动拆分成几个屏幕。当然,这种方法仅限于可以拆分的活动。

一个更广泛适用的方法是使用委派事件,也称为非侵入式事件处理器

委派事件

无侵入式事件处理器,如 jQuery 的 on,可以使用单个事件处理器来响应注册元素内部任意数量的 DOM 元素上的事件。这在处理大型或递归列表时特别有用,因为为每个元素注册单个事件处理器可能会过于昂贵。Knockout 提供了两种实用方法来将这些处理器与绑定上下文中的适当数据连接起来,其方式类似于 Knockout 的点击绑定如何将上下文作为第一个参数提供:

  • ko.dataFor (元素): 这返回可用于元素的 数据

  • ko.contextFor (元素): 这返回元素的绑定上下文(包括绑定上下文属性,如 $parent$root

这可以与提供事件委托的绑定处理器结合使用:

ko.bindingHandlers.on = {
   init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
      var options = valueAccessor();
      var handler = function() {
         options.method.call(bindingContext.$rawData, ko.dataFor(this));
      };

      $(element).on(options.event, options.selector, handler);

      ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
         $(element).off(options.event, options.selector, handler);
      });
   }
};

<ul class="list-unstyled" data-bind="foreach: displayContacts, on: { event: 'click', selector: '.remove-btn', method: deleteContact }">
  <li data-bind="compose: { model: $data, mode: 'templated' }">
  <div data-part="btn-container" class="inline">
    <button class="btn btn-sm btn-default"  data-bind="click: edit">Edit</button>
    <button class="btn btn-sm btn-danger remove-btn">Delete</button>
  </div>
  </li>
</ul>

前面的示例可以在 cp7-unobtrusive 分支中看到。

前述技术并非在所有地方都必需,但在处理大量处理器时,它可能会导致性能上的明显影响。

摘要

再次强调,这些是指导原则而不是规则,其中一些是可能导致同事之间意见分歧的观点。有时,打破模式会产生更清晰、更简洁的代码,并且是使某些事情能够工作的唯一方法;有时,打破模式是唯一能与同事妥协的方法。如果它阻碍了你的工作而没有给你带来任何好处,就不要这样做。软件开发没有唯一正确的方法。

下一章将介绍一些由社区维护的流行 Knockout 插件。

第八章. 插件和其他 Knockout 库

在任何软件领域的有效工作中,熟悉社区使用的工具是一个很大的部分。通常,依靠已经使用并经过实际测试的现有库和插件,比在每个新项目中重新发明轮子要好。在本章中,我们将探讨一些最受欢迎的 Knockout 插件:

  • Knockout Validation

  • Knockout Mapping

  • Knockout Kendo

  • KoGrid

  • Knockout Bootstrap

  • Knockout Switch-Case

  • Knockout Projections

  • Knockout-ES5

Knockout Validation

用户输入的验证是一个常见的任务,几乎每个网络应用都将至少有一些需求。到目前为止,GitHub 上最受欢迎的 Knockout 插件,比下一个与 Knockout 相关的项目多 50% 的星标,Knockout Validation 创建了几个扩展器和绑定处理程序,旨在简化 HTML 表单验证。

插件的用法从将验证逻辑应用于可观察对象而不替换它们开始:

var requiredValue = ko.observable().extend({ required: true });
var multipleValidationValue = ko.observable().extend({
                     required: true,
                     minLength: 3,
                     pattern: {
                          message: 'Hey this doesnt match my pattern',
                          params: '^[A-Z0-9].$'
                     }
                 });

对验证扩展值进行绑定使用正常的 value 绑定:

<input data-bind="value: requiredValue" />

Knockout Validation 修改了标准的值和已检查绑定,以便它们显示无效值警告。默认的显示行为将在值绑定输入元素之后放置一个包含任何错误的 span 元素。当值有效时,错误消息 span 将被隐藏,当无效时将包含错误消息文本。

如果您想手动将验证消息放置在视图中,可以禁用此自动错误插入。为此,请使用 validationMessage 绑定,它具有与插入的 span 相同的行为。

默认验证规则

Knockout Validation 默认提供几个验证扩展器,它称之为规则。像正常的扩展器一样,可以在单个调用中传递多个验证规则以进行扩展,或者它们可以串联:

var myObj = ko.observable().extend({ required: true });
var myObj = ko.observable().extend({ number: true, min: 10, max: 30 });
var myObj = ko.observable().extend({ number: true})
    .extend({ min: 10, max: 30 });

默认规则涵盖了检查值的大部分标准情况,包括但不限于数值的最小和最大值、字符串长度的最小和最大值、正则表达式模式、日期和值相等性。

配置验证选项

Knockout Validation 的行为非常可配置。一些更有用的选项包括:

  • insertMessages (默认: true): 如果为真,将在绑定到已验证可观察对象的输入之后插入一个 span

  • errorElementClass (默认: validationElement): 这是一个在已验证的可观察对象无效时应用于元素的类。

  • messagesOnModified (默认: true): 如果为真,验证消息将不会显示,直到已验证的值被修改,这样它们就会在用户与表单交互之前隐藏。

  • messageTemplate (默认: null): 这是一个脚本元素的 ID,将用作验证消息模板,而不是将消息插入到 span 中。

可以通过传递一个对象到 ko.validation.init 来全局设置配置选项:

ko.validation.init({
   insertMessages: false,
   errorElementClass: 'text-danger'
});

可以使用 validationOption 绑定(参见下一节)或通过传递配置对象到 ko.applyBindingsWithValidation 来上下文设置选项:

ko.applyBindingsWithValidation(viewModel, rootNode, {
   insertMessages: false,
   errorElementClass: 'text-danger'
});

验证绑定处理程序

Knockout 验证添加了一些绑定处理程序来帮助显示验证错误。

validationMessage 绑定在验证的观察者无效时显示错误信息。当值有效时,元素将被隐藏:

<div>
   <input type="text" data-bind="value: someValue"/>
   <p data-bind="validationMessage: someValue"></p>
</div>

validationElement 绑定对于应用属性和类到元素很有用。它将标题属性设置为验证消息,这对于显示工具提示很有用,并且当 decorateElement 配置选项为 true 时,它将 errorElementClass(默认为 validationElement)设置为元素的类属性:

<div>
   <label data-bind="validationElement: someValue">
     <input type="text" data-bind="value: someValue"/>
   </label>
</div>

validationOptions 绑定与控制流绑定类似,因为它将指定的配置选项应用于所有子 DOM 节点。它可以采用与配置选项相同的对象格式:

<div data-bind="validationOptions: { insertMessages: false } ">
   <input type="text" data-bind="value: someValue"/>
   <p data-bind="validationMessage: someValue"></p>
</div>

创建自定义规则

自定义规则可以全局创建,以便多个扩展器可以重用,或者内联创建,以便在单个扩展器中使用。添加全局验证规则是通过将规则对象添加到 ko.validation.rules 对象来完成的。规则有两个组成部分,即 验证函数默认消息

ko.validation.rules['contains'] = {
    validator: function (val, substring) {
        return val.indexof(substring) !== -1;
    },
    message: 'The field must contain {0}'
};

验证函数接收两个参数:观察者的值和传递给验证扩展器的值。验证扩展器可以接受任何有效的 JavaScript 值,包括对象和函数。

一旦添加了验证规则,其扩展器将通过以下调用创建:

ko.validation.registerExtenders();

然后,它可以用来扩展观察者:

var title = ko.observable().extend({ contains: 'Sr.' });

内联验证规则通过传递相同的验证规则对象到验证扩展器来工作:

var title = ko.observable().extend({
   validation: {
       validator: function (val, substring) {
           return val.indexof(substring) !== -1;
       },
       message: 'The field must contain {0}',
       params: 'Sr.'
   }
});

当使用内联验证规则时,validator 函数的第二个参数由验证规则的 params 属性定义。

注意

Knockout 验证是一个具有许多功能和选项的大型库,这些功能和选项在本节中未讨论。Knockout 验证库的完整文档可以在其 GitHub 仓库中找到:github.com/Knockout-Contrib/Knockout-Validation

Knockout 映射

Knockout 映射插件是针对那些想要绑定到其服务器 AJAX 响应,而不需要手动编写 JavaScript 类以将它们转换为观察者的项目的解决方案。映射插件将 JavaScript 对象或 JSON 字符串转换为具有可观察属性的观察者对象:

var mappedViewmodel = ko.mapping.fromJS({
   name: 'Timothy Moran',
   age: 24
});
ko.applyBindings(mappedViewmodel);

对于 JSON,请查看以下代码:

var serverResponse = "{"name":"Timothy Moran","age":24}";
var mappedViewmodel = ko.mapping.fromJSON(serverResponse);
ko.applyBindings(mappedViewmodel);

映射插件通过将它们转换为 observableArrays 来处理数组。它还创建了对象的副本,允许从服务器到客户端的完整对象图被转换为可观察对象。

可以通过将 viewmodel 作为 fromJSfromJSON 的第二个参数传递来执行针对使用映射插件创建的 viewmodel 的更新:

ko.mapping.fromJS(data, viewModel);

你可以在 cp8-mapping 分支中看到一个映射插件作用的简单示例。

更新 viewmodel

fromJSfromJSON 方法也可以用来更新整个 viewmodel,以便通过传递 viewmodel 作为第三个参数来处理未来的服务器更新响应:

ko.mapping.fromJS(data, {}, viewModel);

解包

通常,在将数据发送回服务器时,你会使用 ko.toJSko.toJSON 将 viewmodel 解包为一个具有正常 JavaScript 属性的对象,而不是可观察属性。因为映射插件为你的 viewmodel 添加了几个用于内部使用的属性,ko.toJS 将产生一个杂乱的副本。相反,你可以使用 ko.mapping.toJSko.mapping.toJSON 来获取一个不带附加映射属性的解包 viewmodel。

映射选项

要控制映射插件如何创建或更新对象,可以在 viewmodel 首次创建时传递选项。映射插件将使用这些选项来构建 viewmodel,然后存储这些选项,以便它们可以用于所有未来的更新:

var mapping = {
   // options
};

var vm = ko.mapping.fromJS(data, mapping);

使用键进行数组更新

更新数组的默认行为是用新值替换任何不完美匹配的元素。当处理对象的数组时,通常期望元素将在原地更新其值。为了告诉映射插件如何确定要更新的值中的元素与旧值相同,可以定义一个键:

var mapping = {
   people: {
      key: function(person) {
         return ko.unwrap(person.id);
      }
   }
};
var vm = ko.mapping.fromJS(data, mapping);

使用 create 进行对象构造

你可以提供一个回调来控制单个属性的创建。一个常见的用例是提供一个对象的构造函数:

var mapping = {
   people: {
      key: function(person) { /* same as before */ },
      create: function(options) {
         return new Person(options.data);
      }
   }
};
var vm = ko.mapping.fromJS(data, mapping);

控制更新

与创建类似,可以提供一个更新回调。返回值将用作属性的值:

var mapping = {
   price: {
      update: function (options) {
         return parseMoney(options.data);
      }
   }
};
var vm = ko.mapping.fromJS(data, mapping);

选择哪些属性被映射

映射选项可以指定一个属性名称数组,以控制映射的各个方面:

  • ignore: 映射将不包括这些属性在生成的 viewmodel 中。

  • copy: 映射将直接复制这些属性的值,而不是将它们转换为可观察属性。

  • observe: 如果存在,只有此数组中的属性将被转换为 viewmodel 上的可观察属性。这是之前选项的逆操作。

  • include: 通常在使用 ko.mapping.toJS 时,只有原始映射中的属性会出现在输出中。include 数组中的任何属性也会被复制到输出中,即使它们不在原始的 viewmodel 中。

所有这些数组都将与 ko.mapping.defaultOptions 对象中的默认值合并。默认情况下,所有默认值都是空的,但它们可以被修改:

ko.mapping.defaultOptions().ignore = ["alwaysIgnoreThis"];
ko.mapping.defaultOptions().copy = ["alwaysCopyThis"];

挑战

当服务器响应驱动应用程序的工作时,Knockout 映射插件非常有用。当应用程序需要与模型一起工作时,在模型被服务器发送之前,映射插件将无法创建视图模型。这种情况在填写表单以创建新模型时经常发生。视图模型的属性也只讲了一半的故事;对于大多数视图模型,仍然需要编写业务逻辑。由于映射插件填充的属性不在将作为参考的类中,因此针对这些属性编写函数或计算属性可能会很具挑战性。在非常复杂的情况下,某些对象的映射逻辑可能超过了用正常 JavaScript 定义的相同逻辑。虽然这可以在具有许多服务器响应的中等到大型应用程序中节省时间,但它可能并不总是适合项目。

尽管它很受欢迎,但它已不再在 GitHub 上维护。然而,截至版本 3.2,它仍然与 Knockout 兼容。

备注

映射插件的文档位于官方 Knockout 网站上,链接为 knockoutjs.com/documentation/plugins-mapping.html

击倒剑道

Kendo UI (www.telerik.com/kendo-ui) 是 Telerik 提供的一个流行的 HTML5 小部件库,它提供了大量外观专业的控件。Knockout Kendo 是一个绑定库,允许 Knockout 视图模型使用 Kendo 控件。Knockout Kendo 有超过 30 个绑定,每个绑定都有多种选项,这里无法一一介绍。虽然 Knockout Kendo 是免费的,但 Kendo UI 本身不是免费的,并且需要您购买许可证才能使用。

大多数绑定都是围绕 Kendo 小部件的简单包装,提供了一个带有一些惊喜的 API。例如,这里有 autocomplete 绑定,它接受一个选项数组和一个绑定选择的观察:

<input data-bind="kendoAutoComplete: { data: autocompleteOptions, value: autocompleteValue }" /><br>

DateTimePicker 创建了两个独立的日期和时间选择控件,绑定到一个单一的观察 Date 对象:

<input data-bind="kendoDateTimePicker: startDate" />

如果您之前使用过 Kendo,您将熟悉可用的控件,Knockout Kendo 甚至还有非免费的专业 UI 小部件的绑定。您可以在 cp8-kendo 分支中看到一些 Kendo 控件的示例。

备注

您可以在 Knockout Kendo 的 GitHub 网站上找到完整的文档,链接为 rniemeyer.github.io/knockout-kendo/

KoGrid

KoGrid 是一个插件,它创建了一个渲染表格数据的绑定。正如其 GitHub 页面所述,它是“ng-grid 的直接 Knockout 版本,ng-grid 最初受到 KoGrid 的启发,而 KoGrid 又受到 SlickGrid 的启发。”其历史可能受到了祖父悖论的影响。

在其最基本的工作模式下,KoGrid 可以绑定到一个对象数组,将它们的属性转换为列,将它们的值转换为单元格:

var vm = {
      people: ko.observableArray([{name: "Moroni", age: 50},
                                      {name: "Tiancum", age: 43},
                                      {name: "Jacob", age: 27},
                                      {name: "Nephi", age: 29},
                                      {name: "Enos", age: 34}])
   }
<div class="gridStyle" data-bind="koGrid: { data: people }"></div>

您可以在cp8-kogrid分支中看到这个示例。除了需要手动通过 CSS 样式指定网格本身的尺寸外,其他所有内容都是自动的。您可以通过点击列进行行排序,切换列的可见性,使用滚动条处理溢出,显示项目计数,并且可以通过拖动重新排序列。最大的缺点是数据不是使用真正的表格元素渲染的,而是使用一堆div元素渲染的。

当然,这只是一个基本的工作模式。KoGrid 自带了您从完整的网格小部件中期望的大多数功能:

  • 列定义:这指定了哪些行属性被显示为列。

  • 分组:这允许用户选择一个列来旋转表格,通过匹配所选列的值将所有行分组。

  • 选中行:使用此功能,可以将可观察数组绑定到表格的选中行。当multiSelect选项为 false 时,这可以用来创建一个主/详细视图,其中包含选中的行。

  • 模板:这为网格提供行和单元格模板。

  • 主题:这指定了每个网格的绑定选项,从而指定主题。

  • 服务器端分页:这提供了回调函数,允许网格从外部源异步获取数据。

注意

如果您想要一个输出真实表格元素的绑定,并且不需要 KoGrid 提供的所有功能,请查看 knockout-table 插件github.com/mbest/knockout-table

在这些功能中,模板可能是最重要的。虽然它们的示例页面将模板直接放在视图模型代码中,但这不是推荐的做法,除非您是从外部源(如 AJAX 或 RequireJS 文本加载器)加载字符串。KoGrid 还支持通过引用其 ID 使用脚本元素作为模板,例如 Knockout 的模板系统。然而,最简单的方法是使用 URL 字符串来引用 HTML 部分文件作为模板:

<div data-bind="koGrid: { data: people,
      canSelectRows: false,
      displaySelectionCheckbox: false,
      columnDefs: [
         { field: 'name', displayName: 'Name', width: '*' },
         { field: 'age', displayName: 'Age', width: '*' },	
         { field: '', displayName: ' ', 
            cellTemplate: 'app/deleteButtonCell.html', 
            width: '**' 
         }]}" class="gridStyle"></div>

前面的示例向您展示了几个网格选项以及指定要显示哪些列的列定义。请注意,最后一列没有属性,但有一个模板,会显示一个删除按钮。

可以在视图模型中定义这些选项,并将单个对象传递给 KoGrid 绑定;然而,这会导致视图模型与其使用紧密耦合,这是违反 MVVM 模式的。在视图中定义网格选项可以保持视图模型与显示方式无关。

删除按钮模板将由 KoGrid 在单元格的绑定上下文中渲染:

<div data-bind="attr: { 'class': 'kgCellText colt' + $index()}">
   <button class="btn btn-xs btn-danger" data-bind="click: function() { $parent.$userViewModel.remove($parent.entity) }">Delete</button>
</div>

这里不会涵盖单元格和行模板的完整文档,但前面的模板展示了几个重要组件。

为了正确控制其宽度和位置,单元格需要包含kgCellText类以及代表该列的 0 索引类。由于单元格将在列循环中使用,它能够访问特殊的绑定上下文属性$index()来获取这个值。

在 Knockout 中,点击绑定的默认值是当前绑定上下文。在单元格模板内部,这将是指单元格对象而不是数据数组中的项目。可以通过$parent.entity访问绑定项。要访问视图模型,网格绑定到$parent.$userViewModel。在这两种情况下,$parent是行的绑定上下文;在创建行模板时,可以使用$data.entity$userViewModel来访问相同的属性。

你可以在cp8-kogrid-template分支中看到这个自定义模板的例子。

注意

KoGrid 的完整文档可以在其 GitHub Wiki 页面上找到,网址为github.com/Knockout-Contrib/KoGrid/wiki

Knockout Bootstrap

Twitter Bootstrap 有几个依赖于 jQuery 的美丽小部件,可以从 JavaScript 中使用,或者在某些情况下,使用它们的data-*属性。如果你使用 Knockout,则需要做一些工作才能使其与可观察对象一起工作,并从绑定处理程序中初始化它。Knockout Bootstrap 是一个流行的插件,它解决了这个问题。不幸的是,在撰写本文时,它还没有更新以支持 Bootstrap 3,因此,其中一些功能无法正常工作。当与 Knockout 3 和 Bootstrap 3 一起工作时,ToolTipPopoverAlerts绑定可以正常工作,但Progress BarTypeahead绑定则无法工作。

如同 Knockout Kendo,如果你使用过 Bootstrap 小部件,Knockout Bootstrap 中的绑定应该会立即熟悉。绑定以它们的部件命名,并接受一个具有与 jQuery 插件初始化器相同属性的对象。当合理时,这些属性可以绑定到:

//Tooltip
<p>This is a paragraph with a <span data-bind="tooltip: { title: tooltipText, placement: 'bottom' }"> tooltip span</span> inside.
</p>

//Popover
<button class="btn btn-primary" data-bind="popover: {template: 'popoverTemplate', title: 'Oh Yea'}">
    Launch Simple Popover
</button>

//Alerts
<div data-bind="foreach: alerts">
    <div data-bind="alert: $data"></div>
</div>

这些都可以在cp8-knockout-bootstrap分支中看到。需要注意的是,当在 UI 上关闭警报时,警报绑定不会从绑定数组中删除警报,尽管它会根据添加或删除数组元素来显示或隐藏数组元素。

注意

Knockout Bootstrap 的完整文档可在billpull.com/knockout-bootstrap找到。

Knockout Switch-Case

尽管 Knockout Switch-Case 插件针对的是单一、特定的用例,但它在 GitHub 上的流行程度证明了 switch/case 控制流绑定是一个非常实用的工具。与其编写一系列if/ifnot绑定,不如使用单个 case-switch 绑定:

<div data-bind="switch: orderStatus">
    <div data-bind="case: 'shipped'">
        Your order has been shipped. Your tracking number is <span data-bind="text: trackingNumber"></span>.
    </div>
    <div data-bind="case: 'pending'">
        Your order is being processed. Please be patient.
    </div>
    <div data-bind="case: 'incomplete'">
        Your order could not be processed. Please go back and complete the missing data.
    </div>
    <div data-bind="case: $default">
        Please call customer service to determine the status of your order.
    </div>
</div>

之前提到的例子可以在cp8-case-switch分支中看到。

Switch 绑定也可以作用于真值。这可以通过在一系列中查找第一个匹配值来完成:

<div data-bind="switch: true">
    <div data-bind="case: trackingNumber">
        Your order has been shipped.
    </div>
    <div data-bind="case: isReady">
        Your order is being processed.
    </div>
    <div data-bind="casenot: isComplete">
        Your order has been processed.
    </div>
    <div data-bind="case: $else">
        Your order could not be processed.
    </div>
</div>

或者,它也可以作为一对 if/ifnot 绑定的缩写来完成:

<div data-bind="switch: isReady">
    <div data-bind="case: true">You are ready!</div>
    <div data-bind="case: false">You are not ready!</div>
</div>

Switch-case 绑定也可以用作前面情况组合中的无容器绑定。

如您可能已经注意到的,还有特殊的 $default$else 选项,可以在找不到匹配值时使用。

注意

Knockout Switch-Case 的源代码可在 GitHub 上找到,链接为 github.com/mbest/knockout-switch-case

Knockout 投影

使用计算观察者来过滤或投影观察者数组是一个极其常见的操作;我认为我从未见过一个没有至少一次执行此操作的 Knockout 投影。Knockout Projections 是一个插件,它向观察者数组添加了映射和过滤功能,这创建了一个仅在其依赖元素发生变化时才重新计算其回调的计算观察者,而不是重新评估每个依赖元素。

注意

Steven Sanderson 通过他的博客 blog.stevensanderson.com/2013/12/03 介绍了这个插件。

为了更好地理解此插件解决的问题,我们将查看 Sanderson 在他的博客上使用的示例,以说明正常计算观察者数组和使用 Knockout Projections 制成的数组之间的区别。

考虑以下模型:

function Product(data) {
    this.name = ko.observable(data.name);
    this.isSelected = ko.observable(false);
}
function PageViewModel() {
    // Some data, perhaps loaded via an Ajax call
    this.products = ko.observableArray([ /* several Products /* ]);
    this.selectedProducts = ko.computed(function() {
        return this.products().filter(function(product) {
            return product.isSelected();
        });
    }, this);
}

这个 selectedProducts 计算是通过标准的 ES5 数组的 filter 函数定义的,调用 products() 返回底层的 JavaScript 数组。每次运行时,它都会遍历所有产品,并返回一个包含所有 isSelected() === true 的元素的数组。这里的问题是计算观察者总是在其依赖项的任何一项发生变化时重新运行;计算只能通过运行其回调来执行重新评估,并且每次运行时都必须重新检查每个产品。这扩展性不好;它在 O(N) 时间内运行。

当使用 Knockout Projections 时,您将使用观察者数组上的 filter 函数创建相同的计算:

this.selectedProducts = this.products.filter(function(product) {
    return product.isSelected();
});

这创建了一个只读的观察者数组,它为每个产品的 isSelected 观察者创建单独的依赖。当产品发生变化时,回调将仅针对该产品运行,并且 selectedProducts 数组将根据变化进行更新。性能现在有一个固定的成本:无论数组有多大,回调都将在每个依赖项变化时只运行一次。声明代码也更短,更容易阅读!

Knockout Projections 还在观察者上创建了一个映射函数,该函数运行一个产生数组转换的回调而不是过滤。例如,您可以创建一个只接收单个名称更改时更新的产品名称的观察者数组:

this.productNames = this.products.map(function(product) {
    return product.name();
});

由于由 filter 和 map 创建的只读数组也是可观察数组,因此可以将这些方法链接在一起:

this.selectedNames = this.selectedProducts.map(function(product) {
    return product.name();
});

使用 Knockout Projections 的性能提升在小数组中微乎其微,但在大数组中则非常显著。如果您正在处理即使是中等规模的数据集,使用 Knockout Projections 是不言而喻的。

Knockout-ES5

Knockout-ES5 是 Knockout 的一个插件,它使用 JavaScript 的 getter 和 setter 来隐藏在对象属性背后的可观察对象,允许您的应用程序代码使用标准语法与它们一起工作。基本上,它消除了可观察对象的括号:

查看以下代码:

var latestOrder = this.orders()[this.orders().length - 1];
latestOrder.isShipped(true);

之前的代码变为这样:

var latestOrder = this.orders[this.orders.length - 1];
latestOrder.isShipped = true;

如果您还记得 Durandal 的可观察插件,它们非常相似;它们甚至几乎同时出现。这两个插件之间最大的区别是,Durandal 的可观察插件执行深度对象转换,而 Knockout ES5 执行浅度转换。

将视图模型的属性转换为可观察对象,请调用 ko.track:

function Person(init) {
   var self = this,
      data = init || {};

   self.name = data.name || '';
   self.age = data.age || '';
self.alive = data.alive !== undefined ? data.alive : true;
   self.job = data.job || '';

   ko.track(self);
}

要可选地指定要转换的属性,以便传递一个属性名称数组,请查看以下代码:

ko.track(self, ['name', 'age']);

已经存在于模型上的可观察对象,例如使用 ko.observableko.computed 创建的,也会被 ko.track 转换为 ES5 属性。您还可以使用 ko.defineProperty 定义计算可观察对象:

ko.defineProperty(self, 'canRemove', function() {
   return !self.alive;
});

第三个参数遵循与发送给 ko.computed 的第一个参数相同的规则;一个函数将用于创建只读计算,或者一个对象可以用来提供读写函数。

创建了可观察对象后,您可以使用 ko.getObservable 来访问它们:

ko.getObservable(self, 'age').subscribe(function(newValue) {
   console.log(self.name + ' age was changed to ' + newValue);
});

这在应用扩展器或添加订阅时非常有用。扩展器也可以通过在调用 ko.track 之前使用 ko.observable 创建可观察对象来应用。

所有这些技术的示例可以在 cp8-es5 分支中看到。

浏览器支持

由于 Knockout-ES5 使用 JavaScript 的 getter 和 setter,它将不支持此功能的浏览器中无法工作。这不是可以通过脚本模拟或填充的功能。

由于 Knockout 在创建可观察对象函数时的决策导致其语法受到很多批评。根据 Stack Overflow 上问题的流行程度,这无疑是新用户最困惑的方面之一。做出这个决定的目的是为了支持像 Internet Explorer 6 这样的旧浏览器,这些浏览器不支持 JavaScript 的 getter 和 setter。现在,随着 Internet Explorer 6 终于开始失去对浏览器市场份额的控制,这个问题对网络开发者来说变得越来越不重要。不幸的是,直到 IE 9,Internet Explorer 才添加了对 ES5 getter 和 setter 的支持,这对大多数项目来说仍然是一个很高的门槛。

实际上,由于使用 Knockout ES5 对应用语法有如此大的影响,因此在项目已经开始的情况下切换到它通常是不切实际的。Knockout ES5 应仅考虑用于没有旧浏览器支持要求的新项目。

摘要

确定要包含在本章中的插件和库是一个难题。Knockout 在 GitHub 上的 Wiki 页面包含了一个长长的插件列表(github.com/knockout/knockout/wiki/Plugins)——这里讨论的太多,难以一一列举。如果你在使用 Knockout,我们鼓励你查看社区提供的资源,因为这可能会为你节省大量工作。并非所有这些插件都对每个人或每个项目都有用,但希望它们能给你一些关于 Knockout 可以做什么的灵感,并激励你与社区分享你的一些工作。

在下一章中,我们将深入探讨 Knockout 的内部机制,以了解其工作原理。

第九章:内部机制

我们已经涵盖了 Knockout 的基础知识,学习了如何扩展 Knockout 的绑定系统,并看到了如何组织应用程序。现在,是时候满足我们内心的工匠精神了。在本章中,我们将探讨 Knockout 的内部机制,看看是什么让它运转。到本章结束时,你应该熟悉 Knockout 如何处理以下内容:

  • 依赖项跟踪

  • 原型链

  • 解析绑定属性表达式

  • 应用绑定

  • 模板化

此外,我们还将探讨ko.utils命名空间,它为常见操作提供了许多有用的工具。

注意

注意,本章中讨论的所有代码都是基于 Knockout 3.2 版本发布的。将来这部分内容可能会发生变化,这是可能的,也是很可能的。

依赖项跟踪

绑定处理程序和计算可观察对象需要在它们的可观察依赖项更新时重新评估。这意味着跟踪依赖项并订阅它们。三个对象构成了依赖项跟踪功能:可观察对象、计算可观察对象和依赖项检测模块。

这里有一个基本的概述。当一个计算被评估时,它会请求ko.dependencyDetection开始跟踪事物。当访问可观察对象时,它们会将自己注册到ko.dependencyDetection。当计算完成评估后,它会记录所有已注册的依赖项并订阅每个依赖项。

好的,现在让我们看看一些代码。

ko.dependencyDetection

依赖项检测模块非常小——小到足以在这里完整地复制出来:

ko.computedContext = ko.dependencyDetection = (function () {
  var outerFrames = [],
  currentFrame,
  lastId = 0;

  function getId() {
    return ++lastId;
  }

  function begin(options) {
    outerFrames.push(currentFrame);
    currentFrame = options;
  }

  function end() {
    currentFrame = outerFrames.pop();
  }

  return {
    begin: begin,
    end: end,
    registerDependency: function (subscribable) {
      if (currentFrame) {
        if (!ko.isSubscribable(subscribable))
        throw new Error("Only subscribable things can act as dependencies");
        currentFrame.callback(subscribable, subscribable._id || (subscribable._id = getId()));
      }
    },
    ignore: function (callback, callbackTarget, callbackArgs) {
      try {
        begin();
        return callback.apply(callbackTarget, callbackArgs || []);
      } finally {
        end();
      }
    },
    getDependenciesCount: function () {
      if (currentFrame)
      return currentFrame.computed.getDependenciesCount();
    },
    isInitial: function() {
      if (currentFrame)
      return currentFrame.isInitial;
    }
  };
})();

上述代码使用了揭示模块模式来隐藏outerFramescurrentFramelastIdgetId函数的内部变量。

注意

更多关于揭示模块模式的信息,请查看 Todd Motto 的博客:toddmotto.com/mastering-the-module-pattern

这里的想法是,begin要么被调用时带有可用于跟踪的帧,要么不带任何内容来禁用跟踪。当调用end时,之前的帧会被弹出并设置为当前帧。是一个跟踪依赖项的层;帧存在于另一个帧内部,但只有当前帧在访问依赖项时会注册依赖项。这允许依赖项跟踪递归发生,同时每个层只接收其直接依赖项。

传递给beginoptions对象应该公开以下属性:

  • callback: 这是一个函数,当依赖项注册自身时,它会接收一个依赖项及其 ID

  • computed: 这是一个在帧上执行依赖项跟踪的计算可观察对象

  • isInitial: 这是一个布尔值,表示这是否是当前帧首次请求依赖项跟踪

当调用 registerDependency 时,当前帧的回调被传递给依赖及其 ID。ID 是按顺序生成的数字,如果依赖项缺失,则分配给依赖项。

ignore 函数提供了一个在 try/finally 块内对 beginend 的简单包装。对 begin 的调用没有选项,因此不会触发依赖检测。这使得在你知道依赖检测不会或不应使用的情况下评估数据变得容易。Knockout 在几个绑定处理程序以及可订阅对象的 notifySubscribers 函数内部也这样做。

最后两个属性,即 getDependenciesCountisInitial,暴露了当前帧上同名属性的特性。

注册依赖项

当读取可观察对象时,它必须通知 ko.dependencyDetection 以指示已访问依赖项。因为计算值和可观察对象都是可订阅的子类,而可订阅的子类不注册依赖项,因此它们各自都有自己的类似依赖项注册逻辑。

当可观察对象不带参数被调用时,发生可观察对象的实现:

function observable() {
  if (arguments.length > 0) {
    /* write new value */
  }
  else {
    // Read
    ko.dependencyDetection.registerDependency(observable); 
    return _latestValue;
  }
}

在将自己注册为依赖项之后,它返回其当前值。计算版本几乎相同:

function dependentObservable() {
  if (arguments.length > 0) {
    /* write new value */
  } else {
    ko.dependencyDetection.registerDependency(dependentObservable);
    if (_needsEvaluation) //suppressChangeNotification
    evaluateImmediate(true);
    return _latestValue;
  }
}

这里的唯一区别是,因为计算值可以异步评估,所以 read 函数在返回其值之前会检查是否需要重新评估。

关于这一点没有太多可说的。可观察数组类型不对注册过程进行任何更改。事实上,它无法进行任何更改。依赖注册是可观察对象的内部逻辑;不能被覆盖。

订阅依赖项

所有可观察对象的原型都是可订阅的。可订阅原型提供了两个用于依赖工作的函数:subscribenotifySubscribers

subscribe 函数在可订阅对象上创建一个订阅。订阅本身不执行任何操作,它只是一个具有 callbackdispose 属性的对象(它还有其他属性;这些只是相关的属性)。订阅存储在 _subscriptions 对象和内部使用属性中。由于订阅可以附加到命名事件,因此订阅对象为每个事件都有一个数组:

_subscriptions: {
  change: [sub1, sub2],
  beforeChange: [sub3, sub4]
};

当创建一个没有名称的订阅时,它默认附加到更改事件。另一个标准事件是 beforeChange 事件,它在可观察对象更新之前触发。这是来自可观察对象的写入逻辑:

function observable() {
  if (arguments.length > 0) {
    // Ignore writes if the value hasn't changed
    if (observable.isDifferent(_latestValue, arguments[0])) {
      observable.valueWillMutate();
      _latestValue = arguments[0];
      observable.valueHasMutated();
    }
    return this; // Permits chained assignments
  }
  else {
    // Read code
  }
}
//...
observable.valueHasMutated = function () {
  observable"notifySubscribers";
}
observable.valueWillMutate = function () {
  observable"notifySubscribers";
}

在可观察对象更新之前,它会调用 valueWillMutate,之后,它会调用 valueHasMutated。这两个都是 notifySubscribers 函数的包装,第一个提供了 beforeChange 事件名称:

notifySubscribers: function (valueToNotify, event) {
  event = event || defaultEvent;
  if (this.hasSubscriptionsForEvent(event)) {
    try {
      // Begin suppressing dependency detection
      ko.dependencyDetection.begin();
      for (var a = this._subscriptions[event].slice(0), 
      i = 0, subscription; 
      subscription = a[i]; ++i) {
        if (!subscription.isDisposed)
        subscription.callback(valueToNotify);
      }
    } finally {
      // End suppressing dependency detection
      ko.dependencyDetection.end(); 
   }
 }
}

再次强调,事件名称是可选的,如果省略,则默认为change。它还会在开始之前检查是否存在该事件的订阅。然后,它禁用依赖检测。如果没有禁用依赖检测,那么原始新值编写者与当前可观察对象的订阅者之间将建立虚假的依赖关系。

注意

这种基本的发布/订阅实现可以很容易地用来创建消息系统。实际上,Ryan Niemeyer 已经创建了一个插件来完成这项工作(见github.com/rniemeyer/knockout-postbox)。

主要工作是遍历订阅并传递当前值给订阅回调。执行检查以确保订阅没有被销毁,因为一个订阅可能因为另一个订阅而被销毁。最后,前面的代码块结束当前帧的依赖检测。

通过这三个部分,Knockout 提供了一个简单且高效的依赖跟踪系统。

订阅可观察数组

从原型上讲,可观察数组仍然是可观察的,但由于它们的更改主要是它们的内容而不是它们的值,它们有很多额外的逻辑来确保高效的通知。

标准数组函数

自从 ECMAScript 的第一个版本以来,JavaScript 就有一套标准的数组函数,所以你应该已经熟悉它们。它们给 Knockout 带来的头痛是它们直接修改数组的内容。由于数组订阅者期望在数组内容发生变化时被通知,Knockout 为observableArray提供了自己的实现。此实现在对原始数组函数进行调用之前调用可观察的标准通知函数。slice函数被跳过,因为它是一个只读函数,不需要通知订阅者:

ko.utils.arrayForEach(["pop", "push", "reverse", "shift", "sort", "splice", "unshift"], function (methodName) {
  ko.observableArray['fn'][methodName] = function () {
    var underlyingArray = this.peek();
    this.valueWillMutate();
    this.cacheDiffForKnownOperation(underlyingArray, methodName, arguments);
    var methodCallResult = underlyingArray[methodName].apply(underlyingArray, arguments);
    this.valueHasMutated();
    return methodCallResult;
  };
});

自从 Knockout 1.0 以来,此函数几乎没有变化,当时它将方法添加到每个实例而不是可观察数组的fn原型。唯一的添加是调用cacheDiffForKnownOperation,它与内部trackArrayChanges扩展器一起工作,为增量更新数组提供更小、更快的变更通知。在此扩展器之前,可观察数组在每次更新时都会广播其全部内容。

此函数与普通可观察对象的write函数没有太大区别;它在执行更新之前调用valueWillMutate,并在之后调用valueHasMutated。它不是设置自己的值,而是将原始方法名应用于底层数组。

slice函数甚至更简单。它不会触发订阅,因为它只读。它所做的只是包装底层数组的原始函数:

ko.utils.arrayForEach(["slice"], function (methodName) {
  ko.observableArray['fn'][methodName] = function () {
    var underlyingArray = this();
    return underlyingArray[methodName].apply(underlyingArray, arguments);
  };
});

工具方法

除了标准方法之外,Knockout 还提供了对 JavaScript 中一些原因尚未实现的一些常见数组更改的友好函数:removeremoveAlldestroydestroyAllreplace

你现在应该能够猜到这些函数的样子;通过peek获取底层数组,调用valueWillMutate,进行一些更改,然后通过valueHasMutated完成。关于前面函数的有趣之处在于它们接受的参数。如果你传递一个对象给remove,如果它存在,它会预期地从这个数组中移除该对象。然而,如果你传递一个函数,它将被用作谓词,移除数组中任何导致谓词返回真值的元素(我非常喜欢这种模式):

remove: function (valueOrPredicate) {
  var underlyingArray = this.peek();
  var removedValues = [];
  var predicate = typeof valueOrPredicate == "function" && !ko.isObservable(valueOrPredicate) ? valueOrPredicate : function (value) { return value === valueOrPredicate; };
  for (var i = 0; i < underlyingArray.length; i++) {
    var value = underlyingArray[i];
    if (predicate(value)) {
      //Remove element, add to removedValues
    }
  }
  if (removedValues.length) {
    this.valueHasMutated();
  }
  return removedValues;
}

这通过将单个值转换为检查严格相等性的谓词来实现。检查!ko.isObservable(valueOrPredicate)很重要,因为可观察对象是函数,但在这里应该被视为值,而不是谓词。

这个相同的模式也用于destroy,只不过它使用_destory属性标记可观察对象,而不是移除它们。

removeAlldestroyAll函数也是重载的:它们可以接受要移除的值的数组,或者在没有提供参数的情况下移除所有元素。在提供值数组的案例中,它们只是调用基于数组的remove/destroy谓词:

removeAll: function (arrayOfValues) {
  // If you passed zero args, we remove everything
  if (arrayOfValues === undefined) {
    //remove all elements
  }
  return this'remove' {
    return ko.utils.arrayIndexOf(arrayOfValues, value) >= 0;
  });
}

原型链

回到第一章, Knockout 基础,我向你展示了这张图:

原型链

这些对象继承函数的方式不是通过正常的 JavaScript 原型链,其中构造函数的 prototype 被分配给一个对象。这是因为可观察对象是函数而不是对象,并且不能使用构造函数或Object.create函数来创建函数。标准的 JavaScript 原型继承对函数不起作用。要了解 Knockout 如何共享方法,让我们看看可订阅对象及其子对象可观察对象的构建方式。

首先,可订阅对象的基本方法定义在fn对象上:

var ko_subscribable_fn = {
  subscribe: function (callback, target, event) { /* logic */ },
  notifySubscribers: function (value, event) { /* logic */ },
  limit: function(limitFunction) { /* logic */ },
  hasSubscriptionsForEvent: function(event) { /* logic */ },
  getSubscriptionsCount: function () { /* logic */ },
  isDifferent: function(oldValue, newValue) { /* logic */ },
  extend: applyExtenders
};
ko.subscribable['fn'] = ko_subscribable_fn;

这是在构建可订阅对象时添加的:

ko.subscribable = function () {
  ko.utils.setPrototypeOfOrExtend(this, ko.subscribable['fn']);
  this._subscriptions = {};
}

setPrototypeOfOrExtend方法将分配对象的__proto__属性——这是更高版本的 IE 版本无法做到的——或者使用ko.utils.extend来扩展对象。

可观察对象是以不同的方式构建的。它们的工厂方法返回一个内部构建的对象,该对象使用ko.subscribable.callsetPrototypeOfOrExtend来继承方法:

ko.observable = function (initialValue) {
  var _latestValue = initialValue;

  function observable() {
    //build observable
  }
  ko.subscribable.call(observable);
  ko.utils.setPrototypeOfOrExtend(observable, ko.observable['fn']);

  observable.peek = function() { return _latestValue };
  observable.valueHasMutated = function () { 
    observable"notifySubscribers"; 
  }
  observable.valueWillMutate = function () { 
    observable"notifySubscribers"; 
  }

  return observable;
}

可观察对象被构建并运行通过可订阅对象的构造函数,扩展了observable[''fn'']对象,并最终添加了自己的方法。

ko.isObservable 函数

在标准的 JavaScript 继承中,instanceof 操作符可以用来检查一个对象或其任何原型是否有与提供的函数匹配的构造函数。因为 Knockout 不使用标准继承,所以它不能使用 instanceof 操作符;相反,Knockout 使用以下代码来实现 ko.isObservable 函数。

var protoProperty = ko.observable.protoProperty = "__ko_proto__";
ko.observable['fn'][protoProperty] = ko.observable;

ko.hasPrototype = function(instance, prototype) {
  if ((instance === null) || (instance === undefined) || (instance[protoProperty] === undefined)) return false;
  if (instance[protoProperty] === prototype) return true;
  return ko.hasPrototype(instance[protoProperty], prototype); // Walk the prototype chain
};

ko.isObservable = function (instance) {
  return ko.hasPrototype(instance, ko.observable);
}

Knockout 在 observable[''fn''] 对象上定义了一个 __ko_proto__ 属性,并将其设置为 ko.observable 对象。这个自定义原型属性被 hasPrototype 用于代替 instanceof 操作符,以确定实例化对象是否是可观察的。

绑定表达式解析器

在数据绑定属性中编写的表达式实际上不是真正的 JavaScript 或 JSON,尽管它们看起来非常相似。Knockout 有自己的解析器将这些属性转换为 JavaScript。比如说你写了一个这样的数据绑定属性:

data-bind="value: name, visible: showName"

然后,绑定提供者的任务是返回一个像这样的对象:

{
  value: function() { return name; },
  visible: function() { return showName; }
}

默认绑定提供者使用 ko.expressionRewriting 模块来完成这项工作,该模块负责调用绑定预处理程序并返回一个类似 JSON 的字符串。在内部,这是通过正则表达式将完整属性解析为一个键/值对数组来完成的。这听起来可能有些混乱,但它完成了工作。话虽如此,即使是对于“内部”的查看,这些细节对 Knockout 来说并不非常相关,因为解析是通用的。如果你仍然好奇,代码位于 github.com/knockout/knockout/blob/master/src/binding/expressionRewriting.js,其内联注释优于平均水平。

解析数据绑定属性后,键/值对数组被迭代以构建一个类似 JSON 的字符串数组:

function processKeyValue(key, val) {
  var writableVal;
  function callPreprocessHook(obj) {
    return (obj && obj['preprocess']) ? (val = obj'preprocess') : true;
  }
  if (!bindingParams) {
    if (!callPreprocessHook(ko'getBindingHandler'))
    return;

    if (twoWayBindings[key] && (writableVal = getWriteableValue(val))) {
      //provide a write method in case the value
      // isn't a writable observable.
      propertyAccessorResultStrings.push("'" + key + "':function(_z){" + writableVal + "=_z}");
    }
  }
  if (makeValueAccessors) {
    val = 'function(){return ' + val + ' }';
  }
  resultStrings.push("'" + key + "':" + val);
}

键用于查找绑定处理程序以调用其 preprocess 函数。如果它返回假值,则处理停止,因为绑定已被移除。当它来自 getBindingAccessors 时,makeValueAccessors 属性将为真,当它来自 getBindings 时为假。然后,结果被添加到一个运行列表中。

twoWayBindings 块向 propertyAccessorResultStrings 添加了一个特殊的功能字符串,在完成所有其他绑定键之后进行检查:

if (propertyAccessorResultStrings.length)
  processKeyValue('_ko_property_writers', "{" + propertyAccessorResultStrings.join(",") + " }");

这添加了一个额外的绑定属性 _ko_property_writers,它是一个函数,可以用来写入而不是读取绑定属性。我们将在下一分钟回到这个问题。

最后,通过连接返回运行列表中的字符串:

return resultStrings.join(",");

示例绑定产生的字符串将看起来像这样:

'value': function() { return name; }, 'visible': function() { return showName; '_ko_property_writers':function(){return {'value':function(_z){ name =_z} } } }

绑定提供者通过将字符串放在函数体中并使用绑定上下文和被绑定的元素调用该函数,将这个字符串转换成一个真正的对象:

var rewrittenBindings = ko.expressionRewriting.preProcessBindings(bindingsString, options),
  functionBody = "with($context){with($data||{}){return{" + rewrittenBindings + "}}}";
  return new Function("$context", "$element", functionBody);

注意

这种使用 new Function 的方法会导致在阻止 new Functioneval 的环境中(如 Google Chrome 扩展程序中)使用 Knockout 的默认绑定提供者失败。Knockout Secure Binding 是一个不使用 new Function 的绑定提供者,它允许 Knockout 与 CSP(见 github.com/brianmhunt/knockout-secure-binding)一起使用。

当这个函数在绑定上下文和元素上评估时,它会产生最终的绑定对象:

{
  value: function() { return name; },
  visible: function() { return showName; },
  _ko_property_writers: function (){
    return {'value':function(_z){query=_z} } 
  }
}

Knockout 属性写入器

我们还没有介绍 _ko_property_writers` 属性,因为它对大多数人来说都很令人惊讶,并且可能会分散注意力。这个属性的作用是暴露非可观察值的写入函数,以便双向绑定处理程序仍然可以更新它们的值。它们不是可观察的,因此不会发生通知,但这仍然是一个受支持的场景。

这种特殊的绑定是在绑定访问器上进行的。当需要更新 viewmodel 的双向绑定,例如 value,它们会调用 ko.expressionRewriting.writeValueToProperty

writeValueToProperty: function(property, allBindings, key, value, checkIfDifferent) {
  if (!property || !ko.isObservable(property)) {
    var propWriters = allBindings.get('_ko_property_writers');
    if (propWriters && propWriters[key])
    propWriterskey;
  } else if (ko.isWriteableObservable(property) && (!checkIfDifferent || property.peek() !== value)) {
    property(value);
  }
}

注意

这部分是 API 的非文档部分,因此可能会在没有通知的情况下更改。

如果属性不是可观察的并且存在一个属性写入器,则使用它来更新值。如果属性是可观察的,则直接写入属性。

应用绑定

绑定应用过程主要发生在 bindingAttributeSyntax 模块中,该模块定义了 ko.bindingContext 类以及 ko.applyBindings 方法。高级概述如下:

  1. 使用 viewmodel 调用 ko.applyBindings 方法。

  2. 使用 viewmodel 构造绑定上下文。

  3. ko.bindingProvider.instance 获取绑定提供者。

  4. Knockout 与 DOM 树协同工作:

    • 它通过绑定提供者的节点预处理器传递(除了根节点)

    • 使用绑定提供者构建节点的绑定处理程序

    • 通过确保它们的 after 属性中的任何绑定首先加载来对绑定处理程序进行排序

    • 遍历绑定处理程序,调用每个处理程序的 initupdate 函数。

前三个步骤相当直接;即使是遍历算法也只是一个简单的递归,它将绑定应用于一个节点,然后遍历其子节点以预处理和绑定它们。这个过程的真正核心是 applyBindingsToNodeInternal 函数,它实际上执行将绑定应用于节点的操作。

函数的前半部分是安全检查。我们将跳过这部分代码,因为它对于理解绑定部分的工作方式并不非常重要。因为我们已经介绍了绑定提供者如何生成绑定,所以我们只将查看最后两个要点。

排序绑定处理程序

Knockout 使用拓扑排序来对绑定处理程序进行排序。

如果你对拓扑排序不熟悉,请记住它来自图论。我们不会在这里详细介绍图论(如果你感兴趣,谷歌可以告诉你所有关于它的事情),但拓扑排序基本上是元素的排序,确保一个元素的依赖项都在该元素本身之前。拓扑排序不保证每次都保证相同的顺序;只是不存在依赖循环。

这是 Knockout 用来排序绑定处理程序的排序函数;它是一个相当常见的实现:

function topologicalSortBindings(bindings) {
  // Depth-first sort
  var result = [],                // The list of key/handler pairs that we will return
  bindingsConsidered = {},    // A temporary record of which bindings are already in 'result'
  cyclicDependencyStack = []; // Keeps track of a depth-search so that, if there's a cycle, we know which bindings caused it
  ko.utils.objectForEach(bindings, function pushBinding(bindingKey) {
    if (!bindingsConsidered[bindingKey]) {
      var binding = ko'getBindingHandler';
      if (binding) {
        // First add dependencies (if any) of the current binding
        if (binding['after']) {
          cyclicDependencyStack.push(bindingKey);
          ko.utils.arrayForEach(binding['after'], function(bindingDependencyKey) {
            if (bindings[bindingDependencyKey]) {
              if (ko.utils.arrayIndexOf(cyclicDependencyStack, bindingDependencyKey) !== -1) {
                throw Error("Cannot combine the following bindings, because they have a cyclic dependency: " + cyclicDependencyStack.join(", "));
              } else {
                pushBinding(bindingDependencyKey);
              }
            }
          });
          cyclicDependencyStack.length--;
        }
        // Next add the current binding
        result.push({ key: bindingKey, handler: binding });
      }
      bindingsConsidered[bindingKey] = true;
    }
  });

  return result;
}

这个函数会遍历提供的绑定,跳过它已经处理过的绑定;如果它有一个after属性,它将开始依赖检查。它将当前绑定推入跟踪依赖的数组中,然后遍历after属性中的每个绑定。如果已经发现依赖绑定在依赖数组中,Knockout 会抛出一个异常,这意味着存在循环依赖。如果依赖绑定未找到,它将递归到循环处理程序中,以便检查其依赖项。

在检查完依赖绑定后,将依赖数组中的最后一个元素移除,并将当前绑定推入结果数组和已处理绑定数组。如果未来的绑定需要它作为依赖项,循环处理程序将立即返回,表示未来的绑定可以安全继续。

运行绑定处理程序

在获取正确的绑定处理程序顺序后,它们将被迭代。进行最后一次安全检查以确保如果节点是注释节点,则允许虚拟元素使用绑定处理程序。然后在try/catch块中调用initupdate函数:

// Run init, ignoring any dependencies
var handlerInitFn = bindingKeyAndHandler.handler["init"];
if (typeof handlerInitFn == "function") {
  ko.dependencyDetection.ignore(function() {
    var initResult = handlerInitFn(node, 
    getValueAccessor(bindingKey),
    allBindings,
    bindingContext['$data'],
    bindingContext);

    // If this binding handler claims to control descendant bindings, make a note of this
    if (initResult && initResult['controlsDescendantBindings']) {
      if (bindingHandlerThatControlsDescendantBindings !== undefined)
      throw new Error("Multiple bindings (" + bindingHandlerThatControlsDescendantBindings + " and " + bindingKey + ") are trying to control descendant bindings of the same element. You cannot use these bindings together on the same element.");
      bindingHandlerThatControlsDescendantBindings = bindingKey;
    }
  });
}

整个过程在一个禁用依赖检测的作用域中运行,因为init函数不会运行两次。init处理程序传递所有必需的参数,并检查结果以查看此处理程序是否想要控制子代绑定。如果不是第一个控制子代绑定的处理程序,Knockout 会抛出一个异常:

// Run update in its own computed wrapper
var handlerUpdateFn = bindingKeyAndHandler.handler["update"];
if (typeof handlerUpdateFn == "function") {
  ko.dependentObservable(
    function() {
      handlerUpdateFn(node, 
      getValueAccessor(bindingKey), 
      allBindings, 
      bindingContext['$data'], 
      bindingContext);
    },
    null,
    { disposeWhenNodeIsRemoved: node }
  );
}

update处理程序在计算可观察值(dependantObservable是计算值的原始名称,仍在源代码中使用)内部运行,当依赖项发生变化时,它会自动重新运行。这是 Knockout 我最喜欢的部分之一:绑定处理程序在可观察依赖项变化时自动重新运行,因为它们本身就在可观察值内部

一旦所有绑定处理程序都已遍历,applyBindingsToNodeInternal函数返回一个对象,告诉其调用者是否需要使用init处理程序的结果中的标志递归到当前节点的子节点:

return {
  'shouldBindDescendants': bindingHandlerThatControlsDescendantBindings === undefined
};

模板

Knockout 的模板系统非常灵活:它支持匿名模板、命名模板,并允许覆盖渲染模板的引擎。模板绑定还用于foreach绑定,它只是{ foreach: someExpression }模板的语法糖。要了解模板系统是如何工作的,让我们从模板绑定处理程序开始。

模板绑定处理程序

模板绑定的init函数理解模板可以是命名的(从源加载)或内联(使用绑定元素的內容加载):

'init': function(element, valueAccessor) {
  // Support anonymous templates
  var bindingValue = ko.utils.unwrapObservable(valueAccessor());
  if (typeof bindingValue == "string" || bindingValue['name']) {
    // It's a named template - clear the element
    ko.virtualElements.emptyNode(element);
  } else {
    var templateNodes = ko.virtualElements.childNodes(element),
    container = ko.utils.moveCleanedNodesToContainerElement(templateNodes);
    new ko.templateSources.anonymousTemplate(element)'nodes';
  }
  return { 'controlsDescendantBindings': true };
}

如果绑定值只是一个字符串,或者绑定值是一个具有name属性的对象,那么我们正在使用一个命名源,并且需要完成的工作仅仅是清空节点。命名源需要在模板名称更改时进行更改,因此实际渲染模板的所有工作都在update方法中。

如果是一个匿名模板,moveCleanedNodesToContainerElement将移除元素的孩子并将它们放置在一个div容器中,但div容器不会被放置在 DOM 中。使用该元素创建一个新的匿名模板源,并将div容器传递给模板的nodes函数。nodes函数使用utils.domData存储容器。

模板源是一个由模板引擎使用的对象,用于提供渲染模板所需的 DOM。它必须提供一个返回包含要使用节点的容器的nodes函数,或者提供一个提供相同内容的字符串化的text函数。ko.templateSources数组包含两种模板源类型:domElement用于命名源,anonymousTemplate用于内联源。

最后,init函数返回{ 'controlsDescendantBindings': true }

update函数有三个不同的分支:渲染单个模板的分支、使用foreach渲染模板数组的分支,以及如果存在if(或ifnot)绑定且为假时删除所有内容的分支。最后一个分支不需要太多解释,而前两个分支在功能上非常相似:它们在模板引擎上调用renderTemplate,该引擎返回一个 DOM 节点数组,然后这些节点被添加到 DOM 中。之后,它们各自在模板上调用applyBindings

模板引擎

模板引擎负责生成 DOM 节点。然而,它不能单独使用,因为它只是一个基类。当在基模板引擎上调用renderTemplate时,它调用makeTemplateSource并将结果传递给renderTemplateSource

默认的makeTemplateSource方法接受一个模板参数。如果模板是一个字符串,它将尝试通过该名称查找脚本并创建一个domElement源。如果模板是一个节点,它将从中创建并返回一个新的anonymousTemplate源。

默认的renderTemplateSource方法未实现,将抛出错误。模板实现必须覆盖此方法才能工作。

Knockout 提供了两个模板引擎实现:原生和 jQuery.tmpl。jQuery.tmpl 引擎自 2011 年以来就没有再进行开发了,我认为 Knockout 继续包含在标准分发中可能更多的是向后兼容性,而不是任何人真正需要的。它在那里,但我们将会忽略它。

原生模板引擎使用此方法覆盖renderTemplateSource

function (templateSource, bindingContext, options) {
  // IE<9 cloneNode doesn't work properly
  var useNodesIfAvailable = !(ko.utils.ieVersion < 9),
  templateNodesFunc = useNodesIfAvailable ? templateSource['nodes'] : null,
  templateNodes = templateNodesFunc ? templateSource['nodes']() : null;

  if (templateNodes) {
    return ko.utils.makeArray(templateNodes.cloneNode(true).childNodes);
  } else {
    var templateText = templateSource['text']();
    return ko.utils.parseHtmlFragment(templateText);
  }
};

如果存在nodes,它将被用来获取模板节点容器,克隆它,并返回它。如果它在更高的 IE 版本中,克隆不起作用,或者如果没有提供nodes,文本源将由ko.utils解析,并返回。

模板引擎不会将节点添加到 DOM 中,也不会绑定它们;它只是返回它们。模板绑定在从模板引擎获取生成的模板后负责这部分。

ko.utils参考

ko.utils命名空间是 Knockout 的实用函数桶。并非所有这些函数都是公开暴露的——至少不是以可用的方式。Knockout 的压缩过程使其中超过一半的函数变得模糊。由于未模糊的方法是 Knockout 承诺提供的公共 API,因此更改它们将是一个重大的变更。尽管考虑了 API 的ko.utils部分的公开方法,但 Knockout 并没有为它们提供任何文档。

以下是 Knockout 3.2 版本中ko.utils的公共函数完整列表:

  • addOrRemoveItem(array, item, included): 如果includedtrue,它将如果项目不在数组中则将其添加到数组中;如果includedfalse,它将如果项目存在则从数组中移除它。

  • arrayFilter(array, predicate): 这返回一个数组,其中包含从数组中返回谓词true的元素,使用predicate(element, index)

  • arrayFirst(array, predicate, predicateOwner): 这返回数组中第一个使谓词返回true的元素,使用predicate.call(predicateOwner, element, index)。这使得predicateOwner成为一个可选参数,它控制谓词中的这部分。

  • arrayForEach(array, action): 这将在数组的每个元素上调用操作,使用action(element, index)

  • arrayGetDistinctValues(array): 这返回一个只包含原始数组中唯一元素的新数组。它使用ko.utils.arrayIndexOf来确定唯一性。

  • arrayIndexOf(array, item): 如果存在Array.prototype.indexOfarrayIndexOf(array, item)将调用它,否则它将手动遍历数组并返回索引或如果找不到元素则返回-1。这是针对小于 9 版本的 Internet Explorer 的 polyfill。

  • arrayMap(array, mapping): 这并不是Array.prototype.map的 polyfill;这个函数通过在原始数组的每个元素上调用mapping(element, index)来返回一个新数组。

  • arrayPushAll(array, valuesToPush): 这个函数将 valuesToPush 参数推入 array 参数。此函数处理 valuesToPush 类似于数组但实际上不是数组的情况,例如 HTMLCollection,在正常情况下调用 array.push.apply(array, valuesToPush) 会失败。

  • arrayRemoveItem(array, itemToRemove): 这个函数通过剪切或移动,根据项目索引从数组中移除项目。

  • domData: 此对象提供 getsetclear 方法,以便在 DOM 节点上处理任意键/值对。Knockout 内部使用它来跟踪绑定信息,但也可以用来存储任何内容。

  • domNodeDisposal: 此对象提供与 DOM 清理任务相关的以下实用工具:

    • addDisposeCallback(node, callback): 这个函数向具有 domData 的节点添加回调。如果 Knockout 通过模板或控制流删除节点,将使用此回调。

    • cleanNode(node): 这个函数运行所有与 addDisposeCallback 注册的关联的销毁回调。此函数别名为 ko.cleanNode

    • cleanExternalData(node): 这个函数使用 jQuery 的 cleanData 函数来移除 jQuery 插件添加的数据。如果未找到 jQuery,则不执行任何操作。

    • removeDisposeCallback(node, callback): 这个函数从节点的 domData 函数中移除回调。

    • removeNode(node): 这个函数使用 cleanNode 清理节点,然后将其从 DOM 中删除。此函数别名为 ko.removeNode

  • Extend(target, source): 这是一个普通的扩展方法;它将源上的所有属性添加或覆盖到目标上。它使用 hasOwnProperty 过滤源属性。

  • fieldsIncludedWithJsonPost: 这是一个默认字段数组,用于 postJson,如果没有指定 includeFields 选项。

  • getFormFields(form, fieldName): 这个函数返回所有与 fieldname 匹配的 inputtextarea 字段,其中 fieldname 可以是一个字符串、一个正则表达式,或者一个包含测试谓词的对象,该谓词接受字段名称。

  • objectForEach(obj, action): 这个函数对 obj 中的每个属性调用 action(properyName, propetyValue),使用 hasOwnProperty 过滤。

  • parseHtmlFragment(html): 如果存在 jQuery,此函数使用其 parseHTML 函数;否则,它使用简单的内部 HTML 解析。它返回 DOM 节点。

  • parseJson(jsonString): 通过解析提供的字符串,这将返回一个 JavaScript 对象。如果 JSON 对象存在,它将被使用;否则,将使用 new Function

  • peekObservable(value): 就像 ko.unwrap 一样,这是一个安全方法。如果值是可观察的,它将返回其 peek 的结果;否则,它将只返回值。

  • postJson(urlOrForm, data, options): 这将通过创建一个新的表单,将其附加到 DOM 上,并在其上调用 submit 来执行一个 POST 操作。表单将使用 data 来创建其字段。如果 urlOrForm 是一个表单,并且其字段与 options['includeFields'](或如果 options['includeFields'] 不存在,则为 fieldsIncludedWithJsonPost)匹配,则其字段将包含在数据中,并且其 action 将用作 URL。

  • Range(min, max): 这返回一个介于 minmax 之间的值数组。它对两个参数都使用 ko.unwrap

  • registerEventHandler(element, eventType, handler): 这将事件处理器附加到元素上。如果可能,它使用 jQuery,如果可用,则使用 addEventListener,或者作为最后的手段使用 attachEvent(Internet Explorer)。如果使用 attachEvent,它将注册一个清理处理器来调用 detachEvent,因为 IE 不会自动这样做。

  • setHtml(node, html): 这会清空节点的内容,解包 HTML,并使用 jQuery.html(如果可用)或 parseHtmlFragement 来设置节点的 HTML。

  • stringifyJson(data, replacer, space): 这使用 ko.unwrap 来处理可观察数据,并调用 JSON.stringifyreplacerspace 参数是可选的。如果 JSON 对象不存在,它将抛出异常。

  • toggleDomNodeCssClass(node, classNames, shouldHaveClass): 这使用 shouldHaveClass 布尔值来决定是否添加或移除节点上所有 classNames 布尔值。

  • triggerEvent(element, eventType): 这在元素上触发事件。当适用时,它使用 jQuery,并处理在 IE 和 jQuery 中引发点击事件的已知问题。

  • unwrapObservable(value): 这是 ko.unwrap 的原始名称,为了向后兼容而保留。它将返回可观察值的底层值,或者如果不是可观察值,则返回其本身。

摘要

虽然这当然不是对 Knockout 内部结构的详尽分析,你可能根本不希望这样做,但你至少应该对 Knockout 如何完成大多数重要任务有一个很好的理解。本章涵盖了依赖跟踪、原型fn)链、绑定表达式解析器、ko.applyBindings 的工作方式、Knockout 如何处理模板以及 ko.utils 命名空间。希望你会对每个系统如何内部工作感到舒适。了解这些组件如何组合在一起应该有助于你在调试那些真正棘手的错误时。

posted @ 2025-10-26 08:58  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报