原生JS与React的事件差异

在从原生 JavaScript 转向 React 开发时,我曾对事件处理产生过不少困惑:为什么 React 的事件写法和原生类似,行为却有差异?为什么有时候 e.target 会莫名变成 null?为什么父组件的事件会比子组件先触发?

带着这些疑问,我翻了不少文档逐渐理清了原生JS和React事件中的差异。下面我将成果分享给大家!

原生 JavaScript 事件机制

DOM0 与 DOM2

DOM 标准的发展催生了不同的事件处理模型。DOM0 级事件作为最早的实现方式,通过直接赋值事件属性完成绑定:

js 复制代码
// HTML 内联方式
<button onclick="handleClick()">点击</button>

// JS 赋值方式
const btn = document.querySelector('button');
btn.onclick = handleClick;

DOM0 级事件的局限性显而易见:同一事件只能绑定一个处理函数,后续绑定会覆盖前者,且缺乏事件阶段控制能力。

DOM2 级事件 通过 addEventListener 方法实现了更完善的事件处理机制,其函数签名为:

js 复制代码
target.addEventListener(type, listener, useCapture);

type:事件类型(如 clickkeydown

listener:事件处理函数

useCapture:布尔值,指定事件在捕获阶段(true)还是冒泡阶段(false)触发,默认值为 false

DOM2 级事件支持为同一元素的同一事件绑定多个处理函数,且能通过 removeEventListener 精准移除,解决了 DOM0 级的核心痛点。

为什么没有DOM1级事件?

这是因为DOM1专注文档结构标准化,未定义事件模型。当时浏览器厂商已有各自事件实现(如DOM0的onclick),W3C暂未统一,直到DOM2(2000年)才标准化addEventListener。

事件流

浏览器对事件的处理遵循事件流模型,完整流程包含三个阶段:

  1. 捕获阶段
    事件从最顶层的 document 开始,逐层向下传播至目标元素的父节点,此阶段的目的是让上层节点有机会提前拦截事件。
  2. 目标阶段
    事件到达实际触发的目标元素(event.target),此时无论 useCapture 为何值,都会执行该元素上的事件处理函数。
  3. 冒泡阶段
    事件从目标元素逐层向上传播至 document,这是事件委托机制的基础。

通过以下代码示例可清晰观察事件流执行顺序:

html 复制代码
<div class="outer">
  <div class="inner">点击区域</div>
</div>
js 复制代码
const outer = document.querySelector('.outer');
const inner = document.querySelector('.inner');

// 捕获阶段触发
outer.addEventListener('click', () => console.log('outer capture'), true);
// 冒泡阶段触发
outer.addEventListener('click', () => console.log('outer bubble'), false);
// 目标元素事件
inner.addEventListener('click', () => console.log('inner target'), false);

点击 inner 元素后,控制台输出顺序为:

kotlin 复制代码
outer capture  // 捕获阶段:从外层到内层
inner target   // 目标阶段:触发目标元素事件
outer bubble   // 冒泡阶段:从内层到外层

理解事件流是掌握事件委托、阻止冒泡等高级用法的前提。

事件委托

事件委托(Event Delegation) 利用事件冒泡特性,将子元素的事件处理委托给父元素,通过 event.target 识别具体触发元素。其核心实现如下:

html 复制代码
<div id="root">
    <ul id="myList">
        <li data-item="123">Item 1</li>
        <li data-item="456">Item 2</li>
        <li data-item="789">Item 3</li>
        <li data-item="012">Item 4</li>
    </ul>
    <div id="container" data-item="ab">hello</div>
</div>
js 复制代码
document.getElementById('root').addEventListener('click', function(event) {
    if (event.target.tagName === 'LI') {
        console.log('Clicked on:', event.target.textContent);
    }   
    if(event.target.dataset.item === 'ab')
    {
        console.log('Clicked on:', event.target.textContent);
        const newLi = document.createElement('li');
        newLi.appendChild(
            document.createTextNode('item-new')
        )
        newLi.addEventListener('click', function() {
            console.log('haha');
        })
        document.getElementById('myList').appendChild(newLi)
    }

});

以上面的代码为例,如果我们要给所有li元素添加点击事件,最直观的做法可能是循环遍历每个li,逐个绑定事件处理函数。但这样做存在明显的隐患:当列表项数量庞大(比如成百上千个)时,每一个li都会创建一个独立的事件处理函数并与 DOM 节点绑定,这不仅会占用大量内存,还会增加页面初始化时的加载时间 ------ 毕竟 DOM 操作本身就是性能消耗的重灾区。

而我们采取的方法则巧妙得多:我们只需要给所有li的共同祖先元素root注册一次点击事件,就能实现对所有li的事件监听。这背后的核心原理就是事件委托机制 ------ 利用 DOM 事件的冒泡特性,当点击某个li时,事件会从这个li逐级向上冒泡,最终被root元素的事件处理函数捕获。

root的事件处理函数中,我们通过event.target可以精准定位到实际被点击的li元素(event.target始终指向触发事件的最具体节点)。再通过判断event.target.tagName === 'LI',就能确保只有点击li时才执行对应的逻辑,完全等效于给每个li单独绑定事件的效果,但只需要一次事件绑定操作。

这种方式的优势不止于性能优化:当页面存在动态生成的li元素时(比如代码中点击data-item="ab"div后新增的li),这些新元素无需重新绑定事件,因为它们的点击事件会自动冒泡到root,被早已注册好的事件处理函数捕获并处理。这就避免了动态元素频繁绑定 / 解绑事件的繁琐操作,也减少了因忘记解绑事件而导致内存泄漏的风险。

事件委托的技术优势:

  1. 性能优化
    减少事件绑定数量,尤其在列表等包含大量子元素的场景中,可显著降低内存占用与初始化时间。
  2. 动态元素支持
    对于通过 AJAX 动态加载的元素,无需重新绑定事件,父元素的委托机制天然支持新元素的事件处理。
  3. 统一管理
    集中处理同类事件,便于统一添加日志、权限校验等横切逻辑。

还有一点要说的是,事件委托常与 data-* 属性结合使用,通过唯一标识快速定位目标元素,通过上面的代码也能看出,每一个元素都有一个唯一标识

阻止冒泡

html 复制代码
<div id="toggleBtn">Toggle Menu</div>
<div id="menu">
    <p>Menu Context</p>
    <a href="www.baidu.com" id="closeInside">Don't close</a>
</div>
js 复制代码
const toggleBtn = document.getElementById('toggleBtn');
const menu = document.getElementById('menu');
const closeInside = document.getElementById('closeInside');

toggleBtn.addEventListener('click',function(e){
    e.stopPropagation()
   menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
})

document.addEventListener('click',function(e){
    menu.style.display = 'none';
})
closeInside.addEventListener('click',function(e){
    e.preventDefault()
    e.stopPropagation();
    alert('Menu button onClicked')
})

这段代码实现了一个简单的菜单交互:

  • 点击toggleBtn("Toggle Menu" 按钮),菜单menu会切换显示 / 隐藏状态
  • 点击页面其他区域(除了菜单内部和按钮),菜单会隐藏
  • 点击菜单内的closeInside链接,会弹出提示,但不会跳转,也不会关闭菜单

实现上面的功能有两个关键点

  1. preventDefault():阻止默认行为。

每个 HTML 元素都有其默认行为(比如链接点击会跳转、表单提交会刷新页面)。preventDefault()的作用是取消元素的默认行为 ,但不会影响事件的冒泡传播。代码中closeInside(菜单内的链接)的点击事件处理函数用到了这个方法,链接<a href="www.baidu.com">的默认行为是点击后跳转到www.baidu.com,加上e.preventDefault()后,点击链接不会跳转,只会执行后续的alert提示,如果去掉这句,点击链接会先弹出提示,然后跳转到百度,这显然不符合 "Don't close" 的预期。

  1. stopPropagation():阻止事件冒泡

事件冒泡是指事件从触发元素开始,逐级向上传播到父元素、根元素的过程。stopPropagation()的作用就是中断这个传播过程 ,让事件不再向上传递。当我们点击toggleBtn时,会触发按钮的click事件, 如果没有e.stopPropagation(),这个click事件会继续向上冒泡,最终被documentclick事件监听器捕获,而document的事件处理函数会执行menu.style.display = 'none',导致刚打开的菜单立刻被关闭。正因为e.stopPropagation()阻止了事件冒泡,toggleBtn的点击事件才不会传递到document,菜单才能正常切换显示状态。

阻止冒泡的优势

  1. 避免上级事件误触发:防止事件向上传播导致父元素的同类型事件被意外执行(如弹窗内操作不触发外部关闭逻辑)。
  2. 提升处理精准性:在复杂嵌套结构中,确保事件仅作用于目标元素,避免上级逻辑干扰。
  3. 优化性能:减少不必要的事件传播,避免冗余处理函数执行

React 事件机制

React 并未直接使用原生 DOM 事件,而是构建了一套合成事件系统(SyntheticEvent) ,在保持开发体验一致的同时,解决了跨浏览器兼容性问题,并提供了性能优化手段。

为什么需要合成事件?

最核心的原因有两个:

1. 跨浏览器兼容性

不同浏览器的事件实现存在差异(比如 IE 浏览器的 attachEvent 和标准的 addEventListener),React 通过合成事件将这些差异抹平,让开发者不用关心底层浏览器的实现细节。

2. 性能优化

React 会将所有事件委托到顶层容器(在 React 17 之前是 document,之后改为 React 根节点),而不是每个元素单独绑定事件。这种设计大幅减少了 DOM 事件绑定的数量,尤其在大型应用中能显著提升性能。 举个例子,一个包含 1000 个列表项的组件,原生开发可能需要绑定 1000 个 click 事件,而 React 只会在根节点绑定 1 个事件,通过事件委托处理所有列表项的点击。

合成事件的核心特性

1. 与原生事件相似的 API

React 合成事件的接口设计和原生事件几乎一致,比如都有 e.targete.preventDefault()e.stopPropagation() 等,这让开发者能快速上手。

但要注意,合成事件是 React 自己实现的对象,并非原生 DOM 事件对象。不过可以通过 e.nativeEvent 属性获取原生事件:

jsx 复制代码
const handleClick = (e) => {
  console.log(e instanceof SyntheticEvent); // true
  console.log(e.nativeEvent instanceof Event); // true(原生事件对象)
};

2. 事件池(Event Pooling)

为减少内存分配与垃圾回收开销,React 会对合成事件对象进行复用。事件处理函数执行完毕后,事件对象的属性会被清空并放回事件池。这也是为什么在异步操作中直接访问事件属性会得到 null

js 复制代码
const handleClick = (e) => {
  console.log(e.target); // 正常输出
  setTimeout(() => {
    console.log(e.target); //  null
  }, 0);
};

第一种解决方案是提前保存需要的属性

jsx 复制代码
const handleClick = (e) => {
  const target = e.target; // 提前保存 e.target
  setTimeout(() => {
    console.log(target); // 正常输出
  }, 0);
};

;第二种解决方案是使用 e.persist() 方法将事件对象从池中取出:

js 复制代码
const handleClick = (e) => {
  e.persist(); // 保留事件对象
  setTimeout(() => {
    console.log(e.target); // 正常输出
  }, 0);
};

注意:React 17 及以上版本对事件池机制进行了调整,大部分场景下无需手动调用 persist(),但了解其原理仍有助于理解历史代码。

React 事件的执行流程

  1. 事件捕获
    原生 DOM 事件触发后,冒泡至 React 顶层容器,被 React 事件系统捕获。
  2. 事件分发
    React 内部维护了事件插件系统(Event Plugins),根据事件类型(如 onClickonChange)找到对应的处理插件,生成合成事件对象。
  3. 模拟事件流
    React 会模拟原生事件的捕获与冒泡流程,依次执行组件树中对应的事件处理函数。与原生事件不同的是,React 事件的冒泡仅在虚拟 DOM 层面进行,不会影响原生 DOM 事件流。
  4. 事件处理
    执行用户定义的事件处理函数,传入合成事件对象,该对象与原生事件对象类似,包含 targetpreventDefault() 等常用属性与方法。
jsx 复制代码
import React from 'react';

function EventFlowExample() {
  // 父组件事件处理函数(冒泡阶段)
  const handleParentBubble = (e) => {
    console.log('父冒泡:', e.target.textContent);
  };

  // 父组件事件处理函数(捕获阶段)
  const handleParentCapture = (e) => {
    console.log('父捕获:', e.target.textContent);
  };

  // 子组件事件处理函数(冒泡阶段)
  const handleChildBubble = (e) => {
    console.log('子冒泡:', e.target.textContent);
    // 尝试阻止冒泡(仅影响 React 事件流)
    // e.stopPropagation();
  };

  // 子组件事件处理函数(捕获阶段)
  const handleChildCapture = (e) => {
    console.log('子捕获:', e.target.textContent);
  };

  return (
    <div 
      className="parent" 
      onClick={handleParentBubble}
      onClickCapture={handleParentCapture}
    >
      Parent Element
      <div 
        className="child" 
        onClick={handleChildBubble}
        onClickCapture={handleChildCapture}
      >
        Child Element
      </div>
    </div>
  );
}

输出顺序是

makefile 复制代码
父捕获: Child Element
子捕获: Child Element
子冒泡: Child Element
父冒泡: Child Element

React 事件与原生事件的交互

在实际开发中,难免会遇到 React 事件与原生事件混用的场景,最需要注意的是两者的执行顺序和冒泡行为:

  1. 执行顺序
    原生事件的处理函数会比 React 事件的处理函数先执行。 因为 React 事件依赖原生事件冒泡到根节点才会触发,而原生事件在冒泡过程中就会执行自己的处理函数。
jsx 复制代码
const App = () => {
  useEffect(() => {
    // 原生事件
    document.body.addEventListener('click', () => {
      console.log('原生事件');
    }, false);
  }, []);

  // React 事件
  const handleClick = () => {
    console.log('React 事件');
  };

  return <button onClick={handleClick}>点击</button>;
};

点击按钮后,输出顺序为:原生事件React 事件。若在原生事件中添加 e.stopPropagation(),则 React 事件不会触发。

  1. 冒泡阻止
  • 原生事件中调用 e.stopPropagation() 会阻止事件冒泡至顶层容器,导致 React 事件无法触发。

  • React 合成事件中调用 e.stopPropagation() 仅阻止 React 内部的事件冒泡,不会影响原生事件流, 如需同时阻止原生事件,需使用 e.nativeEvent.stopPropagation()

总结

从原生 JS 的事件委托到 React 的事件系统,核心思想是一致的:利用事件冒泡,通过父元素处理子元素的事件,优化性能和扩展性。希望这篇文章能帮助和我代码开发者少走弯路,夯实基础。如果有不对的地方,欢迎大神在评论区指正~

相关推荐
姑苏洛言31 分钟前
编写产品需求文档:黄历日历小程序
前端·javascript·后端
知识分享小能手1 小时前
Vue3 学习教程,从入门到精通,使用 VSCode 开发 Vue3 的详细指南(3)
前端·javascript·vue.js·学习·前端框架·vue·vue3
姑苏洛言1 小时前
搭建一款结合传统黄历功能的日历小程序
前端·javascript·后端
hackchen2 小时前
Go与JS无缝协作:Goja引擎实战之错误处理最佳实践
开发语言·javascript·golang
你的人类朋友2 小时前
🤔什么时候用BFF架构?
前端·javascript·后端
知识分享小能手3 小时前
Bootstrap 5学习教程,从入门到精通,Bootstrap 5 表单验证语法知识点及案例代码(34)
前端·javascript·学习·typescript·bootstrap·html·css3
一只小灿灿3 小时前
前端计算机视觉:使用 OpenCV.js 在浏览器中实现图像处理
前端·opencv·计算机视觉
前端小趴菜053 小时前
react状态管理库 - zustand
前端·react.js·前端框架
Jerry Lau4 小时前
go go go 出发咯 - go web开发入门系列(二) Gin 框架实战指南
前端·golang·gin