浏览代码

Created View listeners API.

Aleksander Nowodzinski 10 年之前
父节点
当前提交
6b2d48d806

+ 56 - 0
packages/ckeditor5-engine/src/ui/template.js

@@ -24,6 +24,36 @@ CKEDITOR.define( function() {
 		constructor( def ) {
 		constructor( def ) {
 			/**
 			/**
 			 * Definition of this Template.
 			 * Definition of this Template.
+			 *
+			 *     {
+			 *         tag: 'p',
+			 *         children: [
+			 *             {
+			 *                 tag: 'span',
+			 *                 attributes: { ... },
+			 *                 listeners: { ... }
+			 *             },
+			 *             {
+			 *                 ...
+			 *             },
+			 *             ...
+			 *         ],
+			 *         attributes: {
+			 *             'class': 'a',
+			 *             id: 'b',
+			 *             style: callback,
+			 *             ...
+			 *         },
+			 *         listeners: {
+			 *             w: 'a'
+			 *             x: [ 'b', 'c', callback ],
+			 *             'y@selector': 'd',
+			 *             'z@selector': [ 'e', 'f', callback ],
+			 *             ...
+			 *         },
+			 *         text: 'abc'
+			 *     }
+			 *
 			 */
 			 */
 			this.def = def;
 			this.def = def;
 		}
 		}
@@ -61,6 +91,9 @@ CKEDITOR.define( function() {
 		// Invoke children recursively.
 		// Invoke children recursively.
 		renderElementChildren( def, el );
 		renderElementChildren( def, el );
 
 
+		// Prepare binding for listeners.
+		prepareElementListeners( def, el );
+
 		return el;
 		return el;
 	}
 	}
 
 
@@ -108,5 +141,28 @@ CKEDITOR.define( function() {
 		}
 		}
 	}
 	}
 
 
+	function prepareElementListeners( def, el ) {
+		if ( def.listeners ) {
+			for ( var l in def.listeners ) {
+				var domEvtDef = l.split( '@' );
+				var name, selector;
+
+				if ( domEvtDef.length == 2 ) {
+					name = domEvtDef[ 0 ];
+					selector = domEvtDef[ 1 ];
+				} else {
+					name = l;
+					selector = null;
+				}
+
+				if ( Array.isArray( def.listeners[ l ] ) ) {
+					def.listeners[ l ].map( i => i( el, name, selector ) );
+				} else {
+					def.listeners[ l ]( el, name, selector );
+				}
+			}
+		}
+	}
+
 	return Template;
 	return Template;
 } );
 } );

+ 117 - 14
packages/ckeditor5-engine/src/ui/view.js

@@ -61,9 +61,6 @@ CKEDITOR.define( [
 			// Render the element using the template.
 			// Render the element using the template.
 			this._el = this.render();
 			this._el = this.render();
 
 
-			// Attach defined listeners.
-			this.listeners.map( l => l.call( this ) );
-
 			return this._el;
 			return this._el;
 		}
 		}
 
 
@@ -99,17 +96,6 @@ CKEDITOR.define( [
 			}.bind( this );
 			}.bind( this );
 		}
 		}
 
 
-		/**
-		 * Binds native DOM event listener to View event.
-		 *
-		 * @param {HTMLElement} el DOM element that fires the event.
-		 * @param {String} domEvt The name of DOM event the listener listens to.
-		 * @param {String} fireEvent The name of the View event fired then DOM event fires.
-		 */
-		domListener( el, domEvt, fireEvt ) {
-			el.addEventListener( domEvt, this.fire.bind( this, fireEvt ) );
-		}
-
 		/**
 		/**
 		 * Renders View's {@link el} using {@link Template} instance.
 		 * Renders View's {@link el} using {@link Template} instance.
 		 *
 		 *
@@ -123,11 +109,17 @@ CKEDITOR.define( [
 				);
 				);
 			}
 			}
 
 
+			// Prepare pre–defined listeners.
+			this._prepareTemplateListeners();
+
 			this._template = new Template( this.template );
 			this._template = new Template( this.template );
 
 
 			return this._template.render();
 			return this._template.render();
 		}
 		}
 
 
+		/**
+		 * Destroys the View.
+		 */
 		destroy() {
 		destroy() {
 			// Drop the reference to the model.
 			// Drop the reference to the model.
 			this.model = null;
 			this.model = null;
@@ -145,6 +137,117 @@ CKEDITOR.define( [
 			// Remove all listeners related to this view.
 			// Remove all listeners related to this view.
 			this.stopListening();
 			this.stopListening();
 		}
 		}
+
+		/**
+		 * Iterates over all "listeners" properties in {@link template} and replaces
+		 * listener definitions with functions which, once executed in a context of
+		 * a DOM element, will attach native DOM listeners to elements.
+		 *
+		 * The execution is performed by {@link Template} class.
+		 */
+		_prepareTemplateListeners() {
+			/**
+			 * For a given event name or callback, returns a function which,
+			 * once executed in a context of an element, attaches native DOM listener
+			 * to the element. The listener executes given callback or fires View's event
+			 * of given name.
+			 *
+			 * @param {String|Function} evtNameOrCallback Event name to be fired on View or callback to execute.
+			 * @returns {Function} A function to be executed in the context of an element.
+			 */
+			var getDOMListenerAttacher = ( evtNameOrCallback ) => {
+				/**
+				 * Attaches a native DOM listener to given element. The listener executes the
+				 * callback or fires View's event.
+				 *
+				 * Note: If the selector is supplied, it narrows the scope to relevant targets only.
+				 * So instead of
+				 *
+				 *     children: [
+				 *         { tag: 'span', listeners: { click: 'foo' } }
+				 *         { tag: 'span', listeners: { click: 'foo' } }
+				 *     ]
+				 *
+				 * a single, more efficient listener can be attached that uses **event delegation**:
+				 *
+				 *     children: [
+				 *     	   { tag: 'span' }
+				 *     	   { tag: 'span' }
+				 *     ],
+				 *     listeners: {
+				 *     	   'click@span': 'foo',
+				 *     }
+				 *
+				 * @param {HTMLElement} el Element, to which the native DOM Event listener is attached.
+				 * @param {String} domEventName The name of native DOM Event.
+				 * @param {String} [selector] If provided, the selector narrows the scope to relevant targets only.
+				 */
+				var attacher = ( el, domEvtName, selector ) => {
+					// Use View's listenTo, so the listener is detached, when the View dies.
+					this.listenTo( el, domEvtName, ( evt, domEvt ) => {
+						if ( !selector || domEvt.target.matches( selector ) ) {
+							if ( typeof evtNameOrCallback == 'function' ) {
+								evtNameOrCallback( domEvt );
+							} else {
+								this.fire( evtNameOrCallback, domEvt );
+							}
+						}
+					} );
+				};
+
+				return attacher;
+			};
+
+			/**
+			 * Iterates over "listeners" property in {@link template} definition to recursively
+			 * replace each listener declaration with a function which, once executed in a context
+			 * of an element, attaches native DOM listener to the element.
+			 *
+			 * @param {Object} def Template definition.
+			 */
+			function prepareElementListeners( def ) {
+				if ( def.listeners ) {
+					let listeners = def.listeners;
+					let evtNameOrCallback;
+
+					for ( let domEvtName in listeners ) {
+						evtNameOrCallback = listeners[ domEvtName ];
+
+						// Listeners allow definition with an array:
+						//
+						//    listeners: {
+						//        'DOMEvent@selector': [ 'event1', callback ],
+						//        'DOMEvent': [ callback, 'event2', 'event3' ]
+						//        ...
+						//    }
+						if ( Array.isArray( evtNameOrCallback ) ) {
+							listeners[ domEvtName ] = listeners[ domEvtName ].map(
+								evtNameOrCallback => getDOMListenerAttacher( evtNameOrCallback )
+							);
+						}
+						// Listeners allow definition with a string containing event name:
+						//
+						//    listeners: {
+						//       'DOMEvent@selector': 'event1',
+						//       'DOMEvent': 'event2'
+						//       ...
+						//    }
+						else {
+							listeners[ domEvtName ] = getDOMListenerAttacher( evtNameOrCallback );
+						}
+					}
+				}
+
+				// Repeat recursively for the children.
+				if ( def.children ) {
+					def.children.map( prepareElementListeners );
+				}
+			}
+
+			if ( this.template ) {
+				prepareElementListeners( this.template );
+			}
+		}
 	}
 	}
 
 
 	utils.extend( View.prototype, DOMEmitterMixin );
 	utils.extend( View.prototype, DOMEmitterMixin );

+ 65 - 0
packages/ckeditor5-engine/tests/ui/template.js

@@ -122,6 +122,71 @@ describe( 'callback value', function() {
 		spy1.firstCall.args[ 1 ]( el, 'foo' );
 		spy1.firstCall.args[ 1 ]( el, 'foo' );
 		expect( el.outerHTML ).to.be.equal( '<p>foo</p>' );
 		expect( el.outerHTML ).to.be.equal( '<p>foo</p>' );
 	} );
 	} );
+
+	it( 'works for "listeners" property', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+		var spy3 = bender.sinon.spy();
+		var spy4 = bender.sinon.spy();
+
+		var el = new Template( {
+			tag: 'p',
+			children: [
+				{
+					tag: 'span',
+					listeners: {
+						bar: spy2
+					}
+				}
+			],
+			listeners: {
+				foo: spy1,
+				baz: [ spy3, spy4 ]
+			}
+		} ).render();
+
+		sinon.assert.calledWithExactly( spy1, el, 'foo', null );
+		sinon.assert.calledWithExactly( spy2, el.firstChild, 'bar', null );
+		sinon.assert.calledWithExactly( spy3, el, 'baz', null );
+		sinon.assert.calledWithExactly( spy4, el, 'baz', null );
+	} );
+
+	it( 'works for "listeners" property with selectors', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+		var spy3 = bender.sinon.spy();
+		var spy4 = bender.sinon.spy();
+
+		var el = new Template( {
+			tag: 'p',
+			children: [
+				{
+					tag: 'span',
+					attributes: {
+						'id': 'x'
+					}
+				},
+				{
+					tag: 'span',
+					attributes: {
+						'class': 'y'
+					},
+					listeners: {
+						'bar@p': spy2
+					}
+				},
+			],
+			listeners: {
+				'foo@span': spy1,
+				'baz@.y': [ spy3, spy4 ]
+			}
+		} ).render();
+
+		sinon.assert.calledWithExactly( spy1, el, 'foo', 'span' );
+		sinon.assert.calledWithExactly( spy2, el.lastChild, 'bar', 'p' );
+		sinon.assert.calledWithExactly( spy3, el, 'baz', '.y' );
+		sinon.assert.calledWithExactly( spy4, el, 'baz', '.y' );
+	} );
 } );
 } );
 
 
 function createClassReferences() {
 function createClassReferences() {

+ 242 - 34
packages/ckeditor5-engine/tests/ui/view.js

@@ -8,15 +8,23 @@
 
 
 'use strict';
 'use strict';
 
 
-var modules = bender.amd.require( 'ckeditor', 'ui/view', 'ui/region', 'ckeditorerror', 'model' );
+var modules = bender.amd.require( 'ckeditor', 'ui/view', 'ui/region', 'ckeditorerror', 'model', 'eventinfo' );
 var View, TestView;
 var View, TestView;
 var view;
 var view;
 
 
 bender.tools.createSinonSandbox();
 bender.tools.createSinonSandbox();
-beforeEach( createViewInstance );
+
+beforeEach( updateModuleReference );
 
 
 describe( 'constructor', function() {
 describe( 'constructor', function() {
+	beforeEach( function() {
+		setTestViewClass();
+		setTestViewInstance();
+	} );
+
 	it( 'accepts the model', function() {
 	it( 'accepts the model', function() {
+		setTestViewInstance( { a: 'foo', b: 42 } );
+
 		expect( view.model ).to.be.an.instanceof( modules.model );
 		expect( view.model ).to.be.an.instanceof( modules.model );
 
 
 		expect( view ).to.have.deep.property( 'model.a', 'foo' );
 		expect( view ).to.have.deep.property( 'model.a', 'foo' );
@@ -25,6 +33,11 @@ describe( 'constructor', function() {
 } );
 } );
 
 
 describe( 'instance', function() {
 describe( 'instance', function() {
+	beforeEach( function() {
+		setTestViewClass();
+		setTestViewInstance();
+	} );
+
 	it( 'has no default element', function() {
 	it( 'has no default element', function() {
 		expect( () => view.el ).to.throw( modules.ckeditorerror );
 		expect( () => view.el ).to.throw( modules.ckeditorerror );
 	} );
 	} );
@@ -39,7 +52,11 @@ describe( 'instance', function() {
 } );
 } );
 
 
 describe( 'bind', function() {
 describe( 'bind', function() {
+	beforeEach( createViewInstanceWithTemplate );
+
 	it( 'returns a function that passes arguments', function() {
 	it( 'returns a function that passes arguments', function() {
+		setTestViewInstance( { a: 'foo' } );
+
 		var spy = bender.sinon.spy();
 		var spy = bender.sinon.spy();
 		var callback = view.bind( 'a', spy );
 		var callback = view.bind( 'a', spy );
 
 
@@ -65,7 +82,7 @@ describe( 'bind', function() {
 			};
 			};
 		} );
 		} );
 
 
-		view = new TestView( { foo: 'bar' } );
+		setTestViewInstance( { foo: 'bar' } );
 
 
 		expect( view.el.outerHTML ).to.be.equal( '<p class="bar">abc</p>' );
 		expect( view.el.outerHTML ).to.be.equal( '<p class="bar">abc</p>' );
 
 
@@ -87,7 +104,7 @@ describe( 'bind', function() {
 			};
 			};
 		} );
 		} );
 
 
-		view = new TestView( { foo: 'bar' } );
+		setTestViewInstance( { foo: 'bar' } );
 
 
 		expect( view.el.outerHTML ).to.be.equal( '<p>bar<b>baz</b></p>' );
 		expect( view.el.outerHTML ).to.be.equal( '<p>bar<b>baz</b></p>' );
 
 
@@ -110,7 +127,7 @@ describe( 'bind', function() {
 			};
 			};
 		} );
 		} );
 
 
-		view = new TestView( { foo: 3 } );
+		setTestViewInstance( { foo: 3 } );
 		expect( view.el.outerHTML ).to.be.equal( '<p class="positive">positive</p>' );
 		expect( view.el.outerHTML ).to.be.equal( '<p class="positive">positive</p>' );
 
 
 		view.model.foo = -7;
 		view.model.foo = -7;
@@ -134,7 +151,7 @@ describe( 'bind', function() {
 			};
 			};
 		} );
 		} );
 
 
-		view = new TestView( { foo: 'moo' } );
+		setTestViewInstance( { foo: 'moo' } );
 		expect( view.el.outerHTML ).to.be.equal( '<p>moo</p>' );
 		expect( view.el.outerHTML ).to.be.equal( '<p>moo</p>' );
 
 
 		view.model.foo = 'changed';
 		view.model.foo = 'changed';
@@ -142,65 +159,234 @@ describe( 'bind', function() {
 	} );
 	} );
 } );
 } );
 
 
-describe( 'listeners', function() {
-	it( 'accept plain definitions', function() {
+describe( 'listener definition', function() {
+	it( 'accepts plain binding', function() {
+		var spy = bender.sinon.spy();
+
 		setTestViewClass( function() {
 		setTestViewClass( function() {
 			return {
 			return {
 				tag: 'p',
 				tag: 'p',
 				listeners: {
 				listeners: {
 					x: 'a',
 					x: 'a',
-					y: [ 'b', 'c' ],
 				}
 				}
 			};
 			};
 		} );
 		} );
 
 
-		view = new TestView();
+		setTestViewInstance();
+
+		view.on( 'a', spy );
+
+		dispatchEvent( view.el, 'x' );
+		sinon.assert.calledWithExactly( spy,
+			sinon.match.has( 'name', 'a' ),
+			sinon.match.has( 'target', view.el )
+		);
+	} );
+
+	it( 'accepts an array of event bindings', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				listeners: {
+					x: [ 'a', 'b' ]
+				}
+			};
+		} );
+
+		setTestViewInstance();
 
 
-		view.el.dispatchEvent( new Event( 'x' ) );
-		view.el.dispatchEvent( new Event( 'y' ) );
+		view.on( 'a', spy1 );
+		view.on( 'b', spy2 );
+
+		dispatchEvent( view.el, 'x' );
+		sinon.assert.calledWithExactly( spy1,
+			sinon.match.has( 'name', 'a' ),
+			sinon.match.has( 'target', view.el )
+		);
+		sinon.assert.calledWithExactly( spy2,
+			sinon.match.has( 'name', 'b' ),
+			sinon.match.has( 'target', view.el )
+		);
 	} );
 	} );
 
 
-	it( 'accept definition with selectors', function() {
+	it( 'accepts DOM selectors', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+		var spy3 = bender.sinon.spy();
+
 		setTestViewClass( function() {
 		setTestViewClass( function() {
 			return {
 			return {
 				tag: 'p',
 				tag: 'p',
 				children: [
 				children: [
 					{
 					{
 						tag: 'span',
 						tag: 'span',
-						'class': '.y'
+						attributes: {
+							'class': 'y',
+						},
+						listeners: {
+							'test@p': 'c'
+						}
 					},
 					},
 					{
 					{
 						tag: 'div',
 						tag: 'div',
 						children: [
 						children: [
 							{
 							{
 								tag: 'span',
 								tag: 'span',
-								'class': '.y'
+								attributes: {
+									'class': 'y',
+								}
 							}
 							}
 						],
 						],
 					}
 					}
 				],
 				],
 				listeners: {
 				listeners: {
-					'x@.y': 'a',
-					'y@div': 'b'
+					'test@.y': 'a',
+					'test@div': 'b'
+				}
+			};
+		} );
+
+		setTestViewInstance();
+
+		view.on( 'a', spy1 );
+		view.on( 'b', spy2 );
+		view.on( 'c', spy3 );
+
+		// Test "test@p".
+		dispatchEvent( view.el, 'test' );
+
+		sinon.assert.callCount( spy1, 0 );
+		sinon.assert.callCount( spy2, 0 );
+		sinon.assert.callCount( spy3, 0 );
+
+		// Test "test@.y".
+		dispatchEvent( view.el.firstChild, 'test' );
+
+		expect( spy1.firstCall.calledWithExactly(
+			sinon.match.has( 'name', 'a' ),
+			sinon.match.has( 'target', view.el.firstChild )
+		) ).to.be.true;
+
+		sinon.assert.callCount( spy2, 0 );
+		sinon.assert.callCount( spy3, 0 );
+
+		// Test "test@div".
+		dispatchEvent( view.el.lastChild, 'test' );
+
+		sinon.assert.callCount( spy1, 1 );
+
+		expect( spy2.firstCall.calledWithExactly(
+			sinon.match.has( 'name', 'b' ),
+			sinon.match.has( 'target', view.el.lastChild )
+		) ).to.be.true;
+
+		sinon.assert.callCount( spy3, 0 );
+
+		// Test "test@.y".
+		dispatchEvent( view.el.lastChild.firstChild, 'test' );
+
+		expect( spy1.secondCall.calledWithExactly(
+			sinon.match.has( 'name', 'a' ),
+			sinon.match.has( 'target', view.el.lastChild.firstChild )
+		) ).to.be.true;
+
+		sinon.assert.callCount( spy2, 1 );
+		sinon.assert.callCount( spy3, 0 );
+	} );
+
+	it( 'accepts function callbacks', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				children: [
+					{
+						tag: 'span'
+					}
+				],
+				listeners: {
+					x: spy1,
+					'y@span': [ spy2, 'c' ],
+				}
+			};
+		} );
+
+		setTestViewInstance();
+
+		dispatchEvent( view.el, 'x' );
+		dispatchEvent( view.el.firstChild, 'y' );
+
+		sinon.assert.calledWithExactly( spy1,
+			sinon.match.has( 'target', view.el )
+		);
+
+		sinon.assert.calledWithExactly( spy2,
+			sinon.match.has( 'target', view.el.firstChild )
+		);
+	} );
+
+	it( 'supports event delegation', function() {
+		var spy = bender.sinon.spy();
+
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				children: [
+					{
+						tag: 'span'
+					}
+				],
+				listeners: {
+					x: 'a',
 				}
 				}
 			};
 			};
 		} );
 		} );
 
 
-		view = new TestView();
+		setTestViewInstance();
 
 
-		view.el.childNodes[ 0 ].dispatchEvent( new Event( 'x' ) );
-		view.el.childNodes[ 1 ].dispatchEvent( new Event( 'x' ) ); // false
-		view.el.childNodes[ 1 ].childNodes[ 0 ].dispatchEvent( new Event( 'x' ) );
+		view.on( 'a', spy );
 
 
-		view.el.childNodes[ 0 ].dispatchEvent( new Event( 'y' ) ); // false
-		view.el.childNodes[ 1 ].dispatchEvent( new Event( 'y' ) );
-		view.el.childNodes[ 1 ].childNodes[ 0 ].dispatchEvent( new Event( 'y' ) ); // false
+		dispatchEvent( view.el.firstChild, 'x' );
+		sinon.assert.calledWithExactly( spy,
+			sinon.match.has( 'name', 'a' ),
+			sinon.match.has( 'target', view.el.firstChild )
+		);
+	} );
+
+	it( 'works for future elements', function() {
+		var spy = bender.sinon.spy();
+
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				listeners: {
+					'test@div': 'a'
+				}
+			};
+		} );
+
+		setTestViewInstance();
+
+		view.on( 'a', spy );
+
+		var div = document.createElement( 'div' );
+		view.el.appendChild( div );
+
+		dispatchEvent( div, 'test' );
+		sinon.assert.calledWithExactly( spy, sinon.match.has( 'name', 'a' ), sinon.match.has( 'target', div ) );
 	} );
 	} );
 } );
 } );
 
 
 describe( 'render', function() {
 describe( 'render', function() {
+	beforeEach( createViewInstanceWithTemplate );
+
 	it( 'creates an element from template', function() {
 	it( 'creates an element from template', function() {
-		view = new TestView( { a: 1 } );
+		setTestViewInstance( { a: 1 } );
 
 
 		expect( view.el ).to.be.an.instanceof( HTMLElement );
 		expect( view.el ).to.be.an.instanceof( HTMLElement );
 		expect( view.el.nodeName ).to.be.equal( 'A' );
 		expect( view.el.nodeName ).to.be.equal( 'A' );
@@ -208,6 +394,8 @@ describe( 'render', function() {
 } );
 } );
 
 
 describe( 'destroy', function() {
 describe( 'destroy', function() {
+	beforeEach( createViewInstanceWithTemplate );
+
 	it( 'detaches the model', function() {
 	it( 'detaches the model', function() {
 		expect( view.model ).to.be.an.instanceof( modules.model );
 		expect( view.model ).to.be.an.instanceof( modules.model );
 
 
@@ -217,8 +405,6 @@ describe( 'destroy', function() {
 	} );
 	} );
 
 
 	it( 'detaches the element', function() {
 	it( 'detaches the element', function() {
-		view = new TestView();
-
 		// Append the views's element to some container.
 		// Append the views's element to some container.
 		var container = document.createElement( 'div' );
 		var container = document.createElement( 'div' );
 		container.appendChild( view.el );
 		container.appendChild( view.el );
@@ -253,7 +439,8 @@ describe( 'destroy', function() {
 			};
 			};
 		} );
 		} );
 
 
-		view = new TestView( { foo: 'bar' } );
+		setTestViewInstance( { foo: 'bar' } );
+
 		var model = view.model;
 		var model = view.model;
 
 
 		expect( view.el.outerHTML ).to.be.equal( '<p>bar</p>' );
 		expect( view.el.outerHTML ).to.be.equal( '<p>bar</p>' );
@@ -268,20 +455,41 @@ describe( 'destroy', function() {
 	} );
 	} );
 } );
 } );
 
 
-function createViewInstance() {
+function updateModuleReference() {
 	View = modules[ 'ui/view' ];
 	View = modules[ 'ui/view' ];
-	view = new View( { a: 'foo', b: 42 } );
+}
 
 
-	setTestViewClass( () => {
-		return { tag: 'a' };
-	} );
+function createViewInstanceWithTemplate() {
+	setTestViewClass( () => { return { tag: 'a' }; } );
+	setTestViewInstance();
 }
 }
 
 
 function setTestViewClass( template ) {
 function setTestViewClass( template ) {
 	TestView = class V extends View {
 	TestView = class V extends View {
 		constructor( model ) {
 		constructor( model ) {
 			super( model );
 			super( model );
-			this.template = template.call( this );
+
+			if ( template ) {
+				this.template = template.call( this );
+			}
 		}
 		}
 	};
 	};
 }
 }
+
+function setTestViewInstance( model ) {
+	view = new TestView( model );
+
+	if ( view.template ) {
+		document.body.appendChild( view.el );
+	}
+}
+
+function dispatchEvent( el, domEvtName ) {
+	if ( !el.parentNode ) {
+		throw( 'To dispatch an event, element must be in DOM. Otherwise #target is null.' );
+	}
+
+	el.dispatchEvent( new Event( domEvtName, {
+		bubbles: true
+	} ) );
+}