【前端进阶】重新认识HTML与DOM

By 大Van家 on 2021-09-05
阅读时间 37 分钟
文章共 9.5k
阅读量

要点:HTML标签、DOM、DOM事件(防抖、节流、代理)。

一、HTML标签

提到 HTML 标签,前端工程师会非常熟悉,因为在开发页面时经常使用。但往往关注更多的是页面渲染效果及交互逻辑,也就是对用户可见可操作的部分,比如表单、菜单栏、列表、图文。

其实还有一些非常重要却容易被忽视的标签,这些标签大多数用在页面头部 head 标签内,虽然对用户不可见,但如果在某些场景下,比如交互实现、性能优化、搜索优化,合理利用它们就可以达到事半功倍的效果。

这一节就和你来聊聊那些“看不见”的 HTML 标签及其使用场景。

1.1 交互实现

编码原则:Less code, less bug

在实现一个功能的时候,我们编写的代码越多,不仅开发成本越高,而且代码的健壮性也越差。
它和 KISS(Keep it simple, stupid)原则及奥卡姆剃刀原则(如无必要,勿增实体)有相同的意思,都是提倡编码简约

下面介绍几个标签,来看看如何帮助我们更简单地实现一些页面交互效果。

meta 标签:自动刷新/跳转

假设要实现一个类似 PPT 自动播放的效果,你很可能会想到使用 JavaScript 定时器控制页面跳转来实现。但其实有更加简洁的实现方法,比如通过 meta 标签来实现:

1
<meta http-equiv="Refresh" content="5; URL=page2.html">

上面的代码会在 5s 之后自动跳转到同域下的 page2.html 页面。我们要实现 PPT 自动播放的功能,只需要在每个页面的 meta 标签内设置好下一个页面的地址即可。

另一种场景,比如每隔一分钟就需要刷新页面的大屏幕监控,也可以通过 meta 标签来实现,只需去掉后面的 URL 即可:

1
<meta http-equiv="Refresh" content="60">

细心的你可能会好奇,既然这样做又方便又快捷,为什么这种用法比较少见呢?

一方面是因为不少前端工程师对 meta 标签用法缺乏深入了解,另一方面也是因为在使用它的时候,刷新和跳转操作是不可取消的,所以对刷新时间间隔或者需要手动取消的,还是推荐使用 JavaScript 定时器来实现。但是,如果你只是想实现页面的定时刷新或跳转(比如某些页面缺乏访问权限,在 x 秒后跳回首页这样的场景)建议你可以实践下 meta 标签的用法。

title 标签与 Hack 手段:消息提醒

作为前端工程师的你对 B/S 架构肯定不陌生,它有很多的优点,比如版本更新方便、跨平台、跨终端,但在处理某些场景,比如即时通信场景时,就会变得比较麻烦。

因为前后端通信深度依赖 HTTP 协议,而 HTTP 协议采用“请求-响应”模式,这就决定了服务端也只能被动地发送数据。一种低效的解决方案是客户端通过轮询机制获取最新消息(HTML5 下可使用 WebSocket 协议)。

消息提醒功能实现则比较困难,HTML5 标准发布之前,浏览器没有开放图标闪烁、弹出系统消息之类的接口,只能借助一些 Hack 的手段,比如修改 title 标签来达到类似的效果(HTML5 下可使用 Web Notifications API 弹出系统消息)。

下面这段代码中,通过定时修改 title 标签内容,模拟了类似消息提醒的闪烁效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
let msgNum = 1 // 消息条数
let cnt = 0 // 计数器
const inerval = setInterval(() => {
cnt = (cnt + 1) % 2
if(msgNum===0) {
// 通过DOM修改title
document.title += `聊天页面`
clearInterval(interval)
return
}
const prefix = cnt % 2 ? `新消息(${msgNum})` : ''
document.title = `${prefix}聊天页面`
}, 1000)

实现效果如下图所示,可以看到标签名称上有提示文字在闪烁。

categories.gif

通过模拟消息闪烁,可以让用户在浏览其他页面的时候,及时得知服务端返回的消息。

定时修改 title 标签内容,除了用来实现闪烁效果之外,还可以制作其他动画效果,比如文字滚动,但需要注意浏览器会对 title 标签文本进行去空格操作。

动态修改 title 标签的用途不仅在于消息提醒,你还可以将一些关键信息显示到标签上(比如下载时的进度、当前操作步骤),从而提升用户体验。

1.2 性能优化

性能优化是前端开发中避不开的问题,性能问题无外乎两方面原因:渲染速度慢、请求时间长。性能优化虽然涉及很多复杂的原因和解决方案,但其实只要通过合理地使用标签,就可以在一定程度上提升渲染速度以及减少请求时间。

script 标签:调整加载顺序提升渲染速度

浏览器渲染机制

由于浏览器的底层运行机制,渲染引擎在解析 HTML 时,若遇到 script 标签引用文件,则会暂停解析过程,同时通知网络线程加载文件,文件加载后会切换至 JavaScript 引擎来执行对应代码,代码执行完成之后切换至渲染引擎继续渲染页面。

async、defer、type属性

在这一过程中可以看到,页面渲染过程中包含了请求文件以及执行文件的时间,但页面的首次渲染可能并不依赖这些文件,这些请求和执行文件的动作反而延长了用户看到页面的时间,从而降低了用户体验。

为了减少这些时间损耗,可以借助 script 标签的 3 个属性来实现。

  • async 属性。立即请求文件,但不阻塞渲染引擎,而是文件加载完毕后阻塞渲染引擎并立即执行文件内容。
  • defer 属性。立即请求文件,但不阻塞渲染引擎,等到解析完 HTML 之后再执行文件内容。
  • HTML5 标准 type 属性,对应值为“module”。让浏览器按照 ECMA Script 6 标准将文件当作模块进行解析,默认阻塞效果同 defer,也可以配合 async 在请求完成后立即执行。

具体效果可以参看下图:

1583465393011-810652f489ca6136.png

其中,绿色的线表示执行解析 HTML ,蓝色的线表示请求文件,红色的线表示执行文件。

从图中可以得知,采用 3 种属性都能减少请求文件引起的阻塞时间,只有 defer 属性以及 type=”module” 情况下能保证渲染引擎的优先执行,从而减少执行文件内容消耗的时间,让用户更快地看见页面(即使这些页面内容可能并没有完全地显示)。

除此之外还应当注意,当渲染引擎解析 HTML 遇到 script 标签引入文件时,会立即进行一次渲染。所以这也就是为什么构建工具会把编译好的引用 JavaScript 代码的 script 标签放入到 body 标签底部,因为当渲染引擎执行到 body 底部时会先将已解析的内容渲染出来,然后再去请求相应的 JavaScript 文件。如果是内联脚本(即不通过 src 属性引用外部脚本文件直接在 HTML 编写 JavaScript 代码的形式),渲染引擎则不会渲染。

在我们对大型单页应用进行性能优化时,也许会用到按需懒加载的方式,来加载对应的模块,但如果能合理利用 link 标签的 rel 属性值来进行预加载,就能进一步提升渲染速度。

dns-prefetch

当 link 标签的 rel 属性值为“dns-prefetch”时,浏览器会对某个域名预先进行 DNS 解析并缓存。这样,当浏览器在请求同域名资源的时候,能省去从域名查询 IP 的过程,从而减少时间损耗。下图是淘宝网设置的 DNS 预解析。

1583466667742-993b502f80fa3567.png

preconnect

让浏览器在一个 HTTP 请求正式发给服务器前预先执行一些操作,这包括 DNS 解析、TLS 协商、TCP 握手,通过消除往返延迟来为用户节省时间

prefetch/preload

两个值都是让浏览器预先下载并缓存某个资源,但不同的是,prefetch 可能会在浏览器忙时被忽略,而 preload 则是一定会被预先下载。

prerender

浏览器不仅会加载资源,还会解析执行页面,进行预渲染。

这几个属性值恰好反映了浏览器获取资源文件的过程:

浏览器获取资源文件的流程

1.3 搜索优化

你所写的前端代码,除了要让浏览器更好执行,有时候也要考虑更方便其他程序(如搜索引擎)理解。合理地使用 meta 标签和 link 标签,恰好能让搜索引擎更好地理解和收录我们的页面。

meta 标签:提取关键信息

通过 meta 标签可以设置页面的描述信息,从而让搜索引擎更好地展示搜索结果。

例如,在百度中搜索“拉勾”,就会发现网站的描述信息,这些描述信息就是通过 meta 标签专门为搜索引擎设置的,目的是方便用户预览搜索到的结果。

1583481178981-737e6b76d555f457.png

为了让搜索引擎更好地识别页面,除了描述信息之外还可以使用关键字,这样即使页面其他地方没有包含搜索内容,也可以被搜索到(当然搜索引擎有自己的权重和算法,如果滥用关键字是会被降权的,比如 Google 引擎就会对堆砌大量相同关键词的网页进行惩罚,降低它被搜索到的权重)。

当我们搜索关键字“垂直互联网招聘”的时候搜索结果会显示拉勾网的信息,虽然显示的搜索内容上并没有看到“垂直互联网招聘”字样,这就是因为拉勾网页面中设置了这个关键字。

1583481543840-ce4f715602d1b084.png

对应代码如下:

1
<meta content="拉勾,拉勾网,拉勾招聘,拉钩, 拉钩网 ,互联网招聘,拉勾互联网招聘, 移动互联网招聘, 垂直互联网招聘, 微信招聘, 微博招聘, 拉勾官网, 拉勾百科,跳槽, 高薪职位, 互联网圈子, IT招聘, 职场招聘, 猎头招聘,O2O招聘, LBS招聘, 社交招聘, 校园招聘, 校招,社会招聘,社招" name="keywords">

在实际工作中,推荐使用一些关键字工具来挑选,比如 Google Trends站长工具。下图是我使用站长工具搜索“招聘”关键字得到的结果,可以看到得到了相当关键的一些信息,比如全网搜索指数、关键词特点。

image.png

有时候为了用户访问方便或者出于历史原因,对于同一个页面会有多个网址,又或者存在某些重定向页面,比如:

那么在这些页面中可以这样设置:

1
<link href="https://lagou.com/a.html" rel="canonical">

这样可以让搜索引擎避免花费时间抓取重复网页。不过需要注意的是,它还有个限制条件,那就是指向的网站不允许跨域

当然,要合并网址还有其他的方式,比如使用站点地图,或者在 HTTP 请求响应头部添加 rel="canonical"

1.4 OGP(开放图表协议)

1583483633365-97ac0eb2d1c6d7d2.png

好了,前面我们说了 HTML5 标准的一些标签和属性,下面再延伸说一说基于 meta 标签扩展属性值实现的第三方协议——OGP(Open Graph Protocal,开放图表协议 )。

OGP 是 Facebook 公司在 2010 年提出的,目的是通过增加文档信息来提升社交网页在被分享时的预览效果。你只需要在一些分享页面中添加一些 meta 标签及属性,支持 OGP 协议的社交网站就会在解析页面时生成丰富的预览信息,比如站点名称、网页作者、预览图片。具体预览效果会因各个网站而有所变化。

下面是微信文章支持 OGP 协议的代码,可以看到通过 meta 标签属性值声明了:网址、预览图片、描述信息、站点名称、网页类型和作者信息。

1583480543843-477274458e5be00b.png

现在百度已经宣布支持,微信文章的不少页面上也添加了相关标签属性,有兴趣的话你可以查看官方网站:https://ogp.me/。

1.5 总结

本节从交互实现、性能优化、搜索优化场景出发,分别讲解了 meta 标签、title 标签、link 标签,以及 script 标签在这些场景中的重要作用,希望这些内容你都能有效地应用到工作场景中,不再只是了解,而是能够熟练运用。

最后布置一道思考题:说一说你还知道哪些“看不见”的标签及用法?

二、DOM

2.1 什么是 DOM

DOM(Document Object Model,文档对象模型)是 JavaScript 操作 HTML 的接口(这里只讨论属于前端范畴的 HTML DOM),属于前端的入门知识,同样也是核心内容,因为大部分前端功能都需要借助 DOM 来实现,比如:

  • 动态渲染列表、表格表单数据;
  • 监听点击、提交事件;
  • 懒加载一些脚本或样式文件;
  • 实现动态展开树组件,表单组件级联等这类复杂的操作。

如果你查看过 DOM V3 标准,会发现包含多个内容,但归纳起来常用的主要由 3 个部分组成:

  • DOM 节点
  • DOM 事件
  • 选择区域

选择区域的使用场景有限,一般用于富文本编辑类业务,我们不做深入讨论;DOM 事件有一定的关联性,将在下一课时中详细讨论;对于 DOM 节点,需与另外两个概念标签和元素进行区分:

  • 标签是 HTML 的基本单位,比如 p、div、input;
  • 节点是 DOM 树的基本单位,有多种类型,比如注释节点、文本节点;
  • 元素是节点中的一种,与 HTML 标签相对应,比如 p 标签会对应 p 元素。

举例说明,在下面的代码中,“p” 是标签, 生成 DOM 树的时候会产生两个节点,一个是元素节点 p,另一个是字符串为“亚里士朱德”的文本节点。

1
<p>亚里士朱德</p>

2.2 会框架更要会 DOM

有的前端工程师因为平常使用 Vue、React 这些框架比较多,觉得直接操作 DOM 的情况比较少,认为熟悉框架就行,不需要详细了解 DOM。这个观点对于初级工程师而言确实如此,能用框架写页面就算合格。

但对于屏幕前想成为高级/资深前端工程师的你而言,只会使用某个框架或者能答出 DOM 相关面试题,这些肯定是不够的。恰恰相反,作为高级/资深前端工程师,不仅应该对 DOM 有深入的理解,还应该能够借此开发框架插件、修改框架甚至能写出自己的框架。

下面谈谈如何高效地操作 DOM。

2.3 为什么说 DOM 操作耗时

要解释 DOM 操作带来的性能问题,我们不得不提一下浏览器的工作机制

线程切换

如果你对浏览器结构有一定了解,就会知道浏览器包含渲染引擎(也称浏览器内核)和 JavaScript 引擎,它们都是单线程运行。单线程的优势是开发方便,避免多线程下的死锁竞争等问题,劣势是失去了并发能力

浏览器为了避免两个引擎同时修改页面而造成渲染结果不一致的情况,增加了另外一个机制,这两个引擎具有互斥性,也就是说在某个时刻只有一个引擎在运行,另一个引擎会被阻塞。

操作系统在进行线程切换的时候需要保存上一个线程执行时的状态信息并读取下一个线程的状态信息,俗称上下文切换。而这个操作相对而言是比较耗时的。

每次 DOM 操作就会引发线程的上下文切换——从 JavaScript 引擎切换到渲染引擎执行对应操作,然后再切换回 JavaScript 引擎继续执行,这就带来了性能损耗。单次切换消耗的时间是非常少的,但是如果频繁地大量切换,那么就会产生性能问题。

比如下面的测试代码,循环读取一百万次 DOM 中的 body 元素的耗时是读取 JSON 对象耗时的 10 倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 测试次数:一百万次
const times = 1000000
// 缓存body元素
console.time('object')
let body = document.body
// 循环赋值对象作为对照参考
for(let i=0;i<times;i++) {
let tmp = body
}
console.timeEnd('object')// object: 1.77197265625ms

console.time('dom')
// 循环读取body元素引发线程切换
for(let i=0;i<times;i++) {
let tmp = document.body
}
console.timeEnd('dom')// dom: 18.302001953125ms

虽然这个例子比较极端,循环次数有些夸张,但如果在循环中包含一些复杂的逻辑或者说涉及到多个元素时,就会造成不可忽视的性能损耗。

重新渲染

另一个更加耗时的因素是元素及样式变化引起的再次渲染,在渲染过程中耗时的两个步骤为重排(Reflow)与重绘(Repaint)。

浏览器在渲染页面时会将 HTML 和 CSS 分别解析成 DOM 树CSSOM 树,然后合并进行排布,再绘制成我们可见的页面。如果在操作 DOM 时涉及到元素、样式的修改,就会引起渲染引擎重新计算样式生成 CSSOM 树,同时还有可能触发对元素的重新排布(简称“重排”)和重新绘制(简称“重绘”)。

可能会影响到其他元素排布的操作就会引起重排,继而引发重绘,比如:

  • 修改元素边距、大小
  • 添加、删除元素
  • 改变窗口大小

与之相反的操作则只会引起重绘,比如:

  • 设置背景图片
  • 修改字体颜色
  • 改变 visibility 属性值

如果想了解更多关于重绘和重排的样式属性,可以参看这个网址:https://csstriggers.com/。

下面是两段验证代码,我们通过 Chrome 提供的性能分析工具来对渲染耗时进行分析。

第一段代码,通过修改 div 元素的边距来触发重排,渲染耗时(粗略地认为渲染耗时为紫色 Rendering 事件和绿色 Painting 事件耗时之和)3045 毫秒。

1
2
3
4
5
6
7
8
9
10
const times = 100000
let html = ''
for(let i=0;i<times;i++) {
html+= `<div>${i}</div>`
}
document.body.innerHTML += html
const divs = document.querySelectorAll('div')
Array.prototype.forEach.call(divs, (div, i) => {
div.style.margin = i % 2 ? '10px' : 0;
})

image.png

第二段代码,修改 div 元素字体颜色来触发重绘,得到渲染耗时 2359 ms。

1
2
3
4
5
6
7
8
9
10
const times = 100000
let html = ''
for(let i=0;i<times;i++) {
html+= `<div>${i}</div>`
}
document.body.innerHTML += html
const divs = document.querySelectorAll('div')
Array.prototype.forEach.call(divs, (div, i) => {
div.style.color = i % 2 ? 'red' : 'green';
})

image (1).png

从两段测试代码中可以看出,重排渲染耗时明显高于重绘,同时两者的 Painting 事件耗时接近,也印证了重排会导致重绘。

2.4 如何高效操作 DOM

明白了 DOM 操作耗时之处后,要提升性能就变得很简单了,反其道而行之,减少这些操作即可。

在循环外操作元素

比如下面两段测试代码对比了读取 1000 次 JSON 对象以及访问 1000 次 body 元素的耗时差异,相差一个数量级。

1
2
3
4
5
6
7
8
9
10
11
12
13
const times = 10000;
console.time('switch')
for (let i = 0; i < times; i++) {
document.body === 1 ? console.log(1) : void 0;
}
console.timeEnd('switch') // 1.873046875ms

var body = JSON.stringify(document.body)
console.time('batch')
for (let i = 0; i < times; i++) {
body === 1 ? console.log(1) : void 0;
}
console.timeEnd('batch') // 0.846923828125ms

当然即使在循环外也要尽量减少操作元素,因为不知道他人调用你的代码时是否处于循环中。

批量操作元素

比如说要创建 1 万个 div 元素,在循环中直接创建再添加到父元素上耗时会非常多。如果采用字符串拼接的形式,先将 1 万个 div 元素的 html 字符串拼接成一个完整字符串,然后赋值给 body 元素的 innerHTML 属性就可以明显减少耗时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const times = 10000;
console.time('createElement')
for (let i = 0; i < times; i++) {
const div = document.createElement('div')
document.body.appendChild(div)
}
console.timeEnd('createElement')// 54.964111328125ms

console.time('innerHTML')
let html=''
for (let i = 0; i < times; i++) {
html+='<div></div>'
}
document.body.innerHTML += html // 31.919921875ms
console.timeEnd('innerHTML')

虽然通过修改 innerHTML 来实现批量操作的方式效率很高,但它并不是万能的。比如要在此基础上实现事件监听就会略微麻烦,只能通过事件代理或者重新选取元素再进行单独绑定。批量操作除了用在创建元素外也可以用于修改元素属性样式,比如下面的例子。

创建 2 万个 div 元素,以单节点树结构进行排布,每个元素有一个对应的序号作为文本内容。现在通过 style 属性对第 1 个 div 元素进行 2 万次样式调整。下面是直接操作 style 属性的代码:

1
2
3
4
5
6
7
8
9
10
11
12
const times = 20000;
let html = ''
for (let i = 0; i < times; i++) {
html = `<div>${i}${html}</div>`
}
document.body.innerHTML += html
const div = document.querySelector('div')
for (let i = 0; i < times; i++) {
div.style.fontSize = (i % 12) + 12 + 'px'
div.style.color = i % 2 ? 'red' : 'green'
div.style.margin = (i % 12) + 12 + 'px'
}

image (2).png

如果将需要修改的样式属性放入 JavaScript 数组,然后对这些修改进行 reduce 操作,得到最终需要的样式之后再设置元素属性,那么性能会提升很多。代码如下:

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
const times = 20000;
let html = ''
for (let i = 0; i < times; i++) {
html = `<div>${i}${html}</div>`
}
document.body.innerHTML += html

let queue = [] // 创建缓存样式的数组
let microTask // 执行修改样式的微任务
const st = () => {
const div = document.querySelector('div')
// 合并样式
const style = queue.reduce((acc, cur) => ({...acc, ...cur}), {})
for(let prop in style) {
div.style[prop] = style[prop]
}
queue = []
microTask = null
}
const setStyle = (style) => {
queue.push(style)
// 创建微任务
if(!microTask)
microTask = Promise.resolve().then(st)
}
for (let i = 0; i < times; i++) {
const style = {
fontSize: (i % 12) + 12 + 'px',
color: i % 2 ? 'red' : 'green',
margin: (i % 12) + 12 + 'px'
}
setStyle(style)
}

从下面的耗时占比图可以看到,紫色 Rendering 事件耗时有所减少。

image (3).png

virtualDOM 之所以号称高性能,其实现原理就与此类似。

缓存元素集合

比如将通过选择器函数获取到的 DOM 元素赋值给变量,之后通过变量操作而不是再次使用选择器函数来获取。

下面举例说明,假设我们现在要将上面代码所创建的 1 万个 div 元素的文本内容进行修改。每次重复使用获取选择器函数来获取元素,代码以及时间消耗如下所示。

1
2
3
for (let i = 0; i < document.querySelectorAll('div').length; i++) {
document.querySelectorAll(`div`)[i].innerText = i
}

image (4).png

如果能够将元素集合赋值给 JavaScript 变量,每次通过变量去修改元素,那么性能将会得到不小的提升。

1
2
3
4
const divs = document.querySelectorAll('div')
for (let i = 0; i < divs.length; i++) {
divs[i].innerText = i
}

image (5).png

对比两者耗时占比图可以看到,两者的渲染时间较为接近。但缓存元素的方式在黄色的 Scripting 耗时上具有明显优势。

2.5 总结

本课时从深入理解 DOM 的必要性说起,然后分析了 DOM 操作耗时的原因,最后再针对这些原因提出了可行的解决方法。

除了这些方法之外,还有一些原则也可能帮助我们提升渲染性能,比如:

  • 尽量不要使用复杂的匹配规则和复杂的样式,从而减少渲染引擎计算样式规则生成 CSSOM 树的时间
  • 尽量减少重排和重绘影响的区域
  • 使用 CSS3 特性来实现动画效果

希望你首先能理解原因,然后记住这些方法和原则,编写出高性能代码。

最后布置一道思考题:说一说你还知道哪些提升渲染速度的方法和原则?

三、DOM事件

DOM 事件数量非常多,即使分类也有十多种,比如键盘事件、鼠标事件、表单事件等,而且不同事件对象属性也有差异,这带来了一定的学习难度。

但页面要与用户交互,接收用户输入,就离不开监听元素事件,所以,DOM 事件是前端工程师必须掌握的重要内容,同时也是 DOM 的重要组成部分。

下面我们就从防抖、节流、代理 3 个场景出发,详细了解 DOM 事件。

3.1 防抖

概念:函数防抖(debounce),就是指触发事件后,在 n 秒内函数只能执行一次,如果触发事件后在 n 秒内又触发了事件,则会重新计算函数延执行时间。

“函数防抖”的关键在于,在 一个事件 发生 一定时间 之后,才执行 特定动作

试想这样的一个场景,有一个搜索输入框,为了提升用户体验,希望在用户输入后可以立即展现搜索结果,而不是每次输入完后还要点击搜索按钮。最基本的实现方式应该很容易想到,那就是绑定 input 元素的键盘事件,然后在监听函数中发送 AJAX 请求。伪代码如下:

1
2
3
4
5
6
7
8
const ipt = document.querySelector('input')
ipt.addEventListener('input', e => {
search(e.target.value).then(resp => {
// ...
}, e => {
// ...
})
})

但其实这样的写法很容易造成性能问题。比如当用户在搜索“lagou”这个词的时候,每一次输入都会触发搜索:

  1. 搜索“l”
  2. 搜索“la”
  3. 搜索“lag”
  4. 搜索“lago”
  5. 搜索“lagou”

而实际上,只有最后一次搜索结果是用户想要的,前面进行了 4 次无效查询,浪费了网络带宽和服务器资源。

所以对于这类连续触发的事件,需要添加一个“防抖”功能,为函数的执行设置一个合理的时间间隔,避免事件在时间间隔内频繁触发,同时又保证用户输入后能即时看到搜索结果。

要实现这样一个功能我们很容易想到使用 setTimeout() 函数来让函数延迟执行。就像下面的伪代码,当每次调用函数时,先判断 timeout 实例是否存在,如果存在则销毁,然后创建一个新的定时器

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
// 代码1
const ipt = document.querySelector('input')
let timeout = null
ipt.addEventListener('input', e => {
if(timeout) {
clearTimeout(timeout)
timeout = null
}
timeout = setTimeout(() => {
search(e.target.value).then(resp => {
// ...
}, e => {
// ...
})
}, 500)
})

// 代码2
function debounce(fn,wait){
var timer = null;
return function(){
if(timer !== null){
clearTimeout(timer);
}
timer = setTimeout(fn,wait);
}
}

function handle(){
console.log(Math.random());
}

window.addEventListener("resize",debounce(handle,1000));

问题确实是解决了,但这并不是最优答案,或者说我们需对这个防抖操作进行一些“优化”。

试想一下,如果另一个搜索框也需要添加防抖,是不是也要把 timeout 相关的代码再编写一次?而其实这个操作是完全可以抽取成公共函数的。

在抽取成公共函数的同时,还需要考虑更复杂的情况:

  • 参数和返回值如何传递?
  • 防抖化之后的函数是否可以立即执行?
  • 防抖化的函数是否可以手动取消?

具体代码如下所示,首先将原函数作为参数传入 debounce() 函数中,同时指定延迟等待时间,返回一个新的函数,这个函数包含 cancel 属性,用来取消原函数执行。flush 属性用来立即调用原函数,同时将原函数的执行结果以 Promise 的形式返回。

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
// 代码2
const debounce = (func, wait = 0) => {
let timeout = null
let args
function debounced(...arg) {
args = arg
if(timeout) {
clearTimeout(timeout)
timeout = null
}
// 以Promise的形式返回函数执行结果
return new Promise((res, rej) => {
timeout = setTimeout(async () => {
try {
const result = await func.apply(this, args)
res(result)
} catch(e) {
rej(e)
}
}, wait)
})
}
// 允许取消
function cancel() {
clearTimeout(timeout)
timeout = null
}
// 允许立即执行
function flush() {
cancel()
return func.apply(this, args)
}
debounced.cancel = cancel
debounced.flush = flush
return debounced
}
// 防抖处理之后的事件绑定
const ipt = document.querySelector('input')
ipt.addEventListener('input', debounce(e => {
search(e.target.value).then(resp => {
// ...
}, e => {
// ...
})
}, 500))

我们在写代码解决当前问题的时候,最初只能写出像代码 1 那样满足需求的代码。但要成为高级工程师,就一定要将问题再深想一层,比如代码如何抽象成公共函数,才能得到较为完善的代码 2,从而自身得到成长。

关于防抖函数还有功能更丰富的版本,比如 lodashdebounce() 函数,有兴趣的话可以到 GitHub 上查阅资料。

3.2 节流

一般用在,连续的事件只需触发一次回调的场合

现在来考虑另外一个场景,一个左右两列布局的查看文章页面,左侧为文章大纲结构,右侧为文章内容。现在需要添加一个功能,就是当用户滚动阅读右侧文章内容时,左侧大纲相对应部分高亮显示,提示用户当前阅读位置。

这个功能的实现思路比较简单,滚动前先记录大纲中各个章节的垂直距离,然后监听 scroll 事件的滚动距离,根据距离的比较来判断需要高亮的章节。伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 监听scroll事件
wrap.addEventListener('scroll', e => {
let highlightId = ''
// 遍历大纲章节位置,与滚动距离比较,得到当前高亮章节id
for (let id in offsetMap) {
if (e.target.scrollTop <= offsetMap[id].offsetTop) {
highlightId = id
break
}
}
const lastDom = document.querySelector('.highlight')
const currentElem = document.querySelector(`a[href="#${highlightId}"]`)
// 修改高亮样式
if (lastDom && lastDom.id !== highlightId) {
lastDom.classList.remove('highlight')
currentElem.classList.add('highlight')
} else {
currentElem.classList.add('highlight')
}
})

功能是实现了,但这并不是最优方法,因为滚动事件的触发频率是很高的,持续调用判断函数很可能会影响渲染性能。实际上也不需要过于频繁地调用,因为当鼠标滚动 1 像素的时候,很有可能当前章节的阅读并没有发生变化。所以我们可以设置在指定一段时间内只调用一次函数,从而降低函数调用频率,这种方式我们称之为“节流”。

一般这些场合使用到:

  1. 搜索框搜索输入。只需用户最后一次输入完,再发送请求;
  2. 用户名、手机号、邮箱输入验证;
  3. 浏览器窗口大小改变后,只需窗口调整完后,再执行 resize 事件中的代码,防止重复渲染。

实现节流函数的过程和防抖函数有些类似,只是对于节流函数而言,有两种执行方式,在调用函数时执行最先一次调用还是最近一次调用,所以需要设置时间戳加以判断。我们可以基于 debounce() 函数加以修改,代码如下所示:

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
const throttle = (func, wait = 0, execFirstCall) => {
let timeout = null
let args
let firstCallTimestamp


function throttled(...arg) {
if (!firstCallTimestamp) firstCallTimestamp = new Date().getTime()
if (!execFirstCall || !args) {
console.log('set args:', arg)
args = arg
}
if (timeout) {
clearTimeout(timeout)
timeout = null
}
// 以Promise的形式返回函数执行结果
return new Promise(async(res, rej) => {
if (new Date().getTime() - firstCallTimestamp >= wait) {
try {
const result = await func.apply(this, args)
res(result)
} catch (e) {
rej(e)
} finally {
cancel()
}
} else {
timeout = setTimeout(async () => {
try {
const result = await func.apply(this, args)
res(result)
} catch (e) {
rej(e)
} finally {
cancel()
}
}, firstCallTimestamp + wait - new Date().getTime())
}
})
}
// 允许取消
function cancel() {
clearTimeout(timeout)
args = null
timeout = null
firstCallTimestamp = null
}
// 允许立即执行
function flush() {
cancel()
return func.apply(this, args)
}
throttled.cancel = cancel
throttled.flush = flush
return throttled
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 节流版本2
var input = document.getElementById('input')
input.addEventListener('input', throttle(handle, 200))
function throttle(fn,delay){
let timer
let flag = true
return function(...args){
if(!flag){
return
}
flag = false
timer = setTimeout(()=>{
fn.apply(this,args)
flag = true
},delay)
}
}
function handle(e){
console.log(e.target.value)
}

节流与防抖都是通过延迟执行,减少调用次数,来优化频繁调用函数时的性能。不同的是,对于一段时间内的频繁调用,防抖是延迟执行后一次调用,节流是延迟定时多次调用。

3.3 代理

下面的 HTML 代码是一个简单的无序列表,现在希望点击每个项目的时候调用 getInfo() 函数,当点击“编辑”时,调用一个 edit() 函数,当点击“删除”时,调用一个 del() 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<ul class="list">
<li class="item" id="item1">项目1
<span class="edit">编辑</span>
<span class="delete">删除</span>
</li>
<li class="item" id="item2">项目2
<span class="edit">编辑</span>
<span class="delete" >删除</span>
</li>
<li class="item" id="item3">项目3
<span class="edit">编辑</span>
<span class="delete">删除</span>
</li>
...
</ul>

要实现这个功能并不难,只需要对列表中每一项,分别监听 3 个元素的 click 事件即可。

但如果数据量一旦增大,事件绑定占用的内存以及执行时间将会成线性增加,而其实这些事件监听函数逻辑一致,只是参数不同而已。此时我们可以以事件代理事件委托来进行优化。不过在此之前,我们必须先复习一下 DOM 事件的触发流程。

事件触发流程如图 1 所示,主要分为 3 个阶段:

  • 捕获,事件对象 Window 传播到目标的父对象,图 1 的红色过程;
  • 目标,事件对象到达事件对象的事件目标,图 1 的蓝色过程;
  • 冒泡,事件对象从目标的父节点开始传播到 Window,图 1 的绿色过程。

1.png

例如,在下面的代码中,虽然我们第二次进行事件监听时设置为捕获阶段,但点击事件时仍会按照监听顺序进行执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<body>
<button>click</button>
</body>
<script>
document.querySelector('button').addEventListener('click', function () {
console.log('bubble')
})
document.querySelector('button').addEventListener('click', function () {
console.log('capture')
}, true)
// 执行结果
// buble
// capture
</script>

我们再回到事件代理,事件代理的实现原理就是利用上述 DOM 事件的触发流程来对一类事件进行统一处理。比如对于上面的列表,我们在 ul 元素上绑定事件统一处理,通过得到的事件对象来获取参数,调用对应的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const ul = document.querySelector('.list')
ul.addEventListener('click', e => {
const t = e.target || e.srcElement
if (t.classList.contains('item')) {
getInfo(t.id)
} else {
id = t.parentElement.id
if (t.classList.contains('edit')) {
edit(id)
} else if (t.classList.contains('delete')) {
del(id)
}
}
})

虽然这里我们选择了默认在冒泡阶段监听事件,但和捕获阶段监听并没有区别。对于其他情况还需要具体情况具体细分析,比如有些列表项目需要在目标阶段进行一些预处理操作,那么可以选择冒泡阶段进行事件代理。

3.4 补充

关于 DOM 事件标准

你知道下面 3 种事件监听方式的区别吗?

1
2
3
4
5
6
7
8
9
10
// 方式1
<input type="text" onclick="click()"/>
// 方式2
document.querySelector('input').onClick = function(e) {
// ...
}
// 方式3
document.querySelector('input').addEventListener('click', function(e) {
//...
})

方式 1 和方式 2 同属于 DOM0 标准,通过这种方式进行事件监会覆盖之前的事件监听函数。

方式 3 属于 DOM2 标准,推荐使用这种方式。同一元素上的事件监听函数互不影响,而且可以独立取消,调用顺序和监听顺序一致。


Tips: Please indicate the source and original author when reprinting or quoting this article.