Przykładowa aplikacja ember.js z Django i Django Rest Framework

Ember.js to jeden z frameworków do tworzenia aplikacji na jednej stronie. Układ kodu nieco podobny do tego w Django może zachęcić pythonistów do jego nauki i wykorzystania w pasujących projektach. Wcześniej opisywałem podstawy embera i przykładową aplikację z wykorzystaniem API generowanego przez Tastypie. W tym artykule postaram się pokazać nieco więcej, jak i wykorzystam nowszego embera jak i Django Rest Framework do wystawienia API.

Aplikacja Ember.js + DRF + Django

Na start jeżeli jeszcze tego nie zrobiłeś, to przeczytaj poprzedni artykuł opisujący podstawy embera i aplikacji Django powiązanej z nim. W tym artykule przedstawię od razu gotowy kod prostej emberowej aplikacji.

W przypadku tworzenia aplikacji zaczynamy od jakiegoś szkieletu. Od strony Django potrzebujemy prosty widok zwracający zrenderowany szablon i zestaw plików JS. Jako przykład zaprezentuję prostą aplikację blog z newsami przypisanymi do kategorii. Aplikacja będzie listować najnowsze wpisy ze wszystkich jak i z jednej wybranej kategorii. Cały gotowy kod dostępny jest na githubie.

Aplikacja na githubie ma dwie implementacje - blog czyli implementacja w Django oraz eblog implementacja w Emberze. Można więc porównać.

Zaczynamy od widoku w Django, który zwróci nam szablon:

from django.views.generic import TemplateView


class EmberView(TemplateView):
    template_name = 'eblog/blog.html'

ember_view = EmberView.as_view()

Szablon blog.html podpina kilka plików JS:

<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="{% static 'vendor/handlebars-v1.3.0.js' %}"></script>
<script src="{% static 'vendor/ember.js' %}"></script>
<script src="{% static 'vendor/ember-data.js' %}"></script>
<script src="{% static 'vendor/ember-data-django-rest-adapter.js' %}"></script>
<script src="{% static 'vendor/moment-with-locales.js' %}"></script>

<script src="{% static 'application/app.js' %}"></script>
<script src="{% static 'application/helpers.js' %}"></script>
<script src="{% static 'application/router.js' %}"></script>
<script src="{% static 'application/routes.js' %}"></script>
<script src="{% static 'application/controllers.js' %}"></script>
<script src="{% static 'application/models.js' %}"></script>
<script src="{% static 'application/views.js' %}"></script>

Na początku mamy jQuery, szablony Handlebars dla embera jak i sam ember oraz adapter django rest framework dla embera. Pliki z katalogu application to już nasze pliki na kod aplikacji. Każda część składowa w oddzielnym pliku dla przejrzystości.

  • app.js: Kod definiujący aplikację Ember.js
  • helpers.js: pomocnicze funkcje dla szablonów Handlebars (opcjonalne)
  • router.js: routing urli do naszych stron w emberze
  • routes.js: konfiguracja logiki biznesowej dla poszczególnych stron
  • controllers.js: kontrolery dla stron z naszą własną logiką
  • models.js: modele emberowe mapujące te z Django
  • views.js: emberowe widoki wpływające na działanie/zachowanie szablonów

Na początku pliki te będą puste. Możesz ściągnąć kod aplikacji z repozytorium i skopiować sobie cały katalog vendor i przygotować pozostałe pliki. Zaczynamy od zdefiniowania aplikacji w app.js:

window.Blog = Ember.Application.create();

Gdzie Blog to nazwa aplikacji wybrana przez nas. Będzie ona używana w dalszej części kodu aplikacji.

Teraz możemy stworzyć emberowe modele. Nasza aplikacja ma dwa modele Django - kategorie i wpisy. models.js wygląda więc następująco:

(function(Blog, $, undefined ) {
    Blog.ApplicationAdapter = DS.DjangoRESTAdapter.extend({
        namespace: "api"
    });
    Blog.Category = DS.Model.extend({
        name: DS.attr('string'),
        slug: DS.attr('string')
    });
    Blog.Post = DS.Model.extend({
        title: DS.attr('string'),
        slug: DS.attr('string'),
        text: DS.attr('string'),
        category: DS.belongsTo('category', {async: true}),
        posted_date: DS.attr('date')
    });
}(window.Blog, jQuery));

ApplicationAdapter to konfiguracja adaptera dla Django Rest Framework w emberze. Praktycznie dla każdego z obsługiwanych RESTowych backendowych generatorów API są dostępne dla embera adaptery, które obsługują specyfikę każdego z nich (np. relacje, filtrowanie, generowanie URLi itp.). W przypadku DRF mamy właśnie ember-django-adapter.

Blog.Category i Blog.Post to modele emberowe, które w naszym przypadku odpowiadają ładnie modelom Django. DS.attr może przyjąć jeden argument oznaczający typ danego pola. Nie jest to konieczne, choć w przypadku np. pola daty konieczne do jej prawidłowej obsługi (inaczej byłby to łańcuch a nie JavaScriptowy obiekt daty/czasu).

Pomiędzy Django a emberem jest Django Rest Framework w postaci serializerów i dwóch widoków CategorySetView i PostSetView.

Mając modele możemy dodać pierwsze szablony, tak by chociaż wyświetlić coś z embera. W szablonie blog.html znajdują się JavaScriptowe wstawki dla szablonów poszczególnych stron emberowych. body.handlebars to szablon główny wykorzystywany przez wszystkie pozostałe:

<script type="text/x-handlebars">
    {% include "eblog/body.handlebars" %}
</script>
<script type="text/x-handlebars" data-template-name="posts">
    {% include "eblog/posts.handlebars" %}
</script>
<script type="text/x-handlebars" data-template-name="categoryPosts">
    {% include "eblog/posts.handlebars" %}
</script>
<script type="text/x-handlebars" data-template-name="post">
    {% include "eblog/post.handlebars" %}
</script>

Oprócz szablonu głównego mamy trzy - dla strony z listą ostatnich postów, listą postów z danej kategorii oraz strony z widokiem szczegółowego posta. Nazwa podana w data-template-name wpływa na nazwy widoku, kontrolera, czy routes. Dla posts dostaniemy PostsController, PostsView itd. Jeżeli nie musimy czegoś do nich dodać to nie definiujemy, a ember użyje wewnętrznych domyślnych.

Rzućmy okiem na główny szablon body.handlebars:

{% verbatim %}
<div class="blog-app">
    <h1>{{#link-to "posts"}}Example Blog{{/link-to}}</h1>
    <nav>
        {{#each category in categories itemController="category"}}
            {{#link-to "categoryPosts" category.id classNameBindings="controller.isActive:current"}}{{category.name}}{{/link-to}}
        {{/each}}
    </nav>
    {{outlet}}
</div>
{% endverbatim %}
<footer>
    <a href="{% url 'posts' %}">Classic version</a>
</footer>

Handlebars używa podobnej składni jak szablony Django więc stosujemy tagi verbatim żeby Django nie parsowało zawartości. Tag link-to służy do tworzenia linków do stron w aplikacji emberowej. outlet oznacza miejsce wstawienia treści z szablonów podrzędnych. W tym szablonie mamy jeszcze iterowanie po liście kategorii i tworzenie listy linków.

Jako ekstra w iteratorze each mamy itemController co pozwala nam podać nazwę kontrolera jaki ma być użyty dla każdego iterowanego elementu. W kontrolerze jak widać mamy właściwość isActive - która jeżeli zwróci wartość prawda to wstawi w tag klasę current (WARUNEK:GDY_PRAWDA:GDY_FAŁSZ).

Rzućmy okiem teraz na router, czyli podlinkowanie naszych stron:

(function(Blog, $, undefined) {
    Blog.Router.map(function() {
        this.route('posts');
        this.route('categoryPosts', {path: 'category/:id'});
        this.route('post', {path: 'posts/:id'});
    });
    if (window.history && window.history.pushState) {
        Blog.Router.reopen({
          location: 'history'
        });
    }
}(window.Blog, jQuery));

Mamy proste podlinkowanie trzech stron za pomocą this.route. W przypadku categoryPosts będącym widokiem szczegółowym kategorii podajemy ścieżkę z dynamicznym parametrem - ID kategorii. Podobnie dla post. Nazwa może być dowolna, wedle potrzeb. Zaraz użyjemy jej w routes. Strony mogą też być zagnieżdżone, np post w kategorii - wtedy post renderowałby się w outlecie wstawionym w szablonie kategorii. Zmienia to też układ logiki. W tym przykładzie zastosowałem prostsze rozwiązanie.

Na końcu mamy jeszcze Blog.Router.reopen, czyli zmiana generowania URLi przez embera - jeżeli przeglądarka wspiera push state to linki będą wyglądać normalnie, a jeżeli nie to ember będzie generował domyślne URLe z haszem (/#posts zamiast /posts/). Taki snippet, który możemy powielać w aplikacjach.

Zobaczmy teraz routes, gdzie mamy logikę biznesową poszczególnych stron:

(function(Blog, $, undefined ) {
    Blog.IndexRoute = Ember.Route.extend({
        redirect: function() {
            this.transitionTo('posts');
        }
    });
    Blog.PostsRoute = Ember.Route.extend({
        model: function() {
            return this.get('store').find('post');
        }
    });
    Blog.CategoryPostsRoute = Ember.Route.extend({
        model: function(params) {
            return this.get('store').find('post', {'category': params.id });
        }
    });
    Blog.PostRoute = Ember.Route.extend({
        model: function(params) {
            return this.get('store').find('post', params.id);
        }
    });
}(window.Blog, jQuery));

IndexRoute jest dla głównej strony i pozwala nam przekierować na konkretną naszą stronę, która ma służyć jako strona startowa. Kolejne Route dotyczą naszych stron. Dla PostsRoute pobieramy wszystkie wpisy, a dla CategoryPostsRoute pobieramy posty z kategorii o podanym ID. params.id to zmienna z URLI (id). Można by też pobrać tutaj kategorię o konkretnym ID, a listę wpisów pobrać w kontrolerze. Dla PostRoute pobieramy post o konkretnym ID.

Strona posts pobiera listę wpisów. Szablon posts.handlebars wygląda następująco:

{% verbatim %}
{{#each post in content}}
    <h2>{{#link-to "post" post.id}}{{post.title}}{{/link-to}}</h2>
    <blockquote>
        {{{post.text}}}
        <p>{{date post.posted_date}}, {{post.category.name}}</p>
    </blockquote>
{{else}}
    <p>No posts.</p>
{{/each}}
{% endverbatim %}

Iterujemy po liście postów - dane pobrane przez Route dostępne są pod zmienną content. Podobnie w przypadku pozostałych szablonów. Jedyna nowość to date użyte razem z datą publikacji. To pomocnik z helpers.js, który formatuje datę za pomocą biblioteki moment.js.

Rzućmy jeszcze okiem na kontrolery:

(function(Blog, $, undefined ) {
    Blog.ApplicationController = Ember.ObjectController.extend({
        categories: function() {
            return this.get('store').find('category');
        }.property(),
        makeCurrentPathGlobal: function() {
            Blog.set('currentPath', this.get('currentPath'));
        }.observes('currentPath')
    });
    Blog.PostController = Ember.ObjectController.extend({
        isPython: function() {
            var title = this.get('content.title').toLowerCase();
            var category = this.get('content.category.name');
            if (category) {
                category = category.toLowerCase();
                return title.indexOf('python') != -1 || category.indexOf('python') != -1;
            }
        }.property('content.title', 'content.category.name')
    });
    Blog.CategoryController = Ember.ObjectController.extend({
        needs: ["post"],
        isActive: function() {
            var path = Blog.get('currentPath');
            return path == 'post' && this.get('content.id') == this.get('controllers.post.content.category.id');
        }.property('content.id', 'controllers.post.content.category.id', 'Blog.currentPath')
    });
}(window.Blog, jQuery));

ApplicationController jest głównym kontrolerem aplikacji, który widoczny jest w bazowym szablonie. Pobieramy w nim kategorie na potrzeby tego szablonu. Jest też tam mały hak ustawiający obecną ścieżkę jako zmienną globalną w obrębie aplikacji. Czasami trzeba uzależnić działanie property albo obserwatorów w widokach od ścieżki (choć jeżeli nie jest to potrzebne to trzeba tego unikać by nie generować nadmiernej ilości przeliczeń). categories jest oznaczone jako property i nie zależy od niczego. Właściwości property przeliczają się tylko, gdy zmieni się wartość jednej z zależnych zmiennych pod warunkiem że dane property jest używane. Kategorie akurat nie zależą od niczego. observes w odróżnieniu od property przelicza się zawsze ze zmianą i można traktować je jako takie reakcje na zdarzenia. Nie zwracają niczego i nie można stosować ich w szablonach. O ile to możliwe należy unikać stosowania obserwatorów (za wyjątkiem może widoków, w których robimy coś z zewnętrznymi skryptami/widżetami JS).

W PostController stworzyłem przykładowe property zależne od nazwy kategorii i tytułu wpisu. To zależy od dwóch zmiennych i zwróci prawdę jeżeli gdzieś w obu łańcuchach występuje python. Zobacz szablon post.handlebars, gdzie jest wykorzystywany. Natomiast CategoryController ma już bardziej praktyczną właściwość isActive, której zadaniem jest zwrócić wartość prawda jeżeli ID danej kategorii (iterując kategorie na liście kategorii) jest takie samo jako ID kategorii wpisu z widoku szczegółowego wpisu. Ta właściwość jest wykorzystywana by wstawić klasę aktywności dla danej kategorii w menu.

Zakończenie

Zaprezentowałem dość prostą emberową aplikację. Ze względu na swój asynchroniczny charakter tworzenie emberowych aplikacji może czasami okazać się dość ciężkie (gdy coś odbiega od standardowej ścieżki kodu). Niemniej takie aplikacje na jednej stronie nie posiadające przeładowań pomiędzy podstronami to duża wartość dodana dla niektórych aplikacji webowych - np. aplikacji konkursowych na FB, czy dynamicznych widżetów-stron prezentujących jakieś informacje i oferujące interaktywność z użytkownikiem.

Jeżeli ember Ciebie zainteresował to polecam poznawać go małymi krokami. Możesz wykorzystać moje przykładowe aplikacje by je nieco zmieniać, rozbudowywać, czy testować jak taka aplikacja się zachowuje. Sporo pomocy jest na stackoverlow, co nieco na kanale IRC embera.

RkBlog

Django, 4 January 2015

Comment article
Comment article RkBlog main page Search RSS Contact