前端杂谈: DOM event 原理

简介:

前端杂谈: DOM event 原理

DOM 事件是前端开发者习以为常的东西. 事件的监听和触发使用起来都非常方便, 但是他们的原理是什么呢? 浏览器是怎样处理 event绑定触发的呢?

让我们通过实现一个简单的event 处理函数, 来详细了解一下.

首先, 如何注册 event ?

这个相比大家都很清楚了, 有三种注册方式:

  1. html 标签中注册
<button onclick="alert('hello!');">Say Hello!</button>
  1. 给 DOM 节点的 onXXX 属性赋值
document.getElementById('elementId').onclick = function() {
  console.log('I clicked it!')
}
  1. 使用 addEventListener() 注册事件 (好处是能注册多个 event handler)
document.getElementById('elementId').addEventListener(
  'click',
  function() {
    console.log('I clicked it!')
  },
  false
)

event 在 DOM 节点间是如何传递的呢 ?

简单的来说: event 的传递是 先自顶向下, 再自下而上

完整的来说: event 的传递分为两个阶段: capture 阶段bubble 阶段

让我们来看一个具体的例子:

<html>
  <head> </head>

  <body>
    <div id="parentDiv">
      <a id="childButton" href="https://github.com"> click me! </a>
    </div>
  </body>
</html>

当我们点击上面这段 html 代码中的 a 标签时. 浏览器会首先计算出从 a 标签到 html 标签的节点路径 (即: html => body => div => a).

然后进入 capture 阶段: 依次触发注册在html => body => div => a上的 capture 类型的 click event handler.

到达 a 节点后. 进入 bubble 阶段. 依次出发 a => div => body => html上注册的 bubble 类型的 click event handler.

最后当 bubble 阶段到达 html 节点后, 会出发浏览器的默认行为(对于该例的 a 标签来说, 就是跳转到指定的网页.)

从下图我们可以更直观的看到 event 的传递流程.

FP1SMT.png

那么, 这样的 event 传递流是如何实现的呢?

让我们来看看 addEventListener的代码实现:

HTMLNode.prototype.addEventListener = function(eventName, handler, phase) {
  if (!this.__handlers) this.handlers = {}
  if (!this.__handlers[eventName]) {
    this.__handlers[eventName] = {
      capture: [],
      bubble: []
    }
  }
  this.__handlers[eventName][phase ? 'capture' : 'bubble'].push(handler)
}

上面的代码非常直观, addEventListener 会根据 eventName 和 phase 将 handler 保存在 __handler 数组中, 其中 capture 类型的 handler 和 bubble 类型的 handler 分开保存.

接下来到了本文的核心部分: event 是如何触发 handler 的 ?

为了便于理解, 这里我们尝试实现一个简单版本的 event 出发函数 handler() (这并不是浏览器处理 event 的源码, 但思路是相同的)

首先让我们理清浏览器处理 event 的流程步骤:

  1. 创建 event 对象, 初始化需要的数据
  2. 计算触发 event 事件的 DOM 节点到 html 节点的节点路径 (DOM path)
  3. 触发 capture 类型的 handlers
  4. 触发绑定在 onXXX 属性上的 handler
  5. 触发 bubble 类型的 handlers
  6. 触发该 DOM 节点的浏览器默认行为
1. 创建 event 对象, 初始化需要的数据
function initEvent(targetNode) {
  let ev = new Event()
  ev.target = targetNode // ev.target 是当前用户真正出发的节点
  ;(ev.isPropagationStopped = false), // 是否停止event的传播
    (ev.isDefaultPrevented = false) // 是否阻止浏览器默认的行为

  ev.stopPropagation = function() {
    this.isPropagationStopped = true
  }
  ev.preventDefault = function() {
    this.isDefaultPrevented = true
  }
  return ev
}
2. 计算触发 event 事件的 DOM 节点到 html 节点的节点路径
function calculateNodePath(event) {
  let target = event.target
  let elements = [] // 用于存储从当前节点到html节点的 节点路径
  do elements.push(target)
  while ((target = target.parentNode))
  return elements.reverse() // 节点顺序为: targetElement ==> html
}
3. 触发 capture 类型的 handlers
// 依次触发 capture类型的handlers, 顺序为: html ==> targetElement
function executeCaptureHandlers(elements, ev) {
  for (var i = 0; i < elements.length; i++) {
    if (ev.isPropagationStopped) break

    var curElement = elements[i]
    var handlers =
      (currentElement.__handlers &&
        currentElement.__handlers[ev.type] &&
        currentElement.__handlers[ev.type]['capture']) ||
      []
    ev.currentTarget = curElement
    for (var h = 0; h < handlers.length; h++) {
      handlers[h].call(currentElement, ev)
    }
  }
}
4. 触发绑定在 onXXX 属性上的 handler
function executeInPropertyHandler(ev) {
  if (!ev.isPropagationStopped) {
    ev.target['on' + ev.type].call(ev.target, ev)
  }
}
5. 触发 bubble 类型的 handlers
// 基本上和 capture 阶段处理方式相同
// 唯一的区别是 handlers 是逆向遍历的: targetElement ==> html

function executeBubbleHandlers(elements, ev) {
  elements.reverse()
  for (let i = 0; i < elements.length; i++) {
    if (isPropagationStopped) {
      break
    }
    var handlers =
      (currentElement.__handlers &&
        currentElement.__handlers[ev.type] &&
        currentElement.__handelrs[ev.type]['bubble']) ||
      []
    ev.currentTarget = currentElement
    for (var h = 0; h < handlers.length; h++) {
      handlers[h].call(currentElement, ev)
    }
  }
}
6. 触发该 DOM 节点的浏览器默认行为
function executeNodeDefaultHehavior(ev) {
  if (!isDefaultPrevented) {
    // 对于 a 标签, 默认行为就是跳转链接
    if (ev.type === 'click' && ev.tagName.toLowerCase() === 'a') {
      window.location = ev.target.href
    }
    // 对于其他标签, 浏览器会有其他的默认行为
  }
}
让我们看看完整的调用逻辑:
// 1.创建event对象, 初始化需要的数据
let event = initEvent(currentNode)

function handleEvent(event) {
  // 2.计算触发 event事件的DOM节点到html节点的**节点路径
  let elements = calculateNodePath(event)
  // 3.触发capture类型的handlers
  executeCaptureHandlers(elements, event)
  // 4.触发绑定在 onXXX 属性上的 handler
  executeInPropertyHandler(event)
  // 5.触发bubble类型的handlers
  executeBubbleHandlers(elements, event)
  // 6.触发该DOM节点的浏览器默认行为
  executeNodeDefaultHehavior(event)
}

以上就是当用户出发 DOM event 时, 浏览器的大致处理流程.

propagation && defaultBehavior

我们知道 event 有 stopPropagation()preventDefault() 两个方法, 他们的作用分别是:

stopPropagation()
  • 停止 event 的传播, 从上面代码的可以看出, 调用 stopPropagation() 后, 后续的 handler 将不会被触发.
preventDefault()
  • 不触发浏览器的默认行为. 如: <a> 标签不进行跳转,<form> 标签点击 submit 后不自动提交表单.

当我们需要对 event handler 执行流进行精细操控时, 这两个方法会非常有用.

一些补充~

默认 addEventListener() 最后一个参数为 false

注册 event handler 时, 浏览器默认是注册的 bubble 类型 (即默认情况下注册的 event handler 触发顺序为: 从当前节点到 html 节点)

addEventListener() 的实现是 native code

addEventListener是由浏览器提供的 api, 并非 JavaScript 原生 api. 用户触发 event 时, 浏览器会向 message queue 中加入 task, 并通过 Event Loop 执行 task 实现回调的效果.

reference links:

https://www.bitovi.com/blog/a-crash-course-in-how-dom-events-work

https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events

想了解更多 前端 / D3.js / 数据可视化 ?

这里是我的博客的 github 地址, 欢迎 star & fork :tada:

D3-blog

如果觉得本文不错的话, 不妨点击下面的链接关注一下 : )

github 主页

知乎专栏

掘金

想直接联系我 ?

邮箱: ssthouse@163.com

目录
相关文章
|
24天前
|
移动开发 缓存 前端开发
深入理解前端路由:原理、实现与应用
本书《深入理解前端路由:原理、实现与应用》全面解析了前端路由的核心概念、工作原理及其实现方法,结合实际案例探讨了其在现代Web应用中的广泛应用,适合前端开发者和相关技术人员阅读。
|
1月前
|
缓存 JavaScript 前端开发
JavaScript 与 DOM 交互的基础及进阶技巧,涵盖 DOM 获取、修改、创建、删除元素的方法,事件处理,性能优化及与其他前端技术的结合,助你构建动态交互的网页应用
本文深入讲解了 JavaScript 与 DOM 交互的基础及进阶技巧,涵盖 DOM 获取、修改、创建、删除元素的方法,事件处理,性能优化及与其他前端技术的结合,助你构建动态交互的网页应用。
47 5
|
1月前
|
前端开发 开发者
本文将深入探讨 BEM 的概念、原理以及其在前端开发中的应用
BEM(Block-Element-Modifier)是一种前端开发中的命名规范和架构方法,旨在提高代码的可维护性和复用性。通过将界面拆分为独立的模块,BEM 提供了一套清晰的命名规则,增强了代码的结构化和模块化设计,促进了团队协作。本文深入探讨了 BEM 的概念、原理及其在前端开发中的应用,分析了其优势与局限性,为开发者提供了宝贵的参考。
53 8
|
1月前
|
缓存 前端开发 JavaScript
JavaScript前端路由的实现原理及其在单页应用中的重要性,涵盖前端路由概念、基本原理、常见实现方式
本文深入解析了JavaScript前端路由的实现原理及其在单页应用中的重要性,涵盖前端路由概念、基本原理、常见实现方式(Hash路由和History路由)、优点及挑战,并通过实际案例分析,帮助开发者更好地理解和应用这一关键技术,提升用户体验。
74 1
|
1月前
|
监控 前端开发 jenkins
Jenkins 在前端项目持续部署中的应用,包括其原理、流程以及具体的实现方法
本文深入探讨了Jenkins在前端项目持续部署中的应用,涵盖其基本原理、流程及具体实现方法。首先介绍了Jenkins的基本概念及其在自动化任务中的作用,随后详细解析了从前端代码提交到生产环境部署的全过程,包括构建、测试、部署等关键步骤。最后,强调了持续部署中的代码质量控制、环境一致性、监控预警及安全管理等注意事项,旨在帮助开发者高效、安全地实施持续部署。
65 5
|
2月前
|
编解码 前端开发 JavaScript
前端:Rem 及其转换原理
Rem是一种用于前端开发的相对字体大小单位,它基于根元素的字体尺寸来定义文本大小,有助于实现响应式布局和可维护性。Rem的转换原理是通过相对于HTML根元素的字体大小来设置子元素的字体大小,从而实现统一的比例调整,提高页面的适应性和灵活性。此方法简化了跨浏览器和设备的布局调整,增强了用户体验。
|
2月前
|
JavaScript 前端开发 编译器
吐血整理:纯前端如何实现批量dom转图片,并下载成压缩包
【10月更文挑战第2天】吐血整理:纯前端如何实现批量dom转图片,并下载成压缩包
61 2
|
3月前
|
移动开发 前端开发 JavaScript
浅谈前端路由原理hash和history
该文章详细解析了前端路由的两种模式——Hash模式与History模式的工作原理及其实现方式,并通过实例代码展示了如何在实际项目中运用这两种路由模式。
|
2月前
|
缓存 JavaScript 前端开发
拿下奇怪的前端报错(三):npm install卡住了一个钟- 从原理搞定安装的全链路问题
本文详细分析了 `npm install` 过程中可能出现的卡顿问题及解决方法,包括网络问题、Node.js 版本不兼容、缓存问题、权限问题、包冲突、过时的 npm 版本、系统资源不足和脚本问题等,并提供了相应的解决策略。同时,还介绍了开启全部日志、使用替代工具和使用 Docker 提供 Node 环境等其他处理方法。
1382 0
|
3月前
|
前端开发 JavaScript
前端基础(七)_DOM元素获取(getElementById、getElementsByTagName、getElementsByClassName、querySelector等)
本文介绍了如何在前端通过不同的方法获取DOM元素,包括getElementById、getElementsByTagName、getElementsByClassName、querySelector和querySelectorAll。
179 3