现今各种框架、工具‘横行’,到处在讲原理和源码,更有跨端技术需要我们去探索,但如果基本功不好,学什么都是事倍功半,效果很不好,花费时间的同时打击自信心。此篇文章,为我所计划的【轻聊前端】系列第(十四)篇,旨在系统地、逻辑性地把原生JavaScript知识分享给大家,帮助各位较为轻松地理清知识体系,更好地理解和记忆,我尽力而为,望不负期待。
前面的十多篇文章,介绍了很多模块的知识,数字、字符串、对象、DOM、函数…
但如果告诉你,对于基础的Web页面,JavaScript代码是不必要的,你怎么看,前面的东西白说了?非也。
网页的结构由HTML负责,美观的布局由CSS负责,对于页面展示,这些就够了,但当一个网页呈现在用户面前,用户想要跟它进行交互,让它对用户的行为做出反应,怎么办?这就需要事件。
有了事件,就能够在“用户和网页”、“用户和浏览器”之间建立联系。事件就和JavaScript有关了。
所以,事件的两个特点:
- 跟操作相关
- 由客户端提供
事件类型
事件的类型有很多,逐个介绍不现实,也无必要,大致可分这几类:
外设事件
:click、mouseenter/up/down、keyup/down/press…
表单事件
:input、change…
界面事件
:load、select、scroll…
焦点事件
:focus、blur…
设备事件
:orientationchange、deviceorientation、devicemotion…
触屏事件
:touchstart/end/move、gesturestart/change/end…
事件有很多形式,我们只需要理解一件事情——用户所有的操作,或者网页状态的变化,都会对应着某个事件,如果想趁此机会做点什么,将其绑定到目标元素即可,元素绑定了事件,就在人机之间建立了联系。
下面看看有哪些绑定事件的方式。
事件绑定
事件绑定有多种方式,并不都常用,但需要了解它们之间的差异和发展过程。
这里涉及到两个概念:绑定方式、处理程序
HTML
第一种是在HTML中直接绑定,这是在网页开发初期最为常见的方式。
1 | <div onclick="console.log('点击事件发生了')"></div> |
这段代码给div绑定了点击事件,使用“on” + “事件名称”给元素设置属性的方式。
这里执行的console方法就是“事件处理程序”,它不仅可以用来执行脚本,还可以调用在页面其他地方定义的方法。
这种方式直观明了,但它的弊端是与HTML结构过于耦合,出于职责分离的原则,我们不希望改动脚本代码的时候还要去动结构,所以通常不推荐。
DOM0
另一种较为传统的方式,是把一个函数赋值给DOM元素的事件处理属性。
当然,要先取得目标DOM对象的引用,然后就是一个简单的赋值动作。
1 | let btn = document.querySelector("#myBtn") |
DOM2
你可能有个疑问,怎么从DOM0直接跳到DOM2?DOM1呢?
这是因为DOM在不同的时间点,不同版本的标准里,推出了不同的内容,DOM1里并没有跟事件相关的内容,DOM2时才有,所以就不会涉及DOM1了。
addEventListener()/removeEventListener()
接收3个参数:
事件名
:就是前面事件类型中提到的,进行了什么操作,就有相对应的事件名;
处理函数
:自定义,是跟操作相关需要达到的交互效果、数据传输或者业务逻辑等。
布尔值
:false(默认值)表示在冒泡阶段调用事件处理程序、true表示在捕获阶段调用事件处理程序。
DOM2方式的优势:
- 可以为同一个事件添加多个事件处理程序,且按照添加顺序来触发。
- 可以精确控制事件触发阶段。
把上面那段代码换成DOM2的形式,就是这样:
1 | let btn = document.querySelector("#myBtn") |
聊到这儿,都是在聊绑定,但事件不是绑定了就万事大吉,就可以放着不管,还需要在合适的时机进行移除,为什么移除,因为事件处理程序本身也是一段代码,被放到内存中,用完了,或者长期不用,不移除就会白白占用空间,是一种浪费。
DOM0级处理程序的移除可以直接置空。
1 | btn.onclick = null |
DOM2级中使用removeEventListener(),并且,通过addEventListener()添加的事件处理程序只能使用removeEventListener()并传入同样的参数来移除。这意味着使用addEventListener()添加的匿名函数无法移除。
面试题——addEventListener方法的第三个参数可以是什么
你可能会问,前面不是说了第三个参数是布尔值?平时也是这么用的,有时甚至直接不写,怎么还问“可以是什么”?
没错,在旧版本的DOM中, addEventListener()的第三个参数是一个布尔值,表示是否在捕获阶段调用事件处理程序。但随着时间的推移,这已经不能满足大家对于事件处理程序更多的操作需求,怎么办,继续加参数?与其在方法之中添加更多参数,倒不如把第三个参数改为一个可以包含不同属性的对象来的方便,然后通过不同的设置达到目的就好。
答案:可以是布尔值,也可以是一个对象。
问题又来了,对象里面可以设置什么。
capture
: 表示listener会在该类型的事件捕获阶段传播到事件对象时触发。
once
: 表示listener 在添加之后最多只调用一次。如果是 true,listener 会在其被调用之后自动移除。
passive
: 设置为true时,表示listener永远不会调用preventDefault()。如果 listener 仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。
signal
:该AbortSignal的abort()方法被调用时,监听器会被移除。
了解到这些,下次碰到这个问题,你应该知道怎么应对了~
IE
看到这位,大家心里是咯噔一下,还是微微一笑呢?浏览器的兼容已经不是现阶段最大的问题了,特别是有了一些工具之后,我们不需要每人都手写一份兼容代码放到项目里,但了解还是要的。
IE实现了与DOM类似的方法,即attachEvent()和detachEvent()。
这两个方法接收两个同样的参数:事件处理程序的名字和事件处理函数。因为IE8及更早版本只支持事件冒泡,所以使用attachEvent()添加的事件处理程序会添加到冒泡阶段。
在IE中使用attachEvent()与使用DOM0方式的主要区别是事件处理程序的作用域。使用DOM0方式时,事件处理程序中的this值等于目标元素。而使用attachEvent()时,事件处理程序是在全局作用域中运行的,因此this等于window。
事件流
客户端为我们提供了事件,也提供了绑定方法,这不就齐了,事件流又是什么?
可以回想一下,前面聊DOM的时候,页面的元素节点组成了DOM树,而这棵树反应到页面上,就是一个个顺序摆放或者嵌套起来矩形盒子。
假想一下:现在有一个列表,有列表项,列表项有个footer元素,footer元素里有个button按钮,那么,我点击button的时候,是在点击谁呢?
你说我在点击button,没错,但同样我是在点击footer,我也是在点击整个列表,
理论上讲,页面上的元素均可点击,但作为开发者,我们想要往哪个元素上绑定事件,又由哪个元素来响应行为呢?这就涉及到事件流。
事件流,即事件传播的流向。
事件流有两种:事件冒泡和事件捕获,它们分别是由IE和Netscape团队提出的。
事件冒泡
事件被定义为从最具体的元素开始触发,然后一直向上传播,直至文档。即从内向外。
事件捕获
和事件冒泡相反,最外层的节点最先收到事件,最具体的节点最后收到事件。即由外向内。
事件捕获实际上是为了在事件到达最终目标前拦截事件。
到这里,你可能要问了,到底现在浏览器是支持哪个?冒泡还是捕获呢?答案是:两个都支持,只是有个对应的流程。
事件流
DOM2 Events规范规定事件流分为3个阶段:事件捕获、到达目标和事件冒泡。事件捕获最先发生,为提前拦截事件提供了可能。然后,实际的目标元素接收到事件。最后一个阶段是冒泡,最迟要在这个阶段响应事件。请看图示:
有关“事件流”先介绍到这,后面有相关的运用。
事件对象(event)
“JavaScript中万物皆对象”,事件也不例外,但事件对象是什么,从哪来,有什么用途呢。
是什么
前面我们讲了,当需要跟网页产生交互的时候,事件提供了这个接口,这个接口,连接了“事件类型”和“事件处理程序”,前面也说,处理程序可以写逻辑,可以调方法,但除此之外,有相当一部分的操作,是需要我们拿到操作对象的某些数据的,比如:表单、表格、列表。这个数据,可以是业务数据,可以是设备相关的数据,也可以是跟事件相关的数据。
事件对象就担当了这样一个角色,他包含了触发事件的元素、事件类型,以及可能与特定事件相关的其他数据。例如,鼠标操作的位置信息,键盘操作中被按下的键的信息等。
而这个事件对象,从哪来呢,还记得前面的绑定事件吗?都会给事件绑定一个处理程序,事件对象就是作为处理程序的入参被传进去。
1 | btn.onclick = function(event){ |
有什么
每种事件都有这个对象,但不同的事件对象包含不同的属性和方法。这就在减少信息冗余的同时提供了针对性。
所有事件对象都会包含下表列出的这些公共属性和方法:
target
:事件目标,常在获取节点内容时使用
currentTarget
:处理程序所在的元素
type
:事件类型,在一个处理程序处理多个事件时很有用。
preventDefault()
:用于阻止特定事件的默认动作。
stopPropagation()
:用于立即阻止事件流在DOM结构中传播,取消后续的事件捕获或冒泡。
eventPhase
:可用于确定事件流当前所处的阶段。如果事件处理程序在捕获阶段被调用,则eventPhase等于1;如果事件处理程序在目标上被调用,则eventPhase等于2;如果事件处理程序在冒泡阶段被调用,则eventPhase等于3。不过要注意的是,虽然“到达目标”是在冒泡阶段发生的,但其eventPhase仍然等于2。
由于数量繁多,这里没有列举完全,关于特定事件的特定属性也略过,有需要大家可以去查阅文档,或者直接写一段代码打印出来便知。
面试题——target 和 currentTarget 的区别
在事件处理程序内部,this对象始终等于currentTarget的值,而target只包含事件的实际目标。如果事件处理程序直接添加在了意图的目标,则this、currentTarget和target的值是一样的,否则它们就会是不同的值。
内存与性能
看起来,事件仅仅跟交互相关,交互动作跟性能有什么关系?
这就不得不重提上面聊过的两个东西:事件绑定 和 处理程序。
事件绑定,即为元素绑定事件处理程序所需访问DOM的次数,这会在前期造成整个页面可交互时间的延迟。
处理程序:即所定义的可调用的函数,函数也是对象,会占用内存空间,对象越多,对性能的影响就越明显。
“过多事件处理程序”的解决方案是使用事件委托。事件委托利用事件冒泡,可以只使用一个事件处理程序来管理一种类型的事件。例如,click事件冒泡到document。这意味着可以为整个页面指定一个onclick事件处理程序,而不用为每个可点击元素分别指定事件处理程序。
那么处理程序怎么优化呢,想要不占用内存,只能释放出来,即及时删除不用的事件处理程序。前面讲绑定的时候提过,这里再深入一些。
删除处理程序就代表不再使用,什么时候不再使用呢,比如:
- 页面元素整个被删除或者替换,比如使用removeChild()或replaceChild()删除的节点,和使用innerHTML整体替换页面的某一部分,元素没了,但事件处理程序并不一定会被垃圾收集程序清理掉。
- 页面卸载,当切换页面或者关闭页面的时候,绑定在页面元素上的处理程序仍在内存中,这也是个容易忽略的点,在页面卸载事件发生时(比如:unload)将其手动清理是推荐的做法。
这些优化是和业务实现无关的,我们常说,实现一个功能有不同的方法,差异就在这些细节上,而细节决定了最终的体验。
事件模拟
在文章开头,我们提到了事件的一个特点,即由客户端提供,非自定义,这是因为,大部分情况下,我们只需要使用它即可,并没有特别的需求,如果有自定义的需求,也是可以实现的。
可以使用document.createEvent()方法创建一个event对象。这个方法接收一个参数,此参数是一个表示要创建事件类型的字符串。
创建event对象之后,需要使用事件相关的信息来初始化。每种类型的event对象都有特定的方法,可以使用相应数据来完成初始化。方法的名字并不相同,这取决于调用createEvent()时传入的参数。
事件模拟的最后一步是触发事件。为此要使用dispatchEvent()方法,这个方法存在于所有支持事件的DOM节点之上。dispatchEvent()方法接收一个参数,即表示要触发事件的event对象。
比如模拟一个鼠标事件:
1 | let btn = document.querySelector("#myBtn") |
整个过程跟EventEmitter的实现有点类似,只是使用了原生提供的方法,而且创建了事件对象相关的信息。
总结
这是本系列的第十四篇,在讲了不同数据类型,函数,DOM 和 BOM之后,加上这篇的事件,不仅网页的各元素之间可以串联起来,人和网页也能产生交互了,似乎已经接近了具有完备功能网页的模样,但是,仍有一些东西,特别对于现代网页来说很重要的东西尚未提及。
革命尚未成功,不过它越来越近了,让我们一起期待最后的几块拼图吧。