Skip to content
This repository has been archived by the owner on Mar 22, 2019. It is now read-only.

Latest commit

 

History

History
668 lines (486 loc) · 30.4 KB

view_layer.md

File metadata and controls

668 lines (486 loc) · 30.4 KB

The Ember.js View Layer

This guide goes into extreme detail about the Ember.js view layer. It is intended for an experienced Ember developer, and includes details that are unnecessary for getting started with Ember.

Ember.js has a sophisticated system for creating, managing and rendering a hierarchy of views that connect to the browser's DOM. Views are responsible for responding to user events, like clicks, drags, and scrolls, as well as updating the contents of the DOM when the data underlying the view changes.

View hierarchies are usually created by evaluating a Handlebars template. As the template is evaluated, child views are added. As the templates for those child views are evaluated, they may have child views added, and so on, until an entire hierarchy is created.

Even if you do not explicitly create child views from your Handlebars templates, Ember.js internally uses the view system to update bound values. For example, every Handlebars expression {{value}} creates a view behind-the-scenes that knows how to update the bound value if it changes.

You can also dynamically make changes to the view hierarchy at application runtime using the Ember.ContainerView class. Rather than being template-driven, a container view exposes an array of child view instances that can be manually managed.

Views and templates work in tandem to provide a robust system for creating whatever user interface you dream up. End users should be isolated from the complexities of things like timing issues while rendering and event propagation. Application developers should be able to describe their UI once, as a string of Handlebars markup, and then carry on with their application without having to worry about making sure that it remains up-to-date.

What problems does it solve?

Child Views

In a typical client-side application, views may represent elements nested inside of each other in the DOM. In the naïve solution to this problem, separate view objects represent each DOM element, and ad-hoc references help the various view object keep track of the views conceptually nested inside of them.

Here is a simple example, representing one main app view, a collection nested inside of it, and individual items nested inside of the collection.

This system works well at first glance, but imagine that we want to open Joe's Lamprey Shack at 8am instead of 9am. In this situation, we will want to re-render the App View. Because the developer needed to build up the references to the children on an ad-hoc basis, this re-rendering process has several problems.

In order to re-render the App View, the App View must also manually re-render the child views and re-insert them into App View's element. If implemented perfectly, this process works well, but it relies upon a perfect, ad hoc implementation of a view hierarchy. If any single view fails to implement this precisely, the entire re-render will fail.

In order to avoid these problems, Ember's view hierarchy has the concept of child views baked in.

When the App View re-renders, Ember is responsible for re-rendering and inserting the child views, not application code. This also means that Ember can perform any memory management for you, such as cleaning up observers and bindings.

Not only does this eliminate quite a bit of boilerplate code, but it eliminates the possibility that an imperfectly implemented view hierarchy will cause unexpected failures.

Event Delegation

In the past, web developers have added event listeners to individual elements in order to know when the user interacts with them. For example, you might have a <div> element on which you register a function that gets called when the user clicks it.

However, this approach often does not scale when dealing with large numbers of interactive elements. For example, imagine a <ul> with 100 <li>s in it, with a delete button next to each item. Since the behavior is the same for all of these items, it would be inefficient to create 100 event listeners, one for each delete button.

To solve this problem, developers discovered a technique called "event delegation". Instead of registering a listener on each element in question, you can register a single listener for the containing element and use event.target to identify which element the user clicked on.

Implementing this is a bit tricky, because some events (like focus, blur and change) don't bubble. Fortunately, jQuery has solved this problem thoroughly; using jQuery's on method reliably works for all native browser events.

Other JavaScript frameworks tackle this problem in one of two ways. In the first approach, they ask you to implement the naïve solution yourself, creating a separate view for each element. When you create the view, it sets up an event listener on the view's element. If you had a list of 500 items, you would create 500 views and each would set up a listener on its own element.

In the second approach, the framework builds in event delegation at the view level. When creating a view, you can supply a list of events to delegate and a method to call when the event occurs. This leaves identifying the context of the click (for example, which item in the list) to the method receiving the event.

You are now faced with an uncomfortable choice: create a new view for each item and lose the benefits of event delegation, or create a single view for all of the items and have to store information about the underlying JavaScript object in the DOM.

In order to solve this problem, Ember delegates all events to the application's root element (usually the document body) using jQuery. When an event occurs, Ember identifies the nearest view that handles the event and invokes its event handler. This means that you can create views to hold a JavaScript context, but still get the benefit of event delegation.

Further, because Ember registers only one event for the entire Ember application, creating new views never requires setting up event listeners, making re-renders efficient and less error-prone. When a view has child views, this also means that there is no need to manually undelegate views that the re-render process replaces.

The Rendering Pipeline

Most web applications specify their user interface using the markup of a particular templating language. For Ember.js, we've done the work to make templates written using the Handlebars templating language automatically update when the values used inside of them are changed.

While the process of displaying a template is automatic for developers, under the hood there are a series of steps that must be taken to go from the original template to the final, live DOM representation that the user sees.

This is the approximate lifecycle of an Ember view:

1. Template Compilation

The application's templates are loaded over the network or as part of the application payload in string form. When the application loads, it sends the template string to Handlebars to be compiled into a function. Once compiled, the template function is saved, and can be used by multiple views repeatedly, each time they need to re-render.

This step may be omitted in applications where the templates are pre-compiled on the server. In those cases, the template is transferred not as the original, human-readable template string but as the compiled code.

Because Ember is responsible for template compilation, you don't have to do any additional work to ensure that compiled templates are reused.

2. String Concatenation

A view's rendering process is kickstarted when the application calls append or appendTo on the view. Calling append or appendTo schedules the view to be rendered and inserted later. This allows any deferred logic in your application (such as binding synchronization) to happen before rendering the element.

To begin the rendering process, Ember creates a RenderBuffer and gives it to the view to append its contents to. During this process, a view can create and render child views. When it does so, the parent view creates and assigns a RenderBuffer for the child, and links it to the parent's RenderBuffer.

Ember flushes the binding synchronization queue before rendering each view. By syncing bindings before rendering each view, Ember guarantees that it will not render stale data it needs to replace right away.

Once the main view has finished rendering, the render process has created a tree of views (the "view hierarchy"), linked to a tree of buffers. By walking down the tree of buffers and converting them into Strings, we have a String that we can insert into the DOM.

Here is a simple example:

In addition to children (Strings and other RenderBuffers), a RenderBuffer also encapsulates the element's tag name, id, classes, style, and other attributes. This makes it possible for the render process to modify one of these properties (style, for example), even after its child Strings have rendered. Because many of these properties are controlled via bindings (e.g. using bindAttr), this makes the process robust and transparent.

3. Element Creation and Insertion

At the end of the rendering process, the root view asks the RenderBuffer for its element. The RenderBuffer takes its completed string and uses jQuery to convert it into an element. The view assigns that element to its element property and places it into the correct place in the DOM (the location specified in appendTo or the application's root element if the application used append).

While the parent view assigns its element directly, each child views looks up its element lazily. It does this by looking for an element whose id matches its elementId property. Unless explicitly provided, the rendering process generates an elementId property and assigns its value to the view's RenderBuffer, which allows the view to find its element as needed.

4. Re-Rendering

After the view inserts itself into the DOM, either Ember or the application may want to re-render the view. They can trigger a re-render by calling the rerender method on a view.

Rerendering will repeat steps 2 and 3 above, with two exceptions:

  • Instead of inserting the element into an explicitly specified location, rerender replaces the existing element with the new element.
  • In addition to rendering a new element, it also removes the old element and destroys its children. This allows Ember to automatically handle unregistering appropriate bindings and observers when re-rendering a view. This makes observers on a path more viable, because the process of registering and unregistering all of the nested observers is automatic.

The most common cause of a view re-render is when the value bound to a Handlebars expression ({{foo}}) changes. Internally, Ember creates a simple view for each expression, and registers an observer on the path. When the path changes, Ember updates the area of the DOM with the new value.

Another common case is an {{#if}} or {{#with}} block. When rendering a template, Ember creates a virtual view for these block helpers. These virtual views do not appear in the publicly available view hierarchy (when getting parentView and childViews from a view), but they exist to enable consistent re-rendering.

When the path passed to an {{#if}} or {{#with}} changes, Ember automatically re-renders the virtual view, which will replace its contents, and importantly, destroy all child views to free up their memory.

In addition to these cases, the application may sometimes want to explicitly re-render a view (usually a ContainerView, see below). In this case, the application can call rerender directly, and Ember will queue up a re-rendering job, with the same semantics.

The process looks something like:

The View Hierarchy

Parent and Child Views

As Ember renders a templated view, it will generate a view hierarchy. Let's assume we have a template form.

{{view App.Search placeholder="Search"}}
{{#view Ember.Button}}Go!{{/view}}

And we insert it into the DOM like this:

var view = Ember.View.create({
  templateName: 'form'
}).append();

This will create a small view hierarchy that looks like this:

You can move around in the view hierarchy using the parentView and childViews properties.

var children = view.get('childViews') // [ <App.Search>, <Ember.Button> ]
children.objectAt(0).get('parentView') // view

One common use of the parentView method is inside of an instance of a child view.

App.Search = Ember.View.extend({
  didInsertElement: function() {
    // this.get('parentView') in here references `view`
  }
})

Lifecycle Hooks

In order to make it easy to take action at different points during your view's lifecycle, there are several hooks you can implement.

  • willInsertElement: This hook is called after the view has been rendered but before it has been inserted into the DOM. It does not provide access to the view's element.
  • didInsertElement: This hook is called immediately after the view has been inserted into the DOM. It provides access to the view's element and is most useful for integration with an external library. Any explicit DOM setup code should be limited to this hook.
  • willDestroyElement: This hook is called immediately before the element is removed from the DOM. This is your opportunity to tear down any external state associated with the DOM node. Like didInsertElement, it is most useful for integration with external libraries.
  • willRerender: This hook is called immediately before a view is re-rendered. This is useful if you want to perform some teardown immediately before a view is re-rendered.
  • becameVisible: This hook is called after a view's isVisible property, or one of its ancestor's isVisible property, changes to true and the associated element becomes visible. Note that this hook is only reliable if all visibility is routed through the isVisible property.
  • becameHidden: This hook is called after a view's isVisible property, or one of its ancestor's isVisible property, changes to false and the associated element becomes hidden. Note that this hook is only reliable if all visibility is routed through the isVisible property.

Apps can implement these hooks by defining a method by the hook's name on the view. Alternatively, it is possible to register a listener for the hook on a view:

view.on('willRerender', function() {
  // do something with view
});

Virtual Views

As described above, Handlebars creates views in the view hierarchy to represent bound values. Every time you use a Handlebars expression, whether it's a simple value or a block helper like {{#with}} or {{#if}}, Handlebars creates a new view.

Because Ember uses these views for internal bookkeeping only, they are hidden from the view's public parentView and childViews API. The public view hierarchy reflects only views created using the {{view}} helper or through ContainerView (see below).

For example, consider the following Handlebars template:

<h1>Joe's Lamprey Shack</h1>
{{controller.restaurantHours}}

{{#view App.FDAContactForm}}
  If you are experiencing discomfort from eating at Joe's Lamprey Shack,
please use the form below to submit a complaint to the FDA.

  {{#if controller.allowComplaints}}
    {{view Ember.TextArea valueBinding="controller.complaint"}}
    <button {{action submitComplaint}}>Submit</button>
  {{/if}}
{{/view}}

Rendering this template would create a hierarchy like this:

Behind the scenes, Ember tracks additional virtual views for the Handlebars expressions:

From inside of the TextArea, the parentView would point to the FDAContactForm and the FDAContactForm's childViews would be an array of the single TextArea view.

You can see the internal view hierarchy by asking for the _parentView or _childViews, which will include virtual views:

var _childViews = view.get('_childViews');
console.log(_childViews.objectAt(0).toString());
//> <Ember._HandlebarsBoundView:ember1234>

Warning! You may not rely on these internal APIs in application code. They may change at any time and have no public contract. The return value may not be observable or bindable. It may not be an Ember object. If you feel the need to use them, please contact us so we can expose a better public API for your use-case.

Bottom line: This API is like XML. If you think you have a use for it, you may not yet understand the problem enough. Reconsider!

Event Bubbling

One responsibility of views is to respond to primitive user events and translate them into events that have semantic meaning for your application.

For example, a delete button translates the primitive click event into the application-specific "remove this item from an array."

In order to respond to user events, create a new view subclass that implements that event as a method:

App.DeleteButton = Ember.View.create({
  click: function(event) {
    var stateManager = this.getPath('controller.stateManager');
    var item = this.get('content');

    stateManager.send('deleteItem', item);
  }
});

When you create a new Ember.Application instance, it registers an event handler for each native browser event using jQuery's event delegation API. When the user triggers an event, the application's event dispatcher will find the view nearest to the event target that implements the event.

A view implements an event by defining a method corresponding to the event name. When the event name is made up of multiple words (like mouseup) the method name should be the camelized form of the event name (mouseUp).

Events will bubble up the view hierarchy until the event reaches the root view. An event handler can stop propagation using the same techniques as normal jQuery event handlers:

  • return false from the method
  • event.stopPropagation

For example, imagine you defined the following view classes:

App.GrandparentView = Ember.View.extend({
  click: function() {
    console.log('Grandparent!');
  }
});

App.ParentView = Ember.View.extend({
  click: function() {
    console.log('Parent!');
    return false;
  }
});

App.ChildView = Ember.View.extend({
  click: function() {
    console.log('Child!');
  }
});

And here's the Handlebars template that uses them:

{{#view App.GrandparentView}}
  {{#view App.ParentView}}
    {{#view App.ChildView}}
      <h1>Click me!</h1>
    {{/view}}
  {{/view}}
{{/view}}

If you clicked on the <h1>, you'd see the following output in your browser's console:

Child!
Parent!

You can see that Ember invokes the handler on the child-most view that received the event. The event continues to bubble to the ParentView, but does not reach the GrandparentView because ParentView returns false from its event handler.

You can use normal event bubbling techniques to implement familiar patterns. For example, you could implement a FormView that defines a submit method. Because the browser triggers the submit event when the user hits enter in a text field, defining a submit method on the form view will "just work".

App.FormView = Ember.View.extend({
  tagName: "form",

  submit: function(event) {
    // will be invoked whenever the user triggers
    // the browser's `submit` method
  }
});
{{#view App.FormView}}
  {{view Ember.TextFieldView valueBinding="controller.firstName"}}
  {{view Ember.TextFieldView valueBinding="controller.lastName"}}
  <button type="submit">Done</button>
{{/view}}

Adding New Events

Ember comes with built-in support for the following native browser events:

Event NameMethod Name
touchstarttouchStart
touchmovetouchMove
touchendtouchEnd
touchcanceltouchCancel
keydownkeyDown
keyupkeyUp
keypresskeyPress
mousedownmouseDown
mouseupmouseUp
contextmenucontextMenu
clickclick
dblclickdoubleClick
mousemovemouseMove
Event NameMethod Name
focusinfocusIn
focusoutfocusOut
mouseentermouseEnter
mouseleavemouseLeave
submitsubmit
changechange
dragstartdragStart
dragdrag
dragenterdragEnter
dragleavedragLeave
dragoverdragOver
dropdrop
dragenddragEnd

You can add additional events to the event dispatcher when you create a new application:

App = Ember.Application.create({
  customEvents: {
    // add support for the loadedmetadata media
    // player event
    'loadedmetadata': "loadedMetadata"
  }
});

In order for this to work for a custom event, the HTML5 spec must define the event as "bubbling", or jQuery must have provided an event delegation shim for the event.

Templated Views

As you've seen so far in this guide, the majority of views that you will use in your application are backed by a template. When using templates, you do not need to programmatically create your view hierarchy because the template creates it for you.

While rendering, the view's template can append views to its child views array. Internally, the template's {{view}} helper calls the view's appendChild method.

Calling appendChild does two things:

  1. Adds the child view to the childViews array.
  2. Immediately renders the child view and adds it to the parent's render buffer.

You may not call appendChild on a view after it has left the rendering state. A template renders "mixed content" (both views and plain text) so the parent view does not know exactly where to insert the new child view once the rendering process has completed.

In the example above, imagine trying to insert a new view inside of the parent view's childViews array. Should it go immediately after the closing </div> of App.MyView? Or should it go after the closing </div> of the entire view? There is no good answer that will always be correct.

Because of this ambiguity, the only way to create a view hierarchy using templates is via the {{view}} helper, which always inserts views in the right place relative to any plain text.

While this works for most situations, occasionally you may want to have direct, programmatic control of a view's children. In that case, you can use Ember.ContainerView, which explicitly exposes a public API for doing so.

Container Views

Container views contain no plain text. They are composed entirely of their child views (which may themselves be template-backed).

ContainerView exposes two public APIs for changing its contents:

  1. A writable childViews array into which you can insert Ember.View instances.
  2. A currentView property that, when set, inserts the new value into the child views array. If there was a previous value of currentView, it is removed from the childViews array.

Here is an example of using the childViews API to create a view that starts with a hypothetical DescriptionView and can add a new button at any time by calling the addButton method:

App.ToolbarView = Ember.ContainerView.create({
  init: function() {
    var childViews = this.get('childViews');
    var descriptionView = App.DescriptionView.create();

    childViews.pushObject(descriptionView);
    this.addButton();

    return this._super();
  },

  addButton: function() {
    var childViews = this.get('childViews');
    var button = Ember.ButtonView.create();

    childViews.pushObject(button);
  }
});

As you can see in the example above, we initialize the ContainerView with two views, and can add additional views during runtime. There is a convenient shorthand for doing this view setup without having to override the init method:

App.ToolbarView = Ember.ContainerView.create({
  childViews: ['descriptionView', 'buttonView'],

  descriptionView: App.DescriptionView,
  buttonView: Ember.ButtonView,

  addButton: function() {
    var childViews = this.get('childViews');
    var button = Ember.ButtonView.create();

    childViews.pushObject(button);
  }
});

As you can see above, when using this shorthand, you specify the childViews as an array of strings. At initialization time, each of the strings is used as a key to look up a view instance or class. That view is automatically instantiated, if necessary, and added to the childViews array.

Template Scopes

Standard Handlebars templates have the concept of a context--the object from which expressions will be looked up.

Some helpers, like {{#with}}, change the context inside their block. Others, like {{#if}}, preserve the context. These are called "context-preserving helpers."

When a Handlebars template in an Ember app uses an expression ({{#if foo.bar}}), Ember will automatically set up an observer for that path on the current context.

If the object referenced by the path changes, Ember will automatically re-render the block with the appropriate context. In the case of a context-preserving helper, Ember will re-use the original context when re-rendering the block. Otherwise, Ember will use the new value of the path as the context.

{{#if controller.isAuthenticated}}
  <h1>Welcome {{controller.name}}</h1>
{{/if}}

{{#with controller.user}}
  <p>You have {{notificationCount}} notifications.</p>
{{/with}}

In the above template, when the isAuthenticated property changes from false to true, Ember will render the block, using the original outer scope as its context.

The {{#with}} helper changes the context of its block to the user property on the current controller. When the user property changes, Ember re-renders the block, using the new value of controller.user as its context.

View Scope

In addition to the Handlebars context, templates in Ember also have the notion of the current view. No matter what the current context is, the view property always references the closest view.

Note that the view property never references the internal views created for block expressions like {{#if}}. This allows you to differentiate between Handlebars contexts, which always work the way they do in vanilla Handlebars, and the view hierarchy.

Because view points to an Ember.View instance, you can access any properties on the view by using an expression like view.propertyName. You can get access to a view's parent using view.parentView.

For example, imagine you had a view with the following properties:

App.MenuItemView = Ember.View.create({
  templateName: 'menu_item_view',
  bulletText: '*'
});

…and the following template:

{{#with controller}}
  {{view.bulletText}} {{name}}
{{/with}}

Even though the Handlebars context has changed to the current controller, you can still access the view's bulletText by referencing view.bulletText.

Template Variables

So far in this guide, we've been handwaving around the use of the controller property in our Handlebars templates. Where does it come from?

Handlebars contexts in Ember can inherit variables from their parent contexts. Before Ember looks up a variable in the current context, it first checks in its template variables. As a template creates new Handlebars scope, they automatically inherit the variables from their parent scope.

Ember defines these view and controller variables, so they are always found first when an expression uses the view or controller names.

As described above, Ember sets the view variable on the Handlebars context whenever a template uses the {{#view}} helper. Initially, Ember sets the view variable to the view rendering the template.

Ember sets the controller variable on the Handlebars context whenever a rendered view has a controller property. If a view has no controller property, it inherits the controller variable from the most recent view with one.

Other Variables

Handlebars helpers in Ember may also specify variables. For example, the {{#with controller.person as tom}} form specifies a tom variable that descendent scopes can access. Even if a child context has a tom property, the tom variable will supersede it.

This form has one major benefit: it allows you to shorten long paths without losing access to the parent scope.

It is especially important in the {{#each}} helper, which provides a {{#each person in people}} form. In this form, descendent context have access to the person variable, but remain in the same scope as where the template invoked the each.

{{#with controller.preferences}}
  <h1>Title</h1>
  <ul>
  {{#each controller.people as person}}
    {{! prefix here is controller.preferences.prefix }}
    <li>{{prefix}}: {{person.fullName}}</li>
  {{/each}}
  <ul>
{{/with}}

Note that these variables inherit through ContainerViews, even though they are not part of the Handlebars context hierarchy.

Accessing Template Variables from Views

In most cases, you will need to access these template variables from inside your templates. In some unusual cases, you may want to access the variables in-scope from your view's JavaScript code.

You can do this by accessing the view's templateVariables property, which will return a JavaScript object containing the variables that were in scope when the view was rendered. ContainerViews also have access to this property, which references the template variables in the most recent template-backed view.

At present, you may not observe or bind a path containing templateVariables.