浅谈JavaScript的事件响应

时间:2023-01-06 07:39:01

原文出处: Christian Heilmann   译文出处:Chajn Science

每当猴子们问我JavaScript和DOM里啥东西最牛逼时,我都会一巴掌打回去:卧槽还用问么当然是事件响应了啊!没它你能有时间和我讨论这个?你早去工地搬砖去了好么!浏览器没有事件响应就没有行为交互,那简直就是一夜回到*的感觉啊。此外,以事件驱动使得功能解耦也是个相当高端大气的技巧了,嘛,以此为主的Node.js 现在可是风生水起的说。

那现在我们就再抠抠事件监听的相关基础,让大家在心情愉悦的状态下获得更多的姿♂势。不过那些经常写<a href=”javascript:void(0)”>和在标签上写onclick=”foo()”的猴子们请自动回避,小心你看不懂又想不开,老衲徒增罪孽呀(偶八年前就解释了内联事件处理是自寻死路)。

再唠叨两句:本文的代码内容只涉及到原生JavaScript,像JQuery,YUI或Dojo什么的所提供的事件处理这里就不加以赘述了。我希望大家能够明白,使用这些库只是为了方便,但我们却不能完全依赖它。了解基础与理解本质是非常重要的,这样你就可以在不能使用傻瓜库的情况下,依旧可以提供一个牛逼的解决方案。

进化主义声明:这里我们使用的事件语法是“DOM Level 3 Events”规范定义的“addEventListener()。除了IE9以下版本以外的现代浏览器都支持。当然,我们可以使用JQuery,它会帮我解决这些浏览器兼容性的问题。但如果你还想让互联网可以良好发展和进化,你就应该立刻停止为兼容低级浏览器而再去写一坨屎一样的傻逼兼容代码。这条路虽然艰辛,但却无比正确。可以试着给你的产品进行功能降级,检测到是低级浏览器就不执行脚本,比如addEventListener()的DOMContentLoaded事件就能确保你的代码不在低级浏览器中运行,而页面可以将主体内容正常显示就OK的。

在我们进入事件的细节之前先看几个牛逼的演示,它利用onscroll事件得到了一个很nice的效果:

  • 因为要招设计师,Wealthfront的工程师们开发了Z轴滚动平移效果。这也是Beercamp 2011 website的一部分。Wealthfront的博客有细节介绍。
  • Stroll.js用的也是类似的手法,用户可以在滚动列表时看到很多种炫酷展现。
  • jQuery Scroll Path是一个JQ插件,它的功能是当用户在页面内能够跟随着一条路径去动态浏览内容。

以上所有都是基于浏览器的事件监听和处理功能,所以,让我们细细品味一下原汁原味的事件机制吧。

基础问题:啥是事件?

1
2
3
4
5
6
7
var log = document.getElementById('log'),
    i = '',
    out = [];
for (i in window) {
  if ( /^on/.test(i)) { out[out.length] = i; }
}
log.innerHTML = out.join(', ');

在浏览器中运行如上代码,亲们可以得到如下:

onmouseenter, onmouseleave, onafterprint, onbeforeprint, onbeforeunload, onhashchange, onmessage, onoffline, ononline, onpopstate, onpagehide, onpageshow, onresize, onunload, ondevicemotion, ondeviceorientation, onabort, onblur, oncanplay, oncanplaythrough, onchange, onclick, oncontextmenu, ondblclick, ondrag, ondragend, ondragenter, ondragleave, ondragover, ondragstart, ondrop, ondurationchange, onemptied, onended, onerror, onfocus, oninput, oninvalid, onkeydown, onkeypress, onkeyup, onload, onloadeddata, onloadedmetadata, onloadstart, onmousedown, onmousemove, onmouseout, onmouseover, onmouseup, onmozfullscreenchange, onmozfullscreenerror, onpause, onplay, onplaying, onprogress, onratechange, onreset, onscroll, onseeked, onseeking, onselect, onshow, onstalled, onsubmit, onsuspend, ontimeupdate, onvolumechange, onwaiting, oncopy, oncut, onpaste, onbeforescriptexecute, onafterscriptexecute

一大坨事件就够你吃几天的了,用addEventListener()方法可以进行事件监听:

1
element.addEventListener(event, handler, useCapture);

举个例子来说:

1
2
var a = document.querySelector('a'); // grab the first link in the document
a.addEventListener('click', ajaxloader, false);

我们在一个element上加了个事件监听,就好像是在命令她,“你被客人摸了你就给我喊起来!” The ajaxloader()是监听事件的回调方法,就好像是,“你就在这儿给我盯着,妞要是喊了,你就过去给客人道歉!” 将第三个参数useCapture设置为false是为了表示这次是在事件冒泡阶段进行触发,而不是在事件捕获阶段。咳咳,这是一个漫长而艰巨的课题,你也可以看看Dev.Opera对capture的解释。哎呀反正不用管那么多啦,99.7434%的情况下设置useCapture为false准没错!其实它默认就是false,所以按理来说是可以不用填写的,但Opera这逗比例外…

在事件被触发之时,回调方法会接收到一个事件对象。请试着在恰当的环境中运行如下代码,也可以直接点击这里测试这个例子,对象内的属性又够吃一盆的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var log = document.getElementById('log'),
    out = '';
 
document.addEventListener('click', logeventinfo, false);
document.addEventListener('keypress', logeventinfo, false);
 
function logeventinfo (ev) {
  log.innerHTML = '';
  out = '<ul>';
  for (var i in ev) {
    if (typeof ev[i] === 'function' || i === i.toUpperCase()) {
      continue;
    }
    out += '<li><span>'+i+'</span>: '+ev[i]+'</li>';
  }
  log.innerHTML += out + '</ul>';
}

你可以对同一事件进行多重监听,也可以对多个事件使用同一方法处理(如本例)。

参数ev是传回来的事件对象,下面是它所带的全部属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
originalTarget: [object HTMLHtmlElement]
type: click
target: [object HTMLHtmlElement]
currentTarget: [object HTMLDocument]
eventPhase: 3
bubbles: true
cancelable: true
timeStamp: 574553210
defaultPrevented: false
which: 1
rangeParent: [object Text]
rangeOffset: 23
pageX: 182
pageY: 111
isChar: false
screenX: 1016
screenY: 572
clientX: 182
clientY: 111
ctrlKey: false
shiftKey: false
altKey: false
metaKey: false
button: 0
relatedTarget: null
mozPressure: 0
mozInputSource: 1
view: [object Window]
detail: 1
layerX: 182
layerY: 111
cancelBubble: false
explicitOriginalTarget: [object HTMLHtmlElement]
isTrusted: true
originalTarget: [object HTMLHeadingElement]
type: click
target: [object HTMLHeadingElement]
currentTarget: [object HTMLDocument]
eventPhase: 3
bubbles: true
cancelable: true
timeStamp: 574554192
defaultPrevented: false
which: 1
rangeParent: [object Text]
rangeOffset: 0
pageX: 1
pageY: 18
isChar: false
screenX: 835
screenY: 479
clientX: 1
clientY: 18
ctrlKey: false
shiftKey: false
altKey: false
metaKey: false
button: 0
relatedTarget: null
mozPressure: 0
mozInputSource: 1
view: [object Window]
detail: 1
layerX: 1
layerY: 18
cancelBubble: false
explicitOriginalTarget: [object Text]
isTrusted: true

试一下本例中点击鼠标和按键盘,不同的事件触发传回来的结果是不同的。可以看看完整的标准事件属性列表

知晓基础之后:阻止事件默认行为的执行和获得触发事件的目标元素

我们需要了解浏览器中关于事件对象有两个很重要的功能。阻止浏览器执行事件默认行为的ev.preventDefault(),和可以获得事件目标元素的ev.target.

比如说一个链接被点击了,但因为业务需要,我们并不想让浏览器打开新页面。这时候可以给这个链接加个点击事件监听,然后在回调函数中调用 preventDefault()方法即可。昂,就如下面这个例子,请看HTML:

1
2
<a class="prevent" href="http://smashingmagazine.com">Smashing, my dear!</a>
<a class="normal" href="http://smashingmagazine.com">Smashing, my dear!</a>

还有JavaScript:

1
2
3
4
5
6
7
8
9
10
11
var normal = document.querySelector('.normal'),
    prevent = document.querySelector('.prevent');
 
prevent.addEventListener('click', function(ev) {
  alert('fabulous, really!');
  ev.preventDefault();
}, false);
 
normal.addEventListener('click', function(ev) {
  alert('fabulous, really!');
}, false);

注意: document.querySelector() 是合理获取DOM元素的一种方式。和jQuery的 $() 差不多。 可以读读 W3C’s specification 和MDN的 explanatory code snippets 去了解。

如果点击.prevent链接,会弹出个对话框,点“确定”后啥事都没发生,呵~呵~,因为处理中有执行ev.preventDefault(),所以不会跳到 http://smashingmagazine.com。没有 preventDefault()的就会在弹对话框,且跳链接咯。不信你可以试一下嘛

通常情况下,处理事件的方法想要访问触发元素只能通过变量和this什么的去关联,虽然看似简单方便,但addEventListener()给了我们更好的选择:事件目标(target),不过它可能被其他的一些东西混淆,所以用ev.currentTarget更保险些。通常想要在点击、悬停或输入事件的回调方法中访问触发元素都是用变量 this 。虽然方便,但这个关键字你懂得…于是 addEventListener() 提供给我们一个更好的获取方式:event.target。 不过它可能会被混淆( this 被绑到奇怪的东西上的时候),所以用 ev.currentTarget 更保险些。

事件代理:高端,大气,上档次!

用事件对象的 target 属性,你可以得到触发事件的元素。

事件被激活后,会像猴子一样沿着DOM树从监听节点下滑到触发节点,然后再上爬回监听节点。也就是说,如果你监听了一个DOM节点,那也就等于你监听了其所有的后代节点。代理的意思就是只监听父节点的事件触发,以来代理对其后代节点的监听,而你需要做的只是通过 target 属性得到触发元素并作出回应。来看我下面的例子

1
2
3
4
5
6
7
<ul id="resources">
  <li><a href="http://developer.mozilla.org">MDN</a></li>
  <li><a href="http://html5doctor.com">HTML5 Doctor</a></li>
  <li><a href="http://html5rocks.com">HTML5 Rocks</a></li>
  <li><a href="http://beta.theexpressiveweb.com/">Expressive Web</a></li>
  <li><a href="http://creativeJS.com/">CreativeJS</a></li>
</ul>

这个例子中的HTML结构是个无序列表,当鼠标悬停会显示相应的标签信息。下面是它JS代码,你将看到它只用到了一个事件监听,然后在处理函数中得到target,以它的 tagName 来进行区分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var resources = document.querySelector('#resources'),
    log = document.querySelector('#log');
 
resources.addEventListener('mouseover', showtarget, false);
 
function showtarget(ev) {
  var target = ev.target;
  if (target.tagName === 'A') {
    log.innerHTML = 'A link, with the href:' + target.href;
  }
  if (target.tagName === 'LI') {
    log.innerHTML = 'A list item';
  }
  if (target.tagName === 'UL') {
    log.innerHTML = 'The list itself';
  }
}

我费事吧啦解释了半天,乃们懂得这么做意味着什么么?这意味着你可以节省大量重复的事件监听,以减少浏览器资源消耗。大多数人可能会用jQuery的$(‘a’).click(…) 啥啥啥的(虽然 jQuery的 on 方法优化的还OK啦不过偶还是蛮鄙视他的),这么做看似一句话蛮带感的,可其实它是把获取到的所有A元素一个一个的注册监听!然后在某个时刻,充斥着无数事件监听的页面终于觉累不爱,自爆以鸣冤屈。

她还有一个好处就是让HTML独立起来,比如之后还有要加子元素的需求,也不需要再为其单独加事件监听了。

事件监测,顺便说一下超级牛逼的CSS平滑效果

以前我们会用mouseover|mouseout事件来暗挫挫的实现hover效果,而现在用CSS伪选择器的:hover和:focus什么的就直接搞定了。想到这里,内心止不住的伤感啊……魔法师们坚持住!咳咳,当然,CSS也并不是万能的,有些事情还是要跟事件配合完成,比如下面这个例子,对鼠标指针进行定位。这是相当简单的了是不,我们先搞个绝对定位的小球元素,下面是它的HTML:

1
<div class="plot"></div>

这是它的CSS:

1
2
3
4
5
6
7
8
9
10
.plot {
  position:absolute;
  background:rgb(175,50,50);
  width: 20px;
  height: 20px;
  border-radius: 20px;
  display: block;
  top:0;
  left:0;
}

我们监听并处理doucment的click事件,利用PageX和pageY对小球进行定位。注意啊这里,我们需要减去球的半径,以让球的中心在鼠标指针上:

1
2
3
4
5
6
var plot = document.querySelector('.plot'),
    offset = plot.offsetWidth / 2;
document.addEventListener('click', function(ev) {
  plot.style.left = (ev.pageX - offset) + 'px';
  plot.style.top = (ev.pageY - offset) + 'px';
}, false);

随便点击屏幕的任意位置,小球都会随之闪现到那。不过它并不是平滑过去的,但如果你勾选这个示例的复选框,你会发现小球就会很圆润的滑过来了。这个效果呢,过去的话可能只能用JS库来完成,但现在啊,时代不同了……我们只需要用CSS写个过渡效果的类,而剩下的事情就让浏览器去处理。为至于此,我们写个类名为smooth的样式,在复选框被选中之后,将其应用到小球上:

1
2
3
4
5
6
7
.smooth {
  -webkit-transition: 0.5s;
     -moz-transition: 0.5s;
      -ms-transition: 0.5s;
       -o-transition: 0.5s;
          transition: 0.5s;
}

添加JavaScript:

1
2
3
4
var cb = document.querySelector('input[type=checkbox]');
cb.addEventListener('click', function(ev) {
  plot.classList.toggle('smooth');
}, false);

随着新世界的来临,CSS和JavaScript双剑合璧,谁与争锋!嘛顺便说下,在JS中也有跟CSS过渡和动画效果有关的事件噢。

撸一个键需要多长时间?

正如你之前在可用事件列表中看到的,我们也可以监听按键输入事件。不过很遗憾的是,浏览器对键盘事件处理的并不是很到位,你可以看看Jan Wolter对此的详细解释接下来让我们看一个keytime的例子,它会输出用户按键的毫秒间隔。代码并不难:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var resources = document.querySelector('#resources'),
    log = document.querySelector('#log'),
    time = 0;
 
document.addEventListener('keydown', keydown, false);
document.addEventListener('keyup', keyup, false);
 
function keydown(ev) {
  if (time === 0) {
    time = ev.timeStamp;
    log.classList.add('animate');
  }
}
function keyup(ev) {
  if (time !== 0) {
    log.innerHTML = ev.timeStamp - time;
    time = 0;
    log.classList.remove('animate');
  }
}

先定义我们想要操纵的元素并设置time为0。然后我们在document上监听两个键盘输入事件 keydown和keyup。

在keydown事件处理中,我们检查变量time是否为0,如果是则把事件对象的timeStamp赋值给time。再加个CSS动画类animate给log节点,让它向滚动条一样动起来

在keyup事件处理中,如果time还是为0则忽略(在按着键盘的期间keydown事件是连续不断被触发的),如果不是则通过两者时间戳相减去计算一次按键操作经过多长时间。最后让time为0并移除log节点的animate类

弄个CSS过渡(动画)效果

当浏览器运行CSS过渡效果是会在JavaScript中触发一个独立事件,叫transitionend。这个事件对象会有两个属性:被其所影响到的属性名propertyName,和其过渡所经历的时间elapsedTime。

可以查看这个demo感受一下,代码很简单,下面是它的CSS:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.plot {
  background:rgb(175,50,50);
  width: 20px;
  height: 20px;
  border-radius: 20px;
  display: block;
  -webkit-transition: 0.5s;
     -moz-transition: 0.5s;
      -ms-transition: 0.5s;
       -o-transition: 0.5s;
          transition: 0.5s;
}
 
.plot:hover {
  width: 50px;
  height: 50px;
  border-radius: 100px;
  background: blue;
}

这是它的JavaScript:

1
2
3
plot.addEventListener('transitionend', function(ev) {
  log.innerHTML += ev.propertyName + ':' + ev.elapsedTime + 's ';
}, false);

但因为Fire/Chrome/Safari/Opera等这些浏览器厂商各自为政,也是因为这些事件还不成熟,所以这些事件名通常都会被加上前缀,那在使用时你就不得不判断下浏览器兼容性。可以看看这个David Calhoun’s gist

CSS动画事件和上面演示的过渡事件基本一个意思,它有三个事件:animationstart,animationend和animationiteration。可以看MDN的demo

速度,距离和角度,没有问题!

事件我们是监听到了,但如果想让它更加屌炸天,我们就需要再来点有深度的,比如在用户在拖拽元素时,给元素来个计算角度、距离和速度差什么的——示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
var plot = document.querySelector('.plot'),
    log = document.querySelector('output'),
    offset = plot.offsetWidth / 2,
    pressed = false,
    start = 0, x = 0, y = 0, end = 0, ex = 0, ey = 0, mx = 0, my = 0,
    duration = 0, dist = 0, angle = 0;
 
document.addEventListener('mousedown', onmousedown, false);
document.addEventListener('mouseup', onmouseup, false);
document.addEventListener('mousemove', onmousemove, false);
 
function onmousedown(ev) {
  if (start === 0 && x === 0 && y === 0) {
    start = ev.timeStamp;
    x = ev.clientX;
    y = ev.clientY;
    moveplot(x, y);
    pressed = true;
  }
}
function onmouseup(ev) {
  end = ev.timeStamp;
  duration = end - start;
  ex = ev.clientX;
  ey = ev.clientY;
  mx = ex - x;
  my = ey - y;
  dist = Math.sqrt(mx * mx + my * my);
  start = x = y = 0;
  pressed = false;
  angle = Math.atan2( my, mx ) * 180 / Math.PI;
  log.innerHTML = '<strong>' + (dist>>0) +'</strong> pixels in <strong>'+
                  duration +'</strong> ms ( <strong>' +
                  twofloat(dist/duration) +'</strong> pixels/ms)'+
                  ' at <strong>' + twofloat(angle) +
                  '</strong> degrees';
}
function onmousemove (ev) {
  if (pressed) {
    moveplot(ev.pageX, ev.pageY);
  }
}
function twofloat(val) {
  return Math.round((val*100))/100;
}
function moveplot(x, y) {
  plot.style.left = (x - offset) + 'px';
  plot.style.top = (y - offset) + 'px';
}

好啦,好像做了很多事情的样子,但事实上并没有那么复杂。监听onmousedown和onmouseup两个事件,我们能得到鼠标当前位置clientX和clientY,还有记录按键时间的timeStamp。当鼠标移动时,检查鼠标是否被按下了(通过在mousedown时设定的布尔值),如果按下了则让小球跟着鼠标移动。

然后是几何——Pythagoras(毕达哥拉斯定理)通过mousedown和mouseup的时间间隔和像素位移而得出它运动的速度。

我们得到了运动开始和结束的xy坐标,相减得到距离差,再平方相加,最后得到和的平方根,即为小球运动的位移。嘛我们还通过计算三角形的反正切得到了它运动前后的偏转角度,高端吧!嘿嘿其实这些都是抄“A Quick Look Into the Math of Animations With JavaScript”的……你可以看下这个在JSFiddle上的示例:

http://jsfiddle.net/codepo8/bAwUf/light/

媒体事件

video和audio这俩很潮的玩意也有一大堆事件供我们使用。比如有趣的time事件,它可以告诉我们这首歌或电影的已播放时长。可以看看MDN这个MGM-inspired dinosaur animation;我也没事闲的录制了一个six-minute screencast来玩弄了一下~。

想看所有的事件动作?去JPlayer看媒体事件的演示页面吧。

其他输入相关

我们知道,浏览器提供了与鼠标键盘的交互,但这还远远不够满足我们更多的硬件交互需求。比如检测手机或平板电脑倾斜度的Device orientationtouch eventsthe Gamepad API让我们可以在浏览器中做游戏控制;postMessage让我们可以在浏览器各窗口之间进行跨域消息传递;pageVisibility让我们可以得知浏览器中当前标签页可见状态。甚至当window的history对象有操作时也能监听的到。查看window对象的事件列表,有的可能已经被实现了,还有更多的在谋划中……

嘛,不管浏览器是否会支持,最终都是要支持的嘛,这些是刚需。我们只要默默等待就可以了,骚年,向着夕阳奔跑吧!=v=

玩出个未来

基本上就这样了,看,事件并不难。一般情况下,你只需要注册监听他们,然后在事件处理函数中使用event对象就好了。如果到现在你还没想到它能做些什么有趣的事情,那这篇文章也只能帮你倒这里了。别再管上文的那些例子了,请你用你那上锈的脑袋好好思考下,玩愤怒小鸟时候,不就是监听个触摸事件的开始与结束,再处理下相应的方向和距离差所得出的射击力量么?最后嗖的一声,小鸟就*飞翔在远方了~。所以,究竟是什么阻止了你的创意?该加了个油了,同学。