第 13 章: 浏览器事件

要为网页添加有趣的功能,仅仅能够检查或修改文档通常是不够的。我们还需要能够检测用户正在做什么,并做出响应。为此,我们将使用称为 事件处理程序的东西。按键是事件,鼠标点击是事件,甚至鼠标移动也可以被视为一系列事件。在 第 11 章 中,我们在一个按钮上添加了一个 onclick 属性,以便在按下它时执行某些操作。这是一个简单的事件处理程序。

浏览器事件的工作原理从根本上来说非常简单。可以为特定的事件类型和特定的 DOM 节点注册处理程序。每当发生 事件时,该事件的处理程序(如果有)就会被调用。对于某些事件,例如按键,仅仅知道事件发生了是不够的,您还需要知道按下了哪个键。为了存储此类信息,每个事件都会创建一个 事件对象,处理程序可以查看该对象。

重要的是要认识到,即使事件可以在任何时候触发,但没有两个处理程序在同一时刻运行。如果其他 JavaScript 代码仍在运行,浏览器将在调用下一个处理程序之前等待它完成。这也适用于以其他方式触发的代码,例如使用 setTimeout。用程序员的行话来说,浏览器 JavaScript 是 单线程的,绝不会有两个 '线程' 同时运行。在大多数情况下,这是一件好事。当多个事件同时发生时,很容易得到奇怪的结果。

未处理的事件可以在 DOM 树中“冒泡”。这意味着,如果您点击了段落中的链接,例如,与链接关联的任何处理程序都会首先被调用。如果没有这样的处理程序,或者这些处理程序没有表明它们已完成处理事件,则会尝试段落的处理程序(它是链接的父元素)。之后,document.body 的处理程序将获得机会。最后,如果没有任何 JavaScript 处理程序处理过该事件,则浏览器会处理它。在单击链接时,这意味着将遵循该链接。


因此,正如您所见,事件很容易。关于它们唯一困难的事情是,浏览器虽然都或多或少地支持相同的功能,但它们通过不同的接口支持此功能。与往常一样,最不兼容的浏览器是 Internet Explorer,它忽略了大多数其他浏览器遵循的标准。其次是 Opera,它没有正确地支持一些有用的事件,例如在离开页面时触发的 onunload 事件,有时会提供关于键盘事件的令人困惑的信息。

有四种与事件相关的操作,您可能希望执行。

它们都不在所有主要浏览器中以相同的方式工作。


作为我们事件处理的练习场,我们用一个按钮和一个文本字段打开一个文档。在本章的其余部分,请保持此窗口打开(并附加)。

attach(window.open("example_events.html"));

第一个操作,注册处理程序,可以通过设置元素的 onclick(或 onkeypress 等等)属性来完成。这确实可以在浏览器之间工作,但它有一个重要的缺点——您只能将一个处理程序附加到一个元素。大多数时候,一个就足够了,但在某些情况下,尤其是当程序必须能够与其他程序(它们也可能正在添加处理程序)一起工作时,这会很烦人。

在 Internet Explorer 中,可以像这样向按钮添加点击处理程序

$("button").attachEvent("onclick", function(){print("Click!");});

在其他浏览器上,它像这样

$("button").addEventListener("click", function(){print("Click!");},
                             false);

注意,在第二种情况下,"on" 被省略了。addEventListener 的第三个参数 false 表示事件应该像往常一样“冒泡”通过 DOM 树。提供 true 可以用来使这个处理程序优先于“下面”的处理程序,但由于 Internet Explorer 不支持这种方式,所以这很少有用。


例 13.1

编写一个名为 registerEventHandler 的函数来包装这两个模型的不兼容性。它接受三个参数:第一个是应该将处理程序附加到的 DOM 节点,然后是事件类型的名称,例如 "click""keypress",最后是处理程序函数。

要确定应该调用哪个方法,请查找这些方法本身——如果 DOM 节点有一个名为 attachEvent 的方法,则可以假设这是正确的方法。请注意,这比直接检查浏览器是否为 Internet Explorer 好得多。如果出现了一个使用 Internet Explorer 模型的新浏览器,或者 Internet Explorer 突然切换到标准模型,代码仍然可以工作。当然,这两种可能性都很低,但以智能的方式做事永远不会有害。

function registerEventHandler(node, event, handler) {
  if (typeof node.addEventListener == "function")
    node.addEventListener(event, handler, false);
  else
    node.attachEvent("on" + event, handler);
}

registerEventHandler($("button"), "click",
                     function(){print("Click (2)");});

不要担心长而笨拙的名字。稍后,我们将不得不添加一个额外的包装器来包装这个包装器,它将有一个更短的名字。

也可以只进行一次此检查,并定义 registerEventHandler 来保存一个不同的函数,具体取决于浏览器。这更有效率,但有点奇怪。

if (typeof document.addEventListener == "function")
  var registerEventHandler = function(node, event, handler) {
    node.addEventListener(event, handler, false);
  };
else
  var registerEventHandler = function(node, event, handler) {
    node.attachEvent("on" + event, handler);
  };

删除事件的工作原理与添加它们非常相似,但这次使用 detachEventremoveEventListener 方法。请注意,要删除处理程序,您需要访问您附加到它的函数。

function unregisterEventHandler(node, event, handler) {
  if (typeof node.removeEventListener == "function")
    node.removeEventListener(event, handler, false);
  else
    node.detachEvent("on" + event, handler);
}

由事件处理程序产生的异常,由于技术限制,无法被控制台捕获。因此,它们由浏览器处理,这可能意味着它们被隐藏在某种“错误控制台”中,或者导致弹出消息。当您编写事件处理程序,并且它似乎不起作用时,它可能由于导致某种错误而静默中止。


大多数浏览器将 事件对象作为参数传递给处理程序。Internet Explorer 将其存储在名为 event 的顶级变量中。在查看 JavaScript 代码时,您经常会看到类似 event || window.event 的东西,它会获取本地变量 event,或者如果它未定义,则获取具有相同名称的顶级变量。

function showEvent(event) {
  show(event || window.event);
}

registerEventHandler($("textfield"), "keypress", showEvent);

在字段中键入几个字符,查看对象,然后将其关闭

unregisterEventHandler($("textfield"), "keypress", showEvent);

当用户单击鼠标时,会生成三个事件。首先是 mousedown,在按下鼠标按钮时。然后是 mouseup,在释放鼠标按钮时。最后是 click,表示已单击某物。如果这种情况在快速连续发生两次,则还会生成一个 dblclick(双击)事件。请注意,mousedownmouseup 事件可能在一段时间后发生——当按住鼠标按钮时。

当您将事件处理程序附加到例如按钮时,它被单击的事实通常是您需要知道的全部信息。另一方面,当处理程序附加到具有子节点的节点时,来自子节点的点击将“冒泡”到它,您将希望找出单击了哪个子节点。为此,事件对象有一个名为 target... 或者 srcElement 的属性,具体取决于浏览器。

另一个有趣的信息是点击发生的精确坐标。与鼠标相关的事件对象包含 clientXclientY 属性,它们以像素为单位给出鼠标在屏幕上的 xy 坐标。但是,文档可以滚动,因此这些坐标通常不会告诉我们太多关于鼠标所处的文档部分的信息。某些浏览器为此目的提供了 pageXpageY 属性,但其他浏览器(猜猜是哪个)没有。幸运的是,有关文档滚动像素数的信息可以在 document.body.scrollLeftdocument.body.scrollTop 中找到。

此处理程序附加到整个文档,拦截所有鼠标点击,并打印一些关于它们的信息。

function reportClick(event) {
  event = event || window.event;
  var target = event.target || event.srcElement;
  var pageX = event.pageX, pageY = event.pageY;
  if (pageX == undefined) {
    pageX = event.clientX + document.body.scrollLeft;
    pageY = event.clientY + document.body.scrollTop;
  }

  print("Mouse clicked at ", pageX, ", ", pageY,
        ". Inside element:");
  show(target);
}
registerEventHandler(document, "click", reportClick);

并再次删除它

unregisterEventHandler(document, "click", reportClick);

显然,在每个事件处理程序中都编写所有这些检查和解决方法并不是您想做的事情。过了一会儿,在我们熟悉了更多的不兼容性之后,我们将编写一个函数来“标准化”事件对象,使其在浏览器之间以相同的方式工作。

有时还可以使用事件对象的 whichbutton 属性来找出按下了哪个鼠标按钮。不幸的是,这非常不可靠——一些浏览器假装鼠标只有一个按钮,其他浏览器将右键单击报告为按住控制键时发生的单击等等。


除了点击之外,我们可能还对鼠标的移动感兴趣。DOM 节点的 mousemove 事件在鼠标在该元素上移动时触发。还有 mouseovermouseout,它们仅在鼠标进入或离开节点时触发。对于这种最后类型的事件,target(或 srcElement)属性指向触发事件的节点,而 relatedTarget(或 toElement,或 fromElement)属性给出鼠标来自的节点(对于 mouseover)或离开的节点(对于 mouseout)。

mouseovermouseout 注册到具有子节点的元素上时,它们可能会很棘手。为子节点触发的事件将冒泡到父元素,因此当鼠标进入子节点之一时,您也会看到一个 mouseover 事件。targetrelatedTarget 属性可以用来检测(并忽略)此类事件。


用户按下每个键时,都会生成三个事件:keydownkeyupkeypress。 通常情况下,当您确实需要知道按下了哪个键时,例如,您想要在按下箭头键时执行某些操作时,您应该使用前两个。 另一方面,keypress 用于您对正在输入的字符感兴趣的情况。 这样做的原因是,keyupkeydown 事件中通常没有字符信息,而 Internet Explorer 根本不会为特殊键(如箭头键)生成 keypress 事件。

找出按下了哪个键本身可能是一项相当大的挑战。 对于 keydownkeyup 事件,事件对象将具有一个 keyCode 属性,该属性包含一个数字。 大多数情况下,这些代码可以用来以一种相当独立于浏览器的的方式识别键。 找出哪个代码对应于哪个键可以通过简单的实验来完成……

function printKeyCode(event) {
  event = event || window.event;
  print("Key ", event.keyCode, " was pressed.");
}

registerEventHandler($("textfield"), "keydown", printKeyCode);
unregisterEventHandler($("textfield"), "keydown", printKeyCode);

在大多数浏览器中,单个键码对应于键盘上的单个物理键。 但是,Opera 浏览器会根据是否按下了 shift 键为某些键生成不同的键码。 更糟糕的是,其中一些 shift-is-pressed 代码与其他键使用的代码相同——shift-9(在大多数键盘上用于输入括号)与向下箭头具有相同的代码,因此很难与之区分。 当这有可能破坏您的程序时,您通常可以通过忽略按下 shift 的键事件来解决它。

要找出在键或鼠标事件期间是否按住 shift、ctrl 或 alt 键,您可以查看事件对象的 shiftKeyctrlKeyaltKey 属性。

对于 keypress 事件,您将想知道输入了哪个字符。 事件对象将具有一个 charCode 属性,如果您幸运的话,它包含与输入的字符相对应的 Unicode 数字,可以使用 String.fromCharCode 将其转换为一个字符字符串。 不幸的是,一些浏览器没有定义此属性,或者将其定义为 0,而是将字符代码存储在 keyCode 属性中。

function printCharacter(event) {
  event = event || window.event;
  var charCode = event.charCode;
  if (charCode == undefined || charCode === 0)
    charCode = event.keyCode;
  print("Character '", String.fromCharCode(charCode), "'");
}

registerEventHandler($("textfield"), "keypress", printCharacter);
unregisterEventHandler($("textfield"), "keypress", printCharacter);

事件处理程序可以“停止”它正在处理的事件。 有两种不同的方法可以做到这一点。 您可以阻止事件冒泡到父节点以及在这些节点上定义的处理程序,并且可以阻止浏览器执行与该事件相关的标准操作。 应该注意的是,浏览器并不总是遵循这一点——阻止某些“热键”按下的默认行为,在许多浏览器中,实际上并不能阻止浏览器执行这些键的正常效果。

在大多数浏览器中,停止事件冒泡是通过事件对象的 stopPropagation 方法完成的,而阻止默认行为是通过 preventDefault 方法完成的。 对于 Internet Explorer,这是通过分别将该对象的 cancelBubble 属性设置为 true,并将 returnValue 属性设置为 false 来完成的。

这就是我们在本章中将要讨论的众多不兼容问题中的最后一个。 这意味着我们终于可以编写事件规范化函数并继续进行更有趣的事情了。

function normaliseEvent(event) {
  if (!event.stopPropagation) {
    event.stopPropagation = function() {this.cancelBubble = true;};
    event.preventDefault = function() {this.returnValue = false;};
  }
  if (!event.stop) {
    event.stop = function() {
      this.stopPropagation();
      this.preventDefault();
    };
  }

  if (event.srcElement && !event.target)
    event.target = event.srcElement;
  if ((event.toElement || event.fromElement) && !event.relatedTarget)
    event.relatedTarget = event.toElement || event.fromElement;
  if (event.clientX != undefined && event.pageX == undefined) {
    event.pageX = event.clientX + document.body.scrollLeft;
    event.pageY = event.clientY + document.body.scrollTop;
  }
  if (event.type == "keypress") {
    if (event.charCode === 0 || event.charCode == undefined)
      event.character = String.fromCharCode(event.keyCode);
    else
      event.character = String.fromCharCode(event.charCode);
  }

  return event;
}

添加了一个 stop 方法,它取消事件的冒泡和默认操作。 一些浏览器已经提供了这一点,在这种情况下,我们将其保留原样。

接下来,我们可以为 registerEventHandlerunregisterEventHandler 编写方便的包装器

function addHandler(node, type, handler) {
  function wrapHandler(event) {
    handler(normaliseEvent(event || window.event));
  }
  registerEventHandler(node, type, wrapHandler);
  return {node: node, type: type, handler: wrapHandler};
}

function removeHandler(object) {
  unregisterEventHandler(object.node, object.type, object.handler);
}

var blockQ = addHandler($("textfield"), "keypress", function(event) {
  if (event.character.toLowerCase() == "q")
    event.stop();
});

新的 addHandler 函数将它提供的处理程序函数包装在一个新函数中,以便它可以处理规范化事件对象。 它返回一个对象,当我们想要删除此特定处理程序时,可以将其提供给 removeHandler。 尝试在文本字段中输入一个“q”。

removeHandler(blockQ);

有了 addHandler 和上一章中的 dom 函数,我们已经准备好迎接更具挑战性的文档操作壮举。 作为练习,我们将实现被称为 Sokoban 的游戏。 这是一款经典游戏,但你可能以前没有见过。 规则如下:有一个网格,由墙壁、空地和一个或多个“出口”组成。 在这个网格上,有一些箱子或石头,以及一个由玩家控制的小人。 这个小人可以水平和垂直地移动到空方格中,并且可以推动周围的巨石,前提是它们后面有空地。 游戏的目标是将一定数量的巨石移动到出口处。

就像 第 8 章 中的 terraria 一样,Sokoban 关卡可以表示为文本。 example_events.html 窗口中的变量 sokobanLevels 包含一个关卡对象的数组。 每个关卡都有一个名为 field 的属性,其中包含关卡的文本表示,以及一个名为 boulders 的属性,指示必须排出多少个巨石才能完成该关卡。

show(sokobanLevels.length);
show(sokobanLevels[1].boulders);
forEach(sokobanLevels[1].field, print);

在这样的关卡中,# 字符是墙壁,空格是空方格,0 字符用于巨石,@ 用于玩家的起始位置,* 用于出口。


但是,在玩游戏时,我们不希望看到这种文本表示。 相反,我们将一个 表格放入文档中。 我做了一个小的 样式表(sokoban.css,如果你想知道它是什么样子的话),为这个表格的单元格提供一个固定的正方形大小,并将其添加到示例文档中。 此表格中的每个单元格都将获得一个背景图像,表示该方格的类型(空、墙或出口)。 为了显示玩家和巨石的位置,图像被添加到这些表格单元格中,并在需要时移动到不同的单元格中。

可以使用此表格作为我们数据的主要表示——当我们想要查看给定方格中是否存在墙壁时,我们只需检查相应表格单元格的背景,并且为了找到玩家,我们只需搜索具有正确 src 属性的图像节点即可。 在某些情况下,这种方法是实用的,但对于这个程序,我选择为网格保留一个单独的数据结构,因为它使事情变得更加直接。

此数据结构是一个二维网格,它由对象组成,表示游戏场的方格。 每个对象都必须存储它所拥有的背景类型,以及该单元格中是否存在巨石或玩家。 它还应该包含一个指向用于在文档中显示它的表格单元格的引用,以便可以轻松地将图像移入和移出该表格单元格。

这给了我们两种类型的对象——一种用于保存游戏场的网格,另一种用于表示此网格中的各个单元格。 如果我们希望游戏也能执行一些操作,例如在适当的时候移动下一关,以及能够在您搞砸时重置当前关,我们还需要一个“控制器”对象,该对象在适当的时候创建或删除场对象。 为了方便起见,我们将使用 第 8 章 末尾概述的原型方法,因此对象类型只是原型,并且 create 方法而不是 new 运算符用于创建新对象。


让我们从表示游戏场方格的对象开始。 他们负责正确设置单元格的背景,以及根据需要添加图像。 img/sokoban/ 目录包含一组基于另一款古老游戏的图像,这些图像将用于可视化游戏。 首先,Square 原型可能看起来像这样。

var Square = {
  construct: function(character, tableCell) {
    this.background = "empty";
    if (character == "#")
      this.background = "wall";
    else if (character == "*")
      this.background = "exit";

    this.tableCell = tableCell;
    this.tableCell.className = this.background;

    this.content = null;
    if (character == "0")
      this.content = "boulder";
    else if (character == "@")
      this.content = "player";

    if (this.content != null) {
      var image = dom("IMG", {src: "img/sokoban/" +
                                   this.content + ".gif"});
      this.tableCell.appendChild(image);
    }
  },

  hasPlayer: function() {
    return this.content == "player";
  },
  hasBoulder: function() {
    return this.content == "boulder";
  },
  isEmpty: function() {
    return this.content == null && this.background == "empty";
  },
  isExit: function() {
    return this.background == "exit";
  }
};

var testSquare = Square.create("@", dom("TD"));
show(testSquare.hasPlayer());

构造函数的 character 参数将用于将来自关卡蓝图的字符转换为实际的 Square 对象。 为了设置单元格的背景,使用样式表类(在 sokoban.css 中定义),这些类被分配给 td 元素的 className 属性。

hasPlayerisEmpty 等方法是“隔离”使用此类型对象的代码与对象内部的一种方式。 在这种情况下,它们并不是严格必需的,但它们将使其他代码看起来更好。


例 13.2

Square 原型添加 moveContentclearContent 方法。 第一个方法以另一个 Square 对象作为参数,并将 this 方格的内容移动到参数中,方法是更新 content 属性并将与该内容关联的图像节点移动。 这将用于在网格周围移动巨石和玩家。 它可以假设方格当前不是空的。 clearContent 从方格中删除内容,而不将其移动到任何地方。 请注意,空方格的 content 属性包含 null

我们在 第 12 章 中定义的 removeElement 函数也在本章中可用,方便您删除节点。 您可以假设图像只是表格单元格的子节点,因此可以通过例如 this.tableCell.lastChild 来访问它们。

Square.moveContent = function(target) {
  target.content = this.content;
  this.content = null;
  target.tableCell.appendChild(this.tableCell.lastChild);
};
Square.clearContent = function() {
  this.content = null;
  removeElement(this.tableCell.lastChild);
};

下一个对象类型将被称为 SokobanField。 它的构造函数接收来自 sokobanLevels 数组的对象,并负责构建 DOM 节点的表格和 Square 对象的网格。 此对象还将负责处理移动玩家和巨石的细节,方法是使用 move 方法,该方法接收一个参数,指示玩家想要向哪个方向移动。

为了标识各个方块,并指示方向,我们将再次使用来自 第 8 章Point 对象类型,正如您可能还记得,它有一个 add 方法。

游戏场地原型看起来像这样

var SokobanField = {
  construct: function(level) {
    var tbody = dom("TBODY");
    this.squares = [];
    this.bouldersToGo = level.boulders;

    for (var y = 0; y < level.field.length; y++) {
      var line = level.field[y];
      var tableRow = dom("TR");
      var squareRow = [];
      for (var x = 0; x < line.length; x++) {
        var tableCell = dom("TD");
        tableRow.appendChild(tableCell);
        var square = Square.create(line.charAt(x), tableCell);
        squareRow.push(square);
        if (square.hasPlayer())
          this.playerPos = new Point(x, y);
      }
      tbody.appendChild(tableRow);
      this.squares.push(squareRow);
    }

    this.table = dom("TABLE", {"class": "sokoban"}, tbody);
    this.score = dom("DIV", null, "...");
    this.updateScore();
  },

  getSquare: function(position) {
    return this.squares[position.y][position.x];
  },
  updateScore: function() {
    this.score.firstChild.nodeValue = this.bouldersToGo + 
                                      " boulders to go.";
  },
  won: function() {
    return this.bouldersToGo <= 0;
  }
};

var testField = SokobanField.create(sokobanLevels[0]);
show(testField.getSquare(new Point(10, 2)).content);

构造函数遍历关卡中的行和字符,并将 Square 对象存储在 squares 属性中。当它遇到带有玩家的方块时,它会将此位置保存为 playerPos,这样我们就可以轻松地找到带有玩家的方块。getSquare 用于找到与游戏场地上的特定 x,y 位置相对应的 Square 对象。请注意,它不考虑游戏场地的边缘——为了避免编写一些枯燥的代码,我们假设游戏场地被正确地封闭,使其不可能走出场地。

在创建 table 节点的 dom 调用中,单词 "class" 被引用为字符串。这是必要的,因为 class 是 JavaScript 中的“保留字”,不能用作变量或属性名称。

必须清除的巨石数量(这可能少于关卡中巨石的总数)存储在 bouldersToGo 中。每当巨石被带到出口时,我们就可以从这里减去 1,并查看游戏是否已经获胜。为了让玩家了解他的进度,我们将不得不以某种方式显示此数量。为此,使用带文本的 div 元素。div 节点是没有任何内在标记的容器。分数文本可以使用 updateScore 方法更新。won 方法将由控制器对象使用,以确定游戏何时结束,以便玩家可以继续下一关。


如果我们想实际看到游戏场地和分数,我们将不得不以某种方式将它们插入文档中。这就是 place 方法的作用。我们还将添加一个 remove 方法,以便在完成游戏场地后轻松地将其移除。

SokobanField.place = function(where) {
  where.appendChild(this.score);
  where.appendChild(this.table);
};
SokobanField.remove = function() {
  removeElement(this.score);
  removeElement(this.table);
};

testField.place(document.body);

如果一切顺利,您现在应该看到一个 Sokoban 游戏场地。


例 13.3

但这个游戏场地目前还没有做很多事情。添加一个名为 move 的方法。它以指定移动的 Point 对象作为参数(例如 -1,0 向左移动),并负责以正确的方式移动游戏元素。

正确的方法是:playerPos 属性可用于确定玩家试图移动到哪里。如果这里有巨石,请查看巨石后面的方块。如果那里有出口,则移除巨石并更新分数。如果那里有空位,则将巨石移入其中。接下来,尝试移动玩家。如果他试图移动到的方块不为空,则忽略移动。

SokobanField.move = function(direction) {
  var playerSquare = this.getSquare(this.playerPos);
  var targetPos = this.playerPos.add(direction);
  var targetSquare = this.getSquare(targetPos);

  // Possibly pushing a boulder
  if (targetSquare.hasBoulder()) {
    var pushTarget = this.getSquare(targetPos.add(direction));
    if (pushTarget.isEmpty()) {
      targetSquare.moveContent(pushTarget);
    }
    else if (pushTarget.isExit()) {
      targetSquare.moveContent(pushTarget);
      pushTarget.clearContent();
      this.bouldersToGo--;
      this.updateScore();
    }
  }
  // Moving the player
  if (targetSquare.isEmpty()) {
    playerSquare.moveContent(targetSquare);
    this.playerPos = targetPos;
  }
};

通过首先处理巨石,移动代码在玩家正常移动和推动巨石时可以以相同的方式工作。请注意巨石后面的方块是如何通过将 direction 添加到 playerPos 两次来找到的。通过向左移动两个方块来测试它

testField.move(new Point(-1, 0));
testField.move(new Point(-1, 0));

如果成功了,我们将巨石移到了我们无法再取回的地方,所以最好扔掉这个游戏场地。

testField.remove();

现在已经处理了所有“游戏逻辑”,我们只需要一个控制器来使其可玩。控制器将是一个名为 SokobanGame 的对象类型,它负责以下事项

我们从一个未完成的原型开始。

var SokobanGame = {
  construct: function(place) {
    this.level = null;
    this.field = null;

    var newGame = dom("BUTTON", null, "New game");
    addHandler(newGame, "click", method(this, "newGame"));
    var reset = dom("BUTTON", null, "Reset level");
    addHandler(reset, "click", method(this, "reset"));
    this.container = dom("DIV", null,
                         dom("H1", null, "Sokoban"),
                         dom("DIV", null, newGame, " ", reset));
    place.appendChild(this.container);

    addHandler(document, "keydown", method(this, "keyDown"));
    this.newGame();
  },

  newGame: function() {
    this.level = 0;
    this.reset();
  },
  reset: function() {
    if (this.field)
      this.field.remove();
    this.field = SokobanField.create(sokobanLevels[this.level]);
    this.field.place(this.container);
  },

  keyDown: function(event) {
    // To be filled in
  }
};

构造函数构建一个 div 元素来容纳游戏场地,以及两个按钮和一个标题。请注意 method 如何用于将 this 对象上的方法附加到事件。

我们可以将 Sokoban 游戏放到我们的文档中,就像这样

var sokoban = SokobanGame.create(document.body);

例 13.4

现在唯一剩下的就是填写键盘事件处理程序。用一个检测箭头键按下的处理程序替换原型的 keyDown 方法,并在找到它们时,以正确方向移动玩家。以下 Dictionary 可能会有用

var arrowKeyCodes = new Dictionary({
  37: new Point(-1, 0), // left
  38: new Point(0, -1), // up
  39: new Point(1, 0),  // right
  40: new Point(0, 1)   // down
});

处理完箭头键后,检查 this.field.won() 以确定这是否是获胜的移动。如果玩家获胜,请使用 alert 显示一条消息,并进入下一关。如果没有下一关(检查 sokobanLevels.length),则重新开始游戏。

在处理完键盘事件后停止处理键盘事件可能是明智之举,否则按向上键和向下键将滚动您的窗口,这非常烦人。

SokobanGame.keyDown = function(event) {
  if (arrowKeyCodes.contains(event.keyCode)) {
    event.stop();
    this.field.move(arrowKeyCodes.lookup(event.keyCode));
    if (this.field.won()) {
      if (this.level < sokobanLevels.length - 1) {
        alert("Excellent! Going to the next level.");
        this.level++;
        this.reset();
      }
      else {
        alert("You win! Game over.");
        this.newGame();
      }
    }
  }
};

需要注意的是,以这种方式捕获键盘事件——向 document 添加一个处理程序并停止您正在查找的事件——在文档中有其他元素时并不友好。例如,尝试将光标移动到文档顶部的文本字段中。——它不起作用,您只会移动 Sokoban 游戏中的小人。如果这样的游戏要在真实网站中使用,最好将其放在自己的框架或窗口中,这样它只捕获针对自身窗口的事件。


例 13.5

当被带到出口时,巨石会突然消失。通过修改 Square.clearContent 方法,尝试对即将被移除的巨石显示“坠落”动画。让它们在消失之前变小一会儿。您可以使用 style.width = "50%" 以及类似的 style.height 来使图像显示为原本大小的一半,例如。

我们可以使用 setInterval 来处理动画的计时。请注意,该方法确保在完成动画后清除间隔。如果您不这样做,它将继续浪费您的计算机时间,直到页面关闭。

Square.clearContent = function() {
  self.content = null;
  var image = this.tableCell.lastChild;
  var size = 100;

  var animate = setInterval(function() {
    size -= 10;
    image.style.width = size + "%";
    image.style.height = size + "%";

    if (size < 60) {
      clearInterval(animate);
      removeElement(image);
    }
  }, 70);
};

现在,如果您有几个小时可以浪费,请尝试完成所有关卡。


其他可能有用事件类型包括 focusblur,它们在可以“聚焦”的元素上触发,例如表单输入。focus 显然是在您将焦点放在元素上时发生的,例如通过单击它。blur 是 JavaScript 中的“取消焦点”的表达,当焦点离开元素时触发。

addHandler($("textfield"), "focus", function(event) {
  event.target.style.backgroundColor = "yellow";
});
addHandler($("textfield"), "blur", function(event) {
  event.target.style.backgroundColor = "";
});

另一个与表单输入相关的事件是 change。当输入的内容发生更改时会触发它……除了对于某些输入(例如文本输入),它直到元素失去焦点时才会触发。

addHandler($("textfield"), "change", function(event) {
  print("Content of text field changed to '",
        event.target.value, "'.");
});

您可以随意输入,该事件只会在您单击输入外部、按 Tab 键或以其他方式取消焦点时触发。

表单还有一个 submit 事件,当表单提交时会触发它。它可以被停止以阻止提交。这为我们提供了一个更好的方法来执行我们在上一章中看到的表单验证。您只需注册一个 submit 处理程序,当表单的内容无效时,它会停止事件。这样,当用户未启用 JavaScript 时,表单仍然可以工作,只是没有即时验证。

窗口对象有一个 load 事件,当文档完全加载时触发,如果您的脚本需要执行某种初始化操作(必须等到整个文档出现),这很有用。例如,本书页面上的脚本遍历当前章节以隐藏练习的解决方案。当练习尚未加载时,您无法做到这一点。还有一个 unload 事件,在用户离开文档时触发,但它并非所有浏览器都完全支持。

大多数情况下,最好将文档的布局留给浏览器,但有一些效果只能通过让一段 JavaScript 设置文档中某些节点的确切大小来实现。当您这样做时,请确保还监听窗口的 resize 事件,并在每次窗口大小调整时重新计算元素的大小。


最后,我必须告诉您一些关于事件处理程序的事情,您可能不想知道。Internet Explorer 浏览器(在撰写本文时,这意味着大多数网民使用的浏览器)有一个错误,会导致值无法正常清理:即使它们不再使用,它们也会保留在机器的内存中。这被称为“内存泄漏”,一旦泄漏的内存足够多,就会严重降低计算机的速度。

什么时候会发生这种泄漏?由于 Internet Explorer 的 垃圾收集器(其目的是回收未使用的值)的缺陷,当您有一个 DOM 节点,通过其某个属性或以更间接的方式引用一个普通的 JavaScript 对象,而这个对象又反过来引用该 DOM 节点时,这两个对象都不会被收集。这与 DOM 节点和其他 JavaScript 对象由不同的系统收集有关——清理 DOM 节点的系统会注意保留任何仍然被 JavaScript 对象引用的节点,反之亦然,用于收集普通 JavaScript 值的系统。

如上所述,问题与事件处理程序本身无关。例如,这段代码会创建一些无法收集的内存

var jsObject = {link: document.body};
document.body.linkBack = jsObject;

即使这样的 Internet Explorer 浏览器跳转到其他页面,它仍然会保留这里显示的 `document.body`。这个bug 通常与事件处理程序相关联的原因是,在注册处理程序时很容易创建这种循环链接。DOM 节点保留对它的处理程序的引用,而处理程序大多数情况下也对 DOM 节点有引用。即使没有刻意创建这种引用,JavaScript 的作用域规则也会隐式地添加它。考虑以下函数

function addAlerter(element) {
  addHandler(element, "click", function() {
    alert("Alert! ALERT!");
  });
}

由 `addAlerter` 函数创建的匿名函数可以“看到” `element` 变量。它没有使用它,但这并不重要——仅仅因为它能看到它,它就会对它有一个引用。通过将此函数注册为同一个 `element` 对象上的事件处理程序,我们创建了一个循环。

有三种方法可以解决这个问题。第一种方法也是最流行的一种,就是忽略它。大多数脚本只会泄漏一点点,因此需要很长时间和很多页面才能发现问题。而且,当问题如此微妙时,谁会追究你的责任?采用这种方法的程序员通常会严厉地谴责微软的糟糕编程,并声明问题不是他们的错,因此他们不应该修复它。

当然,这种理由并非完全没有道理。但是,当一半的用户在你的网页上遇到问题时,就很难否认存在实际问题。这就是为什么在“严肃”网站上工作的人通常会尝试不泄漏任何内存。这就引出了第二种方法: painstakingly making sure that no circular references between DOM objects and regular objects are created。例如,这意味着要像这样重写上面的处理程序

function addAlerter(element) {
  addHandler(element, "click", function() {
    alert("Alert! ALERT!");
  });
  element = null;
}

现在 `element` 变量不再指向 DOM 节点,处理程序也不会泄漏。这种方法是可行的,但需要程序员真正用心。

最后,第三种解决方案是不太担心创建有泄漏的结构,而是确保在完成使用后清除它们。这意味着在不再需要时取消注册任何事件处理程序,并注册一个 `onunload` 事件以取消注册在页面卸载之前需要的处理程序。可以扩展事件注册系统(比如我们的 `addHandler` 函数),以自动执行此操作。采用这种方法时,必须牢记事件处理程序不是造成内存泄漏的唯一可能来源——向 DOM 节点对象添加属性也会导致类似的问题。