DOM基本功,你掌握了多少

1,674 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第14天,点击查看活动详情

理解DOM

文档对象模型 (DOM) 是HTML和XML文档的编程接口。它提供了对文档的结构化的表述,并定义了一种方式可以使从程序中对该结构进行访问,从而改变文档的结构,样式和内容。DOM 将文档解析为一个由节点和对象(包含属性和方法的对象)组成的结构集合。简言之,它会将web页面和脚本或程序语言连接起来。 ——MDN

DOM 的概念看似抽象,简单来说就是确保开发者可以通过 JS 脚本来操作 HTML

DOM 树的解析

在 DOM 中,每个元素都是一个节点,节点类型细数起来可以有很多种,我们这里强调以下 4 种:

Document

Document 就是指这份文件,也就是这份 HTML 档的开端。当浏览器载入 HTML 文档, 它就会成为 Document 对象

Element

Element 就是指 HTML 文件内的各个标签,像是<div>、<span>这样的各种 HTML 标签定义的元素都属于 Element 类型。

Text

Text 就是指被各个标签包起来的文字,举个例子:<span>哈哈哈</span>,这里的“哈哈哈”被 <span> 标签包了起来,它就是这个 Element 的 Text。

Attribute

Attribute 类型表示元素的特性。从技术角度讲,这里的特性就是说各个标签里的属性。

DOM 节点间关系

在树状结构的 DOM 里,节点间关系可以划分为以下两类:

  • 父子节点:表示节点间的嵌套关系
  • 兄弟节点:表示节点层级的平行关系,兄弟节点共享一个父节点

image.png

DOM节点的增删改查

增:DOM 节点的创建

// 首先获取父节点
var container = document.getElementById('container')
// 创建新节点
var targetSpan = document.createElement('span')
// 设置 span 节点的内容
targetSpan.innerHTML = 'hello world'
// 把新创建的元素塞进父节点里去
container.appendChild(targetSpan)

删:DOM 节点的删除

// 获取目标元素的父元素
var container = document.getElementById('container')
// 获取目标元素
var targetNode = document.getElementById('title')
// 删除目标元素
container.removeChild(targetNode)

改:修改 DOM 元素

修改 DOM 元素这个动作可以分很多维度,比如说移动 DOM 元素的位置,修改 DOM 元素的属性等。

现在需要调换 title 和 content 的位置,我们可以考虑 insertBefore 或者 appendChild。这里给出 insertBefore 的操作示范:

// 获取父元素
var container = document.getElementById('container')   
// 获取两个需要被交换的元素
var title = document.getElementById('title')
var content = document.getElementById('content')
// 交换两个元素,把 content 置于 title 前面
container.insertBefore(content, title)

DOM 元素属性的获取和修改

var title = document.getElementById('title')
// 获取 id 属性
var titleId = title.getAttribute('id')
// 修改 id 属性
title.setAttribute('id', 'anothorTitle')

DOM 事件体系

事件流

W3C 标准约定了一个事件的传播过程要经过以下三个阶段:

  1. 事件捕获阶段
  2. 目标阶段
  3. 事件冒泡阶段

image.png

为什么会有捕获过程和冒泡过程

我们现代的 UI 系统,都源自 WIMP 系统。WIMP 是如此成功,以至于今天很多的前端工程师会有一个观点,认为我们能够"点击一个按钮",实际上并非如此,我们只能够点击鼠标上的按钮或者触摸屏,是操作系统和浏览器把这个信息对应到了一个逻辑上的按钮,再使得它的视图对点击事件有反应。这就引出了:捕获与冒泡。

实际上点击事件来自触摸屏或者鼠标,鼠标点击并没有位置信息,但是一般操作系统会根据位移的累积计算出来,跟触摸屏一样,提供一个坐标给浏览器

那么,把这个坐标转换为具体的元素上事件的过程,就是捕获过程了。而冒泡过程,则是符合人类理解逻辑的:当你按电视机开关时,你也按到了电视机。

所以我们可以认为,捕获是计算机处理事件的逻辑,而冒泡是人类处理事件的逻辑。

上面讲的都是pointer 事件,它是由坐标控制,这里我们也提一下键盘事件,也成为焦点

键盘事件是由焦点系统控制的,一般来说,操作系统也会提供一套焦点系统,但是现代浏览器一般都选择在自己的系统内覆盖原本的焦点系统。

焦点系统认为整个 UI 系统中,有且仅有一个"聚焦"的元素,所有的键盘事件的目标元素都是这个聚焦元素。

Tab 键被用来切换到下一个可聚焦的元素,焦点系统占用了 Tab 键,但是可以用 JavaScript 来阻止这个行为。浏览器 API 还提供了 API 来操作焦点,如:

document.body.focus();
document.body.blur();

事件对象

currentTarget

它记录了事件当下正在被哪个元素接收,即正在经过哪个元素。这个元素是一直在改变的,因为事件的传播毕竟是个层层穿梭的过程。

如果事件处理程序绑定的元素,与具体的触发元素是一样的,那么函数中的 this、event.currentTarget、和 event.target 三个值是相同的。我们可以以此为依据,判断当前的元素是否就是目标元素。

target

指触发事件的具体目标,也就是最具体的那个元素,是事件的真正来源。

就算事件处理程序没有绑定在目标元素上、而是绑定在了目标元素的父元素上,只要它是由内部的目标元素冒泡到父容器上触发的,那么我们仍然可以通过 target 来感知到目标元素才是事件真实的来源。

自定义事件

现在想实现这样一种效果:在点击A之后,B 和 C 都能感知到 A 被点击了,并且做出相应的行为——就像这个点击事件是点在 B 和 C 上一样。

<body>
  <div id="divA">我是A</div>
  <div id="divB">我是B</div>
  <div id="divC">我是C</div>
</body>

我们知道,借助事件捕获和冒泡的特性,我们是可以实现父子元素之间的行为联动的。但是此处,A、B、C三者位于同一层级,他们怎么相互感知对方身上发生了什么事情呢?

首先要创建一个本来不存在的"clickA"事件,来表示 A 被点击了,可以这么写:

var clickAEvent = new Event('clickA');

然后完成事件的监听和派发:

// 获取 divB 元素 
var divB = document.getElementById('divB')
// divB 监听 clickA 事件
divB.addEventListener('clickA',function(e){
  console.log('我是小B,我感觉到了小A')
  console.log(e.target)
}) 

// 获取 divC 元素
var divC = document.getElementById('divC')
// divC 监听 clickA 事件
divC.addEventListener('clickA',function(e){
  console.log('我是小C,我感觉到了小A')
  console.log(e.target)
}) 

// A 元素的监听函数也得改造下
divA.addEventListener('click',function(){
  console.log('我是小A')
  // 注意这里 dispatch 这个动作,就是我们自己派发事件了
  divB.dispatchEvent(clickAEvent)
  divC.dispatchEvent(clickAEvent)
})  

事件代理

我希望做到点击每一个 li 元素,都能输出它内在的文本内容。

<ul id="poem">
    <li>鹅鹅鹅</li>
    <li>曲项向天歌</li>
    <li>白毛浮绿水</li>
    <li>红掌拨清波</li>
    <li>锄禾日当午</li>
  </ul>

一个比较直观的思路是让每一个 li 元素都去监听一个点击动作:

// 获取 li 列表
  var liList = document.getElementsByTagName('li')
  // 逐个安装监听函数
  for (var i = 0; i < liList.length; i++) {
    liList[i].addEventListener('click', function (e) {
      console.log(e.target.innerHTML)
    })
  }

这个时候我们可以使用事件代理:

var ul = document.getElementById('poem')
ul.addEventListener('click', function(e){
  console.log(e.target.innerHTML)
}) 

e.target 就是指触发事件的具体目标,它记录着事件的源头。所以说,不管咱们的监听函数在哪一层执行,只要我拿到这个 e.target,就相当于拿到了真正触发事件的那个元素。拿到这个元素后,我们完全可以模拟出它的行为,实现无差别的监听效果。

像这样利用事件的冒泡特性,把多个子元素的同一类型的监听逻辑,合并到父元素上通过一个监听函数来管理的行为,就是事件代理。通过事件代理,我们可以减少内存开销、简化注册步骤,大大提高开发效率。

事件的防抖与节流

事件节流-throttle:第一个说来算

简单理解:节流就是在一段时间中只发生一次回调,而且是第一次触发的回调,在这段时间后面触发的都不执行。

比如在滚动事件中,我要实时地知道滚动的距离,但是我其实只要500ms知道一次滚动的距离,但是500ms我们触发了很多次回调,所以这就可以用节流。

现在一起实现一个 throttle:

// fn是我们需要包装的事件回调, interval是时间间隔的阈值
function throttle(fn, interval) {
  // last为上一次触发回调的时间
  let last = 0
  
  // 将throttle处理结果当作函数返回
  return function () {
      // 保留调用时的this上下文
      let context = this
      // 保留调用时传入的参数
      let args = arguments
      // 记录本次触发回调的时间
      let now = +new Date()
      
      // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
      if (now - last >= interval) {
      // 如果时间间隔大于我们设定的时间间隔阈值,则执行回调
          last = now;
          fn.apply(context, args);
      }
    }
}
// 用throttle来包装scroll的回调
const better_scroll = throttle(() => console.log('触发了滚动事件'), 1000)
document.addEventListener('scroll', better_scroll)

事件防抖-Debounce: 最后一个人说了算

比如用户在输入框输入搜索的关键字,我们不能每输入一个字就去调一次接口,所以需要在一个时间间隔中使用最后输入的关键字去调一次接口即可,这就是防抖。

// fn是我们需要包装的事件回调, delay是每次推迟执行的等待时间
function debounce(fn, delay) {
  // 定时器
  let timer = null
  
  // 将debounce处理结果当作函数返回
  return function () {
    // 保留调用时的this上下文
    let context = this
    // 保留调用时传入的参数
    let args = arguments
    // 每次事件被触发时,都去清除之前的旧定时器
    if(timer) {
        clearTimeout(timer)
    }
    // 设立新定时器
    timer = setTimeout(function () {
      fn.apply(context, args)
    }, delay)
  }
}
// 用debounce来包装scroll的回调
const better_scroll = debounce(() => console.log('触发了滚动事件'), 1000)
document.addEventListener('scroll', better_scroll)