如何绕过 React 的事件系统:在什么场景下我们需要直接给 DOM 绑定原生事件(AddEventListener)?

各位同仁,大家好。

在前端开发的浩瀚宇宙中,React 框架以其声明式、组件化的开发范式,彻底改变了我们构建用户界面的方式。其事件系统作为核心组成部分之一,为开发者提供了极大的便利:它抹平了浏览器差异,优化了性能,并与虚拟 DOM 紧密集成。然而,如同任何强大的工具一样,React 的事件系统也有其设计边界。在某些特定的、对性能或底层控制有极致要求的场景下,我们可能需要暂时绕开这层抽象,直接与浏览器原生的 DOM 事件打交道。

今天,我将带领大家深入探讨 React 事件系统的运作机制,剖析在哪些场景下,以及如何安全、高效地直接绑定原生 DOM 事件,同时避免潜在的陷阱。

一、 React 事件系统的内部运作机制

要理解何时以及为何绕过 React 的事件系统,我们首先需要对其工作原理有一个清晰的认识。React 的事件系统并非简单地将事件监听器直接绑定到每个 DOM 元素上,而是采用了一种更巧妙、更高效的策略。

1.1 合成事件 (SyntheticEvent)

当我们编写 <button onClick={handleClick}> 这样的 JSX 代码时,handleClick 接收到的并不是一个浏览器原生的 MouseEvent 对象,而是一个 React 封装过的 SyntheticEvent 对象。

SyntheticEvent 的特点:

  • 跨浏览器一致性: React 抹平了不同浏览器在事件对象属性上的差异,确保在所有环境中,你都能以一致的方式访问 event.targetevent.preventDefault() 等属性和方法。
  • 性能优化 (事件池 – Event Pooling): 在 React 16 及更早版本中,SyntheticEvent 对象是会被放入一个池子中循环使用的。这意味着事件处理函数执行完毕后,事件对象的属性会被重置,并返还给池子,以减少垃圾回收的压力。因此,如果你需要在异步操作中访问事件对象,必须调用 event.persist()
  • React 17+ 的变化: 从 React 17 开始,事件池机制已被移除。SyntheticEvent 对象现在与原生事件对象保持一致的生命周期,你不再需要调用 event.persist() 来保留事件。

1.2 事件委托 (Event Delegation)

React 事件系统最核心的优化之一就是事件委托。React 并不会在 JSX 中声明的每个元素上都直接绑定事件监听器。相反,它在应用程序的根 DOM 节点(通常是 document 对象,在 React 17+ 中是 ReactDOM.render 挂载的容器 DOM 节点)上注册一个单一的事件监听器。

工作流程:

  1. 当一个原生 DOM 事件(例如 click)在页面上触发时,它会按照 DOM 标准的事件传播机制(捕获阶段 -> 目标阶段 -> 冒泡阶段)进行传播。
  2. 事件最终会冒泡到 React 在根节点上注册的监听器。
  3. React 的事件系统会拦截这个原生事件,并根据事件的 target 属性,模拟出事件是从哪个 React 组件触发的。
  4. 然后,React 会查找该组件及其祖先组件中定义的相应合成事件处理函数(例如 onClick),并以模拟的冒泡顺序执行它们。

事件委托的优势:

  • 减少内存消耗: 无需为每个可交互元素都创建独立的事件监听器。
  • 简化事件管理: 动态添加或移除元素时,无需手动管理事件监听器的绑定和解绑。
  • 性能提升: 事件处理逻辑集中,减少了浏览器事件系统的负担。

1.3 事件传播与阻止

在 React 中,我们常用的 e.stopPropagation()e.preventDefault() 方法,操作的实际上是 SyntheticEvent 对象。

  • e.stopPropagation():阻止当前合成事件继续向上冒泡到父组件的 React 事件处理函数。
  • e.preventDefault():阻止浏览器对原生事件的默认行为(例如,点击链接的跳转、提交表单的刷新)。

一个重要的点: e.stopPropagation() 仅阻止合成事件在 React 内部的传播。它并不会阻止原生 DOM 事件的进一步冒泡到 DOM 树中更高层级的原生事件监听器,除非 React 的事件处理函数被执行,并且该合成事件被标记为已停止传播。在 React 17 之前,由于事件监听器都在 document 上,stopPropagation 会在 React 的根监听器处理完后,阻止原生事件继续冒泡到 document 上的其他原生监听器。但在 React 17 及以后,由于事件监听器被绑定到 React 渲染的根节点,stopPropagation 仅阻止 React 内部的事件传播,原生事件仍可能继续冒泡到 documentwindow 上的其他原生监听器。

为了更清晰地理解,我们可以通过一个表格来对比 React 事件系统的一些关键特性:

特性 React 合成事件系统 原生 DOM 事件系统
事件对象 SyntheticEvent,封装原生事件,提供跨浏览器一致性 浏览器原生事件对象(如 MouseEvent, KeyboardEvent
绑定方式 JSX 属性(如 onClick),内部通过事件委托实现 element.addEventListener('click', handler)
监听位置 在 React 应用程序的根 DOM 节点(React 17+)或 document (React 16 及之前) 直接绑定到指定 DOM 元素,或通过事件委托手动实现
事件池 React 16 及之前有,React 17+ 移除 无此概念,事件对象生命周期与事件本身一致
stopPropagation 阻止合成事件在 React 内部的传播 阻止原生事件在 DOM 树中的进一步传播
preventDefault 阻止原生事件的默认行为,但通过 SyntheticEvent 调用 阻止原生事件的默认行为
性能 通过事件委托优化,减少监听器数量 每个监听器独立,可能导致内存开销,但提供精细控制

二、 为什么我们需要绕过 React 事件系统?场景分析

尽管 React 的事件系统强大且高效,但在某些特定场景下,其抽象层和委托机制可能无法满足我们的需求,甚至成为性能瓶颈或功能障碍。以下是几种常见的情况:

2.1 性能敏感的交互:高频事件与精确控制

某些事件的触发频率极高,例如 mousemovescrollresize。如果这些事件通过 React 的合成事件系统处理,每次事件触发都可能引发 React 内部的调度、合成事件对象的创建与销毁(React 16-)、以及潜在的虚拟 DOM 协调,这会带来不必要的开销。

具体场景:

  • 实时拖拽与缩放: 当用户拖动一个元素时,mousemove 事件可能每秒触发几十甚至上百次。如果每次都经过 React 的完整事件处理流程,可能会导致明显的卡顿。直接监听原生 mousemove 事件,并在回调中直接操作 DOM,可以显著提高响应速度。
  • 无限滚动或虚拟化列表: 监听 scroll 事件来判断何时加载更多数据或更新可见区域。原生监听器结合节流(throttle)或防抖(debounce)可以更高效地控制回调执行频率,避免不必要的渲染。
  • 窗口尺寸变化 (resize): 调整浏览器窗口大小时,resize 事件也会高频触发。

通过直接绑定原生事件,我们可以:

  • 避免 React 的调度开销: 事件回调可以直接执行,无需等待 React 的事件队列处理。
  • 精细控制节流与防抖: 在原生事件监听器中,我们可以更直接地应用节流和防抖函数,确保事件处理逻辑在可控的频率下执行。

2.2 与第三方库集成:DOM 操作与事件冲突

许多第三方 JavaScript 库(特别是那些不基于 React 的)会直接操作 DOM 元素,并且可能期望直接监听原生 DOM 事件。当 React 的事件系统与这些库的事件处理逻辑发生冲突时,问题就出现了。

具体场景:

  • 地图库(如 Leaflet, OpenLayers): 这些库通常会创建自己的地图容器,并在其上监听各种鼠标、触摸事件来实现地图的平移、缩放等功能。如果 React 也在这些 DOM 元素上监听事件,可能会导致行为冲突或事件被阻止。
  • 图表库(如 ECharts, D3.js): 这些库通常会在 <canvas><svg> 元素上绘制,并监听鼠标事件实现交互(如 Tooltip 悬浮、数据点点击)。
  • 拖拽库(如 interact.js, Draggable): 它们通过监听一系列原生事件(mousedown, mousemove, mouseup)来管理拖拽状态。
  • 富文本编辑器(如 TinyMCE, Quill): 这些编辑器会完全接管其容器 DOM 元素的输入和事件处理,React 的事件系统介入可能会干扰其内部逻辑。

在这种情况下,直接绑定原生事件,或者在 React 组件的生命周期中将 DOM 引用传递给第三方库,让库自行管理事件,是更合理的选择。这确保了第三方库能够按照其设计预期运行,避免 React 的合成事件系统对其内部机制的干预。

2.3 底层 DOM 操作和原生事件特性

某些特定的 DOM 事件或事件特性,React 的合成事件系统可能没有完全暴露或无法提供。

具体场景:

  • 捕获阶段的事件监听: DOM 事件传播分为捕获阶段和冒泡阶段。React 的合成事件系统主要关注冒泡阶段(尽管 React 17+ 允许在根节点捕获事件,但组件级别 JSX 声明的事件处理函数默认在冒泡阶段执行)。如果我们需要在捕获阶段拦截事件(例如,在事件到达目标元素之前阻止它),就需要使用原生的 addEventListener 并设置 useCapture 参数为 true
  • 非标准或实验性 DOM 事件: 某些浏览器特有的、实验性的或正在标准化过程中的事件(例如某些指针事件的特定属性,或自定义事件)可能不会被 React 的合成事件完全支持。
  • 拖放 API (Drag and Drop API):dragstartdragoverdrop 等事件,虽然 React 也提供了相应的合成事件(onDragStart 等),但在处理复杂的拖放逻辑时,直接访问原生事件对象及其数据传输属性(event.dataTransfer)可能更直接和强大。
  • 媒体事件: 监听 videoaudio 元素的 playpauseended 等事件,虽然 React 也支持,但有时直接在 <video><audio> 元素的引用上进行操作和监听,结合原生 API,能提供更细粒度的控制。

2.4 避免 React 的事件委托机制

在某些场景下,我们可能不希望事件冒泡到 React 的根监听器,或者希望在事件到达特定元素时就立即处理,而不需要经过 React 的事件委托流程。

例如,创建一个全局的事件监听器,监听 document 上的 click 事件以实现“点击外部关闭”的功能。如果这个事件被 React 的某个内部组件的 stopPropagation 阻止,那么这个全局监听器就无法收到事件了。直接在 document 上绑定原生事件,并在捕获阶段监听,可以确保事件被优先捕获。

2.5 内存管理和生命周期控制

虽然 React 框架已经很大程度上简化了内存管理,但直接绑定原生事件时,手动管理其生命周期变得尤为重要。通过 useEffect 钩子,我们可以精确地控制事件监听器的添加和移除,从而避免内存泄漏。

优势: 确保在组件挂载时添加监听器,并在组件卸载时(或依赖项变化时)移除监听器,这比依赖 React 内部机制有时能提供更直观、更可靠的清理保证。

三、 如何直接绑定原生事件:实践指南

在 React 函数组件中,我们通常会结合 useRefuseEffect 钩子来安全、有效地绑定和管理原生 DOM 事件。

3.1 使用 useRef 获取 DOM 元素引用

useRef 钩子允许我们在函数组件中创建一个可变的引用,它在组件的整个生命周期内保持不变。我们可以将这个引用附加到一个 JSX 元素上,从而获取该元素对应的真实 DOM 节点的引用。

import React, { useRef, useEffect } from 'react';

function MyComponent() {
  const myElementRef = useRef(null); // 创建一个引用

  useEffect(() => {
    // 确保 DOM 元素已经挂载
    if (myElementRef.current) {
      console.log('DOM Element:', myElementRef.current);
      // 在这里可以绑定原生事件
    }
  }, []); // 空依赖数组表示只在组件挂载时运行一次

  return <div ref={myElementRef}>这是一个需要原生事件的元素</div>;
}

3.2 useEffect 钩子进行绑定和清理

useEffect 是执行副作用操作(如数据获取、订阅或手动更改 React DOM)的地方。它是绑定和清理原生事件监听器的理想场所。

useEffect 的回调函数在组件挂载后执行,并且在每次依赖项变化后也会重新执行(如果提供了依赖项数组)。它的返回值是一个可选的清理函数,这个函数会在组件卸载时或在下一次 effect 重新执行之前运行。

基本模式:

import React, { useRef, useEffect } from 'react';

function MyNativeEventHandlerComponent() {
  const divRef = useRef(null);

  useEffect(() => {
    const divElement = divRef.current; // 获取 DOM 元素

    if (!divElement) return; // 如果元素不存在,则提前退出

    // 定义事件处理函数
    const handleMouseMove = (event) => {
      console.log('Native Mouse X:', event.clientX, 'Y:', event.clientY);
      // 这里可以直接操作 DOM 或更新组件状态
    };

    // 绑定原生事件监听器
    divElement.addEventListener('mousemove', handleMouseMove);

    // 返回一个清理函数,在组件卸载时或 effect 重新执行前调用
    return () => {
      divElement.removeEventListener('mousemove', handleMouseMove);
      console.log('Native mousemove listener removed.');
    };
  }, []); // 空依赖数组,表示这个 effect 只在组件挂载时运行一次,并在卸载时清理

  return (
    <div
      ref={divRef}
      style={{
        width: '300px',
        height: '200px',
        border: '1px solid blue',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      请将鼠标移动到这里
    </div>
  );
}

依赖数组的重要性:

  • 空数组 [] useEffect(callback, []) 意味着 effect 只在组件挂载时运行一次,并在组件卸载时清理。这适用于只需要绑定一次且不需要响应 props 或 state 变化的事件。
  • 有依赖项的数组 [dep1, dep2] useEffect(callback, [dep1, dep2]) 意味着 effect 会在 dep1dep2 变化时重新运行,并在重新运行前清理上一个 effect。这在事件处理函数需要访问最新的 props 或 state 时非常有用。然而,如果事件处理函数内部引用了组件的 state 或 props,并且你希望事件监听器始终使用最新的值,你需要将这些 state/props 作为依赖项。但这会导致事件监听器频繁地被移除和重新添加,这可能不是你想要的。

    • 解决方案: 使用 useCallback 来记忆事件处理函数,并确保它始终引用最新的 state/props,或者在事件处理函数内部通过 useRef 访问最新的 state/props。
    import React, { useRef, useEffect, useState, useCallback } from 'react';
    
    function CounterWithNativeClick() {
      const buttonRef = useRef(null);
      const [count, setCount] = useState(0);
    
      // 使用 useCallback 记忆事件处理函数,确保其引用最新的 count
      // 但这里为了避免重新绑定事件监听器,我们不将 count 放入 useCallback 的依赖
      // 而是通过 ref 访问最新的 count
      const handleClick = useCallback(() => {
        // 在原生事件处理函数中,如果要访问最新的 state/props
        // 并且不想重新绑定事件监听器,可以使用 useRef 来存储最新的 state/props
        // 例如:const latestCountRef = useRef(count);
        // latestCountRef.current = count;
        // console.log('Native Click! Current count:', latestCountRef.current);
        // 或者直接更新 state,React 会批量更新
        setCount(prevCount => prevCount + 1);
        console.log('Native Click! Current count (might be stale if not using functional update):', count);
      }, []); // 空依赖数组,确保 handleClick 实例不变
    
      useEffect(() => {
        const buttonElement = buttonRef.current;
        if (!buttonElement) return;
    
        buttonElement.addEventListener('click', handleClick);
    
        return () => {
          buttonElement.removeEventListener('click', handleClick);
        };
      }, [handleClick]); // 将 handleClick 作为依赖,确保当 handleClick 变化时重新绑定 (在此例中它不变)
    
      return (
        <div>
          <button ref={buttonRef}>Click me (Native Event)</button>
          <p>Count: {count}</p>
        </div>
      );
    }

    在上述 handleClickuseCallback 例子中,如果 handleClick 真的需要 count 的最新值,那么直接在 useCallback 的依赖数组中添加 count 会导致 handleClick 每次 count 变化时都重新创建,进而导致 useEffect 移除并重新绑定事件监听器。这可能不是我们希望的。更常见的做法是:

    1. 使用 setCount(prevCount => prevCount + 1) 这种函数式更新,它总是能访问到最新的 state。
    2. 或者如果需要访问 count 但不触发重新绑定,可以创建一个 countRef 来存储最新的 count
    import React, { useRef, useEffect, useState, useCallback } from 'react';
    
    function CounterWithNativeClickRef() {
      const buttonRef = useRef(null);
      const [count, setCount] = useState(0);
      const latestCountRef = useRef(count); // 用于存储最新的 count 值
    
      // 每次 count 变化时更新 latestCountRef
      useEffect(() => {
        latestCountRef.current = count;
      }, [count]);
    
      const handleClick = useCallback(() => {
        // 通过 ref 访问最新的 count 值
        console.log('Native Click! Current count:', latestCountRef.current);
        setCount(prevCount => prevCount + 1);
      }, []); // 空依赖数组,确保 handleClick 实例不变
    
      useEffect(() => {
        const buttonElement = buttonRef.current;
        if (!buttonElement) return;
    
        buttonElement.addEventListener('click', handleClick);
    
        return () => {
          buttonElement.removeEventListener('click', handleClick);
        };
      }, [handleClick]); // 依赖 handleClick,由于 handleClick 是用 useCallback 且依赖为空,所以它不会变
    
      return (
        <div>
          <button ref={buttonRef}>Click me (Native Event)</button>
          <p>Count: {count}</p>
        </div>
      );
    }

    这个 CounterWithNativeClickRef 例子是一个更健壮的模式,它避免了不必要的事件监听器重新绑定,同时确保事件处理函数可以访问到最新的 count 值。

3.3 示例:拖拽功能

实现一个简单的可拖拽的 div。这需要监听 mousedownmousemovemouseup 事件。mousemovemouseup 需要在 document 上监听,以确保用户即使鼠标移出拖拽元素也能正常结束拖拽。

import React, { useRef, useEffect, useState } from 'react';

function DraggableDiv() {
  const divRef = useRef(null);
  const [isDragging, setIsDragging] = useState(false);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [offset, setOffset] = useState({ x: 0, y: 0 }); // 鼠标点击位置与元素左上角的偏移

  useEffect(() => {
    const divElement = divRef.current;
    if (!divElement) return;

    const handleMouseDown = (e) => {
      setIsDragging(true);
      // 计算鼠标点击位置与元素当前位置的偏移
      setOffset({
        x: e.clientX - divElement.getBoundingClientRect().left,
        y: e.clientY - divElement.getBoundingClientRect().top,
      });
      // 阻止默认的拖拽行为(如图片拖拽)
      e.preventDefault();
    };

    const handleMouseMove = (e) => {
      if (!isDragging) return;
      // 更新元素位置
      setPosition({
        x: e.clientX - offset.x,
        y: e.clientY - offset.y,
      });
    };

    const handleMouseUp = () => {
      setIsDragging(false);
    };

    // 绑定 mousedown 到拖拽元素
    divElement.addEventListener('mousedown', handleMouseDown);
    // 绑定 mousemove 和 mouseup 到 document,以便在鼠标移出元素时也能捕获事件
    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);

    return () => {
      divElement.removeEventListener('mousedown', handleMouseDown);
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };
  }, [isDragging, offset]); // 依赖 isDragging 和 offset,确保事件处理函数内部访问的是最新值

  return (
    <div
      ref={divRef}
      style={{
        position: 'absolute',
        left: position.x,
        top: position.y,
        width: '100px',
        height: '100px',
        backgroundColor: isDragging ? 'lightblue' : 'lightcoral',
        cursor: isDragging ? 'grabbing' : 'grab',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        color: 'white',
        fontWeight: 'bold',
        borderRadius: '8px',
      }}
    >
      拖动我
    </div>
  );
}

3.4 注意事项

  1. 事件清理是强制性的: 永远不要忘记在 useEffect 的清理函数中移除你添加的原生事件监听器。否则,当组件卸载时,监听器仍然存在,可能导致对已不存在的 DOM 元素的引用,引发内存泄漏和运行时错误。
  2. passive 选项: 对于 scrollwheeltouchstarttouchmove 等事件,浏览器尝试通过优化来提高滚动性能。如果你的事件处理函数不调用 preventDefault() 来阻止默认行为(例如滚动),你可以将 passive 选项设置为 true。这会告诉浏览器你的监听器不会阻止默认行为,从而允许浏览器进行更积极的优化,避免等待你的 JavaScript 执行。
    divElement.addEventListener('scroll', handleScroll, { passive: true });

    这对于提高移动设备上的滚动流畅度尤为重要。

  3. 捕获阶段监听: 如果你需要在事件的捕获阶段而非冒泡阶段处理事件,可以在 addEventListener 的第三个参数中设置 { capture: true }
    document.addEventListener('click', handleCaptureClick, { capture: true });
  4. 与 React 事件系统的共存与冲突:
    • 传播顺序: 原生事件和 React 合成事件可以独立传播。一个原生事件监听器会在 React 的合成事件处理之前或之后被触发,取决于它们在 DOM 树中的位置和绑定方式(捕获/冒泡)。
    • stopPropagation() 的影响:
      • 原生事件监听器中的 e.stopPropagation() 会阻止该原生事件在 DOM 树中的进一步传播,这会阻止该事件冒泡到 React 的根监听器,从而阻止 React 合成事件的触发。
      • React 合成事件中的 e.stopPropagation() (在 React 17+ 中) 只阻止 React 内部的事件传播,并不会阻止原生事件继续冒泡到 documentwindow 上的其他原生监听器。
    • stopImmediatePropagation() 如果一个元素上有多个原生事件监听器,e.stopImmediatePropagation() 不仅会阻止事件在 DOM 树中的进一步传播,还会阻止同一元素上其他同类型事件监听器的执行。这比 stopPropagation() 更强大。

四、 常见误区与最佳实践

直接操作原生事件是一把双刃剑,使用不当可能引入新的问题。

4.1 常见误区

  1. 滥用原生事件: 并非所有事件都需要绕过 React。优先使用 React 的合成事件,它提供了更好的兼容性、性能优化和与组件生命周期的集成。只有当遇到上述特定场景时,才考虑使用原生事件。
  2. 忘记清理监听器: 这是最常见的错误,会导致内存泄漏和难以调试的错误。每次添加监听器,都必须确保在适当的时候移除它。
  3. 混淆 React 事件和原生事件的传播机制:stopPropagation 在两种系统中的不同行为理解不清,可能导致预期外的事件传播结果。
  4. 在渲染阶段绑定事件: 绝对不要在组件的渲染函数(JSX 返回的部分)中直接调用 addEventListener,这会导致每次渲染都添加监听器,造成灾难性的性能问题和内存泄漏。useEffect 是执行副作用的正确位置。

4.2 最佳实践

  1. 权衡利弊,按需使用: 始终评估使用原生事件的必要性。如果 React 的合成事件能够满足需求,就优先使用它。只有当性能成为瓶颈、需要与第三方库集成、或需要访问原生特性时,才考虑绕过。
  2. 封装为自定义 Hook: 将原生事件的绑定、清理以及相关逻辑封装成自定义 Hook,可以提高代码的可重用性、可读性和维护性。

    // useClickOutside.js
    import { useEffect } from 'react';
    
    function useClickOutside(ref, handler) {
      useEffect(() => {
        const listener = (event) => {
          // 如果点击的是 ref 元素本身或其子元素,则不执行 handler
          if (!ref.current || ref.current.contains(event.target)) {
            return;
          }
          handler(event);
        };
    
        document.addEventListener('mousedown', listener);
        document.addEventListener('touchstart', listener); // 考虑触摸屏设备
    
        return () => {
          document.removeEventListener('mousedown', listener);
          document.removeEventListener('touchstart', listener);
        };
      }, [ref, handler]); // 依赖 ref 和 handler
    }
    
    // 在组件中使用
    function Dropdown() {
      const dropdownRef = useRef(null);
      const [isOpen, setIsOpen] = useState(false);
    
      useClickOutside(dropdownRef, () => {
        if (isOpen) setIsOpen(false);
      });
    
      return (
        <div ref={dropdownRef}>
          <button onClick={() => setIsOpen(!isOpen)}>Toggle Dropdown</button>
          {isOpen && <div>Dropdown Content</div>}
        </div>
      );
    }
  3. 明确意图: 在代码中添加注释,说明为什么在这里选择使用原生事件而非 React 的合成事件,这有助于后来的维护者理解你的决策。
  4. 测试: 确保你的原生事件处理逻辑在各种场景下都能正常工作,特别是与 React 组件的生命周期、状态更新和重新渲染的协调。

五、 案例分析:实际场景应用

让我们通过几个具体的案例来加深理解。

5.1 可拖拽的弹窗 (改进版)

基于之前的拖拽示例,我们可以将其封装成一个可重用的 useDraggable Hook。

import React, { useRef, useEffect, useState, useCallback } from 'react';

// useDraggable.js
function useDraggable(ref, initialPosition = { x: 0, y: 0 }) {
  const [position, setPosition] = useState(initialPosition);
  const [isDragging, setIsDragging] = useState(false);
  const offsetRef = useRef({ x: 0, y: 0 }); // 存储鼠标点击时的偏移量

  const handleMouseDown = useCallback((e) => {
    if (!ref.current) return;
    setIsDragging(true);
    // 计算鼠标点击位置与元素左上角的偏移
    offsetRef.current = {
      x: e.clientX - ref.current.getBoundingClientRect().left,
      y: e.clientY - ref.current.getBoundingClientRect().top,
    };
    e.preventDefault(); // 阻止默认的拖拽行为
  }, [ref]);

  const handleMouseMove = useCallback((e) => {
    if (!isDragging) return;
    setPosition({
      x: e.clientX - offsetRef.current.x,
      y: e.clientY - offsetRef.current.y,
    });
  }, [isDragging]); // 依赖 isDragging

  const handleMouseUp = useCallback(() => {
    setIsDragging(false);
  }, []);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    element.addEventListener('mousedown', handleMouseDown);
    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);

    return () => {
      element.removeEventListener('mousedown', handleMouseDown);
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };
  }, [ref, handleMouseDown, handleMouseMove, handleMouseUp]); // 依赖事件处理函数

  return { position, isDragging };
}

// DraggableModal.jsx
function DraggableModal() {
  const modalRef = useRef(null);
  const { position, isDragging } = useDraggable(modalRef, { x: 100, y: 100 });

  return (
    <div
      ref={modalRef}
      style={{
        position: 'absolute',
        left: position.x,
        top: position.y,
        width: '300px',
        height: '200px',
        backgroundColor: 'white',
        border: '2px solid #ccc',
        boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
        borderRadius: '8px',
        zIndex: 1000,
        cursor: isDragging ? 'grabbing' : 'grab',
        display: 'flex',
        flexDirection: 'column',
      }}
    >
      <div
        style={{
          padding: '10px',
          backgroundColor: '#f0f0f0',
          borderBottom: '1px solid #eee',
          cursor: 'grab',
          fontWeight: 'bold',
        }}
      >
        可拖拽的弹窗
      </div>
      <div style={{ padding: '15px', flexGrow: 1 }}>
        这是弹窗内容。可以在这里放置任何 React 组件。
      </div>
    </div>
  );
}

5.2 无限滚动列表

无限滚动通常需要监听容器的 scroll 事件,并结合节流(throttle)来判断何时加载更多数据。

import React, { useRef, useEffect, useState, useCallback } from 'react';

// 简单的节流函数
const throttle = (func, delay) => {
  let inThrottle;
  let lastFn;
  let lastTime;
  return function() {
    const context = this;
    const args = arguments;
    if (!inThrottle) {
      func.apply(context, args);
      lastTime = Date.now();
      inThrottle = true;
    } else {
      clearTimeout(lastFn);
      lastFn = setTimeout(function() {
        if (Date.now() - lastTime >= delay) {
          func.apply(context, args);
          lastTime = Date.now();
        }
      }, Math.max(delay - (Date.now() - lastTime), 0));
    }
  };
};

function InfiniteScrollList() {
  const scrollContainerRef = useRef(null);
  const [items, setItems] = useState(Array.from({ length: 20 }, (_, i) => `Item ${i + 1}`));
  const [loading, setLoading] = useState(false);

  const loadMoreItems = useCallback(() => {
    if (loading) return;
    setLoading(true);
    setTimeout(() => { // 模拟网络请求
      setItems((prevItems) => [
        ...prevItems,
        ...Array.from({ length: 10 }, (_, i) => `Item ${prevItems.length + i + 1}`),
      ]);
      setLoading(false);
    }, 1000);
  }, [loading]);

  useEffect(() => {
    const container = scrollContainerRef.current;
    if (!container) return;

    const handleScroll = throttle(() => {
      // 判断是否滚动到底部
      const { scrollTop, scrollHeight, clientHeight } = container;
      if (scrollHeight - scrollTop - clientHeight < 100) { // 距离底部100px时加载
        loadMoreItems();
      }
    }, 200); // 节流200ms

    container.addEventListener('scroll', handleScroll, { passive: true }); // passive: true 提高滚动性能

    return () => {
      container.removeEventListener('scroll', handleScroll);
    };
  }, [loadMoreItems]);

  return (
    <div
      ref={scrollContainerRef}
      style={{
        height: '400px',
        overflowY: 'scroll',
        border: '1px solid #ccc',
        width: '300px',
        margin: '20px auto',
      }}
    >
      {items.map((item, index) => (
        <div key={index} style={{ padding: '10px', borderBottom: '1px solid #eee' }}>
          {item}
        </div>
      ))}
      {loading && (
        <div style={{ padding: '10px', textAlign: 'center', color: '#888' }}>加载中...</div>
      )}
      {!loading && (
        <div style={{ padding: '10px', textAlign: 'center', color: '#888' }}>已加载全部</div>
      )}
    </div>
  );
}

六、 React 17+ 中的事件系统变化对原生事件的影响

React 17 对事件系统做出了重大调整,主要是为了提高与原生 DOM 事件的互操作性,并避免一些之前版本可能出现的混淆。

主要变化:

  1. 事件委托的绑定位置: 在 React 17 之前,所有的 React 事件监听器都统一绑定在 document 对象上。从 React 17 开始,事件监听器被绑定到 React 应用程序渲染的根 DOM 节点上(通过 ReactDOM.createRoot()ReactDOM.render() 传入的容器 DOM 元素)。
  2. e.stopPropagation() 的行为:
    • React 16 及之前: 在 React 事件处理函数中调用 e.stopPropagation(),会阻止原生事件继续冒泡到 document 上的其他原生监听器。
    • React 17+: 在 React 事件处理函数中调用 e.stopPropagation(),只会阻止事件在 React 内部的合成事件树中传播。它不会阻止原生事件继续冒泡到 React 根节点之上的 documentwindow 上的其他原生监听器。
      这个变化意味着,如果你在 React 组件内部的 onClick 中调用 e.stopPropagation(),一个绑定在 document 上的原生 click 监听器仍然会收到该事件。这使得 React 的事件系统与原生事件系统更加解耦,行为更符合直觉。

对原生事件绑定的影响:

  • 更清晰的隔离: React 17+ 的变化使得原生事件和 React 合成事件之间的界限更加清晰。如果你希望一个事件完全不被 React 处理或不影响 React 外部的事件,直接绑定原生事件并使用 e.stopPropagation()e.stopImmediatePropagation() 将具有更可预测的行为。
  • 兼容性考虑: 如果你的项目依赖于 React 16 中 stopPropagation 的行为(即阻止原生事件冒泡到 document),那么升级到 React 17 后可能需要调整相关逻辑,改用原生事件绑定并结合 e.stopImmediatePropagation() 来达到相同的效果。

七、 深入理解底层机制,做出明智选择

React 的事件系统是其强大功能集的重要组成部分,它极大地简化了前端开发。然而,作为专业的开发者,理解其底层机制,并知道何时以及如何绕过它,是掌握框架并解决复杂问题的关键。直接绑定原生 DOM 事件并非对 React 的否定,而是对其能力的补充。

通过今天对 React 事件系统内部机制的剖析,以及对原生事件绑定场景、实践和注意事项的探讨,我希望大家能够更加自信地在 React 项目中做出明智的事件处理决策。请记住,始终优先考虑 React 的合成事件,只有当性能、集成或底层控制成为明确的需求时,才将原生事件作为有力的备选方案。理解并运用这两种事件处理方式,将使你的应用在性能、灵活性和维护性之间达到最佳平衡。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注