Динамичное веб-приложение на основе Laravel, PrettyForms и Backbone.js

в 12:18, , рубрики: javascript, laravel, веб-приложения

На днях меня пропустили на хабр с моей статьёй про небольшую библиотеку PrettyForms для клиент-серверной валидации форм. Большое спасибо за внимание и за пропуск в сообщество. Честно, мне было очень приятно наконец попасть сюда)

Во второй статье, также отчасти посвященной той библиотеке, я бы хотел рассказать всем об одном простом способе разработки динамичных приложений, на основе которого я создал ранее несколько проектов. Сразу прошу проявить терпение всех, кто использует в своих проектах AngularJS и подобные прекрасные библиотеки для написания качественных и современных веб-приложений. Я нисколько не против подобных подходов к разработке и лишь хочу показать один из, так сказать, небольших лайвхаков, с помощью которого можно делать подобные приложения)

Итак, представим, что нам нужно разработать блок комментировариев к статье на каком-то сайте. Примерно с таким же функционалом, как на хабре: есть комментарии, их можно писать, редактировать и удалять, а также голосовать за них, кликая по стрелочкам рядом с ними.

Кому хочется сразу увидеть результат работы, прошу заглянуть под спойлер. Небольшое замечание к скринкасту: сообщения об ошибках в примере отображаются с помощью простой JS-функции alert(), но данное поведение можно легко изменить. В примере же целью было лишь доказать то, что всё работает как надо.

Скринкаст работы приложения

Динамичное веб-приложение на основе Laravel, PrettyForms и Backbone.js - 1

Так каким образом можно всё это сделать? Присаживайтесь поудобнее, мы уже начинаем наш рассказ)

Как обычно пишут подобные вещи? Обычно, мы для каждой операции (добавление коммента, редактирование, удаление, плюс, минус) пишем свой JS-код, и на самом деле не особо важно, на чём он будет написан: на ангуляре, бакбоне или просто на лапше из ванильного JS в связке с jQuery. В любом случае, зачастую, за каждую функцию отвечает свой отдельный код, просто фреймворки позволяют писать его в более красивом и легкоподдерживаемом виде. Код этот обычно кидает AJAX-запросы на сервер, получает какие-то ответы и после этого выполняет операции обновления DOM, или же выводит сообщения об ошибках.

Я же хочу предложить вам немного другой вариант решения подобной задачи. Шаблонизация будет написана на типичном Backbonejs-коде, а вот операции добавления, редактирования, удаления и голосования будут реализованы с помощью библиотеки PrettyForms и её протокола общения клиента с сервером. Такой подход позволит нам избавиться от написания довольно большого количества однотипного JS кода, а также хорошо прибрать серверный код и привести его к одному общему виду.

Для начала, мы напишем PHP-код генерации страницы комментариев, и здесь я тоже прибегу к одному маленькому лайвхаку, позволяющему совместить код генерации начальной страницы с кодом генерации шаблона для комментария, который впоследствии будет использоваться для моделей Backbonejs. Я забыл вам рассказать об одной хотелке, которую я хочу реализовать дополнительно ко всем вышеперечисленным: мы постараемся избежать проблем с индексированием страницы поисковыми системами, поэтому начальный контент страницы у нас будет сгенерирован сервером, и только потом мы уже навесим на него наш JS-код. Моё личное мнение: в некоторых проектах так делать удобнее, чем тратить время на написание кода генерации статичных копий страниц с помощью какого-нибудь PhantomJS.

Ну да ближе к коду. В своём примере я использую фреймворк Laravel 4.2 и его шаблонизатор Blade. Javascript-шаблоны комментариев будут сгенерированы для шаблонизатора, встроенного в библиотеку Undescrore, от которой зависит Backbone.js, и которой мы будем пользоваться в нашем примере. Laravel вообще очень хорошо подходит для подобных проектов, так как он из коробки имеет удобную и простую возможность отдачи моделей в виде JSON-объектов, и это его достоинство сильно упростит нам написание кода. Итак, посмотрим на наш код.

PHP-код генерации страницы комментариев

<h4>Комментарии</h4>
<div id='article-comments'>
    <?php
    // Для вывода комментариев мы объявляем специальную анонимную функцию,
    // которая у нас будет попутно также служить генератором их JS-шаблона. Это и есть тот
    // маленький лайвхак, о котором я говорил несколькими абзацами выше
    $comment_body = function($as_jstemplate = false, Comment $comment = null) { ?>
        <?php
        // Возвращаем содержимое переменной, либо её название для JS-шаблона
        $var = function($name) use ($comment,$as_jstemplate) {
            if ($as_jstemplate) {
                return "<%= {$name} %>";
            } else {
                return method_exists($comment, $name)
                    ? $comment->$name()
                    : $comment->$name
                ;
            }
        } ?>

        <?php /* Контейнер коментария. Еще одна анонимная функция $var('id') вернёт либо строку "<%= id %>" для Undescore-шаблона, либо id коммента, если это вывод комментария. И так далее везде, где используется эта функция. */ ?>
        <div id="article-comments-<?=$var('id')?>">
            <div class="pull-right">
                <?php /* Кнопка положительного голосования за комментарий: */ ?>
                <span data-link='/comments/vote/<?=$var('id')?>/up' 
                           class='glyphicon glyphicon-arrow-up senddata-token'></span>
                <?=$var('rating')?>
                <span data-link='/comments/vote/<?=$var('id')?>/down' 
                            class='glyphicon glyphicon-arrow-down senddata-token'></span>

                <?php /* Далее описан вывод код кнопок "удалить" и "редактировать",
                              по отдельности для JS-шаблона и для кода генерации */ ?>
                @if ($as_jstemplate)
                    <% if (current_user_id === user.id) { %>
                        <div data-link='/comments/delete/<?=$var('id')?>' 
                                class='btn btn-default btn-xs senddata-token really'>удалить</div>
                        <div class='btn btn-default btn-xs edit'>редактировать</div>
                    <% } %>
                @else
                    @if (Auth::check() AND $comment->user_id === Auth::user()->id)
                        <div data-link='/comments/delete/<?=$var('id')?>' 
                                class='btn btn-default btn-xs senddata-token really'>удалить</div>
                        <div class='btn btn-default btn-xs edit'>редактировать</div>
                    @endif
                @endif
            </div>
            <p><span class="label label-default"><?=$as_jstemplate ? '<%=user.name%>' : $comment->user->name?></span> написал <?=$var('date')?> в <?=$var('time')?>:</p>
            @if ($as_jstemplate)
                <?php /* В JS-шаблоне мы имеем возможность нажать кнопку "редактировать", 
                                  и открыть форму редактирования коммента: */ ?>
                <% if (edit === true) { %>
                    <div id='form-edit-comment-<%=id%>'>
                        <textarea style='width: 100%; min-height: 50px;' name='text'><%=text%></textarea>
                        <div data-input="#form-edit-comment-<%=id%>"
                            data-link="/comments/edit/<%=id%>"
                            data-clearinputs="true"
                            class="btn btn-primary btn-xs senddata-token">Сохранить</div>
                        <div class='btn btn-default btn-xs edit-cancel'>Отмена</div>
                        <br/><br/>
                    </div>
                <% } else { %>
                    <p><%=text%></p>
                <% } %>
            @else
                <p><?=$comment->text?></p>
            @endif
        </div>
    <?php } ?>

    <?php /* Вытаскиваем из БД все комментарии и выводим их на страницу: */ ?>
    <?php $comments = Comment::with('user')->where('article_id','=',5)->get(); ?>
    <?php foreach($comments as $comment) { ?>
        <?=$comment_body(false, $comment)?>
    <?php } ?>
</div>
<script type='text/template' id='article-comment-template'>
    <?php /* А здесь мы используем эту анонимную функцию уже
                        для генерации шаблона для Backbone-приложения */ ?>
    <?=$comment_body(true)?>
</script>

<hr>
<div class='form-article-write-comment'>
    <?php /* Форма написания комментария */ ?>
    <h4>Написать комментарий:</h4>
    <div class="form-group">
        <textarea class="form-control"
            name="text"
            data-validation="notempty;minlength:6"
            placeholder="Ваш комментарий"></textarea>
    </div>
    <?=Form::hidden('article_id',5)?>
    <div class="form-group">
        <div data-input=".form-article-write-comment"
             data-link="/comments/write"
             data-clearinputs="true"
             class="btn btn-default senddata-token">Написать</div>
    </div>
</div>

<script type='text/javascript'>
    $(document).ready(function(){
        // Сохраним токен приложения, который будет отправлять 
        // вместе со всеми запросами для защиты от CSRF-атак
        PrettyForms.token_name = '_token';
        PrettyForms.token_value = '<?=Session::token()?>';

        // Создадим Backbone-вьюшку
        CommentsList = new CommentsListView({
            id              : '#article-comments',
            current_user_id : '<?=Auth::check() ? Auth::user()->id : ''?>', 
            comments        : <?=$comments->toJson()?>
        });
    });
</script>

В этом коде есть много важных моментов:
1. С помощью использования анонимной функции, мы избавились от разделения кода комментариев на код серверной генерации и код описания JS-шаблона. Согласен, выглядит это не так красиво, как на AngularJS, но это — жертва, на которую приходится идти ради того, чтобы объединить коды и обезопасить себя от последующих глупых ситуаций, когда в одном шаблоне было что-то отредактировано, а в другом — нет;
2. Для всех операций с комментариями (добавление, редактирование, удаление и голосование) мы, как я и обещал, используем функционал библиотеки PrettyForms. То есть, все запросы на сервер мы будем отправлять с помощью неё, а сервер будет отвечать нам разными командами. Также, она отвечает за простую валидацию и отображение ошибок, присылаемых сервером во время внештатных ситуаций.
3. При инициализации нашего Backbone-приложения, мы передаём ему номер текущего пользователя или пустую строку, если пользователь не авторизован. Это нам понадобится для того, чтобы отображать или скрывать кнопки «редактировать» и «удалить» у комментариев.
4. Также, при инициализации Backbone-приложения, мы передаём ему JSON-объект со всеми комментариями. Это тоже одна из жертв, на которую приходится идти ради простоты реализации. Нам необходимо заполнить коллекцию моделей для Backbone-приложения, поэтому нам приходится генерировать, фактически, один и тот же контент два раза: сначала наверху, в виде стандартного DOM-дерева, а потом ниже, в виде JSON-объекта. Но, при большом желании, эту проблему можно решить, написав код, который будет брать данные из DOM-дерева и собирать всё в коллекцию.
5. Вместо класса senddata, используемого в PrettyForms в обычных случаях, мы используем немного другой класс: senddata-token. Он делаёт всё тоже самое, только с одним отличием: к параметрам запроса он добавляет токен защиты от CSRF-атак. Таким образом, мы легко обезопасим все наши запросы от одной из известных атак в Интернете.

Далее, нам необходимо написать наше Backbone-приложение.

Код Backbone-приложения

var CommentsListView = Backbone.View.extend({
    initialize: function (options) {
        this.$el = $(options.id);
        this.el = this.$el.get(0);

        // Текущий ID пользователя, либо пустая строка, если зашел гость
        var current_user_id = options.current_user_id; 

        // Сохраним в переменную шаблон комментария,
        // который мы сгенерировали ранее с помощью анонимной функции
        var comment_template = _.template($('#article-comment-template').html());

        // Шаблон комментария
        this.CommentView = Backbone.View.extend({
            events: {
                'click .edit': function(event) {
                    this.model.set('edit',true);
                },
                'click .edit-cancel': function(event) {
                    this.model.set('edit',false);
                },
            },
            initialize: function(options) {
                if (options.container) {
                    // Свяжем вьюшку с указанным контейнером
                    // если он был передан
                    this.$el = options.container;
                    this.el = options.container.get(0);
                }
                this.model.bind('change', this.render, this); // При изменении модели заново отрендерим вьюшку
                this.model.bind('remove', this.remove, this); // Удалим вьюшку из DOM-дерева при удалении модели
            },
            render: function() {
                // Отрендерим внешний вид комментария
                var tmpl_data = this.model.attributes;
                tmpl_data.current_user_id = current_user_id;
                this.$el.html(
                    comment_template(tmpl_data)
                );
                return this;
            }
        });

        // Модель и коллекция комментариев
        var CommentModel = Backbone.Model.extend();
        var CommentsCollection = Backbone.Collection.extend({
            model: CommentModel
        });

        this.collection = new CommentsCollection;
        this.collection.reset(options.comments);

        // Свяжем данные в коллекции с присутствующими на странице DOM-элементами,
        // теми, которые изначально были сгенерированы сервером
        this.collection.each(function(comment){
            // Свойство "edit" мы добавляем каждой модели вручную. Это - флаг редактирования комментария,
            // если он равен true, то js-шаблон отобразит форму редактирования комментария
            comment.set('edit',false); 
            new this.CommentView({
                model: comment,
                container: this.$el.find('#article-comments-'+comment.get('id'))
            });
        },this);

        // При добавлении нового элемента в коллекцию, добавим его на страницу
        this.collection.bind('add',function(comment) {
            var view = new this.CommentView({model: comment});
            this.$el.append(view.render().el);
        }, this);
    },

    // Установить рейтинг одному из комментариев
    setCommentRating: function(id,rating) {
        this.collection.findWhere({id:id}).set('rating',rating);
    }

});

// А теперь - самое интересное. Добавим новые обработчики команд с сервера,
// относящиеся к комментариям.

// Добавление нового комментария на страницу
PrettyForms.Commands.registerHandler('add_comment',function(comment){
    comment.edit = false;
    CommentsList.collection.add(comment);
});

// Изменения какого-то из комментариев
PrettyForms.Commands.registerHandler('edit_comment',function(comment){
    comment.edit = false;
    CommentsList.collection.findWhere({id:comment.id}).set(comment);
});

// Удаление комментария
PrettyForms.Commands.registerHandler('delete_comment',function(comment_id){
    CommentsList.collection.remove(comment_id);
});

// Установка нового значения рейтинга
PrettyForms.Commands.registerHandler('set_comment_rating',function(data){
    CommentsList.setCommentRating(data.id,data.rating);
});

// Отобразим какое-то сообщение, пришедшее с сервера (например, о какой-то ошибке)
// Здесь, вместо алерта, можно написать любой код для красивого оповещения
PrettyForms.Commands.registerHandler('message',function(message){
    alert(message);
});

Итак, самое интересное в этом коде — это отсутствие какого-либо кода, отвечающего за создание, редактирование и удаление наших комментариев. Всё верно: теперь за это у нас отвечает библиотека PrettyForms, и для неё были добавлены обработчики команд с сервера в конце JS-приложения.

Нам осталось написать лиш код серверной части, и если точнее — контроллера CommentsController. Напишем его:

Код контроллера, обрабатывающего запросы, связанные с комментариями

<?php

use PrettyFormsCommands;

class CommentsController extends BaseController {

    public function __construct()
    {
        // Все запросы к контроллеру будут отфильтрованы с помощью фильтра защиты от CSRF-атак
        $this->beforeFilter('csrf');
    }

    // Обработка голосований за комментарий
    function postVote($id,$opinion)
    {
        if (Auth::guest()) {
            // Гости не имеют права голосовать
            return Commands::generate([
                'message' => 'Пожалуйста, авторизуйтесь перед тем, как голосовать за комментарии'
            ]);
        } else {
            $comment = Comment::with('user')->find($id);
            if ($comment->count()) {

                // Самому себе голосовать нельзя
                if ($comment->user->id === Auth::user()->id) {
                    return Commands::generate([
                        'message' => 'Увы, но вы не имеете права голосовать за собственный комментарий'
                    ]);
                }

                // Если пользователь еще не голосовал за этот коммент, так уж и быть: дадим ему это сделать
                // Но после этого сохраним его действие в таблице "comments_rates"
                if ($comment->rates()->where('user_id','=',Auth::user()->id)->count() === 0) {
                    $comment_rate = new Comments_Rate;
                    $comment_rate->user_id = Auth::user()->id;
                    $comment_rate->comment_id = $comment->id;
                    $comment_rate->save();

                    if ($opinion === 'up') {
                        $comment->rating = $comment->rating + 1;
                    } else {
                        $comment->rating = $comment->rating - 1;
                    }
                    $comment->save();

                    // Возвратим клиенту команду, благодаря которой рейтинг к комментарию на странице динамически обновится
                    return Commands::generate([
                        'set_comment_rating' => [
                            'id'     => $comment->id,
                            'rating' => $comment->rating
                        ]
                    ]);
                } else {
                    return Commands::generate([
                        'message' => 'Вы уже голосовали за данный комментарий, больше нельзя.'
                    ]);
                }
            }
        }
    }

    // Запрос удаления комментария
    function postDelete($id)
    {
        if (Auth::guest()) {
            return Commands::generate([
                'message' => 'Вы не можете удалить комментарий, так как вы не авторизованы'
            ]);
        } else {
            $comment = Comment::with('user')->find($id); /* @var $comment Comment */
            if ($comment->count()) {
                // Удалять мы можем только свои комментарии
                if ($comment->user->id !== Auth::user()->id) {
                    return Commands::generate([
                        'message' => 'Увы, но вы не имеете права удалять чужие комментарии'
                    ]);
                }

                $comment_id = $comment->id;
                $comment->delete();

                // Возвратим клиенту команду, благодаря которой рейтинг к комментарию на странице динамически обновится
                return Commands::generate([
                    'delete_comment' => $comment_id
                ]);
            }
        }
    }

    // Запрос написания нового комментария
    function postWrite()
    {
        if (Auth::guest()) {
            return Commands::generate([
                'validation_errors' => 'Пожалуйста, <a href="/login">авторизуйтесь</a> перед написанием комментария'
            ]);
        } else {
            $comment = new Comment;
            $comment->user_id = Auth::user()->id;
            $comment->article_id = Input::get('article_id');
            $comment->text = Input::get('text');
            $comment->validateAndSave();
            return Commands::generate([
                'add_comment' => Comment::with('user')->find($comment->id)->toArray(),
            ]);
        }
    }

    // Запрос редактирования
    function postEdit($id)
    {
        if (Auth::guest()) {
            return Commands::generate([
                'validation_errors' => 'Пожалуйста, <a href="/login">авторизуйтесь</a> перед написанием комментария'
            ]);
        } else {
            $comment = Comment::with('user')->find($id); /* @var $comment Comment */
            if ($comment->count()) {
                // Изменять мы можем только свои комментарии
                if ($comment->user->id !== Auth::user()->id) {
                    return Commands::generate([
                        'message' => 'Увы, но вы не имеете права редактировать чужие комментарии'
                    ]);
                }

                $comment->text = Input::get('text');
                $comment->save();

                return Commands::generate([
                    'edit_comment' => Comment::with('user')->find($comment->id)->toArray(),
                ]);
            }
        }
    }

}

Также, если кому-то интересно посмотреть, вот исходный код модели Comment:

Модель Comment

<?php

use PrettyFormsLaravelValidatorTrait;

class Comment extends Eloquent {

        use LaravelValidatorTrait;

        protected $table = 'comments';
    protected $visible = ['id','user','rating','text','date','time'];
    private $rules = [
        'user_id'    => 'exists:users,id',
        'text'       => 'required',
        'article_id' => 'required'
    ];

    public function toArray()
    {
        $array = parent::toArray();
        $array['date'] = $this->date();
        $array['time'] = $this->time();
        return $array;
    }

    function date() {
        return $this->created_at->format('d.m.Y');
    }

    function time() {
        return $this->created_at->format('G:i');
    }

    public function user()
    {
        return $this->belongsTo('User');
    }

    function rates()
    {
        return $this->hasMany('Comments_Rate');
    }

}

Вот и всё :) Наш функционал полностью закончен. Не правда ли, на сервере у нас всё выглядит очень чисто и просто? Всё это — благодаря тому, что мы теперь можем посылать клиенту простые команды, которые будут обработаны библиотекой PrettyForms.

Если есть желание, можно еще раз посмотреть пример финального приложения:

Скринкаст работы приложения

Динамичное веб-приложение на основе Laravel, PrettyForms и Backbone.js - 2

Давайте перечислим, какие плюсы по получили из подобной реализации:
1. Генерация статического контента, который без проблем будет проиндексирован всеми поисковиками;
2. Полностью рабочее динамичное приложение, которое можно и дальше легко дорабатывать и расширять;
3. Наше JS-приложение практически полностью избавилось от кода добавления, редактирования и удаления комметариев, оно отвечает теперь практически только за корректное отображение данных на странице;
4. Сервер выглядит чистенько, вся логика работы легко читается и воспринимается; кроме того, он возвращает лишь простые примитивные команды;
5. Мы полностью защитили наше приложение от CSRF-атак с помощью фильтрации всего контроллера токеном;
6. Также, в качестве дополнительного бонуса, при желании, мы можем очень легко реализовать динамическую реакцию на глобальные изменения на странице другими людьми. Нам понадобится лишь подключиться к серверу с помощью технологии вебсокетов или, например, long polling, и начать принимать те же самые команды для библиотеки PrettyForms, которые мы описали в конце кода Backbone-приложения. Выполнять команды можно через метод PrettyForms.Commands.execute(command, params). Всё просто до ужасной банальности.

Единственное реально гнилое место во всей этой реализации — это генерация страницы для поисковиков с одновременным написанием шаблона для JS-приложения. В общем-то, можно написать это дело по отдельности, тогда будет выглядеть намного красивее. Но тогда и теряется одно из достинств: придётся поддерживать два шаблона, а это чревато возникновением ошибок из-за банальной невнимательности, которая присуща каждому из рода человеческого.

На этом всё, и надеюсь, что эта статья принесёт пользу людям. И всего вам хорошего, мои дорогие читатели)

Автор: saggid

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js