Search code examples
javascripteventsencapsulation

How do I prevent accidental altering of properties of an event in my custom event dispatching system?


I have build a custom event dispatching mechanism. I'm trying to mimic the DOM event implementation as much as possible. It's still a draft, but works reasonably well so far.

One thing that bothers me though, is that it's fairly easy for listeners of my events to alter particular properties of that event when they should actually be read-only for outsiders. I only want the actual EventDispatcher that is dispatching the event to be able to alter those properties.

Now, I realise that basically any just about any user-space Javascript object is able to be altered, but that's not what I'm worried about. I want to prevent accidental altering of Event properties in listeners, e.g. by:

function someListener( event ) {
    if( event.currentTarget = this ) { // whoops, we've accidentally overwritten event.currentTarget
       // do something
    }
}

The problem is, I have no clear idea (at least not without completely refactoring) on how to implement a reasonably robust solution to this problem. I've tried it (see the parts of the target, currentTarget and eventPhase setters of Event that are commented out in the code I provide beneath), but that failed miserably of course (it was not even viable to begin with). I hope, however, that from inspecting those parts you'll see what I'm aiming for and that perhaps you can offer a workable solution. It doesn't have to be airtight, just reasonably foolproof.

I tried to imagine how DOM events implement this trickery (changing the event.currentTarget, etc.) and concluded that it probably is not implemented in (pure) Javascript itself, but under the hood.

I'd really like to prevent cloning events, or similar implementation ideas, if possible, since DOM events don't seem to clone either when processing the event phases and visiting different listeners.

Here's my current implementation:

codifier.event.Event:

codifier.event.Event = ( function() {

    function Event( type, bubbles, cancelable ) {

        if( !( this instanceof Event ) ) {
            return new Event( type, bubbles, cancelable );
        }

        let privateVars = {
            type: type,
            target: null,
            currentTarget: null,
            eventPhase: Event.NONE,
            bubbles: !!bubbles,
            cancelable: !!cancelable,
            defaultPrevented: false,
            propagationStopped: false,
            immediatePropagationStopped: false
        }

        this.preventDefault = function() {
            if( privateVars.cancelable ) {
                privateVars.defaultPrevented = true;
            }
        }

        this.stopPropagation = function() {
            privateVars.propagationStopped = true;
        }

        this.stopImmediatePropagation = function() {
            privateVars.immediatePropagationStopped = true;
            this.stopPropagation();
        }

        Object.defineProperties( this, {
            'type': {
                configurable: false,
                enumerable: false,
                get: function() {
                    return privateVars.type;
                }
            },
            'target': {
                configurable: false,
                enumerable: false,
                get: function() {
                    return privateVars.target;
                },
                set: function( value ) {
                    /* this was a rather silly attempt
                    if( !( this instanceof codifier.event.EventDispatcher ) || null !== privateVars.target ) {
                        throw new TypeError( 'setting a property that has only a getter' );
                    }
                    */
                    privateVars.target = value;
                }
            },
            'currentTarget': {
                configurable: false,
                enumerable: false,
                get: function() {
                    return privateVars.currentTarget;
                },
                set: function( value ) {
                    /* this was a rather silly attempt
                    if( !( this instanceof codifier.event.EventDispatcher ) ) {
                        throw new TypeError( 'setting a property that has only a getter' );
                    }
                    */
                    privateVars.currentTarget = value;
                }
            },
            'eventPhase': {
                configurable: false,
                enumerable: false,
                get: function() {
                    return privateVars.eventPhase;
                },
                set: function( value ) {
                    /* this was a rather silly attempt
                    if( !( this instanceof codifier.event.EventDispatcher ) ) {
                        throw new TypeError( 'setting a property that has only a getter' );
                    }
                    */
                    privateVars.eventPhase = value;
                }
            },
            'bubbles': {
                configurable: false,
                enumerable: false,
                get: function() {
                    return privateVars.bubbles;
                }
            },
            'cancelable': {
                configurable: false,
                enumerable: false,
                get: function() {
                    return privateVars.cancelable;
                }
            },
            'defaultPrevented': {
                configurable: false,
                enumerable: false,
                get: function() {
                    return privateVars.defaultPrevented;
                }
            },
            'propagationStopped': {
                configurable: false,
                enumerable: false,
                get: function() {
                    return privateVars.propagationStopped;
                }
            },
            'immediatePropagationStopped': {
                configurable: false,
                enumerable: false,
                get: function() {
                    return privateVars.immediatePropagationStopped;
                }
            }
        } );

        Object.freeze( this );
    }

    Event.NONE            = 0;
    Event.CAPTURING_PHASE = 1;
    Event.AT_TARGET       = 2;
    Event.BUBBLING_PHASE  = 3;

    Object.freeze( Event );
    Object.freeze( Event.prototype );

    return Event;

} )();

codifier.event.EventDispatcher (only the most relevant parts):

codifier.event.EventDispatcher = ( function() {

    function EventDispatcher( target, ancestors ) {

        if( !( this instanceof EventDispatcher ) ) {
            return new EventDispatcher( target, ancestors );
        }

        let privateVars = {
            target: target === Object( target ) ? target : this,
            ancestors: [],
            eventListeners: {}
        }

        this.clearAncestors = function() {
            privateVars.ancestors = [];
        }

        this.setAncestors = function( ancestors ) {
            this.clearAncestors();
            if( Array.isArray( ancestors ) ) {
                ancestors.forEach( function( ancestor ) {
                    if( ancestor instanceof EventDispatcher ) {
                        privateVars.ancestors.push( ancestor );
                    }
                } );
            }
        }

        this.dispatchEvent = function( event ) {
            if( event instanceof codifier.event.Event ) {
                if( event.eventPhase === Event.NONE && null === event.target ) {
                    event.target        = privateVars.target;
                    event.currentTarget = privateVars.target;

                    let ancestors = privateVars.ancestors;

                    // Event.CAPTURING_PHASE
                    event.eventPhase = Event.CAPTURING_PHASE;
                    for( let c = ancestors.length - 1; !event.propagationStopped && c >= 0; c-- ) {
                        let ancestor = ancestors[ c ];
                        ancestor.dispatchEvent( event );
                    }

                    // Event.AT_TARGET
                    event.eventPhase = Event.AT_TARGET;
                    if( !event.propagationStopped && this.hasEventListenersForEvent( event.type, true ) ) {
                        for( let listener of privateVars.eventListeners[ event.type ][ Event.CAPTURING_PHASE ].values() ) {
                            if( event.immediatePropagationStopped ) {
                                break;
                            }
                            listener.call( privateVars.target, event );
                        }
                    }

                    if( !event.propagationStopped && this.hasEventListenersForEvent( event.type, false ) ) {
                        for( let listener of privateVars.eventListeners[ event.type ][ Event.BUBBLING_PHASE ].values() ) {
                            if( event.immediatePropagationStopped ) {
                                break;
                            }
                            listener.call( privateVars.target, event );
                        }
                    }

                    // Event.BUBBLING_PHASE
                    if( event.bubbles ) {
                        event.eventPhase = Event.BUBBLING_PHASE;
                        for( let b = 0, l = ancestors.length; !event.propagationStopped && b < l; b++ ) {
                            let ancestor = ancestors[ b ];
                            ancestor.dispatchEvent( event );
                        }
                    }

                    event.eventPhase    = Event.NONE;
                    event.currentTarget = null;
                }
                else if( event.eventPhase == Event.CAPTURING_PHASE || event.eventPhase == Event.BUBBLING_PHASE ) {
                    event.currentTarget = privateVars.target;

                    if( !event.propagationStopped && this.hasEventListenersForEvent( event.type, event.eventPhase == Event.CAPTURING_PHASE ) ) {
                        for( let listener of privateVars.eventListeners[ event.type ][ event.eventPhase ].values() ) {
                            if( event.immediatePropagationStopped ) {
                                break;
                            }
                            listener.call( privateVars.target, event );
                        }
                    }
                }
            }
        }

        Object.freeze( this );

        this.setAncestors( ancestors );
    }

    Object.freeze( EventDispatcher );
    Object.freeze( EventDispatcher.prototype );

    return EventDispatcher;

} )();

Possible usage:

let SomeEventEmittingObject = ( function() {

    function SomeEventEmittingObject() {

        let privateVars = {
            eventDispatcher: new EventDispatcher( this ),
            value: 0
        }

        // this.addEventListener ... proxy to eventDispatcher.addEventListener
        // this.removeEventListener ... proxy to eventDispatcher.removeEventListener
        // etc.

        Object.defineProperty( this, 'value', {
            set: function( value ) {
                privateVars.value = value;
                privateVars.eventDispatcher.dispatchEvent( new Event( 'change', true, false ) );
            },
            get: function()  {
                return privateVars.value;
            }
        } );
    }

    return SomeEventEmittingObject;

} )();

let obj = new SomeEventEmittingObject();
obj.value = 5; // dispatches 'change' event

Do you have any suggestion on how to make this work? I don't expect full-fledged solutions of course; just a few general pointers would be great.


Solution

  • I think I've managed to come up with a (possibly temporary) solution, by moving the actual dispatching routine to the Event itself. I'm not fond of this solution, as I don't think Event should be responsible for the actual dispatching process, but I couldn't think of anything else, for the moment.

    So, I'd still love to hear alternative solutions, if you have any.


    edit: updated with the final(-ish) implementation. /edit

    The refactored (unpolished) implementation, as it stands (might have quite a few a less than earlier amount of bugs):

    codifier.event.Event:

    codifier.event.Event = ( function() {
    
        function Event( type, bubbles, cancelable, detail ) {
    
            if( !( this instanceof Event ) ) {
                return new Event( type, bubbles, cancelable, detail );
            }
    
            let privateVars = {
                instance: this,
                dispatched: false,
                type: type,
                target: null,
                currentTarget: null,
                eventPhase: Event.NONE,
                bubbles: !!bubbles,
                cancelable: !!cancelable,
                detail: undefined == detail ? null : detail,
                defaultPrevented: false,
                propagationStopped: false,
                immediatePropagationStopped: false
            }
    
            let processListeners = function( listeners ) {
                for( let listener of listeners ) {
                    if( privateVars.immediatePropagationStopped ) {
                        return false;
                    }
                    listener.call( privateVars.currentTarget, privateVars.instance );
                }
                return true;
            }
    
            let processDispatcher = function( dispatcher, useCapture ) {
                privateVars.currentTarget = dispatcher.target;
                return processListeners( dispatcher.getEventListenersForEvent( privateVars.type, useCapture ) );
            }
    
            let processDispatchers = function( dispatchers, useCapture ) {
                for( let i = 0, l = dispatchers.length; !privateVars.propagationStopped && i < l; i++ ) {
                    let dispatcher = dispatchers[ i ];
                    if( !processDispatcher( dispatcher, useCapture ) ) {
                        return false;
                    }
                }
    
                return true;
            }
    
            this.dispatch = function( dispatcher ) {
                if( privateVars.dispatched ) {
                    throw new Error( 'This event has already been dispatched.' );
                    return false;
                }
    
                if( !( dispatcher instanceof codifier.event.EventDispatcher ) ) {
                    throw new Error( 'Only EventDispatchers are allowed to dispatch an event.' );
                    return false;
                }
    
                privateVars.dispatched = true;
                let ancestors = dispatcher.getAncestors();
                do_while_label: // javascript needs a label to reference to break out of outer loops
                do {
                    switch( privateVars.eventPhase ) {
                        case Event.NONE:
                            privateVars.target = dispatcher.target;
                            privateVars.currentTarget = dispatcher.target;
                            privateVars.eventPhase = Event.CAPTURING_PHASE;
                        break;
                        case Event.CAPTURING_PHASE:
                            if( !processDispatchers( ancestors.slice().reverse(), true ) ) {
                                break do_while_label;
                            }
                            privateVars.eventPhase = Event.AT_TARGET;
                        break;
                        case Event.AT_TARGET:
                            privateVars.currentTarget = dispatcher.target;
                            if( !processDispatcher( dispatcher, true ) || !processDispatcher( dispatcher, false ) ) {
                                break do_while_label;
                            }
                            privateVars.eventPhase = privateVars.bubbles ? Event.BUBBLING_PHASE : Event.NONE;
                        break;
                        case Event.BUBBLING_PHASE:
                            if( !processDispatchers( ancestors, false ) ) {
                                break do_while_label;
                            }
                            privateVars.currentTarget = null;
                            privateVars.eventPhase = Event.NONE;
                        break;
                        default:
                            // we should never be able to reach this
                            throw new Error( 'This event encountered an inconsistent internal state' );
                        break do_while_label; // break out of the do...while loop.
                    }
    
                } while( !privateVars.propagationStopped && privateVars.eventPhase !== Event.NONE );
    
                privateVars.currentTarget = null;
                privateVars.eventPhase = Event.NONE;
    
                return !privateVars.defaultPrevented;
            }
    
            this.preventDefault = function() {
                if( privateVars.cancelable ) {
                    privateVars.defaultPrevented = true;
                }
            }
    
            this.stopPropagation = function() {
                privateVars.propagationStopped = true;
            }
    
            this.stopImmediatePropagation = function() {
                privateVars.immediatePropagationStopped = true;
                this.stopPropagation();
            }
    
            Object.defineProperties( this, {
                'type': {
                    configurable: false,
                    enumerable: false,
                    get: function() {
                        return privateVars.type;
                    }
                },
                'target': {
                    configurable: false,
                    enumerable: false,
                    get: function() {
                        return privateVars.target;
                    }
                },
                'currentTarget': {
                    configurable: false,
                    enumerable: false,
                    get: function() {
                        return privateVars.currentTarget;
                    }
                },
                'eventPhase': {
                    configurable: false,
                    enumerable: false,
                    get: function() {
                        return privateVars.eventPhase;
                    }
                },
                'bubbles': {
                    configurable: false,
                    enumerable: false,
                    get: function() {
                        return privateVars.bubbles;
                    }
                },
                'cancelable': {
                    configurable: false,
                    enumerable: false,
                    get: function() {
                        return privateVars.cancelable;
                    }
                },
                'detail': {
                    configurable: false,
                    enumerable: true,
                    get: function() {
                        return privateVars.detail;
                    }
                },
                'defaultPrevented': {
                    configurable: false,
                    enumerable: false,
                    get: function() {
                        return privateVars.defaultPrevented;
                    }
                }
            } );
    
            Object.freeze( this );
        }
    
        Event.NONE            = 0;
        Event.CAPTURING_PHASE = 1;
        Event.AT_TARGET       = 2;
        Event.BUBBLING_PHASE  = 3;
    
        Object.freeze( Event );
        Object.freeze( Event.prototype );
    
        return Event;
    
    } )();
    

    codifier.event.EventDispatcher (only the most relevant parts):

    codifier.event.EventDispatcher = ( function() {
    
        function EventDispatcher( target, ancestors ) {
    
            if( !( this instanceof EventDispatcher ) ) {
                return new EventDispatcher( target, ancestors );
            }
    
            let privateVars = {
                instance: this,
                target: target === Object( target ) ? target : this,
                ancestors: [],
                eventListeners: {}
            }
    
            this.clearAncestors = function() {
                privateVars.ancestors = [];
            }
    
            this.setAncestors = function( ancestors ) {
                this.clearAncestors();
                if( Array.isArray( ancestors ) ) {
                    ancestors.forEach( function( ancestor ) {
                        if( ancestor instanceof EventDispatcher ) {
                            privateVars.ancestors.push( ancestor );
                        }
                    } );
                }
            }
    
            this.getAncestors = function() {
                return privateVars.ancestors;
            }
    
            this.getEventListenersForEvent = function( type, useCapture ) {
                if( !this.hasEventListenersForEvent( type, useCapture ) ) {
                    return [];
                }
    
                let eventPhase = useCapture ? Event.CAPTURING_PHASE : Event.BUBBLING_PHASE;
                return privateVars.eventListeners[ type ][ eventPhase ].values();
            }
    
            this.hasEventListenersForEvent = function( type, useCapture ) {
                if( !privateVars.eventListeners.hasOwnProperty( type ) ) {
                    return false;
                }
    
                let eventPhase = useCapture ? Event.CAPTURING_PHASE : Event.BUBBLING_PHASE;
                if( !privateVars.eventListeners[ type ].hasOwnProperty( eventPhase ) ) {
                    return false;
                }
    
                return privateVars.eventListeners[ type ][ eventPhase ].size > 0;
            }
    
            this.hasEventListener = function( type, listener, useCapture ) {
                if( !this.hasEventListenersForEvent( type, useCapture ) ) {
                    return false;
                }
    
                let eventPhase = useCapture ? Event.CAPTURING_PHASE : Event.BUBBLING_PHASE;
                return privateVars.eventListeners[ type ][ eventPhase ].has( listener );
            }
    
            this.addEventListener = function( type, listener, useCapture ) {
                if( !this.hasEventListener( type, listener, useCapture ) ) {
                    if( !privateVars.eventListeners.hasOwnProperty( type ) ) {
                        privateVars.eventListeners[ type ] = {};
                    }
    
                    let eventPhase = useCapture ? Event.CAPTURING_PHASE : Event.BUBBLING_PHASE;
                    if( !privateVars.eventListeners[ type ].hasOwnProperty( eventPhase ) ) {
                        privateVars.eventListeners[ type ][ eventPhase ] = new Map();
                    }
                    privateVars.eventListeners[ type ][ eventPhase ].set( listener, listener );
                }
            }
    
            this.removeEventListener = function( type, listener, useCapture ) {
                if( this.hasEventListener( type, listener, useCapture ) ) {
                    let eventPhase = useCapture ? Event.CAPTURING_PHASE : Event.BUBBLING_PHASE;
                    privateVars.eventListeners[ type ][ eventPhase ].delete( listener );
                }
            }
    
            this.dispatchEvent = function( event ) {
                if( event instanceof codifier.event.Event ) {
                    return event.dispatch( privateVars.instance );
                }
    
                return false;
            }
    
            this.clear = function() {
                Object.getOwnPropertyNames( privateVars.eventListeners ).forEach( function( type ) {
                    Object.getOwnPropertyNames( privateVars.eventListeners[ type ] ).forEach( function( eventPhase ) {
                        privateVars.eventListeners[ type ][ eventPhase ].clear();
                        delete privateVars.eventListeners[ type ][ eventPhase ];
                    } );
                    delete privateVars.eventListeners[ type ];
                } );
            }
    
            Object.defineProperties( this, {
                'target': {
                    configurable: false,
                    enumerable: false,
                    get: function() {
                        return privateVars.target;
                    }
                }
            } );
    
            Object.freeze( this );
    
            this.setAncestors( ancestors );
        }
    
        Object.freeze( EventDispatcher );
        Object.freeze( EventDispatcher.prototype );
    
        return EventDispatcher;
    
    } )();