文章

RN事件传递机制深度解析

深入剖析React Native事件系统从触摸采集、Native手势判定到JS事件分发与冒泡的完整链路

RN事件传递机制深度解析

一句话概括

React Native的事件传递机制是Native层触摸事件采集 → 手势响应系统决策 → Bridge/JSI通道传输 → JS侧合成事件分发与冒泡处理的全链路体系。

背景与意义

RN事件系统的独特挑战

移动端的事件处理与Web端有本质差异:

维度WebReact Native
事件模型冒泡/捕获标准DOM事件需跨Bridge传输的自定义事件
线程模型单线程(UI线程=JS线程)多线程(Native UI线程≠JS线程)
手势判定浏览器内置需Native手势系统介入
响应链DOM树层级Native视图树 + Shadow Tree

这意味着RN必须建立一套跨线程、跨语言的事件传递体系。以支付宝的H5→RN迁移实践为例,早期他们发现RN上的手势响应延迟比H5高出约16ms,根源就在于事件需要从Native线程穿越Bridge到达JS线程。

事件系统的架构演进

1
2
3
RN < 0.40: RCTEventEmitter直接Bridge通信
RN 0.40-0.69: JSEventDispatcher优化批次处理
RN 0.70+: Fabric事件系统 + JSI直接通道

概念与定义

事件流水线全景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
触摸事件 (UI Touch)
    │
    ▼
Native手势判定 (UIGestureRecognizer / View.onTouchEvent)
    │
    ▼
事件序列化 (通过EventDispatcher)
    │
    ▼
Bridge / JSI 通道
    │
    ▼
JS事件合成 (SyntheticEvent)
    │
    ▼
事件派发 (React Native事件系统)
    │
    ▼
组件响应 (onPress / onTouchEnd / 自定义事件)

核心构建块

组件平台职责
Touch系统Android: ViewGroup.onInterceptTouchEvent原始触摸事件的采集与拦截
手势识别器iOS: UIGestureRecognizer识别点击、滑动、长按等手势
EventDispatcherNative (Java/ObjC)批次化发送事件到JS侧
RCTEventEmitterJS/Native边界Bridge两侧的事件序列化/反序列化
Responder系统JS (React Native)决定哪个组件响应当前手势
PanResponderJS高级手势API封装
SyntheticEventJS跨平台标准化事件对象

最小示例:自定义拖拽组件

通过PanResponder手动实现一个可拖拽的方块,展示事件传递的完整链路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
// DraggableBox.tsx
import React, { useRef, useState } from 'react';
import {
  View,
  PanResponder,
  Animated,
  StyleSheet,
  GestureResponderEvent,
  PanResponderGestureState,
  Text,
} from 'react-native';
import type { LayoutChangeEvent } from 'react-native';

const DraggableBox: React.FC = () => {
  const pan = useRef(new Animated.ValueXY()).current;
  const [log, setLog] = useState<string[]>([]);
  const [parentLayout, setParentLayout] = useState({ width: 0, height: 0 });

  const addLog = (msg: string) => {
    setLog(prev => [...prev.slice(-4), msg]);
  };

  const panResponder = useRef(
    PanResponder.create({
      // 1. 是否成为手势响应者
      onStartShouldSetPanResponder: () => true,
      onMoveShouldSetPanResponder: () => true,

      // 2. 响应者授予时触发
      onPanResponderGrant: (e: GestureResponderEvent) => {
        addLog(`🎯 响应者授予: ${e.nativeEvent.touches?.length}指`);
        pan.setOffset({
          x: (pan.x as any)._value,
          y: (pan.y as any)._value,
        });
        pan.setValue({ x: 0, y: 0 });
      },

      // 3. 移动时更新位置
      onPanResponderMove: (
        e: GestureResponderEvent,
        gestureState: PanResponderGestureState,
      ) => {
        const { dx, dy, vx, vy } = gestureState;
        // 边界约束
        const boundedDx = Math.max(
          -parentLayout.width / 2 + 25,
          Math.min(parentLayout.width / 2 - 25, dx),
        );
        const boundedDy = Math.max(
          -parentLayout.height / 2 + 25,
          Math.min(parentLayout.height / 2 - 25, dy),
        );
        Animated.event([null, { dx: pan.x, dy: pan.y }], {
          useNativeDriver: false,
        })(null, { dx: boundedDx, dy: boundedDy } as any);
      },

      // 4. 手势释放
      onPanResponderRelease: (
        _e: GestureResponderEvent,
        gestureState: PanResponderGestureState,
      ) => {
        addLog(
          `✅ 释放: vx=${gestureState.vx.toFixed(2)}, vy=${gestureState.vy.toFixed(2)}`,
        );
        pan.flattenOffset();
      },
    }),
  ).current;

  return (
    <View
      style={styles.container}
      onLayout={(e: LayoutChangeEvent) => {
        const { width, height } = e.nativeEvent.layout;
        setParentLayout({ width, height });
      }}
    >
      <Text style={styles.title}>拖拽我👆</Text>
      <Animated.View
        style={[styles.box, { transform: pan.getTranslateTransform() }]}
        {...panResponder.panHandlers}
      >
        <Text style={styles.boxText}>Draggable</Text>
      </Animated.View>

      {/* 事件日志 */}
      <View style={styles.logContainer}>
        <Text style={styles.logTitle}>事件日志:</Text>
        {log.map((msg, i) => (
          <Text key={i} style={styles.logText}>{msg}</Text>
        ))}
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f0f0f0',
  },
  title: { fontSize: 18, marginBottom: 20, color: '#333' },
  box: {
    width: 80,
    height: 80,
    backgroundColor: '#4A90D9',
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 12,
    elevation: 5,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.3,
    shadowRadius: 4,
  },
  boxText: { color: '#fff', fontWeight: 'bold' },
  logContainer: {
    marginTop: 40,
    padding: 16,
    backgroundColor: '#fff',
    borderRadius: 8,
    width: '85%',
    minHeight: 100,
  },
  logTitle: { fontWeight: 'bold', marginBottom: 8, color: '#555' },
  logText: { fontSize: 12, color: '#666', marginVertical: 2 },
});

export default DraggableBox;

核心知识点拆解

1. 触摸事件的Native层采集

Android端

Android的触摸事件从 ViewGroup.dispatchTouchEvent 开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 简化流程
public boolean dispatchTouchEvent(MotionEvent ev) {
    // 1. 询问是否拦截
    if (onInterceptTouchEvent(ev)) {
        // 拦截后直接处理
        return onTouchEvent(ev);
    }
    // 2. 分发给子View
    for (View child : children) {
        if (child.dispatchTouchEvent(ev)) {
            return true; // 子View消费了
        }
    }
    // 3. 无人消费,自己处理
    return onTouchEvent(ev);
}

React Native在Android端的ReactViewGroup重写了这个方法:

1
2
3
4
5
6
7
8
9
10
// ReactViewGroup.java
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    // 如果JS侧没有注册onStartShouldSetResponder,则不拦截
    if (!mHasResponderHandler) {
        return false;
    }
    // 通过JS Bridge查询是否应该成为响应者
    return dispatchToJS(ev);
}

iOS端

iOS通过UIGestureRecognizer和UIResponder链协同工作:

1
2
3
4
5
UIApplication → UIWindow → UIView → RCTTouchHandler
                                ↓
                     UIGestureRecognizer 判定
                                ↓
                     通过RCTEventDispatcher发送

RCTTouchHandler 是RN在iOS端的触摸事件采集器,它继承自 UIGestureRecognizer

1
2
3
4
5
6
7
8
9
10
11
// RCTTouchHandler.m
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    // 1. 收集所有触摸点
    NSMutableArray *reactTouches = [NSMutableArray new];
    for (UITouch *touch in touches) {
        [reactTouches addObject:[self buildTouchDictionary:touch]];
    }
    // 2. 发送到JS层
    [self.bridge.eventDispatcher sendTouchEventType:RCTTouchEventTypeStart
                                          touches:reactTouches];
}

2. 手势响应系统(Responder System)

RN的Responder系统是事件的决策层,决定了”谁的onPress会被调用”。

响应者生命周期:

1
2
3
4
5
6
7
8
9
用户触摸 → 询问是否成为响应者
              ↓
       onStartShouldSetResponder
              ↓
       是? → 成为响应者 → onResponderGrant
       否? → 询问父组件 → 持续冒泡
              ↓
       父组件是? → 成为父组件的响应者
       父组件否? → 无响应者,事件丢失

终止响应者:

1
2
3
用户释放 → onResponderRelease
手势冲突 → onResponderTerminate
新响应者 → onResponderReject

源码解析(TouchEventsMixin.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// node_modules/react-native/Libraries/ReactNative/ResponderEventPlugin.js
function setResponderAndExtractTransfer(
    responder: Responder,
    event: ResponderEvent,
): Array<Event> {
    // 1. 检查当前响应者是否愿意释放
    if (currentResponder) {
        const shouldRelease = currentResponder.onResponderTerminationRequest(event);
        if (shouldRelease) {
            // 当前响应者释放
            currentResponder.onResponderTerminate(event);
        } else {
            // 拒绝释放,新的响应者尝试失败
            return [];
        }
    }
    // 2. 新的响应者就位
    newResponder.onResponderGrant(event);
    return [grantEvent];
}

3. 事件批次化传输

为了减少Bridge的通信频率,Native侧会缓存一批事件后统一发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// EventDispatcher.java
public void dispatchEvent(Event event) {
    // 1. 计算事件类型
    int type = event.getEventType();
    // 2. 加入调度队列
    mEventQueue.add(event);
    // 3. 定时器驱动批次发送(每帧最多一次)
    if (!mDispatchScheduled) {
        mDispatchScheduled = true;
        mReactContext.runOnJSQueueThread(this::flushEvents);
    }
}

private void flushEvents() {
    // 批量序列化为JSON数组
    WritableNativeArray eventsArray = new WritableNativeArray();
    while (!mEventQueue.isEmpty()) {
        Event event = mEventQueue.poll();
        eventsArray.pushMap(event.serialize());
    }
    // 一次Bridge调用发送全部事件
    mJSModule.receiveTouches(eventsArray);
}

4. JS层的合成事件(SyntheticEvent)

RN在JS侧将Native事件包装为标准化的合成事件对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// SyntheticEvent.js
class SyntheticEvent {
  nativeEvent: NativeTouchEvent;
  _target: number;
  _propagationStopped: boolean;

  // 事件池化——重用事件对象减少GC
  static pool: SyntheticEvent[] = [];

  constructor(nativeEvent: NativeTouchEvent) {
    this.nativeEvent = nativeEvent;
    this._target = nativeEvent.target;
    this._propagationStopped = false;
  }

  stopPropagation() {
    this._propagationStopped = true;
  }

  preventDefault() {
    // 在RN中无效,保留以保持与Web API兼容
  }

  // 事件池回收
  destructor() {
    this.nativeEvent = null;
    SyntheticEvent.pool.push(this);
  }
}

事件池化是RN的一个重要优化:事件对象使用完后放回池子,下次直接重用,避免频繁GC导致的卡顿。

5. 事件冒泡机制

RN自建了一套不在DOM树上的冒泡模型:

1
2
3
4
5
6
TouchableHighlight (子)
    ↓ onStartShouldSetResponder = true → 事件冒泡
TouchableOpacity (父)
    ↓ onStartShouldSetResponder = false → 继续冒泡
ScrollView (祖父)
    ↓ onStartShouldSetResponder = true → 成为ScrollView的响应者

冒泡沿着JS组件树的层级进行,而非Native视图树。这是通过Shadow Tree维护的父子关系实现的。

实战案例:手势冲突处理

结合ScrollView和PanResponder,处理列表项拖拽与列表滚动的冲突。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// GestureConflictExample.tsx
import React, { useRef, useState } from 'react';
import {
  View,
  Text,
  PanResponder,
  Animated,
  ScrollView,
  StyleSheet,
  Dimensions,
  Platform,
} from 'react-native';

const ITEMS = ['Item 1 - 左滑删除', 'Item 2 - 左滑删除', 'Item 3 - 左滑删除'];
const SCREEN_WIDTH = Dimensions.get('window').width;

const SwipeableItem: React.FC<{ text: string; index: number }> = ({ text, index }) => {
  const translateX = useRef(new Animated.Value(0)).current;
  const [isOpen, setIsOpen] = useState(false);

  const panResponder = useRef(
    PanResponder.create({
      // 仅水平滑动时抢夺响应者
      onMoveShouldSetPanResponder: (_, gs) => {
        const { dx, dy } = gs;
        // 水平移动 > 垂直移动时:我要处理(左滑)
        // 垂直移动 > 水平移动时:让给ScrollView(滚动)
        return Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 10;
      },

      onPanResponderMove: (_, gs) => {
        // 左滑最大80px,右滑最多回到0
        const newX = Math.max(-80, Math.min(0, gs.dx));
        translateX.setValue(newX);
      },

      onPanResponderRelease: (_, gs) => {
        if (gs.dx < -40) {
          // 滑动超过阈值,展开删除按钮
          Animated.spring(translateX, {
            toValue: -80,
            useNativeDriver: true,
          }).start();
          setIsOpen(true);
        } else {
          // 回弹
          Animated.spring(translateX, {
            toValue: 0,
            useNativeDriver: true,
          }).start();
          setIsOpen(false);
        }
      },

      onPanResponderTerminate: () => {
        // 被ScrollView抢走响应者,回弹
        Animated.spring(translateX, {
          toValue: 0,
          useNativeDriver: true,
        }).start();
      },
    }),
  ).current;

  return (
    <View style={styles.itemWrapper}>
      {/* 隐藏的删除按钮 */}
      <View style={styles.deleteBtn}>
        <Text style={styles.deleteText}>删除</Text>
      </View>
      {/* 可滑动的内容 */}
      <Animated.View
        style={[styles.item, { transform: [{ translateX }] }]}
        {...panResponder.panHandlers}
      >
        <Text style={styles.itemText}>{text}</Text>
      </Animated.View>
    </View>
  );
};

export const GestureConflictDemo: React.FC = () => {
  const scrollRef = useRef<ScrollView>(null);

  return (
    <View style={styles.container}>
      <Text style={styles.header}>手势冲突演示</Text>
      <Text style={styles.hint}>左滑列表项 → 显示删除 | 上下滑动 → 滚动列表</Text>
      <ScrollView ref={scrollRef} style={styles.scrollView}>
        {ITEMS.map((item, i) => (
          <SwipeableItem key={i} text={item} index={i} />
        ))}
      </ScrollView>
    </View>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, paddingTop: 50, backgroundColor: '#f5f5f5' },
  header: { fontSize: 20, fontWeight: 'bold', textAlign: 'center', marginBottom: 8 },
  hint: { fontSize: 12, color: '#999', textAlign: 'center', marginBottom: 16 },
  scrollView: { flex: 1 },
  itemWrapper: { marginHorizontal: 16, marginVertical: 4, position: 'relative' },
  deleteBtn: {
    position: 'absolute',
    right: 0,
    top: 0,
    bottom: 0,
    width: 80,
    backgroundColor: '#FF3B30',
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 8,
  },
  deleteText: { color: '#fff', fontWeight: 'bold' },
  item: {
    backgroundColor: '#fff',
    padding: 16,
    borderRadius: 8,
    ...Platform.select({
      ios: {
        shadowColor: '#000',
        shadowOffset: { width: 0, height: 1 },
        shadowOpacity: 0.1,
        shadowRadius: 3,
      },
      android: { elevation: 2 },
    }),
  },
  itemText: { fontSize: 16, color: '#333' },
});

底层原理(源码分析)

JavaScriptCore → Hermes 事件处理器差异

特性JSCHermes
事件延迟~8ms~4ms(减少50%)
内存分配每次事件新对象事件池复用
GC暂停30-50ms<10ms(增量GC)

Fabric事件系统架构

新架构(Fabric)下的事件系统变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Fabric Event Emitter (C++)
class EventEmitter {
    void dispatchEvent(
        const std::string& eventName,
        const folly::dynamic& payload,
        const EventPriority& priority
    ) {
        // 1. 使用JSI直接调用JS函数
        jsi::Function handler = getJSFunction(eventName);
        jsi::Object args = jsi::Object(runtime);
        args.setProperty(runtime, "nativeEvent", convertToJSI(payload));
        // 2. 直接调用,无需Bridge序列化
        handler.call(runtime, args);
    }
};

旧架构的事件延迟链:Touch → EventDispatcher → Bridge串行化 → JS线程

新架构的事件延迟链:Touch → JSI直接调用 → JS线程

响应者系统的核心算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 简化自 ResponderEventPlugin.js
function handleTouchStart(topLevelType, nativeEvent) {
    // 1. 从nativeEvent获取触摸目标
    const target = nativeEvent.target;
    // 2. 向上遍历组件树,找到第一个愿意成为响应者的组件
    let node = target;
    while (node) {
        const shouldSet = node.onStartShouldSetResponder(nativeEvent);
        if (shouldSet) {
            return setResponder(node, nativeEvent);
        }
        node = node.parentNode; // 沿Shadow Tree向上
    }
    // 3. 无人认领,事件丢弃
    return null;
}

高频面试题解析

问题1:PanResponder和TouchableOpacity在事件处理上有什么本质区别?

解析

维度PanResponderTouchableOpacity
抽象层级底层API,完全自主控制高层封装,开箱即用
事件粒度onStart/Move/Release 全生命周期onPress/in/out 语义级
手势判定完全自主(需要手动判断滑动vs点击)内置点击手势判定
冲突处理需要手动处理 onMoveShouldSetPanResponder自动处理 ScrollView 冲突
响应链控制精细控制(set、release、terminate)无控制权

选择建议

  • 95%的场景用TouchableOpacity就够了
  • 需要拖拽、滑动、多指手势时用PanResponder
  • 需要手势冲突精细控制时考虑PanResponder + GestureHandler

问题2:RN事件系统如何处理多指触摸?

解析

React Native的触摸事件透传了Native的Touch对象数组:

1
2
3
4
5
6
7
8
9
10
onTouchStart = (e) => {
    const { touches, changedTouches } = e.nativeEvent;
    console.log(`当前触摸点数: ${touches.length}`);
    console.log(`变化的触摸点: ${changedTouches.length}`);
    // touches: 所有触摸点
    // changedTouches: 本轮事件变化的触摸点
    changedTouches.forEach((touch, idx) => {
        console.log(`触摸点${idx}: id=${touch.identifier}, x=${touch.pageX}, y=${touch.pageY}`);
    });
};

多点触摸流程

  1. 第一根手指触摸 → ACTION_DOWN / touchesBegan → onTouchStart
  2. 第二根手指触摸 → ACTION_POINTER_DOWN / touchesBegan → 再次onTouchStart
  3. 双指移动 → ACTION_MOVE → onTouchMove
  4. 手指抬起 → ACTION_POINTER_UP / touchesEnded → onTouchEnd(touches变为1)
  5. 最后一指抬起 → ACTION_UP / touchesEnded → 最后的onTouchEnd

问题3:onResponderTerminationRequest 的作用是什么?

解析

onResponderTerminationRequest 是响应者系统中防止冲突的关键API。当一个组件已成为响应者且收到新的触摸事件时,系统会先询问当前响应者是否愿意释放控制权。

1
2
3
4
5
6
7
8
9
10
11
12
// 场景:ScrollView中的可拖拽卡片
<ScrollView>
  <View
    onStartShouldSetResponder={() => true}
    onResponderGrant={() => console.log('成为响应者')}
    onResponderTerminationRequest={() => {
      // 返回false就锁死响应者,ScrollView将无法滚动
      // 返回true就释放响应者,允许ScrollView接手
      return false; // ❌ 灾难!用户再也无法滚动了
    }}
  />
</ScrollView>

正确的做法:

1
2
3
4
5
onResponderTerminationRequest={(e) => {
    // 判断用户意图——水平移动保留响应者,垂直移动放给ScrollView
    const { dx, dy } = e.nativeEvent;
    return Math.abs(dy) > Math.abs(dx); // 垂直移动时释放
}}

最佳实践:永远不要无脑返回 false,除非你非常确定需要锁定手势。

总结与扩展

核心要点

  1. 全链路理解:RN事件传递是 Native采集 → 响应者系统决策 → Bridge/JSI传输 → JS合成事件 → 组件响应的完整链条
  2. 响应者系统是核心:理解onStartShouldSetResponder、onResponderGrant、onResponderTerminate等生命周期方法
  3. 手势冲突处理:通过 MoveShouldSet 方法中的方向判定解决滑动与滚动的冲突
  4. Fabric架构提升:新架构通过JSI直接调用将事件延迟降低约50%

扩展思考

  • react-native-gesture-handler:为什么社区不直接用PanResponder?答案:PanResponder的Move采样本就在JS线程,而gesture-handler的触摸处理在UI线程(使用UIGestureRecognizer原生线程),手势识别更流畅
  • React Three Fiber (R3F) 的事件系统:在3D场景中重新实现了射线检测(Raycaster)替代触摸坐标检测
  • 事件系统的未来:RN New Architecture中,事件系统正朝着”UI线程直接处理,JS线程仅接收结果”的方向演进,降低主线程阻塞风险
本文由作者按照 CC BY 4.0 进行授权