From 8e33233e998552fd23ff43b334918dcbc3f78b8d Mon Sep 17 00:00:00 2001 From: zoomchan-cxj Date: Fri, 3 Dec 2021 00:01:47 +0800 Subject: [PATCH] feat(react): support event capture phase --- docs/hippy-react/gesture.md | 52 +++++- .../src/components/ListView/index.jsx | 16 +- examples/hippy-react-demo/src/main.js | 4 +- examples/hippy-react-demo/src/pages/entry.jsx | 5 +- packages/hippy-react/src/dom/element-node.ts | 4 + packages/hippy-react/src/events/dispatcher.ts | 161 +++++++++++++----- packages/hippy-react/src/utils/index.ts | 23 ++- 7 files changed, 214 insertions(+), 51 deletions(-) diff --git a/docs/hippy-react/gesture.md b/docs/hippy-react/gesture.md index 8ee2ab470..8692edae8 100644 --- a/docs/hippy-react/gesture.md +++ b/docs/hippy-react/gesture.md @@ -64,12 +64,14 @@ render() { ## 事件冒泡 +[[事件冒泡范例]](//github.com/Tencent/Hippy/tree/master/examples/hippy-react-demo/src/components/ListView) + 点击事件和触屏事件均可以在回调函数中定义是否需要冒泡该事件到上层组件,点击或触屏事件发生时,终端会寻找该触屏点下声明了要处理该事件的最小控件: !> HippyReact 默认不冒泡 -1. 返回 true 或没有返回值:控件处理完事件后,将不再继续冒泡,整个手势事件处理结束; -2. 返回 false:控件处理完事件后,事件将继续往上一层冒泡,如果找到某个父控件也设置了对应事件处理函数,则会调用改该回调函数,并再次根据其返回值决定是否继续冒泡。如果再向上冒泡的过程中达到了根节点,则事件冒泡结束; +1. 返回 `true` 或 `没有返回值`:控件处理完事件后,将不再继续冒泡,整个手势事件处理结束; +2. 返回 `false`:控件处理完事件后,事件将继续往上一层冒泡,如果找到某个父控件也设置了对应事件处理函数,则会调用改该回调函数,并再次根据其返回值决定是否继续冒泡。如果再向上冒泡的过程中达到了根节点,则事件冒泡结束; 我们通过以下示例进一步说明事件冒泡的机制: @@ -80,19 +82,21 @@ render() { onClick={() => { console.log("根节点 点击"); }} > console.log("点击按钮1 点击")} + onClick={() => console.log("按钮1 点击")} > 点击按钮1 { console.log("父控件 点击"); + // 不再向上冒泡到跟节点 return true; }} > { - console.log("点击按钮2 点击"); + console.log("按钮2 点击"); + // 向上冒泡到父控件 return false; }} > @@ -115,6 +119,46 @@ new Hippy({ }).start(); ```` +## 事件捕获 + +> 最低支持版本 2.11.2 + +[[事件捕获范例]](//github.com/Tencent/Hippy/tree/master/examples/hippy-react-demo/src/components/ListView) + +点击事件和触屏事件支持事件捕获,如需注册捕获阶段的事件处理函数,则应在目标元素事件名添加 `Capture` 后缀,如 `onClickCapture`、`onTouchDownCapture`。如果目标元素没有`Capture` 事件处理函数,默认不开启捕获。事件捕获会有一定性能损耗,如非必要尽量不开启。 + +例子如下: + +```js +render() { + return ( + { console.log("根节点 点击"); }} + onClickCapture={() => console.log("根节点 捕获点击")} + > + { + // 点击按钮1不会触发根节点捕获点击 + console.log("按钮1 点击") + }} + > + 点击按钮1 + + + { + // 点击按钮2会触发根节点捕获点击 + console.log("按钮2 点击"); + }} + > + 点击按钮2 + + + + ); +} +``` + ## 事件的拦截 某些场景下,父控件又需要优先拦截到子控件的手势事件,因此 Hippy 也提供了手势事件拦截机制,手势拦截由父控件的两个属性控制 `onInterceptTouchEvent` 和`onInterceptPullUpEvent`,这两个属性仅对能容纳子控件的组件生效,如 `` 这种控件就不支持这两个属性: diff --git a/examples/hippy-react-demo/src/components/ListView/index.jsx b/examples/hippy-react-demo/src/components/ListView/index.jsx index 2d069782f..9bebd6da6 100644 --- a/examples/hippy-react-demo/src/components/ListView/index.jsx +++ b/examples/hippy-react-demo/src/components/ListView/index.jsx @@ -66,8 +66,12 @@ const styles = StyleSheet.create({ function Style1({ index }) { return ( { + console.log('onClickCapture style1'); + }} onClick={() => { console.log('click style1'); + return false; }} > { `${index}: Style 1 UI` } @@ -202,7 +206,12 @@ export default class ListExample extends React.Component { // pass } return ( - + { + console.log('click style outer'); + // return false means trigger bubble + return false; + }}> {styleUI} @@ -230,8 +239,13 @@ export default class ListExample extends React.Component { const { dataSource } = this.state; return ( { + console.log('onClickCapture listview'); + }} onClick={() => { console.log('click listview'); + // return false means trigger bubble + return false; }} bounces={true} overScrollEnabled={true} diff --git a/examples/hippy-react-demo/src/main.js b/examples/hippy-react-demo/src/main.js index 41065ebb8..14aa1ae1c 100644 --- a/examples/hippy-react-demo/src/main.js +++ b/examples/hippy-react-demo/src/main.js @@ -4,8 +4,8 @@ import App from './app'; new Hippy({ appName: 'Demo', entryPage: App, - // set bubbles, default is false - bubbles: true, + // set global bubbles, default is false + bubbles: false, // set log output, default is false silent: false, }).start(); diff --git a/examples/hippy-react-demo/src/pages/entry.jsx b/examples/hippy-react-demo/src/pages/entry.jsx index 4c907285f..3bf47cabd 100644 --- a/examples/hippy-react-demo/src/pages/entry.jsx +++ b/examples/hippy-react-demo/src/pages/entry.jsx @@ -25,7 +25,10 @@ export const Entry = () => ( const Comp = item.component; return ( - + { + console.log('click router'); + }}>
diff --git a/packages/hippy-react/src/dom/element-node.ts b/packages/hippy-react/src/dom/element-node.ts index 869c55273..2ee18193c 100644 --- a/packages/hippy-react/src/dom/element-node.ts +++ b/packages/hippy-react/src/dom/element-node.ts @@ -33,6 +33,7 @@ import { isNumber, warn, convertImgUrl, + isCaptureEvent, } from '../utils'; import ViewNode from './view-node'; import '@localTypes/global'; @@ -426,6 +427,9 @@ class ElementNode extends ViewNode { match: () => true, action: () => { if (typeof value === 'function') { + if (isCaptureEvent(key)) { + key = key.replace('Capture', ''); + } this.attributes[key] = true; } else { this.attributes[key] = value; diff --git a/packages/hippy-react/src/events/dispatcher.ts b/packages/hippy-react/src/events/dispatcher.ts index 7ce2dcc98..418a0eb23 100644 --- a/packages/hippy-react/src/events/dispatcher.ts +++ b/packages/hippy-react/src/events/dispatcher.ts @@ -18,8 +18,9 @@ * limitations under the License. */ +import { Fiber } from 'react-reconciler'; import { getFiberNodeFromId } from '../utils/node'; -import { trace, isBubbles } from '../utils'; +import { trace, isGlobalBubble, isHostComponent } from '../utils'; import HippyEventHub from './hub'; import '@localTypes/global'; @@ -79,6 +80,111 @@ function receiveNativeEvent(nativeEvent: EventParam) { currEventHub.notifyEvent(eventParams); } +interface ListenerObj { listener: Function, isCapture: boolean } + +/** + * convertEventName - convert all special event name + * @param eventName + * @param nodeItem + */ +function convertEventName(eventName: string, nodeItem: Fiber) { + let processedEvenName = eventName; + if (nodeItem.memoizedProps + && !nodeItem.memoizedProps[eventName] + && eventName === 'onClick' + && nodeItem.memoizedProps.onPress) { + // Compatible with React Native + processedEvenName = 'onPress'; + } + return processedEvenName; +} + +function isNodePropFunction(prop: string, nextNodeItem: Fiber) { + return !!(nextNodeItem.memoizedProps && typeof nextNodeItem.memoizedProps[prop] === 'function'); +} + +/** + * doCaptureAndBubbleLoop - process capture phase and bubble phase + * @param {string} originalEventName + * @param {NativeEvent} nativeEvent + * @param {Fiber} nodeItem + */ +function doCaptureAndBubbleLoop(originalEventName: string, nativeEvent: NativeEvent, nodeItem: Fiber) { + const eventQueue: ListenerObj[] = []; + let nextNodeItem: Fiber | null = nodeItem; + let eventName = originalEventName; + // capture and bubble loop + while (nextNodeItem) { + eventName = convertEventName(eventName, nextNodeItem); + const captureName = `${eventName}Capture`; + if (isNodePropFunction(captureName, nextNodeItem)) { + // capture phase to add listener at queue head + eventQueue.unshift({ listener: nextNodeItem.memoizedProps[captureName], isCapture: true }); + } + if (isNodePropFunction(eventName, nextNodeItem)) { + // bubble phase to add listener at queue tail + eventQueue.push({ listener: nextNodeItem.memoizedProps[eventName], isCapture: false }); + } + nextNodeItem = nextNodeItem.return; + while (nextNodeItem && !isHostComponent(nextNodeItem.tag)) { + // only handle HostComponent + nextNodeItem = nextNodeItem.return; + } + } + if (eventQueue.length > 0) { + let listenerObj: ListenerObj | undefined; + let isStopBubble: any = false; + while (!isStopBubble && (listenerObj = eventQueue.shift()) !== undefined) { + try { + if (listenerObj.isCapture) { + listenerObj.listener(nativeEvent); + } else { + isStopBubble = listenerObj.listener(nativeEvent); + // If callback have no return, use global bubble config to set isStopBubble. + if (typeof isStopBubble !== 'boolean') { + isStopBubble = !isGlobalBubble(); + } + } + } catch (err) { + (console as any).reportUncaughtException(err); + } + } + } +} + +/** + * doBubbleLoop - process only bubble phase + * @param {string} originalEventName + * @param {NativeEvent} nativeEvent + * @param {Fiber} nodeItem + */ +function doBubbleLoop(originalEventName: string, nativeEvent: NativeEvent, nodeItem: Fiber) { + let isStopBubble: any = false; + let nextNodeItem: Fiber | null = nodeItem; + let eventName = originalEventName; + // only bubble loop + do { + eventName = convertEventName(eventName, nextNodeItem); + if (isNodePropFunction(eventName, nextNodeItem)) { + try { + isStopBubble = nextNodeItem.memoizedProps[eventName](nativeEvent); + // If callback have no return, use global bubble config to set isStopBubble. + if (typeof isStopBubble !== 'boolean') { + isStopBubble = !isGlobalBubble(); + } + } catch (err) { + (console as any).reportUncaughtException(err); + } + } + if (isStopBubble === false) { + nextNodeItem = nextNodeItem.return; + while (nextNodeItem && !isHostComponent(nextNodeItem.tag)) { + // only handle HostComponent + nextNodeItem = nextNodeItem.return; + } + } + } while (!isStopBubble && nextNodeItem); +} function receiveNativeGesture(nativeEvent: NativeEvent) { trace(...componentName, 'receiveNativeGesture', nativeEvent); @@ -90,47 +196,20 @@ function receiveNativeGesture(nativeEvent: NativeEvent) { if (!targetNode) { return; } - - let eventHandled: any = false; - let nextNodeItem = targetNode; + let hasCapturePhase: boolean = true; let { name: eventName } = nativeEvent; - do { - if (nextNodeItem.memoizedProps - && !nextNodeItem.memoizedProps[eventName] - && eventName === 'onClick' - && nextNodeItem.memoizedProps.onPress) { - // Compatible with React Native - eventName = 'onPress'; - } - - if (nextNodeItem.memoizedProps - && nextNodeItem.memoizedProps[eventName] - && typeof nextNodeItem.memoizedProps[eventName] === 'function') { - try { - eventHandled = nextNodeItem.memoizedProps[eventName](nativeEvent); - // If callback have no return, set global bubbles config to eventHandled. - if (typeof eventHandled !== 'boolean') { - eventHandled = !isBubbles(); - } - } catch (err) { - (console as any).reportUncaughtException(err); // eslint-disable-line - } - } - - // If callback have no return is meaning no need the event bubbling - if (typeof eventHandled !== 'boolean') { - eventHandled = true; - } - - if (eventHandled === false) { - // @ts-ignore - nextNodeItem = nextNodeItem.return; - while (nextNodeItem && nextNodeItem.tag !== 5) { - // @ts-ignore - nextNodeItem = nextNodeItem.return; - } - } - } while (!eventHandled && nextNodeItem); + eventName = convertEventName(eventName, targetNode); + const captureName = `${eventName}Capture`; + const nextNodeItem: Fiber = targetNode; + // if current target has no capture listener, only do bubble phase loop to improve performance + if (targetNode.memoizedProps && typeof targetNode.memoizedProps[captureName] !== 'function') { + hasCapturePhase = false; + } + if (hasCapturePhase) { + doCaptureAndBubbleLoop(eventName, nativeEvent, nextNodeItem); + } else { + doBubbleLoop(eventName, nativeEvent, nextNodeItem); + } } function receiveUIComponentEvent(nativeEvent: string[]) { diff --git a/packages/hippy-react/src/utils/index.ts b/packages/hippy-react/src/utils/index.ts index 4641f7453..0d1b6e9c9 100644 --- a/packages/hippy-react/src/utils/index.ts +++ b/packages/hippy-react/src/utils/index.ts @@ -52,6 +52,15 @@ function unicodeToChar(text: string): string { return text.replace(/\\u[\dA-F]{4}|\\x[\dA-F]{2}/gi, match => String.fromCharCode(parseInt(match.replace(/\\u|\\x/g, ''), 16))); } +const captureEventReg = new RegExp('^on.+Capture$'); +/** + * ensure capture event name + * @param {any} eventName + */ +function isCaptureEvent(eventName: any) { + return captureEventReg.test(eventName); +} + /** * Convert to string as possible */ @@ -114,7 +123,7 @@ function setBubbles(bubbles: boolean = false): void { * get bubbles config * @returns boolean */ -function isBubbles(): boolean { +function isGlobalBubble(): boolean { return defaultBubbles; } @@ -134,15 +143,25 @@ function convertImgUrl(url: string): string { return url; } +/** + * isHostComponent - judge current tag is hostComponent type + * @param {number} tag + */ +function isHostComponent(tag: number) { + return tag === 5; +} + export { trace, warn, unicodeToChar, tryConvertNumber, + isCaptureEvent, isFunction, isNumber, setSilent, setBubbles, - isBubbles, + isGlobalBubble, convertImgUrl, + isHostComponent, };