前端杂谈: 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

目录
相关文章
|
1月前
|
编解码 前端开发 JavaScript
前端:Rem 及其转换原理
Rem是一种用于前端开发的相对字体大小单位,它基于根元素的字体尺寸来定义文本大小,有助于实现响应式布局和可维护性。Rem的转换原理是通过相对于HTML根元素的字体大小来设置子元素的字体大小,从而实现统一的比例调整,提高页面的适应性和灵活性。此方法简化了跨浏览器和设备的布局调整,增强了用户体验。
|
1月前
|
JavaScript 前端开发 编译器
吐血整理:纯前端如何实现批量dom转图片,并下载成压缩包
【10月更文挑战第2天】吐血整理:纯前端如何实现批量dom转图片,并下载成压缩包
46 2
|
24天前
|
缓存 JavaScript 前端开发
拿下奇怪的前端报错(三):npm install卡住了一个钟- 从原理搞定安装的全链路问题
本文详细分析了 `npm install` 过程中可能出现的卡顿问题及解决方法,包括网络问题、Node.js 版本不兼容、缓存问题、权限问题、包冲突、过时的 npm 版本、系统资源不足和脚本问题等,并提供了相应的解决策略。同时,还介绍了开启全部日志、使用替代工具和使用 Docker 提供 Node 环境等其他处理方法。
291 0
|
25天前
|
存储 安全 前端开发
在前端开发中需要考虑的常见web安全问题和攻击原理以及防范措施
在前端开发中需要考虑的常见web安全问题和攻击原理以及防范措施
117 0
|
2月前
|
移动开发 前端开发 JavaScript
浅谈前端路由原理hash和history
该文章详细解析了前端路由的两种模式——Hash模式与History模式的工作原理及其实现方式,并通过实例代码展示了如何在实际项目中运用这两种路由模式。
|
2月前
|
前端开发 JavaScript
前端基础(七)_DOM元素获取(getElementById、getElementsByTagName、getElementsByClassName、querySelector等)
本文介绍了如何在前端通过不同的方法获取DOM元素,包括getElementById、getElementsByTagName、getElementsByClassName、querySelector和querySelectorAll。
91 3
|
2月前
|
XML 缓存 JavaScript
提升对前端的认知,不得不了解Web API的DOM和BOM
该文章强调了在前端开发中理解和掌握DOM(文档对象模型)和BOM(浏览器对象模型)的重要性,并介绍了它们的相关操作和应用。
提升对前端的认知,不得不了解Web API的DOM和BOM
|
2月前
|
JavaScript 前端开发
前端基础(十)_Dom自定义属性(带案例)
本文介绍了DOM自定义属性的概念和使用方法,并通过案例展示了如何使用自定义属性来控制多个列表项点击变色的独立状态。
41 0
前端基础(十)_Dom自定义属性(带案例)
|
2月前
|
移动开发 缓存 前端开发
构建高效的前端路由系统:从原理到实践
在现代Web开发中,前端路由系统已成为构建单页面应用(SPA)不可或缺的核心技术之一。不同于传统服务器渲染的多页面应用,SPA通过前端路由技术实现了页面的局部刷新与无缝导航,极大地提升了用户体验。本文将深入剖析前端路由的工作原理,包括Hash模式与History模式的实现差异,并通过实战演示如何在Vue.js框架中构建一个高效、可维护的前端路由系统。我们还将探讨如何优化路由加载性能,确保应用在不同网络环境下的流畅运行。本文不仅适合前端开发者深入了解前端路由的奥秘,也为后端转前端或初学者提供了从零到一的实战指南。
|
1月前
|
JavaScript 前端开发 UED
【前端基础篇】JavaScript之DOM介绍2
【前端基础篇】JavaScript之DOM介绍
22 0
下一篇
无影云桌面