jQuery3-学习手册-三-

42 阅读49分钟

jQuery3 学习手册(三)

原文:zh.annas-archive.org/md5/B3EDC852976B517A1E8ECB0D0B64863C

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:开发插件

可用的第三方插件提供了丰富的选项来增强我们的编码体验,但有时我们需要更进一步。当我们编写可以被其他人甚至只是我们自己重复使用的代码时,我们可能希望将其打包为一个新的插件。幸运的是,开发插件的过程与编写使用它的代码并没有太大区别。

在本章中,我们将介绍:

  • jQuery命名空间中添加新的全局函数

  • 添加 jQuery 对象方法以允许我们对 DOM 元素进行操作

  • 使用 jQuery UI 小部件工厂创建小部件插件

  • 分发插件

在插件中使用美元($)别名

当我们编写 jQuery 插件时,必须假设 jQuery 库已加载。但是我们不能假设美元()别名可用。回顾一下第三章中的内容,事件处理)别名可用。回顾一下第三章中的内容,*事件处理*,`.noConflict()方法可以放弃对这个快捷方式的控制。为了解决这个问题,我们的插件应该始终使用完整的 jQuery 名称调用 jQuery 方法,或者在内部定义$`自己。

尤其是在较大的插件中,许多开发人员发现缺少美元符号($)快捷方式使得代码更难阅读。为了解决这个问题,可以通过定义一个函数并立即调用它来为插件的范围定义快捷方式。这种定义并立即调用函数的语法,通常被称为立即调用函数表达式IIFE),看起来像这样:

(($) => { 
  // Code goes here 
})(jQuery); 

包装函数接受一个参数,我们将全局jQuery对象传递给它。参数被命名为$,所以在函数内部我们可以使用美元($)别名而不会出现冲突。

添加新的全局函数

jQuery 的一些内置功能是通过我们一直称为全局函数的方式提供的。正如我们所见,这些实际上是 jQuery 对象的方法,但从实际操作上来说,它们是jQuery命名空间中的函数。

这种技术的一个典型例子是$.ajax()函数。$.ajax()所做的一切都可以通过一个名为ajax()的常规全局函数来实现,但是这种方法会使我们容易遇到函数名冲突。通过将函数放置在jQuery命名空间中,我们只需要担心与其他 jQuery 方法的冲突。这个jQuery命名空间还向那些可能使用插件的人们表明,需要 jQuery 库。

jQuery 核心库提供的许多全局函数都是实用方法;也就是说,它们为经常需要但不难手动完成的任务提供了快捷方式。数组处理函数$.each()$.map()$.grep()就是这样的好例子。为了说明创建这种实用方法,我们将向其中添加两个简单的函数。

要将函数添加到jQuery命名空间中,我们只需将新函数作为jQuery对象的属性赋值即可:

(($) => { 
  $.sum = (array) => { 
    // Code goes here 
  }; 
})(jQuery); 

列表 8.1

现在,在使用此插件的任何代码中,我们可以写:

$.sum(); 

这将像基本函数调用一样工作,并且函数内部的代码将被执行。

这个sum方法将接受一个数组,将数组中的值相加,并返回结果。我们插件的代码相当简洁:

(($) => {
  $.sum = array =>
    array.reduce(
      (result, item) =>
        parseFloat($.trim(item)) + result,
      0
    );
})(jQuery); 

清单 8.2

要计算总和,我们在数组上调用reduce(),它简单地迭代数组中的每个项,并将其添加到result中。在前面的代码中,有两个返回值的回调函数。它们都没有return语句,因为它们是箭头函数。当我们不包括花括号({})时,返回值是隐式的。

为了测试我们的插件,我们将构建一个简单的带有杂货清单的表格:

<table id="inventory"> 
  <thead> 
    <tr class="one"> 
      <th>Product</th> <th>Quantity</th> <th>Price</th> 
    </tr> 
  </thead> 
  <tfoot> 
    <tr class="two" id="sum"> 
      <td>Total</td> <td></td> <td></td> 
    </tr> 
    <tr id="average"> 
      <td>Average</td> <td></td> <td></td> 
    </tr> 
  </tfoot> 
  <tbody> 
    <tr> 
      <td><a href="spam.html" data-tooltip-text="Nutritious and        
      delicious!">Spam</a></td> <td>4</td> <td>2.50</td> 
    </tr> 
    <tr> 
      <td><a href="egg.html" data-tooltip-text="Farm fresh or        
      scrambled!">Egg</a></td> <td>12</td> <td>4.32</td> 
    </tr> 
    <tr> 
      <td><a href="gourmet-spam.html" data-tooltip-text="Chef        
      Hermann's recipe.">Gourmet Spam</a></td> <td>14</td> <td>7.89         
      </td> 
    </tr> 
  </tbody> 
</table> 

获取示例代码

您可以从以下 GitHub 存储库访问示例代码:github.com/PacktPublishing/Learning-jQuery-3

现在,我们将编写一个简短的脚本,将适当的表格页脚单元格填充为所有数量的总和:

$(() => {
  const quantities = $('#inventory tbody')
    .find('td:nth-child(2)')
    .map((index, qty) => $(qty).text())
    .get();
  const sum = $.sum(quantities);

  $('#sum')
    .find('td:nth-child(2)')
    .text(sum);
});

清单 8.3

查看呈现的 HTML 页面可验证我们的插件是否正常工作:

添加多个函数

如果我们的插件需要提供多个全局函数,我们可以独立声明它们。在这里,我们将修改我们的插件,添加一个计算数字数组平均值的函数:

(($) => {
  $.sum = array =>
    array.reduce(
      (result, item) =>
        parseFloat($.trim(item)) + result,
      0
    );

  $.average = array =>
    Array.isArray(array) ?
      $.sum(array) / array.length :
      '';
})(jQuery); 

清单 8.4

为了方便和简洁,我们使用$.sum()插件来辅助我们返回$.average()的值。为了减少错误的几率,我们还检查参数以确保其是一个数组,然后再计算平均值。

现在定义了第二种方法,我们可以以相同的方式调用它:

$(() => {
  const $inventory = $('#inventory tbody');
  const prices = $inventory
    .find('td:nth-child(3)')
    .map((index, qty) => $(qty).text())
    .get();
  const average = $.average(prices);

  $('#average')
    .find('td:nth-child(3)')
    .text(average.toFixed(2));
});

清单 8.5

平均值现在显示在第三列中:

扩展全局 jQuery 对象

我们还可以使用$.extend()函数以定义我们的函数的另一种语法:

(($) => {
  $.extend({
    sum: array =>
      array.reduce(
        (result, item) =>
          parseFloat($.trim(item)) + result,
        0
      ),
    average: array =>
      Array.isArray(array) ?
        $.sum(array) / array.length :
        ''
  });
})(jQuery); 

清单 8.6

这样调用时,$.extend()添加或替换全局 jQuery 对象的属性。因此,这与先前的技术产生相同的结果。

在命名空间内隔离函数

现在,我们的插件在jQuery命名空间内创建了两个单独的全局函数。在这里,我们面临一种不同类型的命名空间污染风险;虽然我们仍然可能与其他 jQuery 插件中定义的函数名冲突。为了避免这种情况,最好将给定插件的所有全局函数封装到单个对象中:

(($) => {
  $.mathUtils = {
    sum: array =>
      array.reduce(
        (result, item) =>
          parseFloat($.trim(item)) + result,
        0
      ),
    average: array =>
      Array.isArray(array) ?
        $.mathUtils.sum(array) / array.length :
        ''
  };
})(jQuery); 

清单 8.7

此模式实质上为我们的全局函数创建了另一个命名空间,称为jQuery.mathUtils。虽然我们仍然非正式地称这些函数为全局函数,但它们现在是mathUtils对象的方法,后者本身是全局 jQuery 对象的属性。因此,在我们的函数调用中,我们必须包含插件名称:

$.mathUtils.sum(array); 
$.mathUtils.average(array); 

通过这种技术(和足够独特的插件名称),我们可以在全局函数中避免命名空间冲突。这样,我们就掌握了插件开发的基础知识。将我们的函数保存在名为jquery.mathutils.js的文件中后,我们可以包含此脚本,并在页面上的其他脚本中使用这些函数。

选择命名空间

对于仅供个人使用的功能,将其放置在我们项目的全局命名空间中通常更合理。因此,我们可以选择暴露我们自己的一个全局对象,而不是使用jQuery。例如,我们可以有一个名为ljQ的全局对象,并定义ljQ.mathUtils.sum()ljQ.mathUtils.average()方法,而不是$.mathUtils.sum()$.mathUtils.average()。这样,我们完全消除了选择包含的第三方插件发生命名空间冲突的可能性。

因此,我们现在已经了解了 jQuery 插件提供的命名空间保护和保证库的可用性。然而,这些仅仅是组织上的好处。要真正发挥 jQuery 插件的威力,我们需要学会如何在单个 jQuery 对象实例上创建新方法。

添加 jQuery 对象方法

大多数 jQuery 内置功能是通过其对象实例方法提供的,插件的具有同样出色的表现。每当我们要编写作用于 DOM 一部分的函数时,可能更适合创建一个实例方法

我们已经看到,添加全局函数需要使用jQuery对象扩展新方法。添加实例方法是类似的,但我们要扩展jQuery.fn对象:

jQuery.fn.myMethod = function() { 
  alert('Nothing happens.'); 
}; 

jQuery.fn对象是jQuery.prototype的别名,用于简洁性。

然后,我们可以在使用选择器表达式后,从我们的代码中调用这个新方法:

$('div').myMethod(); 

当我们调用方法时,我们的警报显示(对于文档中的每个<div>都会显示一次)。不过,我们既然没有以任何方式使用匹配的 DOM 节点,我们可能也可以编写一个全局函数。一个合理的方法实现会作用于其上下文。

对象方法上下文

在任何插件方法中,关键字this被设置为当前的 jQuery 对象。因此,我们可以在this上调用任何内置的 jQuery 方法,或者提取其 DOM 节点并对它们进行操作。为了检查我们可以用对象上下文做什么,我们将编写一个小插件来操作匹配元素上的类。

我们的新方法将接受两个类名,并交换每次调用时应用于每个元素的类。虽然 jQuery UI 有一个强大的.switchClass()方法,甚至允许动画地改变类,但我们将提供一个简单的实现作为演示目的:

(function($) {
  $.fn.swapClass = function(class1, class2) {
    if (this.hasClass(class1)) {
      this
        .removeClass(class1)
        .addClass(class2);
    } else if (this.hasClass(class2)) {
      this
        .removeClass(class2)
        .addClass(class1);
    }
  };
})(jQuery);

$(() => {
  $('table')
    .click(() => {
      $('tr').swapClass('one', 'two');
    });
});

图 8.8

在我们的插件中,我们首先测试匹配元素上是否存在class1,如果存在则用class2替换。否则,我们测试是否存在class2,如果必要则切换为class1。如果当前没有任何类,则我们不执行任何操作。

在使用插件的代码中,我们将click处理程序绑定到表格上,在单击表格时对每一行调用.swapClass()。我们希望这将把标题行的类从one更改为two,并将总和行的类从two更改为one

然而,我们观察到了不同的结果:

每一行都收到了two类。要解决这个问题,我们需要正确处理具有多个选定元素的 jQuery 对象。

隐式迭代

我们需要记住,jQuery 选择器表达式总是可以匹配零个、一个或多个元素。在设计插件方法时,我们必须考虑到这些情况中的任何一种。在这种情况下,我们正在调用.hasClass(),它仅检查第一个匹配的元素。相反,我们需要独立地检查每个元素并对其采取行动。

无论匹配的元素数量如何,保证正确行为的最简单方法是始终在方法上下文中调用.each();这强制执行隐式迭代,这对于保持插件和内置方法之间的一致性至关重要。在.each()回调函数中,第二个参数依次引用每个 DOM 元素,因此我们可以调整我们的代码来分别测试和应用类到每个匹配的元素:

(function($) {
  $.fn.swapClass = function(class1, class2) {
    this
      .each((i, element) => {
        const $element = $(element);

        if ($element.hasClass(class1)) {
          $element
            .removeClass(class1)
            .addClass(class2);
        } else if ($element.hasClass(class2)) {
          $element
            .removeClass(class2)
            .addClass(class1);
        }
      });
  };
})(jQuery); 

列表 8.9

现在,当我们点击表格时,切换类而不影响没有应用任何类的行:

启用方法链

除了隐式迭代之外,jQuery 用户还应该能够依赖链接行为。这意味着我们需要从所有插件方法中返回一个 jQuery 对象,除非该方法明确用于检索不同的信息片段。返回的 jQuery 对象通常只是作为this提供的一个。如果我们使用.each()来迭代this,我们可以直接返回其结果:

(function($) {
  $.fn.swapClass = function(class1, class2) {
    return this
      .each((i, element) => {
        const $element = $(element);

        if ($element.hasClass(class1)) {
          $element
            .removeClass(class1)
            .addClass(class2);
        } else if ($element.hasClass(class2)) {
          $element
            .removeClass(class2)
            .addClass(class1);
        }
      });
  };
})(jQuery); 

列表 8.10

之前,当我们调用.swapClass()时,我们必须开始一个新语句来处理元素。然而,有了return语句,我们可以自由地将我们的插件方法与内置方法链接起来。

提供灵活的方法参数

在第七章 使用插件 中,我们看到了一些插件,可以通过参数进行微调,以达到我们想要的效果。我们看到,一个构造巧妙的插件通过提供合理的默认值来帮助我们,这些默认值可以被独立地覆盖。当我们制作自己的插件时,我们应该以用户为重心来遵循这个例子。

为了探索各种方法,让插件的用户自定义其行为,我们需要一个具有多个可以进行调整和修改的设置的示例。作为我们的示例,我们将通过使用更为武断的 JavaScript 方法来复制 CSS 的一个特性--这种方法更适合于演示而不是生产代码。我们的插件将通过在页面上不同位置叠加部分透明的多个副本来模拟元素上的阴影:

(function($) {
  $.fn.shadow = function() {
    return this.each((i, element) => {
      const $originalElement = $(element);

      for (let i = 0; i < 5; i++) {
        $originalElement
          .clone()
          .css({
            position: 'absolute',
            left: $originalElement.offset().left + i,
            top: $originalElement.offset().top + i,
            margin: 0,
            zIndex: -1,
            opacity: 0.1
          })
          .appendTo('body');
      }
    });
  };
})(jQuery); 

代码清单 8.11

对于每个调用此方法的元素,我们会制作多个元素的克隆,并调整它们的不透明度。这些克隆元素被绝对定位在原始元素的不同偏移量处。目前,我们的插件不接受参数,因此调用该方法很简单:

$(() => { 
  $('h1').shadow(); 
}); 

此方法调用会在标题文本上产生一个非常简单的阴影效果:

接下来,我们可以为插件方法引入一些灵活性。该方法的操作依赖于用户可能希望修改的几个数值。我们可以将它们转换为参数,以便根据需要进行更改。

选项对象

我们在 jQuery API 中看到了许多示例,其中options对象被提供为方法的参数,例如.animate()$.ajax()。这可以是向插件用户公开选项的更友好的方式,而不是我们刚刚在.swapClass()插件中使用的简单参数列表。对象文字为每个参数提供了可视标签,并且使参数的顺序变得无关紧要。此外,每当我们可以在我们的插件中模仿 jQuery API 时,我们都应该这样做。这将增加一致性,从而提高易用性:

(($) => {
  $.fn.shadow = function(options) {
    return this.each((i, element) => {
      const $originalElement = $(element);

      for (let i = 0; i < options.copies; i++) {
        $originalElement
          .clone()
          .css({
            position: 'absolute',
            left: $originalElement.offset().left + i,
            top: $originalElement.offset().top + i,
            margin: 0,
            zIndex: -1,
            opacity: options.opacity
          })
          .appendTo('body');
      }
    });
  };
})(jQuery);

代码清单 8.12

现在可以自定义制作的副本数量及其不透明度。在我们的插件中,每个值都作为函数的options参数的属性访问。

现在调用此方法需要我们提供包含选项值的对象:

$(() => {
  $('h1')
    .shadow({ 
      copies: 3, 
      opacity: 0.25 
    }); 
}); 

可配置性是一种改进,但现在我们必须每次都提供两个选项。接下来,我们将看看如何允许我们的插件用户省略任一选项。

默认参数值

随着方法的参数数量增加,我们不太可能总是想要指定每个参数。合理的默认值集合可以使插件接口更加易用。幸运的是,使用对象传递参数可以帮助我们完成这项任务;简单地省略对象中的任何项并用默认值替换它是很简单的:

(($) => {
  $.fn.shadow = function(opts) {
    const defaults = {
      copies: 5,
      opacity: 0.1
    };
    const options = $.extend({}, defaults, opts); 

    // ... 
  }; 
})(jQuery); 

代码清单 8.13

在这里,我们定义了一个名为defaults的新对象。 实用函数$.extend()允许我们使用提供的opts对象作为参数,并使用defaults在必要时创建一个新的options对象。 extend()函数将传递给它的任何对象合并到第一个参数中。 这就是为什么我们将空对象作为第一个参数传递的原因,以便我们为选项创建一个新对象,而不是意外地销毁现有数据。 例如,如果默认值在代码的其他位置定义,并且我们意外地替换了其值呢?

我们仍然使用对象字面量调用我们的方法,但现在我们只能指定需要与其默认值不同的参数:

$(() => { 
  $('h1')
    .shadow({ 
      copies: 3 
    }); 
}); 

未指定的参数使用其默认值。 $.extend()方法甚至接受 null 值,因此如果默认参数都可接受,则我们的方法可以在不产生 JavaScript 错误的情况下调用:

$(() => { 
  $('h1').shadow(); 
}); 

回调函数

当然,有些方法参数可能比简单的数字值更复杂。 我们在整个 jQuery API 中经常看到的一种常见参数类型是回调函数。 回调函数可以为插件提供灵活性,而无需在创建插件时进行大量准备。

要在我们的方法中使用回调函数,我们只需将函数对象作为参数接受,并在我们的方法实现中适当地调用该函数。 例如,我们可以扩展我们的文本阴影方法,以允许用户自定义阴影相对于文本的位置:

(($) => {
  $.fn.shadow = function(opts) {
    const defaults = {
      copies: 5,
      opacity: 0.1,
      copyOffset: index => ({
        x: index,
        y: index
      })
    };
    const options = $.extend({}, defaults, opts);

    return this.each((i, element) => {
      const $originalElement = $(element);

      for (let i = 0; i < options.copies; i++) {
        const offset = options.copyOffset(i);

        $originalElement
          .clone()
          .css({
            position: 'absolute',
            left: $originalElement.offset().left + offset.x,
            top: $originalElement.offset().top + offset.y,
            margin: 0,
            zIndex: -1,
            opacity: options.opacity
          })
          .appendTo('body');
      }
    });
  };
})(jQuery);

列表 8.14

阴影的每个片段与原始文本的偏移量不同。 以前,此偏移量仅等于副本的索引。 但是,现在,我们正在使用copyOffset()函数计算偏移量,该函数是用户可以覆盖的选项。 因此,例如,我们可以为两个维度的偏移提供负值:

$(() => { 
  $('h1').shadow({ 
    copyOffset: index => ({
      x: -index,
      y: -2 * index
    }) 
  }); 
}); 

这将导致阴影向左上方投射,而不是向右下方:

回调函数允许简单修改阴影的方向,或者如果插件用户提供了适当的回调,则允许更复杂的定位。 如果未指定回调,则再次使用默认行为。

可定制的默认值

通过为我们的方法参数提供合理的默认值,我们可以改善使用插件的体验,正如我们所见。 但是,有时很难预测什么是合理的默认值。 如果脚本作者需要多次调用我们的插件,并且需要不同于我们设置的默认值的参数集,那么自定义这些默认值的能力可能会显着减少需要编写的代码量。

要使默认值可定制,我们需要将它们从我们的方法定义中移出,并放入可由外部代码访问的位置:

(() => { 
  $.fn.shadow = function(opts) { 
    const options = $.extend({}, $.fn.shadow.defaults, opts); 
    // ... 
  }; 

  $.fn.shadow.defaults = { 
    copies: 5, 
    opacity: 0.1, 
    copyOffset: index => ({
      x: index,
      y: index
    }) 
  }; 
})(jQuery); 

列表 8.15

默认值现在在阴影插件的命名空间中,并且可以直接使用 $.fn.shadow.defaults 引用。现在,使用我们的插件的代码可以更改所有后续对 .shadow() 的调用所使用的默认值。选项也仍然可以在调用方法时提供:

$(() => { 
  $.fn.shadow.defaults.copies = 10;
  $('h1')
    .shadow({
      copyOffset: index => ({
        x: -index,
        y: index
    })
  });
}); 

这个脚本将使用 10 个元素的副本创建一个阴影,因为这是新的默认值,但也会通过提供的 copyOffset 回调将阴影投射到左侧和向下:

使用 jQuery UI 小部件工厂创建插件。

正如我们在第七章中看到的,使用插件,jQuery UI 有各种各样的小部件--呈现特定类型的 UI 元素的插件,如按钮或滑块。这些小部件向 JavaScript 程序员提供一致的 API。这种一致性使得学习使用其中一个变得容易。当我们编写的插件将创建一个新的用户界面元素时,通过使用小部件插件扩展 jQuery UI 库通常是正确的选择。

小部件是一段复杂的功能,但幸运的是我们不需要自己创建。jQuery UI 核心包含一个名为 $.widget()factory 方法,它为我们做了很多工作。使用这个工厂将有助于确保我们的代码符合所有 jQuery UI 小部件共享的 API 标准。

使用小部件工厂创建的插件具有许多不错的功能。我们只需很少的努力就能得到所有这些好处(以及更多):

  • 插件变得 有状态,这意味着我们可以在应用插件后检查、修改或甚至完全撤销插件的效果。

  • 用户提供的选项会自动与可定制的默认选项合并。

  • 多个插件方法被无缝地合并为单个 jQuery 方法,接受一个字符串来标识调用哪个子方法。

  • 插件触发的自定义事件处理程序可以访问小部件实例的数据。

实际上,这些优势非常好,以至于我们可能希望使用小部件工厂来构建任何合适复杂的插件,无论是 UI 相关的还是其他的。

创建一个小部件。

以我们的示例为例,我们将制作一个插件,为元素添加自定义工具提示。一个简单的工具提示实现会为页面上每个要显示工具提示的元素创建一个 <div> 容器,并在鼠标光标悬停在目标上时将该容器定位在元素旁边。

jQuery UI 库包含其自己内置的高级工具提示小部件,比我们将在这里开发的更为先进。我们的新小部件将覆盖内置的 .tooltip() 方法,这不是我们在实际项目中可能做的事情,但它将允许我们演示几个重要的概念而不会增加不必要的复杂性。

每次调用$.widget()时,小部件工厂都会创建一个 jQuery UI 插件。此函数接受小部件的名称和包含小部件属性的对象。小部件的名称必须被命名空间化;我们将使用命名空间ljq和插件名称tooltip。因此,我们的插件将通过在 jQuery 对象上调用.tooltip()来调用。

第一个小部件属性我们将定义为._create()

(($) => {
  $.widget('ljq.tooltip', {
    _create() {
      this._tooltipDiv = $('<div/>')
        .addClass([
          'ljq-tooltip-text',
          'ui-widget',
          'ui-state-highlight',
          'ui-corner-all'
        ].join(' '))
        .hide()
        .appendTo('body');
      this.element
        .addClass('ljq-tooltip-trigger')
        .on('mouseenter.ljq-tooltip', () => { this._open(); })
        .on('mouseleave.ljq-tooltip', () => { this._close(); });
    }
  });
})(jQuery); 

列表 8.16

此属性是一个函数,当调用.tooltip()时,小部件工厂将每匹配一个元素在 jQuery 对象中调用一次。

小部件属性,如_create,以下划线开头,被认为是私有的。我们稍后将讨论公共函数。

在这个创建函数内部,我们设置了我们的提示以便未来显示。为此,我们创建了新的<div>元素并将其添加到文档中。我们将创建的元素存储在this._tooltipDiv中以备后用。

在我们的函数上下文中,this指的是当前小部件实例,我们可以向该对象添加任何属性。该对象还具有一些内置属性,对我们也很方便;特别是,this.element给了我们一个指向最初选定的元素的 jQuery 对象。

我们使用this.elementmouseentermouseleave处理程序绑定到提示触发元素上。我们需要这些处理程序在鼠标开始悬停在触发器上时打开提示,并在鼠标离开时关闭它。请注意,事件名称被命名空间化为我们的插件名称。正如我们在第三章中讨论的处理事件,命名空间使我们更容易添加和删除事件处理程序,而不会影响其他代码也想要绑定处理程序到元素上。

接下来,我们需要定义绑定到mouseentermouseleave处理程序的._open()._close()方法:

(() => { 
  $.widget('ljq.tooltip', { 
    _create() { 
      // ... 
    }, 

    _open() {
      const elementOffset = this.element.offset();
      this._tooltipDiv
        .css({
          position: 'absolute',
          left: elementOffset.left,
          top: elementOffset.top + this.element.height()
        })
        .text(this.element.data('tooltip-text'))
        .show();
    },

    _close() { 
      this._tooltipDiv.hide(); 
    } 
  }); 
})(jQuery); 

列表 8.17

._open()._close()方法本身是不言自明的。这些不是特殊名称,而是说明我们可以在我们的小部件中创建任何私有函数,只要它们的名称以下划线开头。当提示被打开时,我们用 CSS 定位它并显示它;当它关闭时,我们只需隐藏它。

在打开过程中,我们需要填充提示信息。我们使用.data()方法来做到这一点,它可以获取和设置与任何元素关联的任意数据。在这种情况下,我们使用该方法来获取每个元素的data-tooltip-text属性的值。

有了我们的插件,代码$('a').tooltip()将导致鼠标悬停在任何锚点上时显示提示:

到目前为止,插件并不是很长,但是密集地包含了复杂的概念。为了让这种复杂性发挥作用,我们可以做的第一件事就是使我们的小部件具有状态。小部件的状态将允许用户根据需要启用和禁用它,甚至在创建后完全销毁它。

销毁小部件

我们已经看到,小部件工厂创建了一个新的 jQuery 方法,在我们的案例中称为 .tooltip(),可以不带参数调用以将小部件应用于一组元素。不过,这个方法还可以做更多的事情。当我们给这个方法一个字符串参数时,它会调用相应名称的方法。

内置方法之一称为 destroy。调用 .tooltip('destroy') 将从页面中删除提示小部件。小部件工厂会完成大部分工作,但如果我们在 ._create() 中修改了文档的某些部分(正如我们在这里所做的,通过创建提示文本 <div>),我们需要自己清理:

(($) => {
  $.widget('ljq.tooltip', { 
    _create() { 
      // ... 
    }, 

    destroy() {
      this._tooltipDiv.remove();
      this.element
        .removeClass('ljq-tooltip-trigger')
        .off('.ljq-tooltip');
      this._superApply(arguments);
    },

    _open() { 
      // ... 
    }, 

    _close() { 
      // ... 
    } 
  }); 
})(jQuery); 

列表 8.18

这段新代码被添加为小部件的一个新属性。该函数撤销了我们所做的修改,然后调用原型的 destroy 版本,以便自动清理发生。 _super()_superApply() 方法调用了同名的基础小部件方法。这样做总是一个好主意,这样基础小部件中的适当初始化操作就会执行。

注意 destroy 前面没有下划线;这是一个 public 方法,我们可以用 .tooltip('destroy') 调用它。

启用和禁用小部件

除了完全销毁之外,任何小部件都可以被暂时禁用,稍后重新启用。基础小部件方法 enabledisable 通过将 this.options.disabled 的值设置为 truefalse 来帮助我们。我们所要做的就是在我们的小部件采取任何行动之前检查这个值:

_open() {
  if (this.options.disabled) {
    return;
  }

  const elementOffset = this.element.offset();
  this._tooltipDiv
    .css({
      position: 'absolute',
      left: elementOffset.left,
      top: elementOffset.top + this.element.height()
    })
    .text(this.element.data('tooltip-text'))
    .show();
}

列表 8.19

在这个额外的检查放置后,一旦调用 .tooltip('disable'),提示就停止显示,并且在调用 .tooltip('enable') 之后再次显示。

接受小部件选项

现在是时候使我们的小部件可定制了。就像我们在构建 .shadow() 插件时看到的那样,为小部件提供一组可定制的默认值并用用户指定的选项覆盖这些默认值是友好的。几乎所有这个过程中的工作都是由小部件工厂完成的。我们所需要做的就是提供一个 options 属性:

options: { 
  offsetX: 10, 
  offsetY: 10, 
  content: element => $(element).data('tooltip-text') 
}, 

列表 8.20

options 属性是一个普通对象。我们的小部件的所有有效选项都应该被表示出来,这样用户就不需要提供任何强制性的选项。在这里,我们为提示相对于其触发元素的 x 和 y 坐标提供了一个函数,以及一个为每个元素生成提示文本的函数。

我们代码中唯一需要检查这些选项的部分是 ._open()

_open() {
  if (this.options.disabled) {
    return;
  }

  const elementOffset = this.element.offset();
  this._tooltipDiv
    .css({
      position: 'absolute',
      left: elementOffset.left + this.options.offsetX,
      top:
        elementOffset.top +
        this.element.height() +
        this.options.offsetY
    })
    .text(this.options.content(this.element))
    .show();
} 

列表 8.21

_open方法内部,我们可以使用this.options访问这些属性。通过这种方式,我们总是能够得到选项的正确值:默认值或者用户提供的覆盖值。

我们仍然可以像.tooltip()这样无参数地添加我们的小部件,并获得默认行为。现在我们可以提供覆盖默认行为的选项:.tooltip({ offsetX: -10, offsetX: 25 })。小部件工厂甚至让我们在小部件实例化后更改这些选项:.tooltip('option', 'offsetX', 20)。下次访问选项时,我们将看到新值。

对选项更改做出反应

如果我们需要立即对选项更改做出反应,我们可以在小部件中添加一个_setOption函数来处理更改,然后调用_setOption的默认实现。

添加方法

内置方法很方便,但通常我们希望向插件的用户公开更多的钩子,就像我们使用内置的destroy方法所做的那样。我们已经看到如何在小部件内部创建新的私有函数。创建公共方法也是一样的,只是小部件属性的名称不以下划线开头。我们可以利用这一点很简单地创建手动打开和关闭工具提示的方法:

open() { 
  this._open(); 
},
close() { 
  this._close(); 
}

列表 8.22

就是这样!通过添加调用私有函数的公共方法,我们现在可以使用.tooltip('open')打开工具提示,并使用.tooltip('close')关闭它。小部件工厂甚至会为我们处理一些细节,比如确保链式调用继续工作,即使我们的方法不返回任何东西。

触发小部件事件

一个优秀的插件不仅扩展了 jQuery,而且还为其他代码提供了许多扩展插件本身的机会。提供这种可扩展性的一个简单方法是支持与插件相关的一组自定义事件。小部件工厂使这个过程变得简单:

_open() {
  if (this.options.disabled) {
    return;
  }

  const elementOffset = this.element.offset();
  this._tooltipDiv
    .css({
      position: 'absolute',
      left: elementOffset.left + this.options.offsetX,
      top:
        elementOffset.top +
        this.element.height() +
        this.options.offsetY
    })
    .text(this.options.content(this.element))
    .show();
  this._trigger('open');
},

_close: function() { 
  this._tooltipDiv.hide(); 
  this._trigger('close'); 
} 

列表 8.23

在我们的函数中调用this._trigger()允许代码监听新的自定义事件。事件的名称将以我们的小部件名称为前缀,因此我们不必过多担心与其他事件的冲突。例如,在我们的工具提示打开函数中调用this._trigger('open'),每次工具提示打开时都会发出名为tooltipopen的事件。我们可以通过在元素上调用.on('tooltipopen')来监听此事件。

这只是揭示了一个完整的小部件插件可能具有的潜力,但给了我们构建一个具有 jQuery UI 用户所期望的功能和符合标准的小部件所需的工具。

插件设计建议

现在,我们已经研究了通过创建插件来扩展 jQuery 和 jQuery UI 的常见方式,我们可以回顾并补充我们学到的内容,列出一些建议:

  • 通过使用jQuery或将$传递给 IIFE 来保护$别名免受其他库的潜在干扰,以便它可以用作局部变量。

  • 无论是扩展 jQuery 对象与 $.myPlugin 还是扩展 jQuery 原型与 $.fn.myPlugin,都不要向 $ 命名空间添加超过一个属性。额外的公共方法和属性应添加到插件的命名空间中(例如,$.myPlugin.publicMethod$.fn.myPlugin.pluginProperty)。

  • 提供包含插件默认选项的对象:$.fn.myPlugin.defaults = {size: 'large'}

  • 允许插件用户选择性地覆盖所有后续调用方法的默认设置($.fn.myPlugin.defaults.size = 'medium';)或单个调用的默认设置($('div').myPlugin({size: 'small'});)。

  • 在大多数情况下,当扩展 jQuery 原型时($.fn.myPlugin),返回 this 以允许插件用户将其他 jQuery 方法链接到它(例如,$('div').myPlugin().find('p').addClass('foo'))。

  • 当扩展 jQuery 原型时($.fn.myPlugin),通过调用 this.each() 强制隐式迭代。

  • 在适当的情况下使用回调函数,以允许灵活修改插件的行为,而无需更改插件的代码。

  • 如果插件需要用户界面元素或需要跟踪元素状态,请使用 jQuery UI 小部件工厂创建。

  • 使用像 QUnit 这样的测试框架为插件维护一组自动化单元测试,以确保其按预期工作。有关 QUnit 的更多信息,请参见附录 A。

  • 使用诸如 Git 等版本控制系统跟踪代码的修订。考虑在 GitHub(github.com/)上公开托管插件,并允许其他人贡献。

  • 如果要使插件可供他人使用,请明确许可条款。考虑使用 MIT 许可证,jQuery 也使用此许可证。

分发插件

遵循前述建议,我们可以制作出符合经过时间考验的传统的干净、可维护的插件。如果它执行一个有用的、可重复使用的任务,我们可能希望与 jQuery 社区分享。

除了按照早前定义的方式正确准备插件代码之外,我们还应该在分发之前充分记录插件的操作。我们可以选择适合我们风格的文档格式,但可能要考虑一种标准,比如 JSDoc(在 usejsdoc.org/ 中描述)。有几种自动文档生成器可用,包括 docco(jashkenas.github.com/docco/)和 dox(github.com/visionmedia/dox)。无论格式如何,我们都必须确保我们的文档涵盖了插件方法可用的每个参数和选项。

插件代码和文档可以托管在任何地方;npm(www.npmjs.com/)是标准选项。有关将 jQuery 插件发布为 npm 软件包的更多信息,请查看此页面:blog.npmjs.org/post/112064849860/using-jquery-plugins-with-npm

摘要

在本章中,我们看到 jQuery 核心提供的功能不必限制库的功能。除了我们在第七章使用插件中探讨的现成插件外,我们现在知道如何自己扩展功能菜单。

我们创建的插件包含各种功能,包括使用 jQuery 库的全局函数、用于操作 DOM 元素的 jQuery 对象的新方法以及复杂的 jQuery UI 小部件。有了这些工具,我们可以塑造 jQuery 和我们自己的 JavaScript 代码,使其成为我们想要的任何形式。

练习

挑战练习可能需要使用api.jquery.com/上的官方 jQuery 文档。

  1. 创建名为.slideFadeIn().slideFadeOut()的新插件方法,将.fadeIn().fadeOut()的不透明度动画与.slideDown().slideUp()的高度动画结合起来。

  2. 扩展.shadow()方法的可定制性,以便插件用户可以指定克隆副本的 z-index。

  3. 为工具提示小部件添加一个名为isOpen的新子方法。该子方法应该在工具提示当前显示时返回true,否则返回false

  4. 添加监听我们小部件触发的tooltipopen事件的代码,并在控制台中记录一条消息。

  5. 挑战:为工具提示小部件提供一个替代的content选项,该选项通过 Ajax 获取锚点的href指向页面的内容,并将该内容显示为工具提示文本。

  6. 挑战:为工具提示小部件提供一个新的effect选项,如果指定了,将应用指定的 jQuery UI 效果(比如explode)来显示和隐藏工具提示。

第九章:高级选择器和遍历

2009 年 1 月,jQuery 的创始人约翰·雷西格(John Resig)推出了一个名为Sizzle的新开源 JavaScript 项目。作为一个独立的CSS 选择器引擎,Sizzle 的编写旨在让任何 JavaScript 库都能够在几乎不修改其代码库的情况下采用它。事实上,jQuery 自从 1.3 版本以来一直在使用 Sizzle 作为其自己的选择器引擎。

Sizzle 是 jQuery 中负责解析我们放入$()函数中的 CSS 选择器表达式的组件。它确定要使用哪些原生 DOM 方法,因为它构建了一个我们可以用其他 jQuery 方法操作的元素集合。Sizzle 和 jQuery 的遍历方法集合的结合使得 jQuery 成为查找页面元素的非常强大的工具。

在第二章,选择元素中,我们查看了 jQuery 库中每种基本类型的选择器和遍历方法,以便我们了解在 jQuery 库中可用的内容。在这个更高级的章节中,我们将涵盖:

  • 使用选择器以各种方式查找和过滤数据

  • 编写添加新选择器和 DOM 遍历方法的插件

  • 优化我们的选择器表达式以获得更好的性能

  • 了解 Sizzle 引擎的一些内部工作 ings

选择和遍历重访

为了更深入地了解选择器和遍历,我们将构建一个脚本,提供更多选择和遍历示例以进行检查。对于我们的示例,我们将构建一个包含新闻项列表的 HTML 文档。我们将这些项目放在一个表格中,以便我们可以以几种方式选择行和列进行实验:

<div id="topics"> 
  Topics: 
  <a href="topics/all.html" class="selected">All</a> 
  <a href="topics/community.html">Community</a> 
  <a href="topics/conferences.html">Conferences</a> 
  <!-- continued... --> 
</div> 
<table id="news"> 
  <thead> 
    <tr> 
      <th>Date</th> 
      <th>Headline</th> 
      <th>Author</th> 
      <th>Topic</th> 
    </tr> 
  </thead> 
  <tbody> 
    <tr> 
      <th colspan="4">2011</th> 
    </tr> 
    <tr> 
      <td>Apr 15</td> 
      <td>jQuery 1.6 Beta 1 Released</td> 
      <td>John Resig</td> 
      <td>Releases</td> 
    </tr> 
    <tr> 
      <td>Feb 24</td> 
      <td>jQuery Conference 2011: San Francisco Bay Area</td> 
      <td>Ralph Whitbeck</td> 
      <td>Conferences</td> 
    </tr> 
    <!-- continued... --> 
  </tbody> 
</table> 

获取示例代码

您可以从以下 GitHub 存储库访问示例代码:github.com/PacktPublishing/Learning-jQuery-3

从这个代码片段中,我们可以看到文档的结构。表格有四列,代表日期、标题、作者和主题,但是一些表格行包含一个日历年的副标题,而不是这四个项目:

在标题和表格之间,有一组链接,代表着表格中的每个新闻主题。对于我们的第一个任务,我们将更改这些链接的行为,以原地过滤表格,而不需要导航到不同的页面。

动态表格过滤

为了使用主题链接来过滤表格,我们需要阻止其默认的链接行为。我们还应该为当前选择的主题给用户提供一些反馈:

$(() => {
  $('#topics a')
    .click((e) => {
      e.preventDefault();
      $(e.target)
        .addClass('selected')
        .siblings('.selected')
        .removeClass('selected');
    });
}); 

列表 9.1

当点击其中一个链接时,我们会从所有主题链接中删除selected类,然后将selected类添加到新主题上。调用.preventDefault()可以阻止链接被跟踪。

接下来,我们需要实际执行过滤操作。作为解决此问题的第一步,我们可以隐藏表格中不包含主题文本的每一行:

$(() => {
  $('#topics a')
    .click((e) => {
      e.preventDefault();
      const topic = $(e.target).text();

      $(e.target)
        .addClass('selected')
        .siblings('.selected')
        .removeClass('selected');

      $('#news tr').show();
      if (topic != 'All') {
        $(`#news tr:has(td):not(:contains("${topic}"))`)
          .hide();
      }
    });
}); 

列表 9.2

现在我们将链接的文本存储在常量topic中,以便我们可以将其与表格中的文本进行比较。首先,我们显示所有的表行,然后,如果主题不是全部,我们就隐藏不相关的行。我们用于此过程的选择器有点复杂:

#news tr:has(td):not(:contains("topic")) 

选择器从简单开始,使用#news tr定位表中的所有行。然后我们使用:has()自定义选择器来过滤这个元素集。这个选择器将当前选定的元素减少到那些包含指定后代的元素。在这种情况下,我们正在消除要考虑的标题行(如日历年份),因为它们不包含<td>单元格。

一旦我们找到了表的行,其中包含实际内容,我们就需要找出哪些行与所选主题相关。:contains()自定义选择器仅匹配具有给定文本字符串的元素;将其包装在:not()选择器中,然后我们就可以隐藏所有不包含主题字符串的行。

这段代码运行得足够好,除非主题恰好出现在新闻标题中,例如。我们还需要处理一个主题是另一个主题子串的可能性。为了处理这些情况,我们需要对每一行执行代码:

$(() => {
  $('#topics a')
    .click((e) => {
      e.preventDefault();
      const topic = $(e.target).text();

      $(e.target)
        .addClass('selected')
        .siblings('.selected')
        .removeClass('selected');

      $('#news tr').show();
      if (topic != 'All') {
        $('#news')
          .find('tr:has(td)')
          .not((i, element) =>
            $(element)
              .children(':nth-child(4)')
              .text() == topic
          )
          .hide();
      }
    });
}); 

列表 9.3

这段新代码通过添加 DOM 遍历方法消除了一些复杂的选择器表达式文本。.find()方法的作用就像之前将#newstr分开的空格一样,但是.not()方法做了:not()不能做的事情。就像我们在第二章中看到的.filter()方法一样,.not()可以接受一个回调函数,每次测试一个元素时调用。如果该函数返回true,则将该元素从结果集中排除。

选择器与遍历方法

使用选择器或其等效的遍历方法的选择在性能上也有影响。我们将在本章后面更详细地探讨这个选择。

.not()方法的过滤函数中,我们检查行的子元素,找到第四个(也就是Topic列中的单元格)。对这个单元格的文本进行简单检查就能告诉我们是否应该隐藏该行。只有匹配的行会被显示:

条纹表行

在第二章中,我们的选择器示例之一演示了如何将交替的行颜色应用于表格。我们看到,:even:odd自定义选择器可以轻松完成这项任务,CSS 本地的:nth-child()伪类也可以完成:

$(() => { 
  $('#news tr:nth-child(even)')
    .addClass('alt'); 
}); 

列表 9.4

这个直接的选择器找到每个表行,因为每年的新闻文章都放在自己的<tbody>元素中,所以每个部分都重新开始交替。

对于更复杂的行条纹挑战,我们可以尝试一次给两行设置alt类。前两行将收到类,然后接下来的两行将不会,以此类推。为了实现这一点,我们需要重新审视过滤函数

$(() => { 
  $('#news tr')
    .filter(i => (i % 4) < 2)
    .addClass('alt'); 
}); 

列表 9.5

在第二章中的我们的.filter()示例中,选择元素,以及列表 9.3中的.not()示例中,我们的过滤函数会检查每个元素,以确定是否将其包含在结果集中。但是,在这里,我们不需要关于元素的信息来确定是否应该包含它。相反,我们需要知道它在原始元素集合中的位置。这些信息作为参数传递给函数,并且我们将其称为i

现在,i参数保存了元素的从零开始的索引。有了这个,我们可以使用取模运算符(%)来确定我们是否在应该接收alt类的一对元素中。现在,我们在整个表中有两行间隔。

然而,还有一些松散的地方需要清理。因为我们不再使用:nth-child()伪类,所以交替不再在每个<tbody>中重新开始。另外,我们应该跳过表头行以保持一致的外观。通过进行一些小的修改,可以实现这些目标:

$(() => {
  $('#news tbody')
    .each((i, element) => {
      $(element)
        .children()
        .has('td')
        .filter(i => (i % 4) < 2)
        .addClass('alt');
    });
}); 

列表 9.6

为了独立处理每组行,我们可以使用.each()调用对<tbody>元素进行循环。在循环内部,我们像在列表 9.3中那样排除子标题行,使用.has()。结果是表被分成两行的一组进行条纹处理:

结合过滤和条纹

我们的高级表格条纹现在工作得很好,但在使用主题过滤器时行为奇怪。为了使这两个函数协调良好,我们需要在每次使用过滤器时重新为表添加条纹。我们还需要考虑当前行是否隐藏,以确定在哪里应用alt类:

$(() => {
  function stripe() {
    $('#news')
      .find('tr.alt')
      .removeClass('alt')
      .end()
      .find('tbody')
      .each((i, element) => {
        $(element)
          .children(':visible')
          .has('td')
          .filter(i => (i % 4) < 2)
          .addClass('alt');
      });
  }
  stripe();

  $('#topics a')
    .click((e) => {
      e.preventDefault();
      const topic = $(e.target).text();

      $(e.target)
        .addClass('selected')
        .siblings('.selected')
        .removeClass('selected');

      $('#news tr').show();
      if (topic != 'All') {
        $('#news')
          .find('tr:has(td)')
          .not((i, element) =>
            $(element)
              .children(':nth-child(4)')
              .text() == topic
          )
          .hide();
      }

      stripe();
    });
}); 

列表 9.7

列表 9.3中的过滤代码与我们的行条纹例程结合起来,这个脚本现在定义了一个名为stripe()的函数,当文档加载时调用一次,每当点击主题链接时再次调用。在函数内部,我们负责从不再需要它的行中删除alt类,以及将所选行限制为当前显示的行。我们使用:visible伪类来实现这一点,它(以及它的对应项:hidden)尊重元素是否由于各种原因而隐藏,包括具有display值为none,或widthheight值为0

我们现在可以过滤我们表的行而保留我们的行条纹:

更多选择器和遍历方法

即使在我们看到的所有示例之后,我们也没有接近探索使用 jQuery 在页面上找到元素的每一种方式。我们有数十个选择器和 DOM 遍历方法可用,并且每个方法都有特定的实用性,我们可能需要调用其中的某一个。

要找到适合我们需求的选择器或方法,我们有许多资源可用。本书末尾的快速参考列出了每个选择器和方法,并简要描述了每个选择器和方法。然而,对于更详细的描述和用法示例,我们需要更全面的指南,比如在线 jQuery API 参考。该网站列出了所有选择器在 api.jquery.com/category/selectors/,以及遍历方法在 api.jquery.com/category/traversing/

自定义和优化选择器

我们看到的许多技术都为我们提供了一个工具箱,可用于找到我们想要处理的任何页面元素。然而,故事并没有结束;有很多关于如何有效执行我们的元素查找任务的知识需要学习。这种效率可以以编写和阅读更简单的代码,以及在 web 浏览器内更快执行的代码形式呈现。

编写自定义选择器插件

提高可读性的一种方法是将代码片段封装在可重用组件中。我们通过创建函数一直在做这件事。在 第八章,开发插件 中,我们通过创建 jQuery 插件来为 jQuery 对象添加方法来扩展这个想法。然而,插件不仅仅可以帮助我们重用代码。插件还可以提供额外的选择器表达式,比如 Cycle 在 第七章,使用插件 中给我们的 :paused 选择器。

要添加的最简单类型的选择器表达式是伪类。这是以冒号开头的表达式,比如 :checked:nth-child()。为了说明创建选择器表达式的过程,我们将构建一个名为 :group() 的伪类。这个新选择器将封装我们用来找到表格行以执行条纹化的代码,就像 列表 9.6 中一样。

当使用选择器表达式查找元素时,jQuery 会在内部对象 expr 中查找指令。这个对象中的值的行为类似于我们传递给 .filter().not() 的过滤函数,包含导致每个元素包含在结果集中的 JavaScript 代码,仅当函数返回 true 时才会包含。我们可以使用 $.extend() 函数向这个对象添加新的表达式:

(($) => {
  $.extend($.expr[':'], {
    group(element, index, matches) {
      const num = parseInt(matches[3], 10);

      return Number.isInteger(num) &&
        ($(element).index() - 1) % (num * 2) < num;
    }
  });
})(jQuery); 

列表 9.8

这段代码告诉 jQuery group 是一个有效的字符串,可以跟在选择器表达式的冒号后面,当遇到它时,应调用给定的函数来确定是否应将元素包含在结果集中。

这里评估的函数传递了四个参数:

  • element:要考虑的 DOM 元素。大多数选择器都需要这个,但我们的不需要。

  • index:结果集中的 DOM 元素的索引。不幸的是,这总是 0,我们不能依赖它。这里包括它的唯一原因是因为我们需要对匹配参数进行位置访问。

  • matches:包含用于解析此选择器的正则表达式结果的数组。通常,matches[3]是数组中唯一相关的项目;在形式为:group(2)的选择器中,matches[3]项包含2,即括号内的文本。

伪类选择器可以使用这三个参数中的部分或全部信息来确定元素是否属于结果集。在这种情况下,我们只需要elementmatches。实际上,我们确实需要传递给此函数的每个元素的索引位置。由于无法依赖index参数,因此我们简单地使用.index() jQuery 方法来获取索引。

有了新的:group选择器,我们现在有了一种灵活的方式来选择交替的元素组。例如,我们可以将选择器表达式和.filter()函数从列表 9.5合并为一个单一的选择器表达式:$('#news tr:group(2)'),或者我们可以保留列表 9.7中的每节行为,并将:group()作为一个表达式在.filter()调用中使用。我们甚至可以通过简单地在括号内更改数字来更改要分组的行数:

$(() => { 
  function stripe() {
    $('#news')
      .find('tr.alt')
      .removeClass('alt')
      .end()
      .find('tbody')
      .each((i, element) => {
        $(element)
          .children(':visible')
          .has('td')
          .filter(':group(3)')
          .addClass('alt');
      });
  }

  stripe(); 
}); 

列表 9.9

现在我们可以看到,行条纹以三个一组交替:

选择器性能

在规划任何 web 开发项目时,我们需要记住创建网站所需的时间、我们可以维护代码的轻松程度和速度,以及用户与网站交互时的性能。通常,这些关注点中的前两个比第三个更重要。特别是在客户端脚本编写方面,开发者很容易陷入过早优化微优化的陷阱。这些陷阱会导致我们花费无数小时微调我们的代码,以从 JavaScript 执行时间中削减毫秒,即使一开始没有注意到性能滞后。

一个很好的经验法则是认为开发者的时间比计算机的时间更宝贵,除非用户注意到我们应用程序的速度变慢。

即使性能是一个问题,定位我们的 jQuery 代码中的瓶颈也可能很困难。正如我们在本章前面提到的,某些选择器通常比其他选择器快,将选择器的一部分移到遍历方法中可以帮助加快在页面上查找元素所需的时间。因此,选择器和遍历性能通常是开始检查我们的代码以减少用户与页面交互时可能遇到的延迟量的良好起点。

关于选择器和遍历方法的相对速度的任何判断都可能随着发布更新、更快的浏览器和新版本 jQuery 引入的聪明速度调整而过时。在性能方面,经常质疑我们的假设,并在使用像jsPerfjsperf.com)这样的工具进行测量后优化代码是个好主意。

在这种情况下,我们将检查一些简单的指南,以生成优化的 jQuery 选择器代码。

Sizzle 选择器实现

正如本章开始时所指出的,当我们将选择器表达式传递给$()函数时,jQuery 的 Sizzle 实现会解析表达式并确定如何收集其中表示的元素。在其基本形式中,Sizzle 应用最有效的本地DOM 方法,浏览器支持以获取nodeList,这是一个 DOM 元素的本机类似数组对象,jQuery 最终会将其转换为真正的数组,并将其添加到jQuery对象。以下是 jQuery 内部使用的 DOM 方法列表,以及支持它们的最新浏览器版本:

方法选择支持者
.getElementById()与给定字符串匹配的唯一元素的 ID。所有浏览器
.getElementsByTagName()所有标签名称与给定字符串匹配的元素。所有浏览器
.getElementsByClassName()具有其中一个类名与给定字符串匹配的所有元素。IE9+,Firefox 3+,Safari 4+,Chrome 4+,和 Opera 10+
.querySelectorAll()所有匹配给定选择器表达式的元素。IE8+,Firefox 3.5+,Safari 3+,Chrome 4+,和 Opera 10+

如果选择器表达式的某个部分不能由这些方法之一处理,Sizzle 会回退到循环遍历已经收集的每个元素,并针对表达式的每个部分进行测试。如果选择器表达式的任何部分都不能由 DOM 方法处理,Sizzle 就会以document.getElementsByTagName('*')表示的文档中所有元素的集合开始,并逐个遍历每个元素。

这种循环和测试每个元素的方法在性能上要比任何本地 DOM 方法昂贵得多。幸运的是,现代桌面浏览器的最新版本都包括本地的.querySelectorAll()方法,并且当它不能使用其他更快的本地方法时,Sizzle 会使用它--只有一个例外。当选择器表达式包含像:eq():odd:even这样没有 CSS 对应的自定义 jQuery 选择器时,Sizzle 就别无选择,只能循环和测试。

测试选择器速度

要了解 .querySelectorAll()循环测试 过程之间的性能差异,可以考虑一个文档,其中我们希望选择所有 <input type="text"> 元素。我们可以用两种方式编写选择器表达式:$('input[type="text"]'),使用 CSS 属性选择器,或者 $('input:text'),使用 自定义 jQuery 选择器。为了测试我们在这里感兴趣的选择器部分,我们将移除 input 部分,并比较 $('[type="text"]')$(':text') 的速度。JavaScript 基准测试网站 jsperf.com/ 让我们可以进行这种比较,得出戏剧性的结果。

在 jsPerf 测试中,每个测试用例会循环执行,以查看在一定时间内可以完成多少次,因此数字越高越好。在支持 .querySelectorAll() 的现代浏览器(Chrome 26、Firefox 20 和 Safari 6)中进行测试时,能够利用它的选择器比自定义的 jQuery 选择器要快得多:

图 9.1

但是,在不支持 .querySelectorAll() 的浏览器中,例如 IE 7,这两个选择器的性能几乎相同。在这种情况下,这两个选择器都会强制 jQuery 循环遍历页面上的每个元素,并分别测试每个元素:

图 9.2

当我们查看 $('input:eq(1)')$('input') .eq(1) 时,使用原生方法和不使用原生方法的选择器之间的性能差异也是显而易见的:

图 9.3

尽管每秒操作次数在不同浏览器之间有很大差异,但所有测试的浏览器在将自定义的 :eq() 选择器移出到 .eq() 方法时都显示出显著的性能提升。使用简单的 input 标签名称作为 $() 函数的参数允许快速查找,然后 .eq() 方法简单地调用数组函数来检索 jQuery 集合中的第二个元素。

作为一个经验法则,我们应尽可能使用 CSS 规范中的选择器,而不是 jQuery 的自定义选择器。但在更改选择器之前,先确认是否需要提高性能是有意义的,然后使用诸如 jsperf.com 这样的基准测试工具测试更改能够提升多少性能。

在幕后进行 DOM 遍历

在第二章中,选择元素,以及本章的开头,我们讨论了通过调用 DOM 遍历方法从一个 DOM 元素集合到另一个 DOM 元素集合的方法。我们(远非详尽)的调查包括简单到达相邻单元格的简单方法,例如 .next().parent(),以及更复杂的组合选择器表达式的方式,例如 .find().filter()。到目前为止,我们应该对这些一步步从一个 DOM 元素到另一个 DOM 元素的方法有相当牢固的掌握。

每次我们执行其中一步时,jQuery 都会记录我们的行程,留下一串面包屑,如果需要的话,我们可以按照这些面包屑回到家里。在那一章中我们简要提及的几个方法,.end().addBack(),利用了这种记录。为了能够充分利用这些方法,并且一般来说编写高效的 jQuery 代码,我们需要更多地了解 DOM 遍历方法如何执行它们的工作。

jQuery 遍历属性

我们知道,通常通过将选择器表达式传递给 $() 函数来构造 jQuery 对象实例。在生成的对象内部,存在一个包含与该选择器匹配的每个 DOM 元素引用的数组结构。不过,我们没有看到对象中隐藏的其他属性。例如,当调用 DOM 遍历方法时,.prevObject 属性保存了对调用该遍历方法的 jQuery 对象的引用。

jQuery 对象用于暴露 selectorcontext 属性。由于它们对我们没有提供任何价值,在 jQuery 3 中已经被移除。

要查看 prevObject 属性的作用,我们可以突出显示表格的任意单元格并检查其值:

$(() => { 
  const $cell = $('#release');
    .addClass('highlight'); 
  console.log('prevObject', $cell.prevObject); 
}); 

列表 9.10

此代码段将突出显示所选单个单元格,如下图所示:

我们可以看到 .prevObject 未定义,因为这是一个新创建的对象。但是,如果我们将遍历方法添加到混合中,情况就会变得更加有趣:

$(() => { 
  const $cell = $('#release')
    .nextAll()
    .addClass('highlight'); 
  console.log('prevObject', $cell.prevObject); 
}); 

列表 9.11

此更改改变了高亮显示的单元格,如下图所示:

现在,我们最初选择的单元格后面的两个单元格被突出显示。在 jQuery 对象内部,.prevObject 现在指向 .nextAll() 调用之前的原始 jQuery 对象实例。

DOM 元素栈

由于每个 jQuery 对象实例都有一个 .prevObject 属性,指向前一个对象,我们有了一个实现 的链表结构。每次遍历方法调用都会找到一组新的元素并将此集合推入堆栈。只有在我们可以对此堆栈执行某些操作时,才有用,这就是 .end().addBack() 方法发挥作用的地方。

.end() 方法简单地从堆栈的末尾弹出一个元素,这与获取 .prevObject 属性的值相同。我们在第二章中看到了一个示例,选择元素,在本章后面我们还会看到更多。然而,为了得到更有趣的例子,我们将研究 .addBack() 如何操作堆栈:

$(() => { 
  $('#release')
    .nextAll()
    .addBack()
    .addClass('highlight'); 
}); 

列表 9.12

再次,高亮显示的单元格已更改:

当调用 .addBack() 方法时,jQuery 回顾栈上的上一步并将两个元素集合合并起来。在我们的例子中,这意味着突出显示的单元格包括 .nextAll() 调用找到的两个单元格和使用选择器定位的原始单元格。然后,这个新的、合并的元素集合被推到栈上。

这种栈操作方式非常有用。为了确保在需要时这些技术能够发挥作用,每个遍历方法的实现都必须正确更新栈;这意味着如果我们想提供自己的遍历方法,我们需要了解系统的一些内部工作原理。

编写 DOM 遍历方法插件

和任何其他 jQuery 对象方法一样,遍历方法可以通过向 $.fn 添加属性来添加到 jQuery 中。我们在第八章中看到,我们定义的新的 jQuery 方法应该在匹配的元素集合上操作,然后返回 jQuery 对象,以便用户可以链式调用其他方法。当我们创建 DOM 遍历方法时,这个过程是类似的,但是我们返回的 jQuery 对象需要指向一个新的匹配元素集合。

举个例子,我们将构建一个插件,找到与给定单元格相同列的所有表格单元格。首先我们将完整地查看插件代码,然后逐个地分析它,以了解它的工作原理:

(($) => {
  $.fn.column = function() {
    var $cells = $();

    this.each(function(i, element) {
      const $td = $(element).closest('td, th');

      if ($td.length) {
        const colNum = $td[0].cellIndex + 1;
        const $columnCells = $td
          .closest('table')
          .find('td, th')
          .filter(`:nth-child(${colNum})`);

        $cells = $cells.add($columnCells);
      }
    });

    return this.pushStack($cells);
  };
})(jQuery); 

第 9.13 节

我们的 .column() 方法可以在指向零个、一个或多个 DOM 元素的 jQuery 对象上调用。为了考虑到所有这些可能性,我们使用 .each() 方法循环遍历元素,逐个将单元格列添加到变量 $cells 中。这个 $cells 变量一开始是一个空的 jQuery 对象,但随后通过 .add() 方法扩展到需要的更多 DOM 元素。

这解释了函数的外部循环;在循环内部,我们需要理解 $columnCells 如何填充表列中的 DOM 元素。首先,我们获取正在检查的表格单元格的引用。我们希望允许在表格单元格上或表格单元格内的元素上调用 .column() 方法。.closest() 方法为我们处理了这个问题;它在 DOM 树中向上移动,直到找到与我们提供的选择器匹配的元素。这个方法在事件委托中会非常有用,我们将在第十章中重新讨论,高级事件

有了我们手头的表格单元格,我们使用 DOM 的 .cellIndex 属性找到它的列号。这给了我们一个基于零的单元格列的索引;我们在稍后的一个基于一的上下文中使用它时加上 1。然后,从单元格开始,我们向上移动到最近的 <table> 元素,再返回到 <td><th> 元素,并用 :nth-child() 选择器表达式过滤这些单元格,以获取适当的列。

我们正在编写的插件仅限于简单的、非嵌套的表格,因为 .find('td, th') 调用。要支持嵌套表格,我们需要确定是否存在 <tbody> 标签,并根据适当的数量在 DOM 树中上下移动,这将增加比这个示例适当的更多复杂性。

一旦我们找到了列中的所有单元格,我们需要返回新的 jQuery 对象。我们可以从我们的方法中直接返回 $cells,但这不会正确地尊重 DOM 元素堆栈。相反,我们将 $cells 传递给 .pushStack() 方法并返回结果。该方法接受一个 DOM 元素数组,并将它们添加到堆栈中,以便后续对 .addBack().end() 等方法的调用能够正确地工作。

若要查看我们的插件运行情况,我们可以对单元格的点击做出反应,并突出显示相应的列:

$(() => { 
  $('#news td')
    .click((e) => {
      $(e.target)
        .siblings('.active')
        .removeClass('active')
        .end()
        .column()
        .addClass('active');
    });
}); 

第 9.14 节

active 类将添加到所选列,从而导致不同的着色,例如,当点击其中一位作者的姓名时:

DOM 遍历性能

关于选择器性能的经验法则同样适用于 DOM 遍历性能:在可能的情况下,我们应该优先考虑代码编写和代码维护的便利性,只有在性能是可测量的问题时才会为了优化而牺牲可读性。同样,诸如 jsperf.com/ 这样的网站有助于确定在给定多个选项的情况下采取最佳方法。

虽然应该避免过早地优化,但最小化选择器和遍历方法的重复是一个良好的实践。由于这些可能是昂贵的任务,我们做这些任务的次数越少越好。避免这种重复的两种策略是链式操作对象缓存

使用链式操作来改进性能

我们现在已经多次使用了链式操作,它使我们的代码保持简洁。链式操作也可能带来性能上的好处。

我们来自第 9.9 节stripe() 函数只定位了一次具有 ID news 的元素,而不是两次。它需要从不再需要的行中移除 alt 类,并将该类应用于新的行集。使用链式操作,我们将这两个想法合并成一个,避免了这种重复:

$(() => {
  function stripe() {
    $('#news')
      .find('tr.alt')
      .removeClass('alt')
      .end()
      .find('tbody')
      .each((i, element) => {
        $(element)
          .children(':visible')
          .has('td')
          .filter(':group(3)')
          .addClass('alt');
      });
  }

  stripe();
}); 

第 9.15 节

为了合并两次使用 $('#news'),我们再次利用了 jQuery 对象内部的 DOM 元素堆栈。第一次调用 .find() 将表行推送到堆栈上,但然后 .end() 将其从堆栈中弹出,以便下一次 .find() 调用再次操作 news 表。这种巧妙地操作堆栈的方式是避免选择器重复的便捷方式。

使用缓存来改进性能

缓存只是简单地存储操作的结果,以便可以多次使用而不必再次运行该操作。在选择器和遍历性能的背景下,我们可以将 jQuery 对象缓存到常量中以供以后使用,而不是创建一个新的对象。

回到我们的示例,我们可以重写 stripe() 函数,以避免选择器重复,而不是链接:

$(() => { 
  const $news = $('#news');

  function stripe() {
    $news
      .find('tr.alt')
      .removeClass('alt');
    $news
      .find('tbody')
      .each((i, element) => {
        $(element)
          .children(':visible')
          .has('td')
          .filter(':group(3)')
          .addClass('alt');
      });
  }

  stripe();
}); 

清单 9.16

这两个操作再次是分开的 JavaScript 语句,而不是链接在一起。尽管如此,我们仍然只执行了一次 $('#news') 选择器,通过将结果存储在 $news 中。这种缓存方法比链接更繁琐,因为我们需要单独创建存储 jQuery 对象的变量。显然,在代码中创建更多的常量比链接函数调用更不理想。但有时,链接简单地太复杂了,像这样缓存对象是更好的选择。

因为通过 ID 在页面上选择元素非常快,所以这些示例都不会对性能产生很大的影响,实际上我们会选择看起来最易读和易于维护的方法。但是当性能成为一个关注点时,这些技术是有用的工具。

总结

在本章中,我们更深入地了解了 jQuery 在查找文档中的元素方面的广泛功能。我们看了一些关于 Sizzle 选择器引擎如何工作的细节,以及这对设计有效和高效代码的影响。此外,我们还探讨了扩展和增强 jQuery 选择器和 DOM 遍历方法的方式。

进一步阅读

在本书的 附录 B、“快速参考” 中或在官方 jQuery 文档中,提供了一份完整的选择器和遍历方法列表。

练习

挑战性练习可能需要在 api.jquery.com/ 官方 jQuery 文档中使用。

  1. 修改表格行条纹的例程,使其不给第一行任何类,第二行给予 alt 类,第三行给予 alt-2 类。对每组三行的行重复此模式。

  2. 创建一个名为 :containsExactly() 的新选择器插件,它选择具有与括号内放置的内容完全匹配的文本内容的元素。

  3. 使用这个新的 :containsExactly() 选择器来重写 清单 9.3 中的过滤代码。

  4. 创建一个名为 .grandparent() 的新 DOM 遍历插件方法,它从一个或多个元素移动到它们在 DOM 中的祖父元素。

  5. 挑战:使用 jsperf.com/,粘贴 index.html 的内容并比较使用以下内容查找 <td id="release"> 的最近祖先表元素的性能:

  • .closest() 方法

  • .parents() 方法,将结果限制为找到的第一个表格

  1. 挑战:使用 jsperf.com/,粘贴 index.html 的内容并比较使用以下内容查找每一行中最后一个 <td> 元素的性能:
  • :last-child 伪类

  • :nth-child() 伪类

  • 每行内的.last()方法(使用.each()循环遍历行)

  • 每行内的:last伪类(使用.each()循环遍历行)

第十章:高级事件

要构建交互式的 Web 应用程序,我们需要观察用户的活动并对其做出响应。 我们已经看到,jQuery 的事件系统可以简化此任务,而且我们已经多次使用了这个事件系统。

在第三章,处理事件,我们提到了 jQuery 提供的一些用于对事件做出反应的功能。 在这一更高级的章节中,我们将涵盖:

  • 事件委托及其带来的挑战

  • 与某些事件相关的性能陷阱以及如何解决它们

  • 我们自己定义的自定义事件

  • jQuery 内部使用的特殊事件系统用于复杂的交互。

重新审视事件

对于我们的示例文档,我们将创建一个简单的照片画廊。 画廊将显示一组照片,并在点击链接时显示额外的照片。 我们还将使用 jQuery 的事件系统在鼠标悬停在照片上时显示每个照片的文本信息。 定义画廊的 HTML 如下所示:

<div id="container"> 
  <h1>Photo Gallery</h1> 

  <div id="gallery"> 
    <div class="photo"> 
      <img src="img/skyemonroe.jpg"> 
      <div class="details"> 
        <div class="description">The Cuillin Mountains, 
          Isle of Skye, Scotland.</div> 
        <div class="date">12/24/2000</div> 
        <div class="photographer">Alasdair Dougall</div> 
      </div> 
    </div> 
    <div class="photo"> 
      <img src="img/dscn1328.jpg"> 
      <div class="details"> 
        <div class="description">Mt. Ruapehu in summer</div> 
        <div class="date">01/13/2005</div> 
        <div class="photographer">Andrew McMillan</div> 
      </div> 
    </div> 
    <div class="photo"> 
      <img src="img/024.JPG"> 
      <div class="details"> 
        <div class="description">midday sun</div> 
        <div class="date">04/26/2011</div> 
        <div class="photographer">Jaycee Barratt</div> 
      </div> 
    </div> 
    <!-- Code continues --> 
  </div> 
  <a id="more-photos" href="pages/1.html">More Photos</a> 
</div> 

获取示例代码

您可以从以下 GitHub 存储库访问示例代码:github.com/PacktPublishing/Learning-jQuery-3.

当我们对照片应用样式时,将它们排列成三行将使画廊看起来像以下屏幕截图:

加载更多数据页面

到目前为止,我们已经是对于页面元素点击的常见任务的专家了。当点击“更多照片”链接时,我们需要执行一个 Ajax 请求以获取下一组照片,并将它们附加到 <div id="gallery"> 如下所示:

$(() => {
  $('#more-photos')
    .click((e) => {
      e.preventDefault();
      const url = $(e.target).attr('href');

      $.get(url)
        .then((data) => {
          $('#gallery')
            .append(data);
        })
        .catch(({ statusText }) => {
          $('#gallery')
            .append(`<strong>${statusText}</strong>`)
        });
    });
});

列表 10.1

我们还需要更新“更多照片”链接的目标,以指向下一页照片:

$(() => {
  var pageNum = 1;

  $('#more-photos')
    .click((e) => {
      e.preventDefault();
      const $link = $(e.target);
      const url = $link.attr('href');

      if (pageNum > 19) {
        $link.remove();
        return;
      }

      $link.attr('href', `pages/${++pageNum}.html`);

      $.get(url)
        .then((data) => {
          $('#gallery')
            .append(data);
        })
        .catch(({ statusText }) => {
          $('#gallery')
            .append(`<strong>${statusText}</strong>`)
        });
    });
});

列表 10.2

我们的 .click() 处理程序现在使用 pageNum 变量来跟踪要请求的下一页照片,并使用它来构建链接的新 href 值。 由于 pageNum 在函数外部定义,因此它的值在链接的点击之间保持不变。 当我们到达最后一页照片时,我们会删除该链接。

我们还应考虑使用 HTML5 历史记录 API,以允许用户标记我们加载的 Ajax 内容。 您可以在 Dive into HTML5 (diveintohtml5.info/history.html) 了解有关此 API 的信息,并使用 History 插件 (github.com/browserstate/history.js) 很容易地实现它。

在悬停时显示数据

我们想要在此页面上提供的下一个功能是,当用户的鼠标位于页面的该区域时,显示与每张照片相关的详细信息。 对于显示此信息的首次尝试,我们可以使用 .hover() 方法:

$(() => {
  $('div.photo')
    .hover((e) => {
      $(e.currentTarget)
        .find('.details')
        .fadeTo('fast', 0.7);
  }, (e) => {
      $(e.currentTarget)
        .find('.details')
        .fadeOut('fast');
  });
}); 

列表 10.3

当光标进入照片的边界时,相关信息以 70% 的不透明度淡入,当光标离开时,信息再次淡出:

当然,执行此任务的方法有多种。由于每个处理程序的一部分是相同的,因此可以将两个处理程序合并。我们可以通过用空格分隔事件名称来同时绑定处理程序到mouseentermouseleave,如下所示:

 $('div.photo')
   .on('mouseenter mouseleave', (e) => {
     const $details = $(e.currentTarget).find('.details');

     if (e.type == 'mouseenter') {
       $details.fadeTo('fast', 0.7);
     } else {
       $details.fadeOut('fast');
     }
   });

列表 10.4

对于两个事件都绑定了相同处理程序,我们检查事件的类型以确定是淡入还是淡出详情。然而,定位<div>的代码对于两个事件是相同的,因此我们可以只写一次。

坦率地说,这个例子有点做作,因为此示例中的共享代码如此简短。但是,在其他情况下,这种技术可以显著减少代码复杂性。例如,如果我们选择在mouseenter上添加一个类,并在mouseleave上删除它,而不是动画化透明度,我们可以在处理程序内部用一个语句解决它,如下所示:

$(e.currentTarget)
  .find('.details') 
  .toggleClass('entered', e.type == 'mouseenter'); 

无论如何,我们的脚本现在正在按预期工作,除了我们还没有考虑用户点击更多照片链接时加载的附加照片。正如我们在第三章中所述,处理事件,事件处理程序仅附加到在我们进行.on()调用时存在的元素上。稍后添加的元素,例如来自 Ajax 调用的元素,不会具有行为。我们看到解决此问题的两种方法是在引入新内容后重新绑定事件处理程序,或者最初将处理程序绑定到包含元素并依赖事件冒泡。第二种方法,事件委托,是我们将在这里追求的方法。

事件委托

请记住,为了手动实现事件委托,我们会检查事件对象的target属性,以查看它是否与我们想要触发行为的元素匹配。事件目标表示接收事件的最内部或最深嵌套的元素。然而,这次我们的示例 HTML 提出了一个新的挑战。<div class="photo">元素不太可能是事件目标,因为它们包含其他元素,比如图像本身和图像详情。

我们需要的是.closest()方法,它会从父级元素向上遍历 DOM,直到找到与给定选择器表达式匹配的元素为止。如果找不到任何元素,则它会像任何其他 DOM 遍历方法一样,返回一个新的空 jQuery 对象。我们可以使用.closest()方法从任何包含它的元素中找到<div class="photo">,如下所示:

$(() => { 
  $('#gallery')
    .on('mouseover mouseout', (e) => {
      const $target = $(e.target)
        .closest('div.photo');
      const $related = $(e.relatedTarget)
        .closest('div.photo');
      const $details = $target
        .find('.details');

      if (e.type == 'mouseover' && $target.length) {
        $details.fadeTo('fast', 0.7);
      } else if (e == 'mouseout' && !$related.length) {
        $details.fadeOut('fast');
      }
    });
}); 

列表 10.5

请注意,我们还需要将事件类型从mouseentermouseleave更改为mouseovermouseout,因为前者仅在鼠标首次进入画廊<div>并最终离开时触发,我们需要处理程序在鼠标进入该包装<div>内的任何照片时被触发。但后者引入了另一种情况,即除非我们包含对event对象的relatedTarget属性的附加检查,否则详细信息<div>将重复淡入和淡出。即使有了额外的代码,快速重复的鼠标移动到照片上和移出照片时的处理也不令人满意,导致偶尔会出现详细信息<div>可见,而应该淡出。

使用 jQuery 的委托能力

当任务变得更加复杂时,手动管理事件委托可能会非常困难。幸运的是,jQuery 的.on()方法内置了委托,这可以使我们的生活变得更加简单。利用这种能力,我们的代码可以回到第 10.4 编列的简洁性:

$(() => { 
  $('#gallery')
    .on('mouseenter mouseleave', 'div.photo', (e) => {
      const $details = $(e.currentTarget).find('.details');

      if (e.type == 'mouseenter') {
        $details.fadeTo('fast', 0.7);
      } else {
        $details.fadeOut('fast');
      }
    });
}); 

第 10.6 编列

选择器#gallery第 10.5 编列保持不变,但事件类型返回到第 10.4 编列mouseentermouseleave。当我们将'div.photo'作为.on()的第二个参数传入时,jQuery 将e.currentTarget映射到'#gallery'中与该选择器匹配的元素。

选择委托范围

因为我们处理的所有照片元素都包含在<div id="gallery">中,所以我们在上一个示例中使用了#gallery作为我们的委托范围。然而,任何一个所有照片的祖先元素都可以用作这个范围。例如,我们可以将处理程序绑定到document,这是页面上所有内容的公共祖先:

$(() => {
  $(document)
    .on('mouseenter mouseleave', 'div.photo', (e) => {
      const $details = $(e.currentTarget).find('.details');

      if (e.type == 'mouseenter') {
        $details.fadeTo('fast', 0.7);
      } else {
        $details.fadeOut('fast');
      }
    });
}); 

第 10.7 编列

在设置事件委托时,将事件处理程序直接附加到document可能会很方便。由于所有页面元素都是从document继承而来的,我们不需要担心选择正确的容器。但是,这种便利可能会带来潜在的性能成本。

在深度嵌套的元素 DOM 中,依赖事件冒泡直到多个祖先元素可能是昂贵的。无论我们实际观察的是哪些元素(通过将它们的选择器作为.on()的第二个参数传递),如果我们将处理程序绑定到document,那么页面上发生的任何事件都需要被检查。例如,在第 10.6 编列中,每当鼠标进入页面上的任何元素时,jQuery 都需要检查它是否进入了一个<div class="photo">元素。在大型页面上,这可能会变得非常昂贵,特别是如果委托被大量使用。通过在委托上下文中更加具体,可以减少这种工作。

早期委托

尽管存在这些效率问题,但仍有理由选择将document作为我们的委托上下文。一般来说,我们只能在 DOM 元素加载后绑定事件处理程序,这就是为什么我们通常将代码放在$(() => {})内的原因。但是,document元素是立即可用的,因此我们无需等待整个 DOM 准备就绪才能绑定它。即使脚本被引用在文档的<head>中,就像我们的示例中一样,我们也可以立即调用.on(),如下所示:

(function($) { 
  $(document)
    .on('mouseenter mouseleave', 'div.photo', (e) => {
      const $details = $(e.currentTarget).find('.details');

      if (e.type == 'mouseenter') {
        $details.fadeTo('fast', 0.7);
      } else {
        $details.fadeOut('fast');
      }
    }); 
})(jQuery); 

图 10.8

因为我们不是在等待整个 DOM 准备就绪,所以我们可以确保mouseentermouseleave行为将立即适用于所有页面上呈现的<div class="photo">元素。

要看到这种技术的好处,考虑一个直接绑定到链接的click处理程序。假设此处理程序执行某些操作,并且还阻止链接的默认操作(导航到另一个页面)。如果我们等待整个文档准备就绪,我们将面临用户在处理程序注册之前单击该链接的风险,从而离开当前页面而不是得到脚本提供的增强处理。相比之下,将委托事件处理程序绑定到document使我们能够在不必扫描复杂的 DOM 结构的情况下提前绑定事件。

定义自定义事件

浏览器的 DOM 实现自然触发的事件对于任何交互式 Web 应用程序都至关重要。但是,在我们的 jQuery 代码中,我们不仅限于此事件集合。我们还可以添加自己的自定义事件。我们在第八章中简要介绍了这一点,开发插件,当我们看到 jQuery UI 小部件如何触发事件时,但在这里,我们将研究如何创建和使用自定义事件,而不是插件开发。

自定义事件必须由我们的代码手动触发。从某种意义上说,它们就像我们定义的常规函数一样,我们可以在脚本的另一个地方调用它时执行一块代码。对于自定义事件的.on()调用的行为类似于函数定义,而.trigger()调用的行为类似于函数调用。

但是,事件处理程序与触发它们的代码是解耦的。这意味着我们可以在任何时候触发事件,而无需预先知道触发时会发生什么。常规函数调用会导致执行单个代码块。但是,自定义事件可能没有处理程序,一个处理程序或许多处理程序绑定到它。无论如何,当事件被触发时,所有绑定的处理程序都将被执行。

为了说明这一点,我们可以修改我们的 Ajax 加载功能以使用自定义事件。每当用户请求更多照片时,我们将触发一个nextPage事件,并绑定处理程序来监视此事件并执行以前由.click()处理程序执行的工作:

$(() => { 
  $('#more-photos')
    .click((e) => {
      e.preventDefault();
      $(e.target).trigger('nextPage');
    });
}); 

列表 10.9

.click() 处理程序现在几乎不做任何工作。它触发自定义事件,并通过调用 .preventDefault() 阻止默认的链接行为。重要的工作转移到了对 nextPage 事件的新事件处理程序中,如下所示:

(($) => { 
  $(document)
    .on('nextPage', (e) => {
      $.get($(e.target).attr('href'))
        .then((data) => {
          $('#gallery')
            .append(data);
        })
        .catch(({ statusText }) => {
          $('#gallery')
            .append(`<strong>${statusText}</strong>`)
        });
    });

  var pageNum = 1;

  $(document)
    .on('nextPage', () => {
      if (pageNum > 19) {
        $('#more-photos').remove();
        return;
      }

      $('#more-photos')
        .attr('href', `pages/${++pageNum}.html`);
    });
})(jQuery); 

列表 10.10

自从 列表 10.2 以来,我们的代码并没有太多改变。最大的区别在于,我们将曾经的单个函数拆分为两个。这只是为了说明单个事件触发器可以导致多个绑定的处理程序触发。单击“更多照片”链接会导致下一组图片被追加,并且链接的 href 属性会被更新,如下图所示:

随着 列表 10.10 中的代码更改,我们还展示了事件冒泡的另一个应用。 nextPage 处理程序可以绑定到触发事件的链接上,但我们需要等到 DOM 准备就绪才能这样做。相反,我们将处理程序绑定到文档本身,这个文档立即可用,因此我们可以在 $(() => {}) 外部进行绑定。这实际上是我们在 列表 10.8 中利用的相同原理,当我们将 .on() 方法移到了 $(() => {}) 外部时。事件冒泡起作用,只要另一个处理程序不停止事件传播,我们的处理程序就会被触发。

无限滚动

正如多个事件处理程序可以对同一触发的事件作出反应一样,同一事件可以以多种方式触发。我们可以通过为页面添加无限滚动功能来演示这一点。这种技术允许用户的滚动条管理内容的加载,在用户达到到目前为止已加载内容的末尾时,获取更多内容。

我们将从一个简单的实现开始,然后在后续示例中改进它。基本思想是观察 scroll 事件,测量滚动时的当前滚动条位置,并在需要时加载新内容。以下代码将触发我们在 列表 10.10 中定义的 nextPage 事件:

(($) => { 
  const checkScrollPosition = () => {
    const distance = $(window).scrollTop() +
      $(window).height();

    if ($('#container').height() <= distance) {
      $(document).trigger('nextPage');
    }
  }

  $(() => {
    $(window)
      .scroll(checkScrollPosition)
      .trigger('scroll');
  }); 
})(jQuery); 

列表 10.11

我们在这里介绍的 checkScrollPosition() 函数被设置为窗口 scroll 事件的处理程序。此函数计算文档顶部到窗口底部的距离,然后将此距离与文档中主容器的总高度进行比较。一旦它们达到相等,我们就需要用额外的照片填充页面,因此我们触发 nextPage 事件。

一旦我们绑定了 scroll 处理程序,我们立即通过调用 .trigger('scroll') 触发它。这启动了这个过程,因此如果页面最初未填充照片,则立即进行 Ajax 请求以附加更多照片:

自定义事件参数

当我们定义函数时,我们可以设置任意数量的参数,以在实际调用函数时填充参数值。同样,当触发自定义事件时,我们可能想向任何注册的事件处理程序传递额外信息。我们可以通过使用自定义事件参数来实现这一点。

任何事件处理程序定义的第一个参数,正如我们所见,是 DOM 事件对象,由 jQuery 增强和扩展。我们定义的任何额外参数都可供自行决定使用。

要看到此功能的实际效果,我们将在 清单 10.10nextPage事件中添加一个新选项,允许我们向下滚动页面以显示新添加的内容:

(($) => { 
  $(document)
    .on('nextPage', (e, scrollToVisible) => {
      if (pageNum > 19) {
        $('#more-photos').remove();
        return;
      }

      $.get($('#more-photos').attr('href'))
        .then((data) => {
          const $data = $('#gallery')
            .append(data);

          if (scrollToVisible) {
            $(window)
              .scrollTop($data.offset().top);
          }

          checkScrollPosition();
    })
    .catch(({ statusText }) => {
      $('#gallery')
        .append(`<strong>${statusText}</strong>`)
    });
  }); 
})(jQuery); 

清单 10.12

现在,我们已经为事件回调添加了一个scrollToVisible参数。该参数的值决定了我们是否执行新功能,该功能包括测量新内容的位置并滚动到该位置。使用.offset()方法来进行测量非常容易,该方法返回新内容的顶部和左侧坐标。要向页面下移,我们调用.scrollTop()方法。

现在,我们需要向新参数传递一个参数。所需的一切就是在使用.trigger()调用事件时提供额外的值。当通过滚动触发newPage时,我们不希望出现新行为,因为用户已经直接操作了滚动位置。另一方面,当点击更多照片链接时,我们希望新添加的照片显示在屏幕上,因此我们将一个值为true传递给处理程序:

$(() => { 
  $('#more-photos')
    .click((e) => {
      e.preventDefault();
      $(e.target).trigger('nextPage', [true]);
    });
}); 

清单 10.13

在调用.trigger()时,我们现在提供了一个值数组以传递给事件处理程序。在这种情况下,值true将被传递到 清单 10.12 中事件处理程序的scrollToVisible参数。

请注意,自定义事件参数在交易的双方都是可选的。我们的代码中有两个对.trigger('nextPage')的调用,其中只有一个提供了参数值;当调用另一个时,这不会导致错误,而是处理程序中的每个参数都具有值undefined。同样,一个.on('nextPage')调用中缺少scrollToVisible参数也不是错误;如果在传递参数时不存在参数,那么该参数将被简单地忽略。

事件节流

我们在 清单 10.10 中实现的无限滚动功能的一个主要问题是性能影响。虽然我们的代码很简洁,但checkScrollPosition()函数确实需要做一些工作来测量页面和窗口的尺寸。这种努力可能会迅速积累,因为在一些浏览器中,scroll事件在滚动窗口时会重复触发。这种组合的结果可能是不流畅或性能低下。

几个本地事件有可能频繁触发。常见的罪魁祸首包括 scrollresizemousemove。为了解决这个问题,我们将实现事件节流。这种技术涉及限制我们的昂贵计算,使其仅在一些事件发生之后才发生,而不是每次都发生。我们可以更新我们的代码,以实现这种技术,如下所示:

$(() => { 
  var timer = 0;

  $(window)
    .scroll(() => {
      if (!timer) {
        timer = setTimeout(() => {
          checkScrollPosition();
          timer = 0;
        }, 250);
      }
    })
    .trigger('scroll');
}); 

清单 10.14

我们不直接将 checkScrollPosition() 设置为 scroll 事件处理程序,而是使用 JavaScript 的 setTimeout 函数将调用推迟了 250 毫秒。更重要的是,在做任何工作之前,我们首先检查是否有正在运行的计时器。由于检查一个简单变量的值非常快,我们的大多数事件处理程序调用几乎立即返回。checkScrollPosition() 调用只会在定时器完成时发生,最多每 250 毫秒一次。

我们可以轻松调整 setTimeout() 的值,以达到舒适的数值,从而在即时反馈和低性能影响之间取得合理的折中。我们的脚本现在是一个良好的网络公民。

其他执行节流的方式

我们实施的节流技术既高效又简单,但这并不是唯一的解决方案。根据节流的操作的性能特征和与页面的典型交互,我们可能需要建立页面的单个定时器,而不是在事件开始时创建一个定时器:

$(() => { 
  var scrolled = false;

  $(window)
    .scroll(() => {
      scrolled = true;
    });

  setInterval(() => {
    if (scrolled) {
      checkScrollPosition();
      scrolled = false;
    }
  }, 250);

  checkScrollPosition();
}); 

清单 10.15

与我们以前的节流代码不同,这种轮询解决方案使用一次 JavaScript setInterval() 函数调用来开始每250毫秒检查 scrolled 变量的状态。每次发生滚动事件时,scrolled 被设置为 true,确保下次间隔经过时将调用 checkScrollPosition()。其结果类似于清单 10.14

限制在频繁重复事件期间执行的处理量的第三种解决方案是去抖动。这种技术以电子开关发送的重复信号需要处理后的名字命名,确保即使发生了很多事件,也只有一个单一的最终事件被执行。我们将在第十三章高级 Ajax中看到这种技术的示例。

扩展事件

一些事件,如 mouseenterready,被 jQuery 内部指定为特殊事件。这些事件使用 jQuery 提供的复杂事件扩展框架。这些事件有机会在事件处理程序的生命周期中的各个时刻采取行动。它们可能会对绑定或解绑的处理程序做出反应,甚至可以有可阻止的默认行为,如点击链接或提交表单。事件扩展 API 允许我们创建类似于本机 DOM 事件的复杂新事件。

我们为Listing 10.13中的滚动实现的节流行为是有用的,我们可能想要将其推广到其他项目中使用。我们可以通过在特殊事件钩子内封装节流技术来实现这一点。

要为事件实现特殊行为,我们向$ .event.special对象添加一个属性。这个添加的属性本身是一个对象,它的键是我们的事件名称。它可以包含在事件生命周期中许多不同特定时间调用的回调函数,包括以下内容:

  • add: 每当为该事件的处理程序绑定时调用

  • remove: 每当为事件的处理程序解绑时调用

  • setup: 当为事件绑定处理程序时调用,但仅当没有为元素绑定该事件的其他处理程序时

  • teardown: 这是setup的反义词,当从元素解绑事件的最后一个处理程序时调用

  • _default: 这将成为事件的默认行为,在事件处理程序阻止默认操作之前调用

这些回调函数可以以一些非常有创意的方式使用。一个相当普遍的情景,我们将在我们的示例代码中探讨,就是根据浏览器条件自动触发事件。如果没有处理程序监听事件,监听状态并触发事件是很浪费的,所以我们可以使用setup回调仅在需要时启动这项工作:

(($) => { 
  $.event.special.throttledScroll = { 
    setup(data) { 
      var timer = 0; 
      $(this).on('scroll.throttledScroll', () => { 
        if (!timer) { 
          timer = setTimeout(() => { 
            $(this).triggerHandler('throttledScroll'); 
            timer = 0; 
          }, 250); 
        } 
      }); 
    }, 
    teardown() { 
      $(this).off('scroll.throttledScroll'); 
    } 
  }; 
})(jQuery); 

Listing 10.16

对于我们的滚动节流事件,我们需要绑定一个常规的scroll处理程序,该处理程序使用与我们在Listing 10.14中开发的相同的setTimeout技术。每当计时器完成时,将触发自定义事件。由于我们每个元素只需要一个计时器,因此setup回调将满足我们的需求。通过为scroll处理程序提供自定义命名空间,我们可以在调用teardown时轻松地移除处理程序。

要使用这种新行为,我们只需为throttledScroll事件绑定处理程序。这极大地简化了事件绑定代码,并为我们提供了一个非常可重用的节流机制,如下所示:

(($) => {
  $.event.special.throttledScroll = {
    setup(data) {
      var timer = 0;
      $(this)
        .on('scroll.throttledScroll', () => {
          if (!timer) {
            timer = setTimeout(() => {
              $(this).triggerHandler('throttledScroll');
              timer = 0;
            }, 250);
          }
        });
    },
    teardown() {
      $(this).off('scroll.throttledScroll');
    }
  };

  $(document)
    .on('mouseenter mouseleave', 'div.photo', (e) => {
      const $details = $(e.currentTarget).find('.details');

      if (e.type == 'mouseenter') {
        $details.fadeTo('fast', 0.7);
      } else {
        $details.fadeOut('fast');
      }
    });

  var pageNum = 1;

  $(document)
    .on('nextPage', (e, scrollToVisible) => {
      if (pageNum > 19) {
        $('#more-photos').remove();
        return;
      }

      $.get($('#more-photos').attr('href'))
        .then((data) => {
          const $data = $(data)
            .appendTo('#gallery');

          if (scrollToVisible) {
            $(window)
              .scrollTop($data.offset().top);
          }

          checkScrollPosition();
        })
       .catch(({ statusText }) => {
         $('#gallery')
           .append(`<strong>${statusText}</strong>`)
       });
    });

    $(document)
      .on('nextPage', () => {
        if (pageNum < 20) {
          $('#more-photos')
            .attr('href', `pages/${++pageNum}.html`);
        }
      });

    const checkScrollPosition = () => {
      const distance = $(window).scrollTop()
        + $(window).height();

      if ($('#container').height() <= distance) {
        $(document).trigger('nextPage');
      }
    };

  $(() => {
    $('#more-photos')
      .click((e) => {
        e.preventDefault();
        $(e.target).trigger('nextPage', [true]);
      });

    $(window)
      .on('throttledScroll', checkScrollPosition)
      .trigger('throttledScroll');
  });
})(jQuery);

Listing 10.17

关于特殊事件的更多信息

虽然本章涵盖了处理事件的高级技术,但事件扩展 API 确实非常先进,详细的调查超出了本书的范围。前面的throttledScroll示例涵盖了该功能的最简单和最常见的用法。其他可能的应用包括以下内容:

  • 修改事件对象,以便事件处理程序可以获得不同的信息

  • 导致在 DOM 中的一个位置发生的事件触发与不同元素相关联的行为

  • 对不是标准 DOM 事件的新的和特定于浏览器的事件做出反应,并允许 jQuery 代码对其做出反应,就像它们是标准的一样

  • 改变事件冒泡和委托的处理方式

这些任务中的许多都可能非常复杂。要深入了解事件扩展 API 提供的可能性,我们可以查阅 jQuery 学习中心的文档learn.jquery.com/events/event-extensions/

总结

如果我们选择充分利用 jQuery 事件系统,它可以非常强大。在本章中,我们已经看到了系统的几个方面,包括事件委托方法、自定义事件和事件扩展 API。我们还找到了绕过委托和频繁触发事件相关问题的方法。

进一步阅读

本书的附录 B,快速参考中提供了完整的事件方法列表,或者在官方的jQuery 文档中查看api.jquery.com/

练习

以下挑战练习可能需要使用官方 jQuery 文档api.jquery.com/

  1. 当用户点击照片时,在照片<div>上添加或删除selected类。确保即使是使用下一页链接后添加的照片,这种行为也能正常工作。

  2. 添加一个名为pageLoaded的新自定义事件,当新的图像集已添加到页面上时触发。

  3. 使用nextPagepageLoaded处理程序,仅在加载新页面时在页面底部显示一个加载消息。

  4. 将一个mousemove处理程序绑定到照片上,记录当前鼠标位置(使用console.log())。

  5. 修改此处理程序,以使日志记录不超过每秒五次。

  6. 挑战:创建一个名为tripleclick的新特殊事件,当鼠标按钮在 500 毫秒内点击三次时触发。为了测试该事件,将一个tripleclick处理程序绑定到<h1>元素上,该处理程序隐藏和显示<div id="gallery">的内容。

第十一章:高级效果

自从了解了 jQuery 的动画功能以来,我们发现了许多用途。我们可以轻松地隐藏和显示页面上的对象,我们可以优雅地调整元素的大小,我们可以平滑地重新定位元素。这个效果库是多功能的,包含的技术和专业能力甚至比我们迄今看到的还要多。

在第四章中,样式和动画,您学习了 jQuery 的基本动画功能。在这个更高级的章节中,我们将涵盖:

  • 收集关于动画状态的信息的方法

  • 中断活动动画的方法

  • 全局效果选项,可以一次性影响页面上的所有动画

  • Deferred 对象允许我们在动画完成后执行操作

  • 缓动,改变动画发生的速率

动画再访

为了刷新我们关于 jQuery 效果方法的记忆,我们将在本章中建立一个基线,从一个简单的悬停动画开始构建。使用带有照片缩略图的文档,当用户的鼠标悬停在上面时,我们将使每张照片略微增大,并在鼠标离开时恢复到原始大小。我们将使用的 HTML 标签目前还包含一些暂时隐藏的文本信息,稍后在本章中将使用:

<div class="team"> 
  <div class="member"> 
    <img class="avatar" src="img/rey.jpg" alt="" /> 
    <div class="name">Rey Bango</div> 
    <div class="location">Florida</div> 
    <p class="bio">Rey Bango is a consultant living in South Florida,        
    specializing in web application development...</p> 
  </div> 
  <div class="member"> 
    <img class="avatar" src="img/scott.jpg" alt="" /> 
    <div class="name">Scott González</div> 
    <div class="location">North Carolina</div> 
    <div class="position">jQuery UI Development Lead</div> 
    <p class="bio">Scott is a web developer living in Raleigh, NC...       </p> 
  </div> 
  <!-- Code continues ... --> 
</div> 

获取示例代码

您可以从以下 GitHub 存储库访问示例代码:github.com/PacktPublishing/Learning-jQuery-3

每张图像相关联的文本最初由 CSS 隐藏,通过将每个 <div> 移动到其 overflow: hidden 容器的左侧来实现:

.member { 
  position: relative; 
  overflow: hidden; 
} 

.member div { 
  position: absolute; 
  left: -300px; 
  width: 250px; 
} 

HTML 和 CSS 一起产生一个垂直排列的图像列表:

为了改变图像的大小,我们将把其高度和宽度从 75 像素增加到 85 像素。同时,为了保持图像居中,我们将其填充从 5 像素减少到 0 像素:

$(() => {
  $('div.member')
    .on('mouseenter mouseleave', ({ type, target }) => {
      const width = height = type == 'mouseenter' ?
        85 : 75;
      const paddingTop = paddingLeft = type == 'mouseenter' ?
        0 : 5;

      $(target)
        .find('img')
        .animate({
          width,
          height,
          paddingTop,
          paddingLeft
        });
    });
}); 

清单 11.1

在这里,我们重复了我们在第十章中看到的一种模式,高级事件,因为当鼠标进入区域时,我们执行的大部分工作与离开时相同;我们将 mouseentermouseleave 的处理程序合并为一个函数,而不是使用两个单独的回调调用 .hover()。在这个处理程序内部,我们根据触发的两个事件中的哪一个来确定 sizepadding 的值,并将这些属性值传递给 .animate() 方法。

当您看到将对象字面量表示法包围在函数参数 ({ type, target}) 周围时,这被称为对象解构。这只是一种方便的方法,可以从事件对象中获取我们需要的确切属性,从而在函数本身中编写更简洁的代码。

现在当鼠标光标位于图像上时,它比其他图像稍大:

观察和中断动画

我们的基本动画已经显示出一个问题。只要每次mouseentermouseleave事件后有足够的时间完成动画,动画就会按预期进行。然而,当鼠标光标快速移动并且事件被快速触发时,我们会看到图像在最后一个事件被触发后仍然反复变大和缩小。这是因为,如第四章所述,给定元素上的动画被添加到队列中并按顺序调用。第一个动画立即调用,按分配的时间完成,然后从队列中移除,此时下一个动画变为队列中的第一个,被调用,完成,被移除,依此类推,直到队列为空。

有许多情况下,jQuery 中称为fx的动画队列会引起期望的行为。但在我们这样的悬停动作中,需要绕过它。

确定动画状态

避免动画不良排队的一种方法是使用 jQuery 的自定义:animated选择器。在mouseenter/mouseleave事件处理程序中,我们可以使用该选择器来检查图像并查看它是否正在动画中:

$(() => {
  $('div.member')
    .on('mouseenter mouseleave', ({ type, target }) => {
      const width = height = type == 'mouseenter' ?
        85 : 75;
      const paddingTop = paddingLeft = type == 'mouseenter' ?
        0 : 5;

      $(target)
        .find('img')
        .not(':animated')
        .animate({
          width,
          height,
          paddingTop,
          paddingLeft
        });
      });
});

清单 11.2

当用户的鼠标进入成员<div>时,图像只有在没有被动画化时才会进行动画。当鼠标离开时,动画将无论其状态如何都会发生,因为我们始终希望最终将图像恢复到其原始尺寸和填充状态。

我们成功地避免了在清单 11.1中发生的无限动画,但是动画仍然需要改进。当鼠标快速进入和离开<div>标记时,图像仍然必须完成整个mouseenter动画(增大)才会开始mouseleave动画(缩小)。这肯定不是理想的情况,但是:animated伪类的测试引入了一个更大的问题:如果鼠标在图像缩小时进入<div>标记,那么图像将无法再次增大。只有在动画停止后,下一个mouseleavemouseenter动画才会执行另一个动画。在某些情况下使用:animated选择器可能很有用,但在这里并没有帮助太多。

停止运行的动画

幸运的是,jQuery 有一个方法可以帮助我们解决清单 11.2中显而易见的两个问题。.stop()方法可以立即停止动画。要使用它,我们可以将代码恢复到清单 11.1中的样子,然后在.find().animate()之间简单地插入.stop()

$(() => {
  $('div.member')
    .on('mouseenter mouseleave', ({ type, currentTarget }) => {
      const width = height = type == 'mouseenter' ?
        85 : 75;
      const paddingTop = paddingLeft = type == 'mouseenter' ?
        0 : 5;

      $(currentTarget)
        .find('img')
        .stop()
        .animate({
          width,
          height,
          paddingTop,
          paddingLeft
        });
    });
});

清单 11.3

值得注意的是,在进行新动画之前我们会在当前动画之前停止它。现在当鼠标重复进入和离开时,我们之前尝试的不良效果消失了。当前动画总是立即完成,因此fx队列中永远不会超过一个。当鼠标最终停下时,最终动画完成,因此图像要么完全增长(mouseenter),要么恢复到其原始尺寸(mouseleave),这取决于最后触发的事件。

停止动画时要小心

由于.stop()方法默认在当前位置停止动画,当与速记动画方法一起使用时可能会导致意外结果。在动画之前,这些速记方法确定最终值,然后对该值进行动画处理。例如,如果在其动画过程中使用.stop()停止.slideDown(),然后调用.slideUp(),那么下一次在元素上调用.slideDown()时,它只会滑动到上次停止的高度。为了减轻这种问题,.stop()方法可以接受两个布尔值(true/false)参数,第二个称为goToEnd。如果我们将此参数设置为true,则当前动画不仅停止,而且立即跳转到最终值。尽管如此,goToEnd功能可能会使动画看起来不流畅,因此更好的解决方案可能是将最终值存储在变量中,并显式地使用.animate()进行动画处理,而不是依赖 jQuery 来确定该值。

另一个 jQuery 方法.finish()可用于停止动画。它类似于.stop(true, true),因为它清除所有排队的动画,并将当前动画跳转到最终值。但是,与.stop(true, true)不同,它还会将所有排队的动画跳转到它们的最终值。

使用全局效果属性

jQuery 中的效果模块包含一个方便的$.fx对象,当我们想要全面改变动画特性时可以访问该对象。虽然该对象的一些属性未记录,并且只能在库内部使用,但其他属性则作为工具提供,用于全局改变动画运行方式。在以下示例中,我们将看一些已记录属性。

禁用所有效果

我们已经讨论了如何停止当前正在运行的动画,但是如果我们需要完全禁用所有动画怎么办?例如,我们可能希望默认情况下提供动画,但是在低资源设备(动画可能看起来断断续续)或对于发现动画分散注意力的用户中禁用这些动画。为此,我们只需将$.fx.off属性设置为true。为了演示,我们将显示一个之前隐藏的按钮,以允许用户切换动画的开启和关闭:

$(() => {
  $('#fx-toggle')
    .show()
    .on('click', () => {
      $.fx.off = !$.fx.off;
    });
}); 

列表 11.4

隐藏按钮显示在介绍段落和随后的图像之间:

当用户点击按钮将动画切换关闭时,随后的动画,如我们的放大和缩小图像,将立即发生(持续时间为0毫秒),然后立即调用任何回调函数。

定义效果持续时间

$.fx对象的另一个属性是speeds。该属性本身是一个对象,由 jQuery 核心文件证实,由三个属性组成:

speeds: { 
  slow: 600, 
  fast: 200, 
  // Default speed 
  _default: 400 
} 

您已经学会了 jQuery 的所有动画方法都提供了一个可选的速度或持续时间参数。查看$.fx.speeds对象,我们可以看到字符串slowfast分别映射到 600 毫秒和 200 毫秒。每次调用动画方法时,jQuery 按照以下顺序执行以下步骤来确定效果的持续时间:

  1. 它检查$.fx.off是否为true。如果是,它将持续时间设置为0

  2. 它检查传递的持续时间是否为数字。如果是,则将持续时间设置为该数字的毫秒数。

  3. 它检查传递的持续时间是否匹配$.fx.speeds对象的属性键之一。如果是,则将持续时间设置为属性的值。

  4. 如果持续时间未由上述任何检查设置,则将持续时间设置为$.fx.speeds._default的值。

综合这些信息,我们现在知道,传递除slowfast之外的任何字符串持续时间都会导致持续时间为 400 毫秒。我们还可以看到,添加我们自己的自定义速度就像添加另一个属性到$.fx.speeds一样简单。例如,如果我们写$.fx.speeds.crawl = 1200,我们可以在任何动画方法的速度参数中使用'crawl'以运行动画 1200 毫秒,如下所示:

$(someElement).animate({width: '300px'}, 'crawl'); 

尽管键入'crawl'不比键入1200更容易,但在较大的项目中,当许多共享某个速度的动画需要更改时,自定义速度可能会派上用场。在这种情况下,我们可以更改$.fx.speeds.crawl的值,而不是在整个项目中搜索1200并仅在表示动画速度时替换每个值。

虽然自定义速度可能很有用,但也许更有用的是能够更改默认速度的能力。我们可以通过设置_default属性来做到这一点:

$.fx.speeds._default = 250; 

列表 11.5

现在,我们已经定义了一个新的更快的默认速度,除非我们覆盖它们的持续时间,否则任何新添加的动画都将使用它。为了看到这个过程,我们将向页面引入另一个交互元素。当用户点击其中一个肖像时,我们希望显示与该人物相关联的详细信息。我们将通过将它们从肖像下面移出到最终位置来创建详细信息从肖像中展开的错觉:

$(() => { 
  const showDetails = ({ currentTarget }) => {
    $(currentTarget)
      .find('div')
      .css({
        display: 'block',
        left: '-300px',
        top: 0
      })
      .each((i, element) => {
        $(element)
          .animate({
            left: 0,
            top: 25 * i
          });
      });
  }; 
  $('div.member').click(showDetails); 
}); 

列表 11.6

当点击成员时,我们使用showDetails()函数作为处理程序。该函数首先将详细信息<div>元素设置在成员肖像的下方的起始位置。然后将每个元素动画到其最终位置。通过调用.each(),我们可以计算每个元素的单独最终top位置。

动画完成后,详细信息文本可见:

由于.animate()方法调用是在不同的元素上进行的,所以它们是同时进行的,而不是排队进行的。而且,由于这些调用没有指定持续时间,它们都使用了新的默认持续时间 250 毫秒。

当点击另一个成员时,我们希望隐藏先前显示的成员。我们可以轻松地通过类来跟踪当前屏幕上显示的详细信息:

 const showDetails = ({ currentTarget }) => {
   $(currentTarget)
     .siblings('.active')
     .removeClass('active')
     .children('div')
     .fadeOut()
     .end()
     .end()
     .addClass('active')
     .find('div')
     .css({
       display: 'block',
       left: '-300px',
       top: 0
     })
     .each((i, element) => {
       $(element)
         .animate({
           left: 0,
           top: 25 * i
         });
     });
}; 

列表 11.7

哎呀!十个函数链接在一起?等等,这其实可能比拆分它们更好。首先,像这样链接调用意味着不需要使用临时变量来保存中间的 DOM 值。相反,我们可以一行接一行地读取以了解发生了什么。现在让我们逐个解释一下这些:

  • .siblings('.active'): 这会找到活动的<div>兄弟元素。

  • .removeClass('active'): 这会移除.active类。

  • .children('div'): 这会找到子<div>元素。

  • .fadeOut(): 这会将它们移除。

  • .end(): 这会清除.children('div')查询结果。

  • .end(): 这会清除.siblings('.active')查询结果。

  • .addClass('active'): 这会将.active类添加到事件目标,即容器<div>上。

  • .find('div'): 这会找到所有子<div>元素以显示。

  • .css(): 这会设置相关的显示 CSS。

  • .each(): 这会向topleftCSS 属性添加动画。

请注意,我们的.fadeOut()调用也使用了我们定义的更快的 250 毫秒持续时间。默认值适用于 jQuery 的预打包效果,就像它们适用于自定义.animate()调用一样。

多属性缓动

showDetails()函数几乎实现了我们想要的展开效果,但由于topleft属性以相同的速率进行动画,它看起来更像是一个滑动效果。我们可以通过仅为top属性更改缓动方程式为easeInQuart来微妙地改变效果,从而使元素沿着曲线路径而不是直线路径移动。但请记住,除了swinglinear之外的任何缓动都需要插件,例如 jQuery UI 的效果核心(jqueryui.com/)。

.each((i, element) => {
  $(element)
    .animate({
      left: 0,
      top: 25 * i
    },{
      duration: 'slow',
      specialEasing: {
        top: 'easeInQuart'
      }
    });
 });

列表 11.8

specialEasing选项允许我们为每个正在动画化的属性设置不同的加速曲线。如果选项中不包括的属性,则将使用easing选项的方程式(如果提供)或默认的swing方程式。

现在我们有了一个引人注目的动画,展示了与团队成员相关的大部分细节。但我们还没有展示成员的传记。在这之前,我们需要稍微偏离一下话题,谈谈 jQuery 的延迟对象机制。

使用延迟对象

有时,我们会遇到一些情况,我们希望在过程完成时采取行动,但我们并不一定知道这个过程需要多长时间,或者是否会成功。为了处理这些情况,jQuery 为我们提供了延迟对象(promises)。延迟对象封装了需要一些时间来完成的操作。

可以随时通过调用$.Deferred()构造函数创建一个新的延迟对象。一旦我们有了这样的对象,我们可以执行长时间运行的操作,然后在对象上调用.resolve().reject()方法来指示操作是否成功或失败。然而,手动这样做有点不寻常。通常,我们不是手动创建自己的延迟对象,而是 jQuery 或其插件会创建对象,并负责解决或拒绝它。我们只需要学习如何使用创建的对象。

我们不打算详细介绍$.Deferred()构造函数的操作方式,而是在这里重点讨论 jQuery 效果如何利用延迟对象。在第十三章中,高级 Ajax,我们将进一步探讨在 Ajax 请求的背景下的延迟对象。

每个延迟对象都承诺向其他代码提供数据。这个承诺作为另一个具有自己一套方法的对象来表示。从任何延迟对象,我们可以通过调用它的.promise()方法来获得它的 promise 对象。然后,我们可以调用 promise 的方法来附加处理程序,当 promise 被履行时执行:

  • .then()方法附加了一个处理程序,当延迟对象成功解决时调用。

  • .catch()方法附加了一个处理程序,当延迟对象被拒绝时调用。

  • .always()方法附加了一个处理程序,当延迟对象完成其任务时被调用,无论是被解决还是被拒绝。

这些处理程序非常类似于我们提供给.on()的回调函数,因为它们是在某个事件发生时调用的函数。我们还可以附加多个处理程序到同一个承诺上,所有的会在适当的时候被调用。然而,这里也有一些重要的区别。承诺处理程序只会被调用一次;延迟对象无法再次解决。如果在我们附加处理程序时延迟对象已经被解决,那么承诺处理程序也会立即被调用。

在第六章中,使用 Ajax 发送数据,我们看到了一个非常简单的例子,说明了 jQuery 的 Ajax 系统如何使用延迟对象。现在,我们将再次利用这个强大的工具,通过研究 jQuery 动画系统创建的延迟对象来使用它。

动画的承诺

每个 jQuery 集合都有一组延迟对象与其关联,用于跟踪集合中元素的排队操作的状态。通过在 jQuery 对象上调用 .promise() 方法,我们得到一个在队列完成时解析的 promise 对象。特别是,我们可以使用此 promise 在任何匹配元素上运行的所有动画完成时采取行动。

就像我们有一个 showDetails() 函数来显示成员的名称和位置信息一样,我们可以编写一个 showBio() 函数来显示传记信息。但首先,我们将向 <body> 标签附加一个新的 <div> 标签并设置两个选项对象:

$(() => {
  const $movable = $('<div/>')
    .attr('id', 'movable')
    .appendTo('body');

  const bioBaseStyles = {
    display: 'none',
    height: '5px',
    width: '25px'
  }

  const bioEffects = {
    duration: 800,
    easing: 'easeOutQuart',
    specialEasing: {
      opacity: 'linear'
    }
  };
});

11.9 清单

这个新的可移动 <div> 元素是我们实际上将要动画化的元素,在注入了传记副本后。像这样拥有一个包装元素在动画化元素的宽度和高度时特别有用。我们可以将其 overflow 属性设置为 hidden,并为其中的传记设置显式的宽度和高度,以避免在我们动画化传记 <div> 元素本身时持续不断地重新排列文本。

我们将使用 showBio() 函数根据点击的成员图像确定可移动 <div> 的起始和结束样式。请注意,我们使用 $.extend() 方法将保持不变的一组基本样式与根据成员位置变化的 topleft 属性进行合并。然后,只需使用 .css() 设置起始样式和 .animate() 设置结束样式:

const showBio = (target) => {
  const $member = $(target).parent();
  const $bio = $member.find('p.bio');
  const startStyles = $.extend(
    {},
    bioBaseStyles,
    $member.offset()
  );
  const endStyles = {
    width: $bio.width(),
    top: $member.offset().top + 5,
    left: $member.width() + $member.offset().left - 5,
    opacity: 'show'
  };

  $movable
    .html($bio.clone())
    .css(startStyles)
    .animate(endStyles, bioEffects)
    .animate(
      { height: $bio.height() },
      { easing: 'easeOutQuart' }
    );
}; 

11.10 清单

我们排队了两个 .animate() 方法,以便传记首先从左侧飞出并变宽和完全不透明,然后在到位后向下滑动到其完整高度。

在 第四章,样式和动画 中,我们看到 jQuery 动画方法中的回调函数在集合中每个元素的动画完成时被调用。我们希望在其他 <div> 元素出现后显示成员的传记。在 jQuery 引入 .promise() 方法之前,这将是一项繁重的任务,需要我们在每次执行回调时从总元素数倒计时,直到最后一次,此时我们可以执行动画化传记的代码。

现在我们可以简单地将 .promise().then() 方法链接到我们的 showDetails() 函数内部的 .each() 方法中:

const showDetails = ({ currentTarget }) => {
  $(currentTarget)
    .siblings('.active')
    .removeClass('active')
    .children('div')
    .fadeOut()
    .end()
    .end()
    .addClass('active')
    .find('div')
    .css({
      display: 'block',
      left: '-300px',
      top: 0
    })
    .each((i, element) => {
      $(element)
        .animate({
          left: 0,
          top: 25 * i
        },{
          duration: 'slow',
          specialEasing: {
            top: 'easeInQuart'
          }
        });
    })
    .promise()
    .then(showBio);
}; 

11.11 清单

.then() 方法将我们的 showBio() 函数的引用作为其参数。现在,点击图像将以吸引人的动画序列将所有成员信息显示出来:

自 jQuery 3.0 起,promise() 方法返回的 promises 与原生 ES 2015 promises 完全兼容。这意味着在可能的情况下,我们应该使用相同的 API。例如,使用 then() 代替 done()。它们做的是一样的事情,你的异步代码将与其他异步代码保持一致。

对动画进行细粒度控制

即使我们已经研究了许多高级功能,jQuery 的效果模块还有很多可以探索的地方。jQuery 1.8 的重写为这个模块引入了许多高级开发者调整各种效果甚至更改驱动动画的底层引擎的方法。例如,除了提供 durationeasing 等选项外,.animate() 方法还提供了一些回调选项,让我们在动画的每一步检查和修改动画:

$('#mydiv').animate({ 
  height: '200px', 
  width: '400px' 
}, { 
  step(now, tween) { 
   // monitor height and width 
   // adjust tween properties 
  }, 
  progress(animation, progress, remainingMs) {} 
}); 

step() 函数,每次动画属性动画期间大约每 13 毫秒调用一次,允许我们根据传递的 now 参数的当前值调整 tween 对象的属性,如结束值、缓动类型或实际正在动画的属性。例如,一个复杂的演示可能会使用 step() 函数来检测两个移动元素之间的碰撞,并根据碰撞调整它们的轨迹。

progress() 函数在动画的生命周期中被多次调用:

  • 它与 step() 不同之处在于,它每一步仅在每个元素上调用一次,而不管正在动画多少个属性

  • 它提供了动画的不同方面,包括动画的 promise 对象、进度(一个介于 01 之间的数字)以及动画中剩余的毫秒数。

所有 jQuery 的动画都使用一个名为 setTimeout() 的 JavaScript 计时器函数来重复调用函数 —— 默认情况下每 13 毫秒一次 —— 并在每个时刻改变样式属性。然而,一些现代浏览器提供了一个新的 requestAnimationFrame() 函数,它相对于 setTimeout() 有一些优势,包括增加了精度(因此动画的平滑度更高)和改善了移动设备的电池消耗。

在 jQuery 的动画系统的最低级别上,有它的 $.Animation()$.Tween() 函数。这些函数及其对应的对象可以用来调整动画的每一个可能的方面。例如,我们可以使用 $.Animation 来创建一个动画预处理。这样的预处理可以采用一个

特别

基于传递给 .animate() 方法的 options 对象中的属性的存在,在动画结束时执行动作:

$.Animation.prefilter(function(element, properties, options) { 
  if (options.removeAfter) { 
    this.done(function () { 
      $(element).remove(); 
    }); 
  } 
}); 

使用这段代码,调用 $('#my-div').fadeOut({ removeAfter: true }) 将在淡出完成后自动从 DOM 中删除 <div>

摘要

在本章中,我们进一步研究了几种可以帮助我们制作对用户有用的漂亮动画的技术。我们现在可以单独控制我们正在动画化的每个属性的加速度和减速度,并在需要时单独或全局停止这些动画。我们了解了 jQuery 的效果库内部定义的属性,以及如何更改其中一些属性以适应我们的需求。我们初次涉足了 jQuery 延迟对象系统,我们将在第十三章 高级 Ajax中进一步探索,并且我们品尝到了调整 jQuery 动画系统的许多机会。

进一步阅读

本书附录 B 中提供了完整的效果和动画方法列表,或者您可以在官方 jQuery 文档中找到。

练习

挑战练习可能需要使用官方 jQuery 文档

  1. 定义一个名为zippy的新动画速度常数,并将其应用于传记显示效果。

  2. 更改成员详细信息的水平移动的缓动,使其反弹到位。

  3. 向 promise 添加一个第二个延迟回调函数,将highlight类添加到当前成员位置的<div>中。

  4. 挑战:在动画传记之前添加两秒的延迟。使用 jQuery 的.delay()方法。

  5. 挑战:当点击活动照片时,折叠生物详细信息。在执行此操作之前停止任何正在运行的动画。