Статьи

Создание одностраничных веб-приложений с помощью Sinatra: часть 1

Вы когда-нибудь хотели узнать, как создать одностраничное приложение с помощью Sinatra и Knockout.js? Что ж, сегодня день, когда ты учишься! В этом первом разделе серии из двух частей мы рассмотрим процесс создания одностраничного приложения, в котором пользователи могут просматривать свои задачи, сортировать их, отмечать их как завершенные, удалять их, осуществлять поиск по ним и добавлять новые задачи.


Согласно их веб-сайту:

Sinatra — это DSL для быстрого создания веб-приложений на Ruby с минимальными усилиями.

Синатра позволяет вам делать такие вещи, как:

1
2
3
get «/task/new» do
    erb :form
end

Это маршрут, который обрабатывает запросы GET для «/ task / new» и отображает форму form.erb именем form.erb . Мы не будем использовать Sinatra для визуализации шаблонов Ruby; вместо этого мы будем использовать его только для отправки ответов JSON в наш управляемый интерфейс Knockout.js (и некоторые служебные функции из jQuery, такие как $.ajax ). Мы будем использовать erb только для рендеринга основного файла HTML.


Knockout — это JavaScript-инфраструктура Model-View-ViewModel (MVVM), которая позволяет хранить ваши модели в специальных «наблюдаемых» объектах. Он также поддерживает ваш интерфейс в актуальном состоянии, основываясь на этих наблюдаемых объектах.

01
02
03
04
05
06
07
08
09
10
11
12
-ToDo/
 -app.rb
 -models.rb
—views/
  -index.erb
— public /
— scripts/
   — knockout.js
   — jquery.js
   — app.js
— styles/
   — styles.css

Вот что вы будете строить:

Мы начнем с определения нашей модели, а затем наших действий CRUD в Синатре. Мы будем полагаться на DataMapper и SQLite для постоянного хранения, но вы можете использовать любой ORM, который вы предпочитаете.

Давайте добавим модель задачи в файл models.rb :

01
02
03
04
05
06
07
08
09
10
11
12
DataMapper.setup(:default, ‘sqlite:///path/to/project.db’)
   class Task
     include DataMapper::Resource
 
     property :id, Serial
     property :complete, Boolean
     property :description, Text
     property :created_at, DateTime
     property :updated_at, DateTime
 
   end
   DataMapper.auto_upgrade!

Эта модель задач по существу состоит из нескольких различных свойств, которыми мы хотим манипулировать в нашем приложении.

Далее, давайте напишем наш сервер Sinatra JSON. В файле app.rb мы начнем с нескольких разных модулей:

1
2
3
4
5
6
require ‘rubygems’
   require ‘sinatra’
   require ‘data_mapper’
   require File.dirname(__FILE__) + ‘/models.rb’
   require ‘json’
   require ‘Date’

Следующим шагом является определение некоторых глобальных значений по умолчанию; в частности, нам нужен тип MIME, отправляемый с каждым из наших заголовков ответов, чтобы указать, что каждый ответ является JSON.

1
2
3
before do
    content_type ‘application/json’
end

Вспомогательная функция before запускается перед каждым совпадением маршрута. Вы также можете указать соответствующие маршруты после before ; например, если вы хотите запускать ответы JSON только в том случае, если URL заканчивается на «.json», вы должны использовать это:

1
2
3
before %r{.+\.json$} do
    content_type ‘application/json’
end

Далее мы определяем наши маршруты CRUD, а также один маршрут для обслуживания нашего файла index.erb :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
get «/» do
    content_type ‘html’
    erb :index
end
get «/tasks» do
    @tasks = Task.all
    @tasks.to_json
end
post «/tasks/new» do
    @task = Task.new
    @task.complete = false
    @task.description = params[:description]
    @task.created_at = DateTime.now
    @task.updated_at = null
end
put «/tasks/:id» do
    @task = Task.find(params[:id])
    @task.complete = params[:complete]
    @task.description = params[:description]
    @task.updated_at = DateTime.now
    if @task.save
        {:task => @task, :status => «success»}.to_json
    else
        {:task => @task, :status => «failure»}.to_json
    end
end
delete «/tasks/:id» do
    @task = Task.find(params[:id])
    if @task.destroy
        {:task => @task, :status => «success»}.to_json
    else
        {:task => @task, :status => «failure»}.to_json
    end
end

Итак, файл app.rb теперь выглядит так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
require ‘rubygems’
require ‘sinatra’
require ‘data_mapper’
require File.dirname(__FILE__) + ‘/models.rb’
require ‘json’
require ‘Date’
before do
    content_type ‘application/json’
end
get «/» do
    content_type ‘html’
    erb :index
end
get «/tasks» do
    @tasks = Task.all
    @tasks.to_json
end
post «/tasks/new» do
    @task = Task.new
    @task.complete = false
    @task.description = params[:description]
    @task.created_at = DateTime.now
    @task.updated_at = null
    if @task.save
        {:task => @task, :status => «success»}.to_json
    else
        {:task => @task, :status => «failure»}.to_json
    end
end
put «/tasks/:id» do
    @task = Task.find(params[:id])
    @task.complete = params[:complete]
    @task.description = params[:description]
    @task.updated_at = DateTime.now
    if @task.save
        {:task => @task, :status => «success»}.to_json
    else
        {:task => @task, :status => «failure»}.to_json
    end
end
delete «/tasks/:id» do
    @task = Task.find(params[:id])
    if @task.destroy
        {:task => @task, :status => «success»}.to_json
    else
        {:task => @task, :status => «failure»}.to_json
    end
end

Каждый из этих маршрутов соответствует действию. Существует только одно представление (представление «все задачи»), в котором находится каждое действие. Помните: в Ruby окончательное значение возвращается неявно. Вы можете явно вернуть досрочно, но какой бы контент эти маршруты не возвращали, это будет ответ, отправленный с сервера.


Далее мы начнем с определения наших моделей в нокауте. В app.js поместите следующий код:

1
2
3
4
5
6
7
function Task(data) {
    this.description = ko.observable(data.description);
    this.complete = ko.observable(data.complete);
    this.created_at = ko.observable(data.created_at);
    this.updated_at = ko.observable(data.updated_at);
    this.id = ko.observable(data.id);
}

Как видите, эти свойства напрямую отображаются в нашей модели в models.rb . ko.observable сохраняет значение обновленным в пользовательском интерфейсе при его изменении без необходимости полагаться на сервер или DOM для отслеживания его состояния.

Далее мы добавим TaskViewModel .

1
2
3
4
5
6
7
8
9
function TaskViewModel() {
    var t = this;
    t.tasks = ko.observableArray([]);
    $.getJSON(«/tasks», function(raw) {
        var tasks = $.map(raw, function(item) { return new Task(item) });
        self.tasks(tasks);
    });
}
ko.applyBindings(new TaskListViewModel());

Это начало того, что станет основой нашего приложения. Мы начинаем с создания TaskViewModel конструктора TaskViewModel ; новый экземпляр этой функции передается в функцию Knockout applyBindings() в конце нашего файла.

Внутри нашей TaskViewModel находится начальный вызов для извлечения задач из базы данных через URL-адрес «/ tasks». Затем они отображаются в ko.observableArray , который установлен в t.tasks . Этот массив является сердцем функциональности нашего приложения.

Итак, теперь у нас есть функция поиска, которая показывает задачи. Давайте создадим функцию создания, а затем создадим наш фактический вид шаблона. Добавьте следующий код в TaskViewModel :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
t.newTaskDesc = ko.observable();
t.addTask = function() {
    var newtask = new Task({ description: this.newTaskDesc() });
    $.getJSON(«/getdate», function(data){
        newtask.created_at(data.date);
        newtask.updated_at(data.date);
        t.tasks.push(newtask);
        t.saveTask(newtask);
        t.newTaskDesc(«»);
    })
};
t.saveTask = function(task) {
    var t = ko.toJS(task);
    $.ajax({
         url: «http://localhost:9393/tasks»,
         type: «POST»,
         data: t
    }).done(function(data){
        task.id(data.task.id);
    });
}

Knockout предоставляет удобную возможность итерации …

Сначала мы устанавливаем newTaskDesc как наблюдаемую. Это позволяет нам легко использовать поле ввода для ввода описания задачи. Далее мы определяем нашу addTask() , которая добавляет задачу в observableArray ; он вызывает saveTask() , передавая новый объект задачи.

Функция saveTask() зависит от того, какой тип сохранения она выполняет. (Позже мы используем saveTask() для удаления задач или помечаем их как завершенные.) Важное замечание: мы используем удобную функцию для получения текущей метки времени. Это не будет точная временная метка, сохраненная в базе данных, но она предоставляет некоторые данные для отображения в представлении.

Маршрут очень прост:

1
2
3
get «/getdate» do
    {:date => DateTime.now}.to_json
end

Следует также отметить, что идентификатор задачи не устанавливается до тех пор, пока не завершится запрос Ajax, так как мы должны назначить его на основе ответа сервера.

Давайте создадим HTML, который контролирует наш недавно созданный JavaScript. Большая часть этого файла взята из стандартного индексного файла HTML5. Это входит в файл index.erb :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<!DOCTYPE html >
<html>
<!—[if lt IE 7]> <html class=»no-js lt-ie9 lt-ie8 lt-ie7″> <![endif]—>
<!—[if IE 7]> <html class=»no-js lt-ie9 lt-ie8″> <![endif]—>
<!—[if IE 8]> <html class=»no-js lt-ie9″> <![endif]—>
<!—[if gt IE 8]><!—> <!—<![endif]—>
    <body>
        <meta charset=»utf-8″>
        <meta http-equiv=»X-UA-Compatible» content=»IE=edge,chrome=1″>
        <title>ToDo</title>
        <meta name=»description» content=»»>
        <meta name=»viewport» content=»width=device-width»>
 
        <!— Place favicon.ico and apple-touch-icon.png in the root directory —>
        <link rel=»stylesheet» href=»styles/styles.css»>
        <script src=»scripts/modernizr-2.6.2.min.js»></script>
     
     
        <!—[if lt IE 7]>
            <p class=»chromeframe»>You are using an outdated browser.
        <![endif]—>
        <!— Add your site or application content here —>
        <div id=»container»>
            <section id=»taskforms» class=»clearfix»>
                <div id=»newtaskform» class=»floatleft fifty»>
                    <h2>Create a New Task</h2>
                    <form id=»addtask»>
                        <input>
                        <input type=»submit»>
                    </form>
                </div>
                <div id=»tasksearchform» class=»floatright fifty»>
                    <h2>Search Tasks</h2>
                    <form id=»searchtask»>
                        <input>
                    </form>
                </div>
            </section>
            <section id=»tasktable»>
                <h2>Incomplete Tasks remaining: <span>
                <a>Delete All Complete Tasks</a>
                <table>
                    <tbody><tr>
                        <th>DB ID</th>
                        <th>Description</th>
                        <th>Date Added</th>
                        <th>Date Modified</th>
                        <th>Complete?</th>
                        <th>Delete</th>
                    </tr>
                    <tr>
                        <td></td>
                        <td></td>
                        <td></td>
                        <td></td>
                        <td><input type=»checkbox»> </td>
                        <td class=»destroytask»><a>X</a></td>
                    </tr>
                </tbody></table>
            </section>
        </div>
 
        <script src=»http://ajax.googleapis.com/ajax/libs/jquery/1.8.1/jquery.min.js»></script>
        <script>window.jQuery ||
        <script src=»scripts/knockout.js»></script>
        <script src=»scripts/app.js»></script>
 
        <!— Google Analytics: change UA-XXXXX-X to be your site’s ID.
        <script>
            var _gaq=[[‘_setAccount’,’UA-XXXXX-X’],[‘_trackPageview’]];
            (function(d,t){var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
            g.src=(‘https:’==location.protocol?’//ssl’:’//www’)+’.google-analytics.com/ga.js’;
            s.parentNode.insertBefore(g,s)}(document,’script’));
        </script>
    </body>
</html>

Давайте возьмем этот шаблон и заполните привязки, которые Knockout использует для синхронизации пользовательского интерфейса. В этой части мы рассмотрим создание элементов To-Do. Во второй части мы рассмотрим более сложные функции (включая поиск, сортировку, удаление и пометку как завершенные).

Прежде чем мы продолжим, давайте сделаем нашу страницу немного стильной. Поскольку этот урок не посвящен CSS, мы просто добавим его и продолжим. Следующий код находится внутри CSS-файла HTML5 Boilerplate, который включает в себя сброс и некоторые другие вещи.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
section {
    width: 800px;
    margin: 20px auto;
}
table {
    width: 100%;
}
th {
    cursor: pointer;
}
tr {
    border-bottom: 1px solid #ddd;
}
tr.complete, tr.complete:nth-child(odd) {
    background: #efffd7;
    color: #ddd;
}
tr:nth-child(odd) {
    background-color: #dedede;
}
td {
    padding: 10px 20px;
}
td.destroytask {
    background: #ffeaea;
    color: #943c3c;
    font-weight: bold;
    opacity: 0.4;
}
td.destroytask:hover {
    cursor: pointer;
    background: #ffacac;
    color: #792727;
    opacity: 1;
}
.fifty { width: 50%;
input {
    background: #fefefe;
    box-shadow: inset 0 0 6px #aaa;
    padding: 6px;
    border: none;
    width: 90%;
    margin: 4px;
}
input:focus {
    outline: none;
    box-shadow: inset 0 0 6px rgb(17, 148, 211);
    -webkit-transition: 0.2s all;
    background: rgba(17, 148, 211, 0.05);
}
input[type=submit] {
    background-color: #1194d3;
    background-image: -webkit-gradient(linear, left top, left bottom, from(rgb(17, 148, 211)), to(rgb(59, 95, 142)));
    background-image: -webkit-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142));
    background-image: -moz-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142));
    background-image: -o-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142));
    background-image: -ms-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142));
    background-image: linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142));
    filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr=’#1194d3′, EndColorStr=’#3b5f8e’);
    padding: 6px 9px;
    border-radius: 3px;
    color: #fff;
    text-shadow: 1px 1px 1px #0a3d52;
    border: none;
    width: 30%;
}
input[type=submit]:hover {
    background: #0a3d52;
}
.floatleft { float: left;
.floatright { float: right;

Добавьте этот код в ваш файл styles.css .

Теперь давайте рассмотрим форму «Новая задача». Мы добавим в форму атрибуты data-bind чтобы привязки Knockout работали. Атрибут data-bind это то, как Knockout поддерживает синхронизацию пользовательского интерфейса и обеспечивает привязку событий и другие важные функции. Замените форму «новое задание» следующим кодом.

1
2
3
4
5
6
7
<div id=»newtaskform» class=»floatleft fifty»>
    <h2>Create a New Task</h2>
    <form id=»addtask» data-bind=»submit: addTask»>
        <input data-bind=»value: newTaskDesc»>
        <input type=»submit»>
    </form>
</div>

Мы пройдемся по одному. Во-первых, элемент формы имеет привязку для события submit . Когда форма отправлена, addTask() функция addTask() определенная в TaskViewModel . Первый элемент ввода (который неявно имеет тип = «текст») содержит value ko.observable newTaskDesc которое мы определили ранее. Все, что находится в этом поле при отправке формы, становится свойством description Задачи.

Таким образом, у нас есть способ добавить задачи, но нам нужно отобразить эти задачи. Нам также нужно добавить каждое из свойств задачи. Давайте переберем задачи и добавим их в таблицу. Knockout предоставляет удобную возможность итерации, чтобы облегчить это; определить блок комментария со следующим синтаксисом:

1
2
3
4
5
6
7
8
<!— ko foreach: tasks —>
    <td data-bind=»text: id»></td>
    <td data-bind=»text: description»></td>
    <td data-bind=»text: created_at»></td>
    <td data-bind=»text: updated_at»></td>
    <td> <input type=»checkbox»></td>
    <td> <a>X</a></td>
<!— /ko —>

В Ruby окончательное значение возвращается неявно.

Это использует возможность итерации Knockout. Каждая задача специально определена в TaskViewModel ( t.tasks ), и она синхронизируется по всему пользовательскому интерфейсу. Идентификатор каждой задачи добавляется только после того, как мы завершим вызов БД (поскольку нет способа гарантировать, что у нас будет правильный идентификатор из базы данных, пока он не будет записан), но интерфейс не должен отражать подобные несоответствия.

Теперь вы сможете использовать shotgun app.rb ( gem install shotgun ) из своего рабочего каталога и протестировать свое приложение в браузере по адресу http: // localhost: 9393 . (Примечание: убедитесь, что у вас есть gem install и все ваши зависимости / необходимые библиотеки, прежде чем пытаться запустить ваше приложение.) Вы должны иметь возможность добавлять задачи и видеть, что они сразу появляются.


В этом руководстве вы узнали, как создать интерфейс JSON с помощью Sinatra, а затем как отражать эти модели в Knockout.js. Вы также узнали, как создавать привязки для синхронизации нашего пользовательского интерфейса с нашими данными. В следующей части этого руководства мы поговорим исключительно о Knockout и объясним, как создавать функции сортировки, поиска и обновления.