admin管理员组

文章数量:1325236

Consider the following typical React document structure:

Component.jsx

<OuterClickableArea>
    <InnerClickableArea>
        content
    </InnerClickableArea>
</OuterClickableArea>

Where these ponents are posed as follows:

OuterClickableArea.js

export default class OuterClickableArea extends React.Component {
    constructor(props) {
        super(props)

        this.state = {
            clicking: false
        }

        this.onMouseDown = this.onMouseDown.bind(this)
        this.onMouseUp = this.onMouseUp.bind(this)
    }

    onMouseDown() {
        if (!this.state.clicking) {
            this.setState({ clicking: true })
        }
    }

    onMouseUp() {
        if (this.state.clicking) {
            this.setState({ clicking: false })
            this.props.onClick && this.props.onClick.apply(this, arguments)
            console.log('outer area click')
        }
    }

    render() {
        return (
            <div
                onMouseDown={this.onMouseDown}
                onMouseUp={this.onMouseUp}
            >
                { this.props.children }
            </div>
        )
    }
}

With, for the sake of this argument, InnerClickableArea.js having pretty much the same code except for the class name and the console log statement.

Now if you were to run this app and a user would click on the inner clickable area, the following would happen (as expected):

  1. outer area registers mouse event handlers
  2. inner area registers mouse event handlers
  3. user presses mouse down in the inner area
  4. inner area's mouseDown listener triggers
  5. outer area's mouseDown listener triggers
  6. user releases mouse up
  7. inner area's mouseUp listener triggers
  8. console logs "inner area click"
  9. outer area's mouseUp listener triggers
  10. console logs "outer area click"

This displays typical event bubbling/propagation -- no surprises so far.

It will output:

inner area click
outer area click

Now, what if we are creating an app where only a single interaction can happen at a time? For example, imagine an editor where elements could be selected with mouse clicks. When pressed inside the inner area, we would only want to select the inner element.

The simple and obvious solution would be to add a stopPropagation inside the InnerArea ponent:

InnerClickableArea.js

    onMouseDown(e) {
        if (!this.state.clicking) {
            e.stopPropagation()
            this.setState({ clicking: true })
        }
    }

This works as expected. It will output:

inner area click

The problem?

InnerClickableArea hereby implicitly chose for OuterClickableArea (and all other parents) not to be able to receive an event. Even though InnerClickableArea is not supposed to know about the existence of OuterClickableArea. Just like OuterClickableArea does not know about InnerClickableArea (following separation of concerns and reusability concepts). We created an implicit dependency between the two ponents where OuterClickableArea "knows" that it won't mistakenly have its listeners fired because it "remembers" that InnerClickableArea would stop any event propagation. This seems wrong.

I'm trying not to use stopPropagation to make the app more scalable, because it would be easier to add new features that rely on events without having to remember which ponents use stopPropagation at which time.

Instead, I would like to make the above logic more declarative, e.g. by defining declaratively inside Component.jsx whether each of the areas is currently clickable or not. I would make it app state aware, passing down whether the areas are clickable or not, and rename it to Container.jsx. Something like this:

Container.jsx

<OuterClickableArea
    clickable={!this.state.interactionBusy}
    onClicking={this.props.setInteractionBusy.bind(this, true)} />

    <InnerClickableArea
        clickable={!this.state.interactionBusy}
        onClicking={this.props.setInteractionBusy.bind(this, true)} />

            content

    </InnerClickableArea>

</OuterClickableArea>

Where this.props.setInteractionBusy is a redux action that would cause the app state to be updated. Also, this container would have the app state mapped to its props (not shown above), so this.state.ineractionBusy will be defined.

OuterClickableArea.js (almost same for InnerClickableArea.js)

export default class OuterClickableArea extends React.Component {
    constructor(props) {
        super(props)

        this.state = {
            clicking: false
        }

        this.onMouseDown = this.onMouseDown.bind(this)
        this.onMouseUp = this.onMouseUp.bind(this)
    }

    onMouseDown() {
        if (this.props.clickable && !this.state.clicking) {
            this.setState({ clicking: true })
            this.props.onClicking && this.props.onClicking.apply(this, arguments)
        }
    }

    onMouseUp() {
        if (this.state.clicking) {
            this.setState({ clicking: false })
            this.props.onClick && this.props.onClick.apply(this, arguments)
            console.log('outer area click')
        }
    }

    render() {
        return (
            <div
                onMouseDown={this.onMouseDown}
                onMouseUp={this.onMouseUp}
            >
                { this.props.children }
            </div>
        )
    }
}

The problem is that the Javascript event loop seems to run these operations in the following order:

  1. both OuterClickableArea and InnerClickableArea are created with prop clickable equal to true (because app state interactionBusy defaults to false)
  2. outer area registers mouse event handlers
  3. inner area registers mouse event handlers
  4. user presses mouse down in the inner area
  5. inner area's mouseDown listener triggers
  6. inner area's onClicking is fired
  7. container runs 'setInteractionBusy' action
  8. redux app state interactionBusy is set to true
  9. outer area's mouseDown listener triggers (it's clickable prop is still true because even though the app state interactionBusy is true, react did not yet cause a re-render; the render operation is put at the end of the Javascript event loop)
  10. user releases mouse up
  11. inner area's mouseUp listener triggers
  12. console logs "inner area click"
  13. outer area's mouseUp listener triggers
  14. console logs "outer area click"
  15. react re-renders Component.jsx and passes in clickable as false to both ponents, but by now this is too late

This will output:

inner area click
outer area click

Whereas the desired output is:

inner area click

App state, therefore, does not help in trying to make these ponents more independent and reusable. The reason seems to be that even though the state is updated, the container only re-renders at the end of the event loop, and the mouse event propagation triggers are already queued in the event loop.

My question, therefore, is: is there an alternative to using app state to be able to work with mouse event propagation in a declarative manner, or is there a better way to implement/execute my above setup with app (redux) state?

Consider the following typical React document structure:

Component.jsx

<OuterClickableArea>
    <InnerClickableArea>
        content
    </InnerClickableArea>
</OuterClickableArea>

Where these ponents are posed as follows:

OuterClickableArea.js

export default class OuterClickableArea extends React.Component {
    constructor(props) {
        super(props)

        this.state = {
            clicking: false
        }

        this.onMouseDown = this.onMouseDown.bind(this)
        this.onMouseUp = this.onMouseUp.bind(this)
    }

    onMouseDown() {
        if (!this.state.clicking) {
            this.setState({ clicking: true })
        }
    }

    onMouseUp() {
        if (this.state.clicking) {
            this.setState({ clicking: false })
            this.props.onClick && this.props.onClick.apply(this, arguments)
            console.log('outer area click')
        }
    }

    render() {
        return (
            <div
                onMouseDown={this.onMouseDown}
                onMouseUp={this.onMouseUp}
            >
                { this.props.children }
            </div>
        )
    }
}

With, for the sake of this argument, InnerClickableArea.js having pretty much the same code except for the class name and the console log statement.

Now if you were to run this app and a user would click on the inner clickable area, the following would happen (as expected):

  1. outer area registers mouse event handlers
  2. inner area registers mouse event handlers
  3. user presses mouse down in the inner area
  4. inner area's mouseDown listener triggers
  5. outer area's mouseDown listener triggers
  6. user releases mouse up
  7. inner area's mouseUp listener triggers
  8. console logs "inner area click"
  9. outer area's mouseUp listener triggers
  10. console logs "outer area click"

This displays typical event bubbling/propagation -- no surprises so far.

It will output:

inner area click
outer area click

Now, what if we are creating an app where only a single interaction can happen at a time? For example, imagine an editor where elements could be selected with mouse clicks. When pressed inside the inner area, we would only want to select the inner element.

The simple and obvious solution would be to add a stopPropagation inside the InnerArea ponent:

InnerClickableArea.js

    onMouseDown(e) {
        if (!this.state.clicking) {
            e.stopPropagation()
            this.setState({ clicking: true })
        }
    }

This works as expected. It will output:

inner area click

The problem?

InnerClickableArea hereby implicitly chose for OuterClickableArea (and all other parents) not to be able to receive an event. Even though InnerClickableArea is not supposed to know about the existence of OuterClickableArea. Just like OuterClickableArea does not know about InnerClickableArea (following separation of concerns and reusability concepts). We created an implicit dependency between the two ponents where OuterClickableArea "knows" that it won't mistakenly have its listeners fired because it "remembers" that InnerClickableArea would stop any event propagation. This seems wrong.

I'm trying not to use stopPropagation to make the app more scalable, because it would be easier to add new features that rely on events without having to remember which ponents use stopPropagation at which time.

Instead, I would like to make the above logic more declarative, e.g. by defining declaratively inside Component.jsx whether each of the areas is currently clickable or not. I would make it app state aware, passing down whether the areas are clickable or not, and rename it to Container.jsx. Something like this:

Container.jsx

<OuterClickableArea
    clickable={!this.state.interactionBusy}
    onClicking={this.props.setInteractionBusy.bind(this, true)} />

    <InnerClickableArea
        clickable={!this.state.interactionBusy}
        onClicking={this.props.setInteractionBusy.bind(this, true)} />

            content

    </InnerClickableArea>

</OuterClickableArea>

Where this.props.setInteractionBusy is a redux action that would cause the app state to be updated. Also, this container would have the app state mapped to its props (not shown above), so this.state.ineractionBusy will be defined.

OuterClickableArea.js (almost same for InnerClickableArea.js)

export default class OuterClickableArea extends React.Component {
    constructor(props) {
        super(props)

        this.state = {
            clicking: false
        }

        this.onMouseDown = this.onMouseDown.bind(this)
        this.onMouseUp = this.onMouseUp.bind(this)
    }

    onMouseDown() {
        if (this.props.clickable && !this.state.clicking) {
            this.setState({ clicking: true })
            this.props.onClicking && this.props.onClicking.apply(this, arguments)
        }
    }

    onMouseUp() {
        if (this.state.clicking) {
            this.setState({ clicking: false })
            this.props.onClick && this.props.onClick.apply(this, arguments)
            console.log('outer area click')
        }
    }

    render() {
        return (
            <div
                onMouseDown={this.onMouseDown}
                onMouseUp={this.onMouseUp}
            >
                { this.props.children }
            </div>
        )
    }
}

The problem is that the Javascript event loop seems to run these operations in the following order:

  1. both OuterClickableArea and InnerClickableArea are created with prop clickable equal to true (because app state interactionBusy defaults to false)
  2. outer area registers mouse event handlers
  3. inner area registers mouse event handlers
  4. user presses mouse down in the inner area
  5. inner area's mouseDown listener triggers
  6. inner area's onClicking is fired
  7. container runs 'setInteractionBusy' action
  8. redux app state interactionBusy is set to true
  9. outer area's mouseDown listener triggers (it's clickable prop is still true because even though the app state interactionBusy is true, react did not yet cause a re-render; the render operation is put at the end of the Javascript event loop)
  10. user releases mouse up
  11. inner area's mouseUp listener triggers
  12. console logs "inner area click"
  13. outer area's mouseUp listener triggers
  14. console logs "outer area click"
  15. react re-renders Component.jsx and passes in clickable as false to both ponents, but by now this is too late

This will output:

inner area click
outer area click

Whereas the desired output is:

inner area click

App state, therefore, does not help in trying to make these ponents more independent and reusable. The reason seems to be that even though the state is updated, the container only re-renders at the end of the event loop, and the mouse event propagation triggers are already queued in the event loop.

My question, therefore, is: is there an alternative to using app state to be able to work with mouse event propagation in a declarative manner, or is there a better way to implement/execute my above setup with app (redux) state?

Share Improve this question edited May 22, 2018 at 18:18 Eby Jacob 1,4581 gold badge12 silver badges29 bronze badges asked May 11, 2018 at 18:43 TomTom 8,13735 gold badges140 silver badges237 bronze badges 3
  • 1 Event gets captured and then bubbled up. Solution is probably to stopPropagation at some point. You can probably add it in the event bind call of site appended after ; like onClicking={this.props.setInteractionBusy.bind(this, true); $event.stopPropagation();} – Rikin Commented May 11, 2018 at 19:22
  • @Rikin I don't mind that it's bubbling up, but while bubbling, I would like for other ponents to be able to review app state and determine independently whether they want to act or not, without implicitly depending on other ponents. – Tom Commented May 11, 2018 at 19:45
  • Will consider your suggestion to move the stopPropagation call one level higher though. In essence that achieves what I want, though it is a little hard to follow (less descriptive than setting whether something is clickable or not). – Tom Commented May 11, 2018 at 19:49
Add a ment  | 

5 Answers 5

Reset to default 3

As @Rikin mentioned and as you alluded to in your answer, event.stopPropagation would solve the your issue in its current form.

I'm trying not to use stopPropagation to make the app more scalable, because it would be easier to add new features that rely on events without having to remember which ponents use stopPropagation at which time.

Is this not a premature concern in terms of the current scope of your ponent architecture? If you wish for innerComponent to be nested within outerClickableArea the only way to achieve that is to stopPropagation or to somehow determine which events to ignore. Personally, I would stop propagation for all events, and for those click events for which you selectively want to share state between ponents, capture the events and dispatch an action (if using redux) to the global state. That way you don't have to worry about selectively ignoring/capturing bubbled events and can update a global source of truth for those events that imply shared state

Easiest alternative to this is to add a flag before firing stopPropogation and the flag in this case is an argument.

const onMouseDown = (stopPropagation) => {
stopPropagation && event.stopPropagation();
}

Now even the application state or prop can even decide to trigger the stoppropagation

<div onMouseDown={this.onMouseDown(this.state.stopPropagation)} onMouseUp={this.onMouseUp(this.props.stopPropagation)} >
    <InnerComponent stopPropagation = {true}>
</div>

Instead of handling the click event and clickable state in the ponent, use a LOCK state to Redux state and when user click, the ponents just emit a CLICK action, hold the lock and do the logic. Like this:

  1. Both OuterClickableArea and InnerClickableArea are created
  2. Redux state with the field CLICK_LOCK default to false.
  3. Outer area registers mouse event handlers
  4. Inner area registers mouse event handlers
  5. User click in inner area
  6. Inner area's mouseDown listener triggers
  7. Inner area emits action INNER_MOUSEDOWN
  8. Outer area's mouseDown listener triggers
  9. Outer area emits action OUTER_MOUSEDOWN
  10. INNER_MOUSEDOWN action set the CLICK_LOCK state to true and do logic (log inner area click).
  11. Because CLICK_LOCK is true, OUTER_MOUSEDOWN do nothing.
  12. Inner area's mouseUp listener triggers
  13. Inner area emits action INNER_MOUSEUP
  14. Outer area's mouseUp listener triggers
  15. Outer area emits action OUTER_MOUSEUP
  16. INNER_MOUSEUP action set the CLICK_LOCK state to false.
  17. OUTER_MOUSEUP action set the CLICK_LOCK state to false.
  18. Nothing have to be re-render unless the click logic change some state.

Note:

  • This approach work by having a lock in a region, at one time, only one ponent can click. Each ponent do not have to know about the others, but need a global state (Redux) to orchestrate.
  • If the rate of clicking is high, this approach performance might not be good.
  • Currently Redux actions is guaranteed to be emitted synchronously. Not sure about the future.

I'm suspecting that you are about to create something like overlay modal, where there will be ponent that should be closed when the outer ponent or the container ponent was clicked, if that's the case, you could use react-overlays instead of recreating the whole thing, you could also learn on how they handle this kind of implementation if you have different purpose.

Have you though of passing the information if the event was already processed in the event itself?

const handleClick = e => {
    if (!e.nativeEvent.wasProcessed) {
      // do something
      e.nativeEvent.wasProcessed = true;
    }
  };

This way the outer ponent can check that the event was already processed by one of it's childrens while the event will still bubble up.

I've put up a sample that nests two Clickable ponents. The mousedown event gets processed only by the innermost Clickable ponent user clicked on: https://codesandbox.io/s/2wmxxzormy

Disclaimer: I could not find any information on this exactly being a bad practice, but in general modifying an object you do not own can lead to e.g. naming collisions in the future.

本文标签: javascriptHow to achieve reuseable components with React and mouse event propagationStack Overflow