Javascript, jComponent, Node.js, Total.js 01 января 2020 3 мин. 13998
Сейчас существует два подхода при создании веб-приложений: традиционные веб-приложения, большая часть логики которых выполняется на сервере, а также одностраничные приложения, логика пользовательского интерфейса которых выполняется преимущественно в веб-браузере, а взаимодействие с веб-сервером осуществляется главным образом через AJAX, а навигация по сайту происходит без перезагрузки страниц. За счет такой архитектуры, SPA-приложения работают быстрее, чем «традиционные» сайты.
На рисунке ниже показано отличие традиционного веб-приложения от SPA-приложения.
Исходя из-того, что написано выше можно сделать вывод, что архитектуру SPA можно использовать при разработке следующих типов приложений: crm системы, панели управления, личные кабинеты, приложения для информационных киосков, приложения для ПК (Electron App).
Поэтому предлагаю, разработать SPA приложение "Агрегатор новостей". В процессе разработки будем использовать фреймфорк Total.js
и библиотеку jComponent
.
Мы напишем клиент-серверное приложение, которое будет искать новостные посты со всего мира, по определенным параметрам, после чего выводить их в удобном для нас виде. Сделаем такие разделы приложения, как настройки, история просмотра постов, топ новостей по определенной категории, и поиск новостей. Разумеется, это все будет работать без перезагрузки страниц.
Прежде чем приступить к разработке, нужно понять следующее у SPA приложений все пользовательские «передвижения» должны фиксироваться в истории навигации. При этом навигация должна быть «глубокой». Другими словами, если пользователь откроет скопированную ссылку на внутренний модуль в другом окне или, например, браузере, он должен попасть на необходимую ему страницу. Что бы реализовать подобное необходимо использовать History Api.
Разработка SPA приложения предусматривает использование только одной страницы. Следовательно, все необходимое для функционирования этого приложения (скрипты, стили и т.д.) должно размещаться на единственной веб-странице.
SPA приложение предусматривает загрузку всех необходимых скриптов в процессе инициализации веб-страницы.
SPA загружает дополнительные модули «по требованию» пользователя.
Сегодня есть множество фреймворков, реализующих принцип SPA. Но сейчас я буду использовать библиотеку jComponent
, в которую отдельно включена библиотека jRouting library, которая позволяет реализовать навигацию по SPA приложению.
Перед созданием самого приложения, я сделал простую верстку. Для быстрого создания прототипа использовал фреймворк Bootstrap 3.2
У меня получилась следующая композиция. Сверху навигация, снизу блок,
<div id="body"></div>
именно в эту область мы будем подгружать динамические блоки в зависимости от нужного нам функционала.
В качестве фреймфорка для бэкенда будем использовать Totaljs
. Для начала установим фреймворк следующей командой. Фреймворк из npm репозитария будет установлен в папку node-modules
.
npm install total.js
После чего в этой же папке создадим папки, как на рисунке
Немного о структуре папок, которые я создал на сервере.
conrollers
- в этой папке размещаем описание маршрутов (рут)definations
- здесь размещаем описание определений для фреймворка, этим самым мы будем менять поведение фреймворкаmodules
- здесь находится различные функциональные модули, которые расширяют возможности нашего приложенияpublic
- в этой папке будем размещать css
, javascript
, изображения, вообщем дополнительные ресурсы, которые нужны нашему приложению в браузереviews
- в ней находятся шаблоны, которые необходимы фреймворку для рендеринга на стороне сервера, там будет находится всего один файл index.html
, эта будет именно та страница, которую выдаст наш сервер при обращении.Разберемся, с тем как у нас будет работать сервер.
В папку controllers
разместим файл default.js
. В котором описаны все маршруты для нашего приложения.
exports.install = function() {
//при обращении к приложению, отдадим на клиент отрендереннный файл index.html из папки views
ROUTE('/*', 'index');
//api для поиск новостей, ниже создадим соответсвующие функции для обработки запросов
ROUTE('/api/news/search', news_search);
ROUTE('/api/news/top', news_top);
};
function news_search() {
var self = this;
//Обращаемся к модулю News и функции search, результат передадим клиенту
MODULE('News').search(self.query, (err, res)=>{
return self.json((err)? SUCCESS(false) : SUCCESS(true, res));
})
}
function news_top() {
var self = this;
//Обращаемся к модулю News и функции top, результат передадим клиенту
MODULE('News').top(self.query, (err, res)=>{
return self.json((err)? SUCCESS(false) : SUCCESS(true, res));
})
}
Добавим модуль news.js
в папку modules
. Модуль содержит всего несколько функций для работы с API новостного агрегатора. Мы будем использовать API сервиса https://newsapi.org.
//поиск новостей по определенным параметрам
exports.search = function (param, cb) {
var url = 'https://newsapi.org/v2/everything';
query(url, param, (err, res) => {
return cb(err, res);
})
}
//топ новостей из определенной категории
exports.top = function (param, cb) {
var url = 'https://newsapi.org/v2/top-headlines';
query(url, param, (err, res) => {
return cb(err, res);
})
}
function query(url, param, cb) {
param.apiKey = apiKey;
//создаём REST запрос к API новостного агрегатора
RESTBuilder.make(function(builder) {
builder.url(url);
builder.get(param);
//кэшируем все запросы, чтобы снизить нагрузку на сторониий API сервис
builder.cache('1 hours');
builder.exec(function(err, resp) {
if (err || !resp.status || resp.status=='error') {
return cb(true);
}
return cb(null, resp);
});
})
}
В папке public
разместим наши ресурсы css
и javascript
. И в отдельной подпапке part
разместим дополнительные части нашего приложения. Эти части будут подгружаться в наше SPA приложение в раздел body
. Примеры наших частей:
<!-- Необходима для отображения новостей, которые будут найдены при поиске-->
<h1>Search news</h1>
<div data-bind="option__template" style='color:gray'>
<script type="text/html">
{{ if value.query }}<span>Query string: {{value.query}}</span> | {{ fi }}
{{ if value.language }}<span>Language: {{value.language|select(arr_language)}}</span> | {{ fi }}
{{ if value.sortBy }}<span>Sort: {{value.sortBy|select(arr_sortby)}} </span> | {{ fi }}
{{ if value.use_date }}<span>From date: {{value.from|format('dd.MM.yyyy')}}, To date: {{value.to|format('dd.MM.yyyy')}} | </span>{{ fi }}
</script>
</div>
<hr>
<div class='row search_content'></div>
или
<!-- Часть необходима для отображения история переходов на новостные порталы-->
<h1>History</h1>
<hr>
<table class='table table-hover'>
<thead>
<tr>
<th width='5%'>#</th>
<th width='15%'>Thumb</th>
<th width='15%'>Date view</th>
<th width='auto'>News</th>
</tr>
</thead>
<tbody data-bind="common.history__template">
<script type="text/html">
{{ foreach item in value }}
<tr>
<td>{{ $index + 1}}</td>
<td>{{ if item.thumb }}<img src="{{item.thumb}}" class='img-responsive'>{{ else }}<img src="/no_image.png" class='img-responsive'>{{fi}}</td>
<td>{{item.dt|format('dd.MM.yyyy hh:mm:ss')}}</td>
<td><h4><a href='{{item.url}}' target='_blank'>{{item.title}}</a></h4><p>{{item.description}}</p></td>
</tr>
{{ end }}
</script>
</tbody>
</table>
Теперь в папку definitions
добавим файл merge.js
. Дело в том, что в Total.js
встроен минификатор, который минифицирует и объединяет javascript
и css
файлы. Это позволит уменьшить в размере ресурсы, которые будут передаваться на клиент.
// Css
MERGE('/css/app.min.css', '/css/ui.css', '/css/style.css');
// JavaScript
MERGE('/js/spa.min.js', '/js/jquery-2.2.4.min.js', '/js/jctajr.min.js', '/js/masonry.min.js', '/js/imagesloaded.js');
MERGE('/js/app.min.js', '/js/ui.js', '/js/app.js');
Теперь вместо нескольких фалов, на клиент будет передано всего три файла spa.min.js
, app.min.js
и app.min.css
при этом они будут минифицированы. На мой взгляд это очень удобно, ненужно использовать никаких дополнительный модулей для сборки типа grunt.js
или gulp.js
.
Теперь займемся самым интересным будем писать само приложение для клиента. Файл назовем app.js
и разместим его на сервере в папке public/js
.
Проведем инициализацию настроек. Сами настройки будем хранить в localStorage
, если они отсутствуют, будем использовать значение по умолчанию.
//значение настроек по умолчанию
var options_default = {country: 'ru', category:'general', language: 'ru', sortBy: 'publishedAt', pageSize: 20};
//получим настройки из localstorage, если они отсутсвуют, то будем использовать значения по умолчанию
SET('option', CACHE('option')||options_default);
Инициализируем объект common
в котором будут текущие настройки нашего приложения, текущая страница, номер страницы и другие данные.
//номер текущей страницы для разделов top и search
SET('common', { 'pTop': 1, 'pSearch': 1});
Как я писал выше навигация в SPA приложении немного отличается от обычной и для того, чтобы нам её реализовать обратимся к библиотеке jRouting.js
.
Для начала изменим нашу навигацию и добавим в наши ссылки класс R
. Получим следующее:
<div class="collapse navbar-collapse" id="navbar-collapse" data---="selected__common.page__selector:li;class:active;">
<ul class="nav navbar-nav navbar-left">
<li>
<div class="navbar-form form-inline" role="form" id="form_filter">
<div class="form-group">
<div data---="textbox__option.query__placeholder:Enter the search string;class:form-control;" class='mr5'></div>
</div>
<button type='button' data-jc="click__apply_filter__enter:#form_filter" class='btn btn-default'><i class="fa fa-filter" aria-hidden="true"></i> Apply</button>
</div>
<li>
<li data-if="search"><a href="@{#}/search" class="R"><i class="fa fa-search" aria-hidden="true"></i> Search</a></li>
<li data-if="top"><a href="@{#}/top" class="R"><i class="fa fa-star" aria-hidden="true"></i> Top News</a></li>
<li data-if="history"><a href="@{#}/history" class="R"><i class="fa fa-history" aria-hidden="true"></i> History</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li data-if="setting"><a href="@{#}/setting" class="R"><i class="fa fa-cogs" aria-hidden="true"></i> Setting</a></li>
</ul>
</div>
Теперь инициализируем ссылки. Если пользователь нажмет на ссылку с классом .R
, то будет выполнен внутренний REDIRECT(перенаправление) при этом браузер не выполнит классическое перенаправление на серверную часть, а будет использоваться внутренний обработчик.
//пометим ссылки для внутреннего редиректа
NAV.clientside('.R');
//создадим обработчики для маршрутов /top, /search, /history, /setting
NAV.route('/search', ()=>{
//установим текущую страницу
SET('common.page', 'search');
var lazy = $('.search_content').FIND('lazyload');
if (!lazy) {
setTimeout(()=>{
//на странице search в элемент с классом .search_content добавим компонент lazyload, при скроллинге и при достижении этого объекта, произойдет подгрузка новостей, будет вызвана функция lazyload_search()
$('.search_content').append('<div data-jc="lazyload__null__selector:.lazyload;exec:lazyload_search;"><div class="lazyload"><div class="text-center"><img src="https://componentator.com/img/preloader.gif"></div></div></div>');
COMPILE();
}, 1000);
}
});
NAV.route('/top', ()=>{
//установим текущую страницу
SET('common.page', 'top');
//при попадании на эту страницу, будем каждый раз загружать новости заново
$('.top_content').html('');
SET('common.pTop', 1);
var lazy = $('.top_content').FIND('lazyload');
if (!lazy) {
setTimeout(()=>{
//на странице top в элемент с классом .top_content добавим компонент lazyload, при скроллинге и при достижении этого объекта, произойдет подгрузка новостей, будет вызвана функция lazyload_top()
$('.top_content').append('<div data-jc="lazyload__null__selector:.lazyload;exec:lazyload_top;"><div class="lazyload"><div class="text-center"><img src="https://componentator.com/img/preloader.gif"></div></div></div>');
COMPILE();
}, 1000);
}
});
NAV.route('/history', ()=>{
//установим текущую страницу
SET('common.page', 'history');
//в объект common.history загрузим историю, которая хранится в localStorage
SET('common.history', CACHE('history'))
});
NAV.route('/setting', ()=>{
//установим текущую страницу
SET('common.page', 'setting');
});
Если переменная common.page
меняется, то автоматически, произойдет подгрузка и смена определенной части, если нужная часть, ранее была подгружена, то просто сменится часть. Для этого в файле index.html
в body
сделаем соответствующую разметку, будем использовать компонент j-Part
.
<div id="body" class='m10'>
<div data-import="url:/part/template.html;init:initTemplate"></div>
<div data-jc="part__common.page__url:/part/hello.html" class='hidden'></div>
<!--если значение переменной common.page равно search, по
подгрузим часть, находящуюся на сервере по адресу /part/search.html -->
<div data-jc="part__common.page__if:search;url:/part/search.html" class='hidden'></div>
<div data-jc="part__common.page__if:top;url:/part/top.html" class='hidden'></div>
<div data-jc="part__common.page__if:history;url:/part/history.html" class='hidden'></div>
<div data-jc="part__common.page__if:setting;url:/part/setting.html" class='hidden'></div>
</div>
На страницах /search
и /top
будем использовать компонент lazyload
, при скроллинге страницы будет вызываться соответствующие функции lazyload_search(), lazyload_top()
, которые будет обращаться к серверу, для поиска новостей. При этом новости будут размещаться в адаптивной сетки с использованием библиотеки massonry.js
function lazyload_search(el) {
//формируем объект с параметрами запроса новостей
var query = { q: option.query||'футбол', language: option.language, pageSize: option.pageSize, page: common.pSearch, sortBy: option.sortBy };
if (option.use_date) {
query.from = option.from.toISOString();
query.to = option.to.toISOString();
}
//отправляем запрос на сервер
AJAX('GET api/news/search', query, (res, err)=>{
//если результат с ошибкой, или вовсе отсутсвует
if (err || !res.success) {
$(el).html(tEnd());
return;
}
//результат передадим в функцию шблонизатора для рендеринга и выведем на странице.
//Используется шаблон /part/template.html #tCard
$(el).html(tCard(res.value));
//увеличим индекс текущей старницы
INC('common.pSearch', 1);
var $container = $(el);
// Masonry + ImagesLoaded
$container.imagesLoaded(function(){
$container.masonry({
itemSelector: '.item'
});
//в конце страницы добавим элементом с классом lazyload
el.after('<div class="lazyload"><div class="text-center"><img src="https://componentator.com/img/preloader.gif"></div></div>');
})
})
};
function lazyload_top(el) {
//формируем объект с параметрами запроса новостей
var query = { language: option.language, country: option.country, category: option.category, pageSize: option.pageSize, page: common.pTop, sortBy: option.sortBy };
//отправляем запрос на сервер
AJAX('GET api/news/top', query, (res, err)=>{
if (err || !res.success) {
$(el).html(tEnd());
return;
}
//результат передадим в функцию шблонизатора для рендеринга и выведем на странице.
//Используется шаблон /part/template.html #tCard
$(el).html(tCard(res.value));
//увеличим индекс текущей старницы
INC('common.pTop', 1);
var $container = $(el);
// Masonry + ImagesLoaded
$container.imagesLoaded(function(){
$container.masonry({
itemSelector: '.item',
});
el.after('<div class="lazyload"><div class="text-center"><img src="https://componentator.com/img/preloader.gif"></div></div>');
});
});
};
При нажатии на кнопку Save options
будет вызвана функция save_option()
, если форма заполнена корректна, то сохраним настройки в кэше (localStorage).
function save_option() {
//проверка формы на валидацию
if (!VALIDATE('option.*')) return;
//сохранение настроек в localStorage
CACHE('option', option, '3 months');
//нотификация с текстом
SETTER('notify', 'append', 'Settings successfully saved.');
};
При клике по заголовку новости, будем сохранять информацию о просмотрах в localStorage
. При этом размер истории ограничим 100 элементами, перед каждым добавлением будем проверять длину массива, если размер превышает удалим последний элемент.
function url_view(e) {
//параметры новости, которыую собираемся открыть
var item = {
'url': $(e).attr('href'),
'title': $(e).attr('data-title'),
'description': $(e).attr('data-description'),
'thumb': $(e).attr('data-imgurl'),
'dt': NOW
};
//получим объект history из localStorage
var history = CACHE('history')||[];
//добавим объект в начало массива
history.unshift(item);
//проверка на длину массива, если превышает 100 элементов, удалим элемент с конца
if (history.length > 100) history.pop();
//сохраним историю в localStorage
CACHE('history', history, '3 months');
return false;
};
В этом посте я постарался объяснить, что такое SPA приложение и чем они отличаются от традиционных. Кроме этого написали небольшое SPA приложение. Навигация реализуется с помощью библиотеки jRouting
, которая в свою очередь входит в библиотеку jComponent
, при клике по специальной ссылке мы подгружаем в body
нужную нам часть. Также на некторых страницах используем компонент lazyload
, который в свою очередь в нужный для нас момент обращается к серверу через AJAX с определенными параметрами, сервер в свою очередь создаёт запрос к стороннему API, после чего передаёт результат назад клиенту, клиент используя шаблон формрует страницу с превьюшками новостей и выстраивает в виде адаптивной сетке используя библиотеку massondry.js
.
В моих примерах можно найти SPA приложение без использования серверной части. В дальнейшем я хочу на основе этого приложения сделать полноценное Electron
приложение.