jQuery 热点(三)
原文:
zh.annas-archive.org/md5/80D5F95AD538B43FFB0AA93A33E9B04F译者:飞龙
第九章:一个 jQuery 热图
热图可以告诉您有关您的网站如何使用的很多信息。在分析领域,这是一种有价值的工具,可以告诉您网站的哪些功能被最多使用,以及哪些区域可能需要一些改进以真正吸引访问者。
任务简报
在这个项目中,我们将建立自己的热图,记录任何页面的哪些区域被点击最多。我们需要建立一种实际记录每次点击发生的位置以及将该信息传输到某个地方以便存储的方法。
我们实际上将构建整个热图的两个不同部分 - 客户端部分在访问者的浏览器中执行以捕获点击,并且一个管理控制台,向网站的所有者显示热图。
我们需要考虑不同的分辨率和设备,以便捕获尽可能多的信息,并确保我们的脚本足够高效地在后台运行而不被注意到。
当然,在客户端不会发生任何可见的事情(所有这部分将做的就是记录和存储点击),但是在项目结束时,我们将能够在管理控制台中显示有关页面上所有点击的数量和位置的详细信息,如以下屏幕截图所示:
它为什么很棒?
所有的分析对网站的所有者都是有用的,并且可以提供有关访问网站的人的详细信息,包括他们的计算环境,他们进入网站的页面,他们离开的页面以及他们访问的页面数量。
从开发者的角度来看,热图同样具有信息量。您页面的哪些部分被点击最频繁?热图可以告诉您。
我们将构建的热图将适用于能够根据设备屏幕宽度改变其布局以适应的响应式网站。单个项目远远不足以涵盖响应式设计的所有方面,因为我们主要关注脚本本身,所以我们不会详细介绍它。
如果您已经使用过响应式技术,那么您将不需要额外的信息。如果您之前没有使用过响应式原理,那么这应该是一个关于该主题的温和介绍,应该作为该主题的入门手册。
您的热门目标
在这个项目中,我们将涵盖以下任务:
-
确定并保存环境
-
捕获访问者的点击
-
保存点击数据
-
添加管理控制台
-
请求点击数据
-
显示热图
-
允许选择不同的布局
-
显示每个布局的热图
任务清单
这是唯一一个我们不打算自己构建所需的 HTML 和 CSS 的项目。我们希望我们的热图能够与各种布局配合使用,测试这一点的最佳方法是使用响应式布局。如果我们自己编写代码,我们会在此项目的大部分时间里仅编写和讨论布局,甚至在开始制作热图之前。
我们将在这个项目中使用一个预先构建的响应式模板,这样我们就可以直接进入有趣的部分,而不会分心。我们将使用的模板称为 Simplex,但不幸的是,它已经不再在线上提供了。您需要使用本书附带下载的模板文件。只需将下载存档中的simplex文件夹复制到主jquery-hotshots项目目录中即可。我们需要做的就是在模板的每个 HTML 页面中添加几个脚本引用。应该更新的文件是:
-
contact.html -
gallery.html -
index.html -
who-we-are.html
新的<script>元素可以放在每个页面的<body>底部。首先,我们需要 jQuery:
<script src="img/jquery-1.9.0.min.js"></script>
我们还将使用我们在上一个项目中使用的 imagesLoaded 插件:
<script src="img/jquery.imagesloaded.min.js"></script>
在这个项目中,我们将创建两个脚本,一个用于客户端,一个用于管理控制台。最初,我们将使用客户端脚本,因此我们应该在每个页面中添加以下内容:
<script src="img/heat-map-client.js"></script>
当然,这个文件还不存在,所以在我们进行设置时,我们可以先创建这个文件。它应该保存在js目录中,与我们的其他脚本一起。
确定并保存环境
在我们的第一个任务中,我们将存储一些关于当前浏览环境的信息,例如当前页面的 URL。我们还将解析任何附加的样式表,查找媒体查询。
准备升空
我们将像我们在大多数其他项目中所做的那样,从我们的document ready快捷方式开始。在heat-map-client.js文件中,添加以下代码:
$(function () {
});
我们添加到这个文件的所有附加代码都将放在此回调函数中。
启动推进器
我们首先设置一系列在整个脚本中将使用的变量。我们还需要解析任何附加的样式表,并查找媒体查询,以便我们可以确定为不同布局定义了哪些断点。
注意
媒体查询是一种在 CSS 中指定一组样式的方法,只有在满足某些条件时才会应用,例如屏幕的宽度。有关更多信息,请参阅en.wikipedia.org/wiki/Media_queries。
将以下代码添加到我们刚刚添加的回调函数中:
var doc = $(document),
clickStats = {
url: document.location.href,
clicks: []
},
layouts = [];
$.ajaxSetup({
type: "POST",
contentType: "application/json",
dataType: "json"
});
$.each(doc[0].styleSheets, function (x, ss) {
$.each(ss.rules, function (y, rule) {
if (rule.media&&rule.media.length) {
var jq = $,
current = rule.media[0],
mq = {
min: (current.indexOf("min") !== -1) ?
jq.trim(current.split("min-width:")[1]
.split("px")[0]) : 0,
max: (current.indexOf("max") !== -1) ?
jq.trim(current.split("max-width:")[1]
.split("px")[0]) : "none"
};
layouts.push(mq);
}
});
});
layouts.sort(function (a, b) {
return a.min - b.min;
});
$.ajax({
url: "/heat-map.asmx/saveLayouts",
data: JSON.stringify({ url: url, layouts: layouts })
});
完成目标 - 迷你总结
我们首先定义了一系列变量。我们缓存了对document对象的引用,并使用 jQuery 功能对其进行了包装。然后我们创建了一个名为clickStats的对象,我们将用作会话的通用存储容器。
在对象内部,我们存储页面的 URL,并定义一个名为clicks的空数组,用于存储每次点击事件。最后,我们创建另一个数组,这次在我们的clickStats对象之外,我们将使用它来存储代表文档每个布局的对象。
我们还使用 jQuery 的ajaxSetup()方法为任何 AJAX 请求设置一些默认值,该方法接受包含要设置的选项的对象。我们将进行几个请求,因此设置在两个请求中都设置的任何选项的默认值是有意义的。在本例中,我们需要将type设置为POST,将contentType设置为application/json,并将dataType设置为json。
我们的下一个代码块涉及解析通过<link>元素附加到文档的任何样式表,并提取其中定义的任何媒体查询。
我们首先使用 jQuery 的each()方法来迭代存储在document对象的StyleSheets集合中的样式表对象。对于每个样式表,集合中都会有一个对象,其中包含其所有选择器和规则,包括任何媒体查询。
我们正在迭代的集合由对象组成,因此我们传递给each()方法的回调函数将接收当前对象的索引(我们将其设置为x)和当前对象本身(我们将其设置为ss)作为参数。
在我们的回调函数内部,我们再次使用 jQuery 的each()方法。这次,我们正在迭代传递给回调函数的ss对象的rules集合。此集合将包含一系列对象。我们传递给该方法的回调函数将再次接收索引(这次设置为y)和当前对象(这次设置为rule)作为参数。
对象的类型将取决于其是什么。它可能是一个CSSImportRule,用于@import语句,一个CSSFontFaceRule,用于@font-face规则,一个CSSStyleRule,用于样式表定义的任何选择器,或者一个CSSMediaRule,用于任何媒体查询。
我们只对CSSMediaRule对象感兴趣,因此在嵌套的each()回调中,我们首先检查规则对象是否具有media属性,以及媒体属性是否具有length。
只有CSSMediaRule对象会有一个media属性,但是此属性可能为空,因此我们可以在嵌套的回调中使用if条件检查此属性的存在并检查其是否具有length。
如果这两个条件都为true(或者是真值),我们就知道我们找到了一个媒体查询。我们首先设置一些新变量。第一个变量是media集合的第一项,它将包含定义媒体查询的文本字符串,第二个是一个称为mq的对象,我们将使用它来存储媒体查询的断点。
我们设置了该对象的两个属性 - 媒体查询的min和max值。我们通过检查文本字符串是否包含单词min来设置min属性。如果是,我们首先在术语min-width:上拆分字符串,然后获取split()函数将返回的数组中的第二项,然后在结果字符串上拆分术语px并获取第一项。我们可以像这样链式调用split(),因为该函数返回一个数组,这也是它被调用的方式。
如果字符串不包含单词min,我们将值设置为0。如果存在max-width,我们也执行同样的操作来提取它。如果没有max-width,我们将其设置为字符串none。创建layout对象后,我们将其推送到layouts数组中。
最后,我们对我们的断点数组进行排序,以便按升序排列。我们可以通过向 JavaScript 的sort()方法传递一个排序函数来做到这一点,该方法在数组上调用。我们传递的函数将从我们正在排序的数组中接收两个项目。
如果第一个对象的min属性小于第二个对象b的min属性,则函数将返回一个负数,这会将较小的数字放在数组中较大的数字之前 - 这正是我们想要的。
因此,我们将得到一个数组,其中每个项目都是一个特定的断点,它在数组中逐渐增加,从而使稍后检查哪个断点正在应用变得更加容易。
最后,我们需要将这些数据发送到服务器,可能是为了保存。对于这个请求,我们需要设置的唯一选项是要发送请求的 URL,以及我们用来将页面的 URL 和媒体查询数组发送到服务器的data选项。当然,我们之前设置的 AJAX 默认值也会被使用。
分类情报
如果您已经熟悉媒体查询,请随意跳到下一个任务的开始;如果没有,我们在这里简要地看一下它们,以便我们都知道我们的脚本试图做什么。
媒体查询类似于 CSS 中的if条件语句。CSS 文件中的媒体查询将类似于以下代码片段:
@media screen and (max-width:320px) {
css-selector { property: style; }
}
该语句以@media开头表示媒体查询。查询指定了一个媒介,例如screen,以及可选的附加条件,例如max-width或min-width。只有在满足查询条件时,查询中包含的样式才会被应用。
媒体查询是响应式网页设计的主要组成部分之一,另一个是相对尺寸。通常,一个响应式构建的网页将有一个或多个媒体查询,允许我们为一系列屏幕尺寸指定不同的布局。
我们包含的每个媒体查询都将设置布局之间的断点。当断点超过时,例如在前一个媒体查询中设备的最大宽度小于320px时,布局会按照媒体查询指示进行更改。
捕获访客点击
在这个任务中,我们需要构建捕获页面上发生的任何点击的部分。在页面打开时,我们希望记录有关布局和点击本身的信息。
启动推进器
我们可以使用以下代码捕获点击并记录我们想要存储的其他信息,该代码应直接添加到上一个任务中我们添加到heat-map-client.js中的ajax()方法之后:
$.imagesLoaded(function() {
doc.on("click.jqHeat", function (e) {
var x = e.pageX,
y = e.pageY,
docWidth = doc.outerWidth(),
docHeight = doc.outerHeight(),
layout,
click = {
url: url,
x: Math.ceil((x / docWidth) * 100),
y: Math.ceil((y / docHeight) * 100)
};
$.each(layouts, function (i, item) {
var min = item.min || 0,
max = item.max || docWidth,
bp = i + 1;
if (docWidth>= min &&docWidth<= max) {
click.layout = bp;
} else if (docWidth> max) {
click.layout = bp + 1;
}
});
clickStats.clicks.push(click);
});
});
目标完成 - 小型总结
我们可以通过使用 jQuery 的on()方法添加处理程序来监听页面上的点击,我们还希望确保页面中的任何图像在我们开始捕获点击之前已完全加载,因为图像将影响文档的高度,进而影响我们的计算。因此,我们需要将我们的事件处理程序附加到imagesLoaded()方法的回调函数内。
我们将click指定为要监听的事件,但同时使用jqHeat对事件进行命名空间化。我们可能希望在一系列页面上使用此代码,每个页面可能具有自己的事件处理代码,我们不希望干扰此代码。
在事件处理程序中,我们首先需要设置一些变量。该函数将事件对象作为参数接收,我们使用它来设置我们的前两个变量,这些变量存储点击的x和y位置。此数字将表示页面上的像素点。
我们然后存储文档的宽度和高度。我们每次点击都存储这个的原因是因为页面的宽度,以及因此文档的高度,在页面打开期间可能会发生变化。
有人说只有开发人员在测试响应式构建时调整浏览器大小,但这并不总是事实。根据正在使用的媒体查询定义的断点,设备方向的变化可能会影响文档的宽度和高度,这可能会在页面加载后的任何时间发生。
接下来我们定义layout变量,但我们暂时不为其分配值。我们还创建一个新对象来表示点击。在此对象中,我们最初将点击坐标存储为百分比。
将像素坐标转换为百分比坐标是一个微不足道的操作,只需将像素坐标除以文档的宽度(或高度),然后将该数字乘以100即可。我们使用 JavaScript 的Math.ceil()函数使数字向上舍入到下一个整数。
接下来,我们需要确定我们处于哪种布局中。我们可以再次使用 jQuery 的each()方法迭代我们的layouts数组。回调函数的第一个参数接收layouts数组中当前项目的索引,第二个参数是实际对象。
在回调函数内部,我们首先设置我们的变量。这次我们需要的变量是布局的最小宽度,我们将其设置为对象的min属性,如果没有定义min,则设置为零。我们还将max变量设置为当前项目的max属性,或者如果没有max属性,则设置为文档的宽度。
我们最后的变量只是将当前索引加1。索引是从零开始的,但是对于我们的布局来说,将其标记为1到布局数目比标记为0到布局数目更有意义。
然后,我们使用一个if条件来确定当前应用的是哪个布局。我们首先检查当前文档宽度是否大于或等于媒体查询的最小值,并且小于或等于最大值。如果是,我们就知道我们在当前布局内,因此将转换后的布局索引保存到我们的click对象中。
如果我们没有匹配到任何布局,那么浏览器的大小必须大于媒体查询定义的最大max-width值,所以我们将布局设置为转换后的布局再加一。最后,我们将创建的click对象添加到我们的clickStats对象的clicks数组中。
保存点击数据
有人访问了一个我们的热图客户端脚本正在运行的页面,他们点击了一些内容,到目前为止我们的脚本已记录了每次点击。现在呢?现在我们需要一种将这些信息传输到服务器以进行永久存储并在管理控制台中显示的方法。这就是我们将在本任务中看到的内容。
启动推进器
我们可以确保将捕获的任何点击都发送到服务器以进行永久存储,使用以下代码,应在imagesLoaded()回调函数之后添加:
window.onbeforeunload = function () {
$.ajax({
async: false,
type: "POST",
contentType: "application/json",
url: "/heat-map.asmx/saveClicks",
dataType: "json",
data: JSON.stringify({ clicks: clicks })
});
}
目标完成 - 迷你简报
我们为window对象附加了一个beforeunload事件处理程序,以便在离开页面之前将数据发送到服务器。不幸的是,这个事件并不总是被完全处理 - 有时它可能不会触发。
为了尽量将此功能减少到最小,我们直接将事件处理程序附加到原生的window对象上,而不是 jQuery 包装的对象,我们可以通过数组中的第一个项目访问该对象,该项目是 jQuery 对象。
使用任何 jQuery 方法,包括on(),都会增加额外开销,因为会调用 jQuery 方法以及底层的 JavaScript 函数。为了尽量减少这种开销,我们在这里避免使用 jQuery,并恢复到使用旧式方法来附加事件处理程序,即以on作为事件名的前缀,并将函数分配为它们的值。
在这个函数内部,我们需要做的就是将数据发送到服务器,以便将其插入到数据库中。我们使用 jQuery 的ajax()方法发起请求,并将async选项设置为false以使请求同步进行。
这很重要,并且将确保请求在 Chrome 中发出。无论如何,我们对服务器的响应不感兴趣 - 我们只需确保在页面卸载之前发出请求即可。
我们还将 type 设置为 POST,因为我们正在向服务器发送数据,并将 contentType 设置为 application/json,这将为请求设置适当的头,以确保服务器正确处理数据。
url 明显是我们要发送数据到的 Web 服务的 URL,并且我们将 dataType 设置为 json,这样可以更容易地在服务器上消耗数据。
最后,我们将 clicks 数组转换为字符串并使用浏览器的原生 JSON 引擎将其包装在对象中。我们使用 data 选项将字符串化的数据发送到服务器。
此时,当打开连接到该脚本的页面时,脚本将在后台静静运行,记录页面上点击的任何点的坐标。当用户离开页面时,他们生成的点击数据将被发送到服务器进行存储。
机密情报
不具有 JSON 引擎的浏览器,比如 Internet Explorer 的第 7 版及更低版本,将无法运行我们在此任务中添加的代码,尽管存在可在这些情况下使用的 polyfill 脚本。
更多信息请参阅 Github 上的 JSON 仓库(github.com/douglascrockford/JSON-js)。
添加管理控制台
我在项目开始时说过我们不需要编写任何 HTML 或 CSS。那是一个小小的夸张;我们将不得不自己构建管理控制台页面,但不用担心,我们不需要写太多代码 - 我们在页面上显示的大部分内容都将是动态创建的。
准备起飞
根据我们的标准模板文件创建一个名为 console.html 的新 HTML 页面,并将其保存在我们为此项目工作的 simplex 目录中。接下来创建一个名为 console.js 的新脚本文件,并将其保存在相同的文件夹中。最后,创建一个名为 console.css 的新样式表,并将其保存在 simplex 目录内的 css 文件夹中。
我们应该从新的 HTML 页面的 <head> 中链接到新样式表:
<link rel="stylesheet" href="css/console.css" />
我们还应该在 <body> 的底部链接到 jQuery 和我们的新脚本文件:
<script src="img/jquery-1.9.0.min.js"></script>
<script src="img/console.js"></script>
最后,我们应该将类名 jqheat 添加到 <body> 元素中:
<body class="jqheat">
启动推进器
页面将需要显示一个界面,用于选择要查看点击统计信息的页面。将以下代码添加到 console.html 的 <body> 中:
<header>
<h1>jqHeat Management Console</h1>
<fieldset>
<legend>jqHeat page loader</legend>
<input placeholder="Enter URL" id="url" />
<button id="load" type="button">Load page</button>
</fieldset>
</header>
<section role="main">
<iframe scrolling="no" id="page" />
</section>
我们还可以为这些元素添加一些非常基本的 CSS。将以下代码添加到 console.css 中:
.jqheat{ overflow-y:scroll; }
.jqheat header {
border-bottom:1px solid #707070; text-align:center;
}
.jqheat h1 { display:inline-block; width:100%; margin:1em 0; }
.jqheat fieldset {
display:inline-block; width:100%; margin-bottom:3em;
}
.jqheat legend { display:none; }
.jqheat input {
width:50%; height:34px; padding:0 5px;
border:1px solid #707070; border-radius:3px;
}
.jqheat input.empty{ border-color:#ff0000; }
.jqheat button { padding:9px5px; }
.jqheat section {
width:100%;margin:auto;
position:relative;
}
.jqheat iframe, .jqheat canvas {
Width:100%; height:100%; position:absolute; left:0; top:0;
}
.jqheat canvas { z-index:999; }
在此任务中,我们不会添加任何实际功能,但我们可以准备好我们的脚本文件,以便在下一个任务中使用通常的 document ready 处理程序。在 console.js 中,添加以下代码:
$(function () {
});
目标已完成 - 迷你总结
我们的页面首先包含一个包含<h1>和<fieldset>中页面标题的<header>元素。在<fieldset>内是必须的<legend>和一个非常简单的页面 UI,它包含一个<input>和一个<button>元素。<input>和<button>元素都有id属性,以便我们可以在脚本中轻松选择它们。
页面的主要内容区域由一个<section>元素组成,该元素具有role属性为main。使用此属性标记页面的主要内容区域是标准做法,有助于澄清该区域对辅助技术的意图。
<section>内部是一个<iframe>。我们将使用<iframe>来显示用户想要查看点击统计信息的页面。目前,它只有一个id属性,这样我们就可以轻松选择它,并且非标准的scrolling属性设置为no。我不太喜欢使用非标准属性,但在这种情况下,这是防止在加载内容文档时<iframe>出现无意义滚动条的最简单方法。
页面很可能会导致滚动条出现,而我们可以设置页面的<body>永久具有垂直滚动条,而不是在滚动条出现时发生的移动。除此之外,CSS 主要是一些定位相关的东西,我们不会深入研究。
机密情报
我们在<input>元素上使用了 HTML5 的placeholder属性,在支持的浏览器中,该属性的值会显示在<input>内部,作为内联标签。
这很有用,因为这意味着我们不必添加一个全新的元素来显示一个<label>,但是在撰写时,支持并不是 100%。幸运的是,有一些出色的polyfills可以在不支持的浏览器中提供合理的回退。
注意
Modernizr 团队推荐了一整套placeholder polyfills(还有许多其他推荐)。您可以通过访问github.com/Modernizr/Modernizr/wiki/HTML5-Cross-Browser-Polyfills来查看完整列表。
请求点击数据
控制台页面几乎为空,主要包含一个用于加载我们想要查看点击数据的页面的表单。在这个任务中,我们将看看如何加载该页面并从服务器请求其数据。
启动推进器
在console.js中的空函数中添加以下代码:
var doc = $(document),
input = doc.find("#url"),
button = doc.find("#load"),
iframe = doc.find("#page"),
canvas = document.createElement("canvas");
$.ajaxSetup({
type: "POST",
contentType: "application/json",
dataType: "json",
converters: {
"textjson": function (data) {
var parsed = JSON.parse(data);
return parsed.d || parsed;
}
}
});
然后,我们可以为<button>元素添加一个点击处理程序:
doc.on("click", "#load", function (e) {
e.preventDefault();
var url = input.val(),
len;
if (url) {
input.removeClass("empty").data("url", url);
button.prop("disabled", true);
iframe.attr("src", url).load(function() {
$(this).trigger("iframeloaded");
});
} else {
input.addClass("empty");
button.prop("disabled", false);
}
});
最后,我们可以为自定义的iframeloaded事件添加事件处理程序:
doc.on("iframeloaded", function () {
var url = input.data("url");
$.ajax({
type: "POST",
contentType: "application/json",
url: "/heat-map.asmx/getClicks",
dataType: "json",
data: JSON.stringify({ url:url, layout: 4 }),
converters: {
"textjson": function (data) {
var parsed = JSON.parse(data);
returnparsed.d || parsed;
}
}
});
});
目标完成 - 小型总结
我们像往常一样开始,设置了一些变量。我们存储了一个包装在 jQuery 中的document对象的引用,我们可以使用这个引用作为起点在页面上选择任何元素,而无需每次选择元素或绑定事件处理程序时都创建一个新的 jQuery 对象。
我们还存储了一个包含页面 URL 的<input>元素的引用,一个紧挨着<input>的<button>的引用,以及我们将加载请求页面的<iframe>的引用。最后,我们设置了一个未定义的变量叫做canvas,我们将使用createElement()函数使用 JavaScript 创建一个<canvas>元素的引用。
当然,我们可以使用 jQuery 来创建这个元素,但我们只是创建一个单独的元素,而不是复杂的 DOM 结构,所以我们可以使用纯 JavaScript 同时获得性能提升。
与以前一样,我们可以使用ajaxSetup()方法来设置将发送到服务器的请求的type、contentType和dataType选项。我们还使用了一个转换器来转换服务器将返回的数据。
converters 选项接受一个对象,其中指定要用于数据类型的转换器的键,指定要用作转换器的函数的值。
一些服务器将返回包裹在属性d中的对象中的 JSON 数据,以增加安全性,而其他服务器不会这样做。通常,text json数据类型将使用 jQuery 的parseJSON()方法进行解析,但在这种情况下,我们的代码仍然需要从对象中提取实际数据,然后才能使用它。
相反,我们的转换器使用浏览器的原生 JSON 解析器解析 JSON,然后返回d的内容(如果存在)或解析的数据。这意味着处理数据的代码在数据是否包裹在对象中都是相同的。
虽然在这个特定的例子中并不是必需的,但转换器在代码分发和将在其上运行的平台事先未知的情况下,可以非常有用。
接下来,我们使用 jQuery 的on()方法在事件代理模式下向document添加了一个点击处理程序。为了添加一个代理处理程序,我们将处理程序附加到一个父元素,即document,并使用on()的第二个参数来提供事件应该被过滤的选择器。
事件从触发元素一直冒泡到外部的window对象。只有当触发元素与传递为第二个参数的选择器匹配时,处理程序才会被执行。第一个参数当然是事件类型,第三个参数是处理程序函数本身。
在函数内部,我们首先阻止事件的默认浏览器操作,然后将<input>元素的值存储在名为url的变量中。我们还设置了一个未定义的变量叫做len。我们现在不需要使用它,但以后会用到。
接下来,我们检查我们设置的 url 变量是否具有真值,比如长度不为零的字符串。如果是,则如果 <input> 元素具有 empty 类名,则删除它,然后使用 jQuery 的 data() 方法将 <input> 的内容设置为元素的数据。
以这种方式将 URL 关联到元素是一种很好的持久化数据的方法,这样可以从代码中的其他函数中获取数据,而这些函数无法访问事件处理程序的作用域。我们还禁用了 <button> 以防止重复请求。在热图绘制到屏幕上后,我们可以随后启用它。
然后,我们将从 <input> 元素获得的 URL 添加为 <inframe> 的 src 属性,这会导致 <iframe> 加载该 URL 所指向的页面。我们为 <iframe> 添加了一个 load 事件的处理程序,一旦页面加载完成,该处理程序将被触发。在这个处理程序内部,我们使用 jQuery 的 trigger() 方法触发了一个自定义的 iframeloaded 事件。
如果 url 变量不包含真值,则将 empty 类添加到 <input> 中,并再次启用 <button>。
最后,我们为自定义的 iframeloaded 事件添加了一个事件处理程序。自定义事件会像常规事件一样冒泡到 document,因此我们可以将处理程序附加到我们缓存的 <body> 元素,它仍然会在适当的时间被触发。
在这个处理程序中,我们通过回顾与 <input> 元素相关联的数据来获取已加载页面的 URL。然后,我们使用 jQuery 的 ajax() 方法向服务器发出请求。
我们已经再次使用 ajaxSetup() 设置了一些必需的 AJAX 选项为默认值,因此对于此请求,我们只设置了 url 和 data 选项。这次发送的数据是一个包含页面 URL 和获取点击数据的布局的字符串化对象。作为响应,我们期望收到一个 JSON 对象,其中包含一系列点击对象,每个对象包含指向页面上特定点的 x 和 y 坐标。
请注意,此时我们正在硬编码要加载的布局,我们将其设置为 4。我们将在下一部分回来,并允许用户选择要查看的布局。
显示热图
我们已经准备好显示热图了。在这个任务中,我们将处理点击数据以生成热图,然后使用 <canvas> 元素显示在 <iframe> 上方。
启动推进器
首先,我们可以为上一个任务末尾所做的 AJAX 请求添加一个成功处理程序。我们可以通过将 done() 方法链接到 ajax() 方法来实现这一点:
}).done(function (clicks) {
var loadedHeight = $("html", iframe[0].contentDocument)
.outerHeight();
doc.find("section").height(loadedHeight);
canvas.width = doc.width();
canvas.height = loadedHeight;
$(canvas).appendTo(doc.find("section"))
.trigger("canvasready", { clicks: clicks });
});
接下来,我们可以为自定义的 canvasready 事件添加一个处理程序。这应该直接添加在 iframeloaded 事件处理程序之后:
doc.on("canvasready", function (e, clickdata) {
var docWidth = canvas.width,
docHeight = canvas.height,
ctx = canvas.getContext("2d") || null;
if (ctx) {
ctx.fillStyle = "rgba(0,0,255,0.5)";
$.each(clickdata.clicks, function (i, click) {
var x = Math.ceil(click.x * docWidth / 100),
y = Math.ceil(click.y * docHeight / 100);
ctx.beginPath();
ctx.arc(x, y, 10, 0, (Math.PI/180)*360, true);
ctx.closePath();
ctx.fill();
});
}
button.prop("disabled", false);
});
目标完成 - 迷你总结
一旦 AJAX 请求完成,我们首先存储已在 <iframe> 中加载的文档的高度。jQuery 方法可以在选择器之后传递第二个参数,该参数设置应该被搜索以匹配选择器的上下文。我们可以将上下文设置为页面上第一个 <iframe> 的 contentDocument 对象,我们可以使用 frame[0] 访问它。
设置 <section> 元素的 height 将自动使之前创建的 <iframe> 和 <canvas> 元素的 width 和 height 等于 <section> 的宽度和高度,以便可以全屏查看页面。
接下来,我们设置了上一个任务中创建的 <canvas> 元素的 width 和 height 属性。我们尚未设置 <canvas> 元素的 width 或 height 属性,因此默认情况下,无论 CSS 设置的可见大小如何,它都只有 300 x 300 像素的大小。因此,我们将属性设置为正确的大小。
然后,我们可以将新的 <canvas> 添加到页面上的 <section> 元素中,然后触发自定义的 canvasready 事件。我们将要在此事件的事件处理程序中使用服务器传递的数据,因此我们使用 trigger() 方法的第二个参数将其传递给处理程序函数。
我们接着为 canvasready 事件添加了一个处理程序。该函数接收事件对象和点击数据作为参数。在函数内部,我们首先获取 <canvas> 元素的 width 和 height。我们将点击数据存储为百分比,需要将其转换为像素值。
为了在 <canvas> 上绘制,我们需要获取一个上下文。我们可以使用 canvas 对象的 getContext() 函数获取 <canvas> 的 2D 上下文并将其存储在一个变量中。如果不支持 <canvas> 元素,则 ctx 变量将被设置为 null。因此,只有在上下文不为 null 时,我们才能继续与画布交互。
如果 ctx 不为 null,我们首先使用 canvas API 的 clearRect() 函数清除 <canvas>,然后设置我们将要在画布上绘制的颜色。我们可以将其设置为 RGBA(红、绿、蓝、透明度)字符串 0,0,255,.05,这是一种半透明的蓝色。这只需要设置一次。
然后,我们使用 jQuery 的 each() 方法迭代服务器返回的点击数据。迭代器函数将执行数组中项目的数量,传递当前项目在数组中的索引和 click 对象。
我们首先存储每个点击的像素的 x 和 y 位置。这些数字目前是百分比,因此我们需要将它们转换回像素值。这只是在热力图的客户端部分执行的相反计算。我们只需将百分比乘以 <canvas> 的 width 或 height,然后将该数字除以 100。
然后,我们可以在点击发生的地方在<canvas>上绘制一个点。我们通过使用 canvas 对象的beginPath()方法开始一个新路径来实现这一点。点是使用arc()方法绘制的,该方法传递了一些参数。前两个是圆弧中心的坐标,我们将其设置为刚计算的x和y值。
第三个参数是圆的半径。如果我们将点设置为单个像素,数据将非常难以解释,因此使用大点而不是单个像素将大大提高热图的外观。
第三个和第四个参数是弧开始和结束的角度,以弧度而不是度表示。我们可以通过从零弧度开始,到约 6.5 弧度结束来绘制完整的圆。
定义了弧之后,我们可以使用closePath()方法关闭路径,并使用fill()方法填充弧形颜色。此时,我们应该能够在浏览器中运行控制台,输入模板页面之一的 URL,并看到对应于点击的点的页面。
允许选择不同的布局
在项目的这个任务中,我们需要允许用户选择页面支持的每个布局。我们可以通过使用<select>框来实现这一点,在页面加载时用不同的布局填充它。
启动推进器
首先,我们可以将<select>元素添加到页面中。这可以放在console.html顶部的搜索字段和按钮之间:
<select id="layouts"></select>
接下来,我们需要在页面加载时进行请求,为<select>元素填充每个不同布局的<option>。我们可以在之前在console.js中添加的<button>的点击处理程序中执行此操作。
它需要放在条件语句的第一个分支中,该条件语句检查是否已将 URL 输入到<input>中,直接在我们设置<iframe>的src之前。
$.ajax({
url: "/heat-map.asmx/getLayouts",
data: JSON.stringify({ url: url })
}).done(function (layouts) {
var option = $("<option/>"),
max;
len = layouts.length;
function optText(type, i, min, max) {
var s,
t1 = "layout ";
switch (type) {
case "normal":
s = [t1, i + 1, " (", min, "px - ", max, "px)"];
break;
case "lastNoMax":
s = [t1, len + 1, " (", min, "px)"];
break;
case "lastWithMax":
s = [t1, len + 1, " (", max, "px+)"];
break;
}
return s.join("");
}
$.each(layouts, function (i, layout) {
var lMin = layout.min,
lMax = layout.max,
text = optText("normal", i, lMin, lMax);
if (i === len - 1) {
if (lMax === "none") {
text = optText("lastNoMax", null, lMin, null);
} else {
max = lMax;
}
}
option.clone()
.text(text)
.val(i + 1)
.appendTo("#layouts");
});
if (max) {
var fText = optText("lastWithMax", null, null, max);
option.clone()
.text(fText)
.val(len + 1)
.prop("selected",true)
.appendTo("#layouts");
}
});
我们还可以为我们的新<select>元素添加一点 CSS。我们可以将这些内容放在console.css的底部:
.jqheat select {
width:175px; height:36px; padding:5px;
margin:0 .25em 0 .5em; border:1px solid #707070;
border-radius:3px;
}
目标完成 - 小型总结
首先,我们向服务器发出请求以获取布局信息。url设置为返回布局的 Web 服务,data是我们想要布局的页面的 URL。
我们使用done()方法设置了一个成功处理程序,这是向承诺对象添加成功处理程序的推荐技术,以便在它们解决时调用。在处理程序中,我们首先设置了一些变量。
我们创建一个<option>元素,因为我们每个布局都需要一个,所以可以使用clone()方法克隆它,需要多少次就可以克隆多少次。我们还更新了之前创建但未定义的len变量,将其更新为布局的数量,即函数将接收的数组的length,以及一个未定义的变量max。
接下来,我们定义了一个名为optText()的函数,我们可以使用它来为我们创建的每个<option>元素生成文本。该函数将接受要创建的字符串类型、索引和min和max值。
在此函数中,我们设置了几个变量。第一个变量称为s,在这一点上是未定义的。第二个变量t1用于存储在字符串的每个变体中使用的一些简单文本。
然后,我们使用switch条件来确定要构建的字符串,该字符串基于类型确定,该类型将作为第一个参数传递到函数中,并将设置为normal、lastNoMax或lastWithMax,并应该考虑可能找到的不同类型的媒体查询。
在正常情况下,我们指定了min和max值。当没有max值时,我们使用min值构建字符串,当有max值时,我们使用max值构建字符串。
每个字符串都使用数组构造,然后在函数末尾,我们通过连接所创建的任一数组来返回一个字符串。
然后我们使用 jQuery 的each()方法来迭代服务器返回的layouts对象。与往常一样,迭代器函数会传入当前项的索引和当前项本身作为参数。
在迭代器函数内部,我们设置了变量,这些变量在这种情况下是当前布局对象的min和max属性值,以及文本字符串的普通变体,我们肯定会至少使用一次。我们调用我们的optText()函数并将结果存储供以后使用。
然后我们检查是否处于最后一次迭代,我们会在索引等于之前存储的layouts数组长度减去1时知道。如果我们处于最后一次迭代,我们会检查max值是否等于字符串none。如果是,我们再次调用我们的optText()函数,并将文本设置为lastNoMax类型,该类型为我们生成所需的文本字符串。如果不是,则将max变量设置为当前对象的max值,该变量最初被声明为未定义。最后,我们为layouts数组中的每个对象创建所需的<option>元素。给定我们设置的文本,以及索引加1的值。创建完成后,将<option>追加到<select>元素中。
最后,我们检查max变量是否有一个真值。如果是,我们再次调用我们的optText()函数,这次使用lastWithMax类型,并创建另一个<option>元素,将其设置为选定项。这是必需的,因为我们的布局比layouts数组中的对象多一个。
当我们在浏览器中运行页面时,我们应该发现,当我们在<input>中输入 URL 并点击加载页面时,<select>元素会填充一个<option>,每个布局对应一个选项。
机密情报
在我们的optText()函数中,switch语句中的中间case(lastNoMax)实际上在这个示例中不会被使用,因为我们使用的模板中的媒体查询的结构如何。在这个示例中,最后一个断点的媒体查询是769px到1024px。有时,媒体查询可能结构化,使得最后一个断点只包含min-width。
我已经包含了switch的这个case,以使代码支持这种其他类型的媒体查询格式,因为这是相当常见的,当您自己使用媒体查询时,您可能会遇到它。
显示每个布局的热图
现在,我们在<select>元素中有每个布局后,我们可以将其连接起来,以便当所选布局更改时,页面更新为显示该布局的热图。
启动推进器
在这个任务中,我们需要修改先前任务中编写的一些代码。我们需要更改<button>的点击处理程序,以便布局不会硬编码到请求中。
首先,我们需要将len变量传递给iframeloaded事件的处理程序。我们可以通过向trigger()方法添加第二个参数来实现这一点:
$(this).trigger("iframeloaded", { len: len });
现在,我们需要更新回调函数,以便该对象由该函数接收:
doc.on("iframeloaded", function (e, maxLayouts) {
现在,我们可以修改硬编码的布局4的位,在向服务器请求点击数据时传递给服务器的数据中:
data: JSON.stringify({ url: url, layout: maxLayouts.len + 1 }),
现在我们准备好在<select>更改时更新热图了。在console.js的canvasready处理程序之后直接添加以下代码:
doc.on("change", "#layouts", function () {
var url = input.data("url"),
el = $(this),
layout = el.val();
$.ajax({
url: "/heat-map.asmx/getClicks",
data: JSON.stringify({ url: url, layout: layout })
}).done(function (clicks) {
doc.find("canvas").remove();
var width,
loadedHeight,
opt = el.find("option").eq(layout - 1),
text = opt.text(),
min = text.split("(")[1].split("px")[0],
section = doc.find("section"),
newCanvas = document.createElement("canvas");
if (parseInt(layout, 10) === el.children().length) {
width = doc.width();
} else if (parseInt(min, 10) > 0) {
width = min;
} else {
width = text.split("- ")[1].split("px")[0];
}
section.width(width);
newCanvas.width = width;
loadedHeight = $("html",
iframe[0].contentDocument).outerHeight();
section.height(loadedHeight);
newCanvas.height = loadedHeight;
canvas = newCanvas;
$(newCanvas).appendTo(section).trigger("canvasready", {
clicks: clicks });
});
});
完成目标 - 小结
我们首先委派我们的处理程序给文档,就像我们大多数其他事件处理程序一样。这次,我们正在监听由具有id为layouts的元素触发的change事件,这是我们在上一个任务中添加的<select>元素。
然后,我们继续遵循以前的形式,设置一些变量。我们获取保存为<input>元素的data的 URL。我们还缓存了<select>元素和所选<option>的值。
接下来,我们需要发起一个 AJAX 请求来获取所选布局的热图。我们将url设置为将返回此信息的 Web 服务,并将我们想要的热图的url和布局作为请求的一部分发送。不要忘记,此请求也将使用我们使用ajaxSetup()设置的默认值。
我们再次使用done()方法添加一个请求的成功处理程序。当收到响应时,我们首先从页面中删除现有的<canvas>元素,然后设置一些更多的变量。
前两个变量一开始是未定义的;我们马上会填充这些。我们存储了所选的<option>,以便我们可以获取其文本,该文本存储在下一个变量中。我们通过分割我们刚刚存储的文本来获取断点的最小宽度,然后缓存页面上的<section>的引用。最后,我们创建一个新的<canvas>元素来显示新的热图。
后续的条件 if 语句处理设置我们的第一个未定义变量 - width。第一个分支测试所请求的布局是否是最后一个布局,如果是,则将新的<canvas>设置为屏幕的宽度。
如果未请求最后一个布局,则条件的下一个分支检查布局的最小宽度是否大于0。如果是,则将width变量设置为最小断点。
当断点的最小宽度为0时,使用最终分隔<option>文本获得的最大断点width。
然后,我们使用刚刚计算出的宽度来设置<section>元素和新的<canvas>元素的宽度。
接下来,我们可以定义我们的第二个未定义变量 - loadedHeight。这个变量的计算方式与之前相同,通过访问加载到<iframe>中的文档,并使用 jQuery 的outerHeight()方法获取其document对象的高度来获取,其中包括元素可能具有的任何填充。一旦我们有了这个值,我们就可以设置<section>元素和新的<canvas>元素的高度。
当我们消耗点击数据并生成热图时,我们将再次触发我们的canvasready事件。不过,在此之前,我们只需将新创建的<canvas>元素保存回我们在console.js顶部设置的canvas变量即可。
此时,我们应该能够加载 URL 的默认热图,然后使用<select>元素查看另一个布局的热图:
机密情报
我使用了MS SQL数据库来存储数据,并使用包含此项目所需的各种 Web 方法的C# Web 服务。在本书附带的代码下载中包含了数据库的备份和 Web 服务文件的副本,供您使用。
MS SQL express 是 SQL 服务器的免费版本,可以将数据库恢复到该版本,而免费的 Visual Studio 2012 for web 将愉快地通过其内置的开发服务器运行 Web 服务。
如果您没有安装这些产品,并且您可以访问 Windows 机器,我强烈建议您安装它们,这样您就可以看到此项目中使用的代码运行情况。也可以轻松地使用开源替代产品 PHP 和 MySQL,尽管您将需要自己编写此代码。
任务完成
在这个项目中,我们构建了一个简单的热图生成器,用于捕获使用响应式技术构建的网页上的点击数据。我们将热图生成器分为两部分——一些在网站访问者的浏览器中运行的代码,用于捕获屏幕上的每次点击,以及一个与之配合使用的简单管理控制台,可以在其中选择要为其生成热图的页面的 URL 和要显示的布局。
虽然我们必须允许一定的误差范围,以考虑像素到百分比的转换及其逆过程,不同的屏幕分辨率,以及不同断点之间的范围,但这个易于实现的热图仍然可以为我们提供有价值的信息,了解我们的网站如何使用,哪些功能受欢迎,哪些功能浪费了屏幕空间。
你准备好全力以赴了吗?挑战热血青年
我们还没有处理的一个问题是颜色。我们的热图由均匀蓝色的点构成。由于它们是半透明的,在密集区域出现更多点时会变暗,但是随着足够多的数据,我们应该尽量改变颜色,从红色、黄色一直到白色为最多点击的区域。看看你是否能自己添加这个功能,真正为项目锦上添花。
第十章:带有 Knockout.js 的可排序、分页表格
Knockout.js 是一个很棒的 JavaScript 模型-视图-视图模型(MVVM)框架,可以帮助你在编写复杂的交互式用户界面时节省时间。它与 jQuery 配合得非常好,甚至还具有用于构建显示不同数据的重复元素的内置基本模板支持。
任务简报
在本项目中,我们将使用 jQuery 和 Knockout.js 从数据构建分页表格。客户端分页本身是一个很好的功能,但我们还将允许通过提供可点击的表头对表格进行排序,并添加一些附加功能,如根据特定属性过滤数据。
到此任务结束时,我们将建立如下屏幕截图所示的东西:
为什么这很棒?
构建快速响应用户交互的复杂 UI 是困难的。这需要时间,而且应用程序越复杂或交互性越强,花费的时间就越长,需要的代码也越多。而应用程序需要的代码越多,就越难以保持组织和可维护性。
虽然 jQuery 擅长帮助我们编写简洁的代码,但它从未旨在构建大规模、动态和交互式应用程序。它功能强大,擅长自己的工作以及它被设计用来做的事情;只是它并没有被设计用来构建整个应用程序。
在构建大规模应用程序时需要其他东西,需要提供一个框架,可以在其中组织和维护代码。Knockout.js 就是这样一个旨在实现此目标的框架之一。
Knockout.js 被称为一个 MVVM 框架,它基于三个核心组件 - 模型、视图 和 视图模型。这类似于更为人熟知的 MVC 模式。这些和其他类似的模式的目的是提供清晰的应用程序可视部分和管理数据所需代码之间的分离。
模型 可以被认为是应用程序的数据。实际上,实际数据是模型的结果,但在客户端工作时,我们可以忽略数据是如何被服务器端代码访问的,因为通常我们只是发出 AJAX 请求,数据就会被传递给我们。
视图 是数据的可视化表示,实际的 HTML 和 CSS 用于向用户呈现模型。在使用 Knockout.js 时,应用程序的这一部分也可以包括绑定,将页面上的元素映射到特定的数据部分。
视图模型 位于模型和视图之间,实际上是视图的模型 - 视图状态的简化表示。它管理用户交互,生成并处理对数据的请求,然后将数据反馈到用户界面。
你的炫酷目标
完成此任务所需的任务如下:
-
渲染初始表格
-
对表格进行排序
-
设置页面大小
-
添加上一页和下一页链接
-
添加数字页面链接
-
管理类名
-
重置页面
-
过滤表格
任务清单
在这个项目中我们将使用 Knockout.js,所以现在你需要获取它的副本。这本书印刷时的最新版本为 2.2.1,可以从以下网址下载:[knockoutjs.com/downloads/index.html](http://
knockoutjs.com/downloads/i…
我们还需要一些数据来完成这个项目。我们将需要使用一个相当大的数据集,其中包含可以按多种方式排序的数据。我们将使用元素周期表的 JSON 格式作为我们的数据源。
我已经提供了一个文件作为这个示例的一部分,名为table-data.js,其中包含一个名为elements的属性的对象。该属性的值是一个对象数组,其中每个对象表示一个元素。对象的格式如下:
{
name: "Hydrogen",
number: 1,
symbol: "H",
weight: 1.00794,
discovered: 1766,
state: "Gas"
}
渲染初始表格
在项目的第一个任务中,我们将构建一个超级简单的 ViewModel,添加一个基本的 View,并将 Model 渲染到一个裸的<table>中,没有任何增强或附加功能。这将使我们能够熟悉 Knockout 的一些基本原理,而不是直接投入到深水区。
准备起飞
此时我们创建项目中将要使用的文件。将模板文件另存为sortable-table.html,保存在根项目目录中。
我们还需要一个名为sortable-table.css的样式表,应将其保存在css文件夹中,并且一个名为sortable-table.js的 JavaScript 文件,当然应将其保存在js目录中。
HTML 文件应链接到每个资源,以及knockout-2.2.1.js文件。样式表应在common.css之后直接链接,我们迄今为止在本书中大部分项目中都使用了它,而knockout.js、table-data.js和这个项目的自定义脚本文件(sortable-table.js)应在链接到 jQuery 之后添加,按照这个顺序。
启动推进器
首先我们可以构建 ViewModel。在sortable-table.js中,添加以下代码:
$(function () {
var vm = {
elements: ko.observableArray(data.elements)
}
ko.applyBindings(vm);
});
接下来,我们可以添加 View,它由一些简单的 HTML 构建而成。将以下标记添加到sortable-table.html的<body>中,位于<script>元素之前:
<table>
<thead>
<tr>
<th>Name</th>
<th>Atomic Number</th>
<th>Symbol</th>
<th>Atomic Weight</th>
<th>Discovered</th>
</tr>
</thead>
<tbody data-bind="foreach: elements">
<tr>
<td data-bind="text: name"></td>
<td data-bind="text: number"></td>
<td data-bind="text: symbol"></td>
<td data-bind="text: weight"></td>
<td data-bind="text: discovered"></td>
</tr>
</tbody>
</table>
最后,我们可以通过将以下代码添加到sortable-table.css来为我们的<table>及其内容添加一些基本样式:
table {
width:650px; margin:auto; border-collapse:collapse;
}
tbody { border-bottom:2px solid #000; }
tbodytr:nth-child(odd) td { background-color:#e6e6e6; }
th, td {
padding:10px 50px 10px 0; border:none; cursor:default;
}
th {
border-bottom:2px solid #000;cursor:pointer;
position:relative;
}
td:first-child, th:first-child { padding-left:10px; }
td:last-child { padding-right:10px; }
目标完成 - 迷你简报
在我们的脚本中,首先添加了通常的回调函数,在文档加载时执行。在此之中,我们使用存储在变量vm中的对象字面量创建了 ViewModel。
此对象唯一的属性是elements,其值是使用 Knockout 方法设置的。Knockout 添加了一个全局的ko对象,我们可以使用它来调用方法。其中之一是observableArray()方法。该方法接受一个数组作为参数,并且传递给该方法的数组将变为可观察的。这就是我们应用程序的数据。
在 Knockout 中,诸如字符串或数字之类的基本类型可以是可观察的,这使它们能够在其值更改时通知订阅者。可观察数组类似,只是它们与数组一起使用。每当向可观察数组添加或删除值时,它都会通知任何订阅者。
定义了我们的 ViewModel 之后,我们需要应用可能存在于 View 中的任何绑定。我们马上就会看到这些绑定;暂时只需知道,在调用 Knockout 的 applyBindings() 方法之前,我们添加到 View 的任何绑定都不会生效。
我们添加的 HTML 几乎毫无特色,只是一个简单的<table>,每个元素的属性都有一个列。如果你查看table-data.js文件,你会看到数组中每个元素的属性与<th>元素匹配。
第一件有趣的事情是我们添加到<tbody>元素的data-bind属性。这是 Knockout 用于实现声明式绑定的机制。这是我们将 View 中的元素与 ViewModel 属性连接起来的方式。
data-bind属性的值由两部分组成 - 绑定和要连接到的 ViewModel 属性。第一部分是绑定,我们将其设置为foreach。这是 Knockout 的流程控制绑定之一,其行为方式类似于常规 JavaScript 中的标准for循环。
绑定的第二部分是要绑定到的 ViewModel 属性。我们目前的 ViewModel 只有一个属性,即elements,其中包含一个可观察数组。foreach绑定将映射到一个数组,然后为数组中的每个项渲染任何子元素。
此元素的子元素是一个<tr>和一系列<td>元素,因此我们将在elements数组中的每个项中获得一个表格行。为了将<td>元素填充内容,我们将使用另一个 Knockout 绑定 - text绑定。
text绑定绑定到单个可观察属性,因此我们有一个<td>绑定到elements数组中每个对象的每个属性。每个<td>的文本将设置为当前数组项中每个属性的值。
我们在任务结束时添加的 CSS 纯粹是为了表现目的,与 Knockout 或 jQuery 无关。此时,我们应该能够在浏览器中运行页面,并在一个整洁的<table>中看到来自table-data.js的数据显示出来。
机密情报
View 元素和 ViewModel 属性之间的绑定是 Knockout 的核心。ViewModel 是 UI 状态的简化版本。由于绑定,每当底层 ViewModel 发生更改时,视图将更新以反映这些更改。
因此,如果我们以编程方式向可观察数组添加一个新的元素对象,则<table>将立即更新以显示新元素。类似地,如果我们从 ViewModel 中的数组中删除一个项目,则相应的<tr>将立即被删除。
对表格进行排序
在这个任务中,我们可以更改<th>元素,使其可点击。当其中一个被点击时,我们可以按照被点击的列对表格行进行排序。
启动推进器
首先,我们可以更新sortable-table.html中包含的<tr>和<th>元素:
<tr data-bind="click: sort">
<th data-bind="css: nameOrder">Name</th>
<th data-bind="css: numberOrder">Atomic Number</th>
<th data-bind="css: symbolOrder">Symbol</th>
<th data-bind="css: weightOrder">Atomic Weight</th>
<th data-bind="css: discoveredOrder">Discovered</th>
</tr>
接下来,我们可以在sortable-table.js中的 ViewModel 中添加一些新的可观察属性:
nameOrder: ko.observable("ascending"),
numberOrder: ko.observable("ascending"),
symbolOrder: ko.observable("ascending"),
weightOrder: ko.observable("ascending"),
discoveredOrder: ko.observable("ascending"),
我们还添加了一个名为sort的新方法:
sort: function (viewmodel, e) {
var orderProp = $(e.target).attr("data-bind")
.split(" ")[1],
orderVal = viewmodel[orderProp](),
comparatorProp = orderProp.split("O")[0];
viewmodel.elements.sort(function (a, b) {
var propA = a[comparatorProp],
propB = b[comparatorProp];
if (typeof (propA) !== typeof (propB)) {
propA = (typeof (propA) === "string") ? 0 :propA;
propB = (typeof (propB) === "string") ? 0 :propB;
}
if (orderVal === "ascending") {
return (propA === propB) ? 0 : (propA<propB) ? -1 : 1;
} else {
return (propA === propB) ? 0 : (propA<propB) ? 1 : -1;
}
});
orderVal = (orderVal === "ascending") ? "descending" : "ascending";
viewmodelorderProp;
for (prop in viewmodel) {
if (prop.indexOf("Order") !== -1 && prop !== orderProp) {
viewmodelprop;
}
}
}
最后,我们可以添加一些额外的 CSS 来样式化我们可点击的<th>元素:
.ascending:hover:after {
content:""; display:block; border-width:7px;
border-style:solid; border-left-color:transparent;
border-right-color:transparent; border-top-color:#000;
border-bottom:none; position:absolute; margin-top:-3px;
right:15px; top:50%;
}
.descending:hover:after {
content:""; display:block; border-width:7px;
border-style:solid; border-left-color:transparent;
border-right-color:transparent; border-bottom-color:#000;
border-top:none; position:absolute; margin-top:-3px;
right:15px; top:50%;
}
目标完成 - 小结
首先,我们使用更多的绑定更新了我们的 HTML。首先,我们使用data-bind属性在父级<tr>上添加了click绑定。click绑定用于向任何 HTML 元素添加事件处理程序。
处理程序函数可以是 ViewModel 方法或任何常规 JavaScript 函数。在这个示例中,我们将处理程序绑定到一个名为sort的函数,它将是我们 ViewModel 的一个方法。
请注意,我们将绑定添加到父级<tr>而不是各个<th>元素。我们可以利用事件向上冒泡的特性来实现一种非常简单且计算成本低廉的事件委派形式。
我们还为每个<th>元素添加了css绑定。css绑定用于向元素添加类名。因此,元素获取的类名取决于它绑定到的 ViewModel 属性。我们的每个<th>元素都绑定到不同的 ViewModel 属性,并将用作我们排序的一部分。
接下来,我们对我们的脚本文件进行了一些更改。首先,我们添加了一系列新的可观察属性。我们添加了以下属性:
-
nameOrder -
numberOrder -
symbolOrder -
weightOrder -
discoveredOrder
这些属性中的每一个都是可观察的,这是必需的,以便当任何一个属性发生更改时,<th>元素的类名会自动更新。每个属性最初都设置为字符串ascending,因此每个<th>元素都将被赋予这个类名。
对数据进行排序
接下来,我们将我们的sort方法添加到 ViewModel 中。因为此方法是事件处理绑定的一部分(我们添加到<tr>的click绑定),所以该方法将自动传递两个参数 - 第一个是 ViewModel,第二个是事件对象。我们可以在函数中使用这两个参数。
首先我们定义一些变量。我们使用 jQuery 选择被点击的任何<th>元素。我们可以使用事件对象的target属性来确定这一点,然后我们用 jQuery 包装它,以便我们可以在所选元素上调用 jQuery 方法。
我们可以使用 jQuery 的attr()方法获取元素的data-bind属性,然后根据绑定名称和绑定到的属性之间的空格拆分它。所以例如,如果我们在浏览器中点击包含Name的<th>,我们的第一个变量orderProp将被设置为nameOrder。
下一个变量orderVal被设置为 ViewModel 属性的当前值,orderProp变量指向的属性。Knockout 提供了一种简单的方法来以编程方式获取或设置任何 ViewModel 属性。
如果我们想获取属性的值,我们将其调用为函数,如下所示:
property();
如果我们想设置属性,我们仍然像调用函数一样调用它,但是我们将要设置的值作为参数传递:
property(value);
因此,继续上述点击包含Name的<th>的例子,orderVal变量将具有值ascending,因为这是每个…Order属性的默认值。请注意我们如何使用orderProp变量和方括号表示法获取正确的值。
我们的最后一个变量comparatorProp很方便地存储我们将要根据其对elements数组中的对象进行排序的属性。我们的 ViewModel 属性在末尾有字符串Order,但是elements数组中的对象内部的属性没有。因此,为了获取正确的属性,我们只需要在大写O上拆分字符串,并从split()返回的数组中取第一个项目。
observableArray
接下来我们使用sort()方法进行排序。看起来我们在使用 JavaScript 的普通sort()函数,但实际上我们并不是。不要忘记,elements数组不只是一个普通数组;它是一个observableArray,因此虽然我们可以从元素的viewModel属性中获取基础数组,然后在其上调用普通的 JavaScriptsort()函数,但 Knockout 提供了更好的方法。
Knockout 提供了一系列可以在 observable 数组上调用的标准 JavaScript 数组函数。在很大程度上,这些函数的工作方式与它们的原始 JavaScript 对应函数非常相似,但是尽可能使用 Knockout 变体通常更好,因为它们在浏览器中得到了更好的支持,特别是传统浏览器,比原始 JavaScript 版本。一些 Knockout 方法还为我们提供了一些额外的功能或便利。
其中一个例子是使用 Knockout 的sort()方法。这并不是我们在这里使用该方法的原因,但这是 Knockout 如何改进原始 JavaScript 函数的一个例子。
JavaScript 内置的默认sort()函数对数字的排序效果不是很好,因为它会自动将数字转换为字符串,然后根据字符串而不是数字进行排序,导致我们得到意料之外的结果。
Knockout 的sort()方法不会自动对字符串或数字数组进行排序。在这一点上,我们不知道我们将排序字符串,数字,还是两者兼有,因为elements数组中的对象既包含字符串又包含数字,有时在同一个属性中。
就像 JavaScript 的sort()函数一样,传递给 Knockout 的sort()方法的函数将自动传递两个值,这两个值是当前要排序的项。与 JavaScript 的sort()函数一样,Knockout 的sort()方法应返回0,如果要比较的值相等,返回负数,如果第一个值较小,或者返回正数,如果第一个值较大。
在传递给sort()的函数中,我们首先从对象中获取我们将要比较的值。传递给函数的两个值都将是对象,但我们只想比较每个对象内部的一个属性,所以我们为了方便起见将要比较的属性存储在propA和propB变量中。
比较不同类型的值
我之前提到有时我们可能会比较不同类型的值。这可能发生在我们按日期列排序时,其中可能包含形式为年份的数字,或者可能是字符串Antiquity,而这些对象中有一些包含这样的值。
所以我们使用 JavaScript 的typeof运算符和普通的if语句来检查要比较的两个值是否属于相同的类型。如果它们不是相同的类型,我们检查每个属性是否是字符串,如果是,就将其值转换为数字0。在if语句内部,我们使用 JavaScript 的三元运算符来简洁地表达。
检查顺序
然后,我们检查我们在一会儿设置的orderProp变量是否设置为 ascending。如果是,我们执行标准排序。我们检查两个值是否相等,如果是,返回0。如果两个值不相等,我们可以检查第一个值是否小于第二个值,如果是,返回-1,如果不是,返回1。为了将整个语句保持在一行上,我们可以使用复合的三元运算符。
如果顺序不是ascending,那么必须是descending,所以我们可以执行降序排序。这段代码几乎与之前的代码相同,只是如果第一个值小于第二个值,我们返回1,如果不是,我们返回-1,这与条件语句的第一个分支相反。
然后,我们需要更新我们刚刚排序过的列的…Order属性的值。这段代码的作用类似于一个简单的开关 - 如果值当前设置为ascending,我们将其设置为descending。如果它设置为descending,我们只需将其设置为ascending。这种行为允许的是,当单击<th>元素第一次时,它将执行默认的升序排序。如果再次单击它,它将执行降序排序。
最后,如果我们的 ViewModel 的其他…Order属性已更改,我们希望重置它们。我们使用一个简单的 JavaScript for in循环来迭代我们的 ViewModel 的属性。对于每个属性,我们检查它是否包含字符串Order,以及它是否不是我们刚刚更新的属性。
如果这两个条件都满足,我们将当前属性的值重置为默认值ascending。
添加图标
我们添加的 CSS 用于在悬停时向每个<th>元素添加一个小的排序图标。我们可以利用 CSS 形状技术来创建一个向下指向的箭头,表示升序,和一个向上指向的箭头,表示降序。我们还使用:after CSS 伪选择器来避免硬编码非语义元素,比如<span>或类似的元素,来显示形状。显示哪个箭头取决于我们绑定到 ViewModel 的…Order属性的类名。
注意
如果您以前从未使用过 CSS 形状,我强烈建议您研究一下,因为它们是创建图标的绝佳方法,而无需非语义占位符元素或 HTTP 重的图像。有关更多信息,请查看 css-tricks.com/examples/ShapesOfCSS/ 上的 CSS 形状指南。
此时,我们应该能够在浏览器中运行页面,并单击任何一个标题,一次执行升序排序,或者点击两次执行降序排序:
设置页面大小
所以我们添加的排序功能非常棒。但是<table>仍然相当大且笨重 - 实际上太大了,无法完整地在页面上显示。所以分页正好适用。
我们需要做的一件事是确定每页应包含多少项数据。我们可以在脚本中硬编码一个值,表示每页显示的项目数,但更好的方法是添加一个 UI 功能,让用户可以自己设置每页显示的项目数。这就是我们将在此任务中做的事情。
启动推进器
我们可以从添加一些额外的标记开始。直接在<tbody>元素之后添加以下元素:
<tfoot>
<tr>
<tdcolspan="5">
<div id="paging" class="clearfix">
<label for="perPage">Items per page:</label>
<select id="perPage" data-bind="value: pageSize">
<option value="10">10</option>
<option value="30">30</option>
<option value="all">All</option>
</select>
</div>
</td>
</tr>
</tfoot>
我们还需要对<tbody>元素进行一些小改动。它目前具有对观察到的元素数组的foreach绑定。我们将在稍后为我们的 ViewModel 添加一个新属性,然后需要更新sortable-table.html中的绑定,以便它链接到这个新属性:
<tbody data-bind="foreach: elementsPaged">
接下来,我们可以在 sortable-table.js 中添加一些新的 ViewModel 属性:
pageSize: ko.observable(10),
currentPage: ko.observable(0),
elementsPaged: ko.observableArray(),
最后,我们可以添加一个特殊的新变量,称为 computed observable。这应该在 vm 变量之后出现:
vm.createPage = ko.computed(function () {
if (this.pageSize() === "all") {
this.elementsPaged(this.elements.slice(0));
} else {
var pagesize = parseInt(this.pageSize(), 10),
startIndex = pagesize * this.currentPage(),
endIndex = startIndex + pagesize;
this.elementsPaged(this.elements.slice(startIndex,endIndex));
}
}, vm);
完成目标 - 小结
我们从添加一个包含一个行和一个单元格的 <tfoot> 元素开始这项任务。单元格内是用于我们分页元素的容器。然后我们有一个 <label> 和一个 <select> 元素。
<select> 元素包含一些选项,用于显示不同数量的项目,包括一个查看所有数据的选项。它还使用 Knockout 的 value data-bind 属性将 <select> 元素的值链接到 ViewModel 上的一个名为 pageSize 的属性。这种绑定意味着每当 <select> 元素的值更改时,例如用户进行选择时,ViewModel 属性将自动更新。
此绑定是双向的,因此如果我们在脚本中以编程方式更新 pageSize 属性,则页面上的元素将自动更新。
然后,我们将 <tbody>foreach 绑定到我们的 ViewModel 上的一个新属性,称为 elementsPaged。我们将使用这个新属性来存储 elements 数组中项目的一个子集。该属性中的实际项目将构成数据的单个页面。
接下来,我们在存储在 vm 变量中的对象字面量中添加了一些新属性,也称为我们的 ViewModel。这些属性包括我们刚刚讨论的 currentPage、pageSize 和 elementsPaged 属性。
我们最后要做的是添加一个名为 computed observable 的 Knockout 功能。这是一个非常有用的功能,它让我们监视一个或多个变量,并在任何可观察变量更改值时执行代码。
我们使用 ko.computed() 方法将计算的 observable 设置为 ViewModel 的一个方法,将函数作为第一个参数传入。ViewModel 作为第二个参数传入。现在我们不在一个附加到我们的 ViewModel 的方法中,所以我们需要将 ViewModel 传递给 computed() 方法,以便将其设置为 ViewModel。
在作为第一个参数传递的函数中,我们引用了刚刚添加的三个新 ViewModel 属性。在此函数中引用的任何 ViewModel 属性都将被监视变化,并在此发生时调用该函数。
此函数的全部功能是检查 pageSize() 属性是否等于字符串 all。如果是,则将元素数组中的所有对象简单地添加到 elementsPaged 数组中。它通过取 elements 数组的一个切片来实现这一点,该切片从第一个项目开始。当 slice() 与一个参数一起使用时,它将切片到数组的末尾,这正是我们需要获得整个数组的方式。
如果pageSize不等于字符串all,我们首先需要确保它是一个整数。因为这个 ViewModel 属性与页面上的<select>元素相关联,有时值可能是一个数字的字符串而不是实际的数字。我们可以通过在属性上使用parseInt() JavaScript 函数并将其存储在变量pagesize中,在函数的其余部分中使用它来确保它始终是一个数字。
接下来,我们需要确定传递给slice()作为第一个参数的起始索引应该是什么。要解决此问题,我们只需将pageSize属性的值乘以最初设置为0的currentPage属性的值。
然后,我们可以使用elements数组的一个片段来填充elementsPaged数组,该片段从我们刚刚确定的startIndex值开始,到endIndex值结束,该值将是startIndex加上每页项目数。
当我们在浏览器中运行页面时,<select>框将最初设置为值 10,这将触发我们的计算可观察到的行为,选择elements数组中的前 10 个项目,并在<table>中显示它们。
我们应该发现,我们可以使用<select>来动态更改显示的条目数量。
机密情报
在此任务中,我们使用了slice() Knockout 方法。您可能认为我们使用的是 JavaScript 的原生Array.slice()方法,但实际上我们使用的是 Knockout 版本,而且有一种简单的方法来识别它。
通常,当我们想要获取可观察属性内部的值时,我们会像调用函数一样调用属性。因此,当我们想要获取 ViewModel 的pageSize属性时,我们使用了this.pageSize()。
然而,当我们调用slice()方法时,我们没有像调用函数那样调用元素属性,因此实际数组在属性内部并未返回。slice()方法直接在可观察对象上调用。
Knockout 重新实现了一系列可以在数组上调用的原生方法,包括push()、pop()、unshift()、shift()、reverse()和sort(),我们在上一个任务中使用了它们。
建议使用这些方法的 Knockout 版本而不是原生 JavaScript 版本,因为它们在 Knockout 支持的所有浏览器中都受到支持,从而保持了依赖跟踪并保持了应用程序的 UI 同步。
添加上一页和下一页链接
此时,我们的页面现在只显示前 10 个项目。我们需要添加一个界面,允许用户导航到其他数据页面。在此任务中,我们可以添加上一页和下一页链接,以便以线性顺序查看页面。
启动推进器
我们将再次从添加此功能的 HTML 组件开始。在<tfoot>元素中的<select>元素之后直接添加以下新标记:
<nav>
<a href="#" title="Previous page"
data-bind="click: goToPrevPage">«</a>
<a href="#" title="Next page"
data-bind="click: goToNextPage">»</a>
</nav>
接下来,我们可以向我们的 ViewModel 添加一些新方法。这些可以直接添加到我们之前在sortable-table.js中添加的sort方法后面:
totalPages: function () {
var totalPages = this.elements().length / this.pageSize() || 1;
return Math.ceil(totalPages);
},
goToNextPage: function () {
if (this.currentPage() < this.totalPages() - 1) {
this.currentPage(this.currentPage() + 1);
}
},
goToPrevPage: function () {
if (this.currentPage() > 0) {
this.currentPage(this.currentPage() - 1);
}
}
最后,我们可以通过将以下代码添加到 sortable-table.css 来为此部分添加的新元素以及上一部分添加的元素添加一些 CSS 以进行整理:
tfoot label, tfoot select, tfootnav {
margin-right:4px; float: left; line-height:24px;
}
tfoot select { margin-right:20px; }
tfootnav a {
display:inline-block; font-size:30px; line-height:20px;
text-decoration:none; color:#000;
}
目标完成 - 小结
我们首先通过向页面添加包含两个 <a> 元素的 <nav> 元素来开始,这些元素制作了上一页和下一页链接。我们为链接添加了数据绑定,将上一页链接连接到 goToPrevPage() 方法,将下一页链接连接到 goToNextPage() 方法。
然后,我们添加了一个小的实用方法,以及这两个新方法到我们的 ViewModel。我们的方法不必像 sort() 方法那样接受参数,我们可以在方法中使用 this 访问我们的 ViewModel。
第一个方法 totalPages() 简单地通过将 elements 数组中的总项目数除以 pageSize 属性中保存的值来返回总页数。
有时 currentPage 属性将等于字符串 all,当在数学运算中使用时将返回 NaN,因此我们可以添加双竖线 OR (||) 来在这种情况下返回 1。我们还使用 Math.ceil() 来确保我们获得一个整数,因此当有 11.8 页的数据时(基于每页 10 个项目的默认值),该方法将返回 12。Ceil() 函数将总是向上舍入,因为我们不能有部分页面。
我们在上一个任务中添加的 createPage 计算的可观察对象实际上为我们做了大部分工作。接下来的两个方法只是更新了 currentPage 属性,这将自动触发 createPage() 计算的可观察对象。
在 goToNextPage() 方法中,我们首先检查我们是否已经在最后一页,只要我们不是,我们就将 currentPage 属性增加一。在我们检查是否在最后一页时,我们使用 totalPages() 方法。
goToPrevPage() 方法同样简单。这次我们检查我们是否已经在数据的第一页(如果 currentPage 等于 0),如果不是,我们将 currentPage 的值减去 1。
我们添加的少量 CSS 只是整理了 <tfoot> 元素中的元素,使它们能够与彼此并排浮动,并使新链接比默认情况下稍大一些。
添加数字页面链接
现在,我们可以添加任意数量的链接,以便允许用户直接访问任何页面。这些是直接链接到每个单独页面的数字页面链接。
启动推进器
首先,我们需要在我们的 ViewModel 中的现有可观察属性之后直接添加一个新的可观察属性,在 sortable-table.js 中:
pages: ko.observableArray(),
在此之后,我们可以向我们的 ViewModel 中添加一个新方法。这可以添加在 goToPrevPage() 方法之后,位于 vm 对象字面量内部:
changePage: function (obj, e) {
var el = $(e.target),
newPage = parseInt(el.text(), 10) - 1;
vm.currentPage(newPage);
}
不要忘记在goToPrevPage()方法后面加上逗号!然后我们可以添加一个新的计算可观察属性,方式与我们之前添加的一样。这可以直接放在我们在上一个任务中添加的createPage计算可观察属性之后:
vm.createPages = ko.computed(function () {
var tmp = [];
for (var x = 0; x < this.totalPages(); x++) {
tmp.push({ num: x + 1 });
}
this.pages(tmp);
}, vm);
接下来,我们需要在 HTML 页面中添加一些新的标记。这应该在我们在上一个任务中添加的Previous和Next链接之间添加:
<ul id="pages" data-bind="foreach: pages">
<li>
<a href="#" data-bind="text: num,
click: $parent.changePage"></a>
</li>
</ul>
最后,我们可以添加一点 CSS 来定位sortable-table.css中的新元素:
tfoot nav ul { margin:3px 0 0 10px; }
tfoot nav ul, tfootnav li { float:left; }
tfoot nav li { margin-right:10px; }
tfoot nav li a { font-size:20px; }
目标完成 - 小结。
首先,我们在 ViewModel 中添加了一个新的pages可观察数组。一开始我们没有给它一个数组;我们会在合适的时候动态添加。
我们添加的计算可观察属性createPages用于构建一个数组,其中数组中的每个项目表示数据的一个页面。我们可以像之前一样使用我们的totalPages()方法获取总页数。
一旦确定了这一点,也就是每当pageSize()可观察属性发生变化时,我们就可以填充刚刚添加的可观察数组。
添加到数组中的对象是使用简单的for循环创建的,以创建一个对象并将其推入数组中。一旦我们为每个页面构建了一个对象,我们就可以将数组设置为pages属性的值。
我们创建的每个对象都只有一个属性,称为num,其值是循环中使用的x计数器变量的当前值。
在 HTML 页面中,我们使用foreach数据绑定来迭代我们添加到pages数组中的数组。对于数组中的每个对象,我们创建一个<li>元素和一个<a>元素。<a>使用data-bind属性指定了两个绑定。
第一个是text绑定,它设置元素的文本。在这种情况下,我们将文本设置为每个对象具有的num属性的值。
第二个绑定是一个点击绑定,它调用一个名为changePage的方法。然而,在foreach绑定中,上下文被设置为pages数组中的当前对象,所以我们需要使用特殊的$parent上下文属性来访问 ViewModel 上的方法。
最后,我们添加了changePage方法,它被<a>元素使用。在这个简单的方法中,我们需要做的就是获取被点击元素的文本,从其值中减去1,因为实际的页码是从零开始的,并更新我们 ViewModel 的curentPage可观察属性。在这个方法中,由于某种原因,this的值并没有设置为被点击的元素,正如我们之前遇到的sort()方法所期望的那样。
因为触发changePage方法的<a>元素是在foreach绑定内创建的,所以传递给changePage的第一个参数将是pages数组中与<a>元素关联的对象。幸运的是,我们仍然可以使用变量vm访问 ViewModel。
我们添加的 CSS 简单地将列表项浮动在一起,稍微间隔开它们,并设置文本的颜色和大小。
机密情报
除了 $parent 上下文属性允许我们访问在 foreach 绑定中迭代的 ViewModel 属性的父对象之外,我们还可以利用 $data,它指向正在迭代的数组。
除此之外,还有一个 $index 属性,允许我们访问当前迭代的索引,我们可以在这个示例中使用它,而不是在每个对象上设置 num 属性。
管理类名
在这个任务中,我们可以向用户显示反馈,描述当前正在查看的页面。如果我们在数据的第一页或最后一页,我们也可以禁用 Previous 或 Next 链接。我们可以使用更多的脚本和一些简单的 CSS 来完成所有这些。
启动推进器
首先,我们需要在 sortable-table.js 中的现有方法后直接添加另一个方法到我们的 ViewModel 中:
manageClasses: function () {
var nav = $("#paging").find("nav"),
currentpage = this.currentPage();
nav.find("a.active")
.removeClass("active")
.end()
.find("a.disabled")
.removeClass("disabled");
if (currentpage === 0) {
nav.children(":first-child").addClass("disabled");
} else if (currentpage === this.totalPages() - 1) {
nav.children(":last-child").addClass("disabled");
}
$("#pages").find("a")
.eq(currentpage)
.addClass("active");
}
然后,我们需要从我们现有的代码中的几个位置调用这个方法。首先,我们需要在 createPage() 和 createPages() 计算观察函数的末尾调用它,通过在每个函数的最后一行(以 this 开头的行)添加以下代码:
.manageClasses();
然后,为了在与表格交互之前添加初始类名,我们需要在 ViewModel 之后的 applyBindings() 方法之后调用它:
vm.manageClasses();
最后,我们可以添加任务介绍中提到的额外 CSS:
tfoot nav a.disabled, tfoot nav a.disabled:hover {
opacity: .25; cursor: default; color:#aaa;
}
tfoot nav li a.active, tfoot a:hover { color:#aaa; }
目标完成 - 小结
在这个任务中,我们首先向我们的 ViewModel 添加了一个新方法 - manageClasses() 方法。该方法负责向 Previous 和 Next 链接添加或移除 disabled 类,并向当前页对应的数字链接添加活动类。
在方法内部,我们首先缓存包含 <nav> 元素的选择器,以便我们能够尽可能高效地访问需要更新的元素。我们还获取 curentPage ViewModel 属性,因为我们将多次比较其值。
然后,我们找到具有 disabled 和 active 类的元素,并将它们移除。注意我们在移除 active 类后如何使用 jQuery 的 end() 方法返回到原始的 <nav> 选择。
现在我们只需要将类重新放回适当的元素上。如果 currentPage 是 0,我们使用 jQuery 的 :first-child 选择器与 children() 方法一起将 disabled 类添加到 <nav> 中的第一个链接。
或者,如果我们在最后一页,我们将 disabled 类添加到 <nav> 的最后一个子元素,这次使用 :last-child 选择器。
使用 jQuery 的 eq() 方法轻松地选择要应用 active 类的元素,该方法将元素的选择减少到作为指定索引的单个元素。我们使用 currentpage 作为要在选择中保留的元素的索引。
CSS 仅用于为具有不同样式的类名的元素添加样式,因此可以轻松地看到类何时添加和删除。
现在在浏览器中运行页面时,我们应该发现上一页链接一开始是禁用的,并且数字1是活动的。如果我们访问任何页面,该数字将获得 active 类。
重置页面
现在我们已经连接了我们的数字分页链接,一个问题变得明显起来。有时,在更改每页项目数时,将显示空表格。
我们可以通过向 <select> 元素添加另一个绑定来修复此问题,该绑定在 <select> 元素的 value 更改时重置当前页面。
启动推进器
首先,我们可以将新的绑定添加到 HTML 中。将 <select> 元素更改为以下内容:
<select id="perPage" data-bind="value: pageSize, event: {
change: goToFirstPage
}">
现在我们可以将 goToFirstPage() 方法添加到 ViewModel 中:
goToFirstPage: function () {
this.currentPage(0);
}
目标完成 - 迷你总结
首先,我们将 event 绑定添加为 <select> 元素的第二个绑定,负责设置每页项的数量。此绑定的格式与我们在此项目中使用的其他绑定略有不同。
在绑定的名称之后,event 在本例中,我们在大括号内指定事件的名称和事件发生时要调用的处理程序。之所以使用此格式是因为如果需要,我们可以在括号内指定多个事件和处理程序。
然后,我们将新的事件处理程序 goToFirstPage() 添加为 ViewModel 的方法。在处理程序中,我们只需要将 currentPage 可观察值设置为 0,这将自动将我们移回到结果的第一页。每当 <select> 元素的值发生变化时,都会发生这种情况。
对表进行过滤
为了完成项目,我们可以添加过滤器,以便可以显示不同类型的元素。表的数据包含我们尚未使用的列——元素的 state(实际物理元素,而不是 HTML 元素!)
在此任务中,我们可以添加一个 <select> 元素,以允许我们根据其状态对元素进行过滤。
启动推进器
首先,我们需要向 ViewModel 添加一个新的可观察数组,该数组将用于存储表示元素可能的不同状态的对象:
states: ko.observableArray(),
我们还可以向 ViewModel 添加一个简单的非可观察属性:
originalElements: null,
接下来,我们需要填充新数组。我们可以在调用 vm.manageClasses() 之后直接执行此操作:
var tmpArr = [],
refObj = {};
tmpArr.push({ state: "Filter by..." });
$.each(vm.elements(), function(i, item) {
var state = item.state;
if (!refObj.hasOwnProperty(state)) {
var tmpObj = {state: state};
refObj[state] = state;
tmpArr.push(tmpObj);
}
});
vm.states(tmpArr);
然后,我们可以添加新的 HTML,该 HTML 将创建用于过滤 <table> 数据的 <select> 元素:
<div class="filter clearfix">
<label for="states">Filter by:</label>
<select id="states" data-bind="foreach: states, event: {
change: filterStates
}">
<option data-bind="value: state, text: state">
</option>
</select>
</div>
现在我们需要向 ViewModel 添加一个最终方法,该方法在进行选择时实际过滤数据:
filterStates: function (obj, e) {
if (e.originalEvent.target.selectedIndex !== 0) {
var vm = this,
tmpArr = [],
state = e.originalEvent.target.value;
vm.originalElements = vm.elements();
$.each(vm.elements(), function (i, item) {
if (item.state === state) {
tmpArr.push(item);
}
});
vm.elements(tmpArr).currentPage(0);
var label = $("<span/>", {
"class": "filter-label",
text: state
});
$("<a/>", {
text: "x",
href: "#",
title: "Remove this filter"
}).appendTo(label).on("click", function () {
$(this).parent().remove();
$("#states").show().prop("selectedIndex", 0);
vm.elements(vm.originalElements).currentPage(0);
});
label.insertBefore("#states").next().hide();
}
}
最后,我们可以向sortable-table.css添加一点 CSS,只是为了整理新元素:
tfoot .filter { float:right; }
tfoot .filter label {
display:inline-block; height:0; line-height:0;
text-indent:-9999em; overflow:hidden;
}
tfoot .filter select { margin-right:0; float:right; }
tfoot .filter span {
display:block; padding:0 7px; border:1px solid #abadb3;
border-radius:3px; float:right; line-height:24px;
}
tfoot .filter span a {
display:inline-block; margin-left:4px; color:#ff0000;
text-decoration:none; font-weight:bold;
}
完成目标 - 小结
首先,我们添加了一个名为states的新的可观察数组,该数组将用于包含构成我们数据的元素的不同状态。这些状态是固体、液体、气体或未知状态。
我们还向 ViewModel 添加了一个简单的属性,称为originalElements,它将用于存储完整的元素集合。该属性只是一个常规对象属性,因为我们不需要观察其值。
填充状态数组
接下来,我们将状态数组填充为数据中找到的所有唯一状态。我们只需要填充一次这个数组,所以它可以出现在 ViewModel 之外。我们首先创建一个空数组和一个空对象。
然后,我们向数组添加一个单个项目,该项目将用于<select>元素中的第一个<option>元素,并在与<select>框交互之前作为标签起作用。
然后,我们可以使用 jQuery 的each()方法迭代elements数组。对于数组中的每个项目(如果您记得的话,它将是表示单个元素的对象),我们获取其state并检查这是否存储在引用对象中。我们可以使用hasOwnProperty()JavaScript 函数来检查这一点。
如果状态在对象中不存在,我们将其添加。如果已经存在,则我们不需要做任何事情。如果对象不包含该状态,我们还将状态推入空数组。
一旦each()循环结束,我们应该有一个数组,其中包含数据中找到的每个state的单个实例,因此我们可以将此数组添加为states可观察数组的值。
构建<select>框
过滤功能的底层标记非常简单。我们添加了一个带有几个类名的容器<div>,一个<label>和一个<select>。<label>类名只是为了可访问性而添加的,我们不会显示它,因为<select>元素的第一个<option>将作为标签。
<select>元素有几个 Knockout 绑定。我们使用了foreach绑定,它连接到状态数组,因此一旦这个数组被填充,<select>的<option>元素就会自动添加。
我们还一次使用了event绑定,为change事件添加了一个处理程序,每当与<select>框交互时就会触发。
在<select>元素内部,我们为<option>元素添加了一个模板。每个选项将被赋予states数组中当前对象的state属性的text和value。
过滤数据
然后,我们添加了负责过滤<table>中显示的数据的 ViewModel 的方法。在方法中,我们首先检查第一个<option>是否未被选中,因为这只是一个标签,不对应任何状态。
我们可以通过查看target元素(<select>)的selectedIndex属性来确定这一点,该属性在originalEvent对象中可用。这本身是自动传递给我们的事件处理程序的事件对象的一部分。
因为我们将要更改elements可观察数组(以触发对过滤元素的分页),所以我们希望稍后存储原始元素。我们可以将它们存储在 ViewModel 的originalElements属性中。
接下来,我们需要构建一个新数组,其中仅包含具有在<select>元素中选择的state的元素。为此,我们可以创建一个空数组,然后迭代elements数组并检查每个元素的state。如果匹配,则将其推入新数组。
我们可以再次使用传递给我们的事件处理程序的事件对象来获取从<select>元素中选择的state。这次我们在originalEvent对象中使用target元素的value属性。
一旦新数组被填充,我们就更新elements数组,使其仅包含我们刚刚创建的新数组,然后将currentPage设置为0。
我们添加的过滤器是互斥的,因此一次只能应用一个过滤器。选择过滤器后,我们希望隐藏<select>框,以便无法选择另一个过滤器。
我们还可以创建一个标签,显示当前正在应用的过滤器。此标签由一个<span>元素制成,显示过滤器的文本,并且还包含一个<a>元素,可用于删除过滤器并将<table>返回到其最初显示所有元素的状态。
我们可以使用 jQuery 的on()方法在创建并附加到页面后立即附加<a>元素的处理程序。在处理程序中,我们只需将 ViewModel 的elements属性设置回保存在originalEvents属性中的数组,并将<table>重新设置为第一页,方法是将currentPage属性设置为0。
现在我们应该发现,我们可以在<select>框中选择其中一个选项,仅查看过滤后的数据和过滤标签,然后单击过滤标签中的红色叉号以返回初始的<table>。以下是数据的筛选选择和筛选标签的截图:
任务完成
我们的应用程序主要依赖 Knockout 功能运行,它允许我们轻松地将动态元素填充到内容中,添加事件处理程序,并通常管理应用程序的状态。我们也使用 jQuery,主要是在 DOM 选择容量方面,还偶尔使用它来使用实用程序,例如我们多次利用的$.each()方法。
完全可以纯粹使用 jQuery 构建此应用程序,而不使用 Knockout;但是,jQuery 本身从未被设计或打算成为构建复杂动态应用程序的完整解决方案。
当我们尝试仅使用 jQuery 构建复杂动态应用程序时,通常会发现我们的脚本很快变成一堆事件处理程序的混乱代码,既不容易阅读,也不易于维护或在将来更新。
使用 Knockout 来处理应用程序状态的维护,并使用 jQuery 来实现它的预期角色,为我们提供了使用非常少的代码构建高度动态、数据驱动的复杂应用程序的理想工具集。
在整个示例中,我尽量使各个方法尽可能简单,并且让它们只做一件事情。以这种方式将功能单元保持隔离有助于保持代码的可维护性,因为很容易看到每个现有函数的功能,也很容易添加新功能而不会破坏已有的内容。
你准备好全力以赴了吗?挑战热门的高手?
Knockout 可以轻松地从数据数组中构建一个<table>,由于数据是动态的,因此很容易编辑它或向其添加新项目,并使应用程序中的数据得以更新。尽管在此示例中数据是存储在本地文件中的,但将数据存储在服务器上并在页面加载时使用简单的 AJAX 函数填充我们的元素数组是很简单的。
如果你想进一步学习这个示例,这将是首要任务。完成这个任务后,为什么不试试使表格单元格可编辑,以便可以更改它们的值,或添加一个允许你插入新行到<table>的功能。完成这些后,你会想把新数据发送回服务器,以便永久存储。