Создаём Single Page Application

Javascript, jComponent, Node.js, Total.js 01 января 2020 3 мин. 1148


Что такое SPA

Сейчас существует два подхода при создании веб-приложений: традиционные веб-приложения, большая часть логики которых выполняется на сервере, а также одностраничные приложения, логика пользовательского интерфейса которых выполняется преимущественно в веб-браузере, а взаимодействие с веб-сервером осуществляется главным образом через AJAX, а навигация по сайту происходит без перезагрузки страниц. За счет такой архитектуры, SPA-приложения работают быстрее, чем «традиционные» сайты.
На рисунке ниже показано отличие традиционного веб-приложения от SPA-приложения.

Маршрутизациы Total.js
Отличие SPA-приложения от традиционного

Плюсы SPA-приложений

  • Высокая скорость и отзывчивость приложения
  • Не требуется server-side рендеринг
  • Уменьшение затрат на разработку backend
  • Возможность вместо собственного backend использовать сторонние API сервисы

Минусы SPA-приложений

  • Плохо индексируются поисковыми системами
  • SPA-приложения не работают без включенного JS в браузере

Исходя из-того, что написано выше можно сделать вывод, что архитектуру SPA можно использовать при разработке следующих типов приложений: crm системы, панели управления, личные кабинеты, приложения для информационных киосков, приложения для ПК (Electron App).


Описание проекта

Поэтому предлагаю, разработать SPA приложение "Агрегатор новостей". В процессе разработки будем использовать фреймфорк Total.js и библиотеку jComponent.

Мы напишем клиент-серверное приложение, которое будет искать новостные посты со всего мира, по определенным параметрам, после чего выводить их в удобном для нас виде. Сделаем такие разделы приложения, как настройки, история просмотра постов, топ новостей по определенной категории, и поиск новостей. Разумеется, это все будет работать без перезагрузки страниц.


Принципы работы Single Page Applications

Прежде чем приступить к разработке, нужно понять следующее у SPA приложений все пользовательские «передвижения» должны фиксироваться в истории навигации. При этом навигация должна быть «глубокой». Другими словами, если пользователь откроет скопированную ссылку на внутренний модуль в другом окне или, например, браузере, он должен попасть на необходимую ему страницу. Что бы реализовать подобное необходимо использовать History Api.

Разработка SPA приложения предусматривает использование только одной страницы. Следовательно, все необходимое для функционирования этого приложения (скрипты, стили и т.д.) должно размещаться на единственной веб-странице.

SPA приложение предусматривает загрузку всех необходимых скриптов в процессе инициализации веб-страницы.

SPA загружает дополнительные модули «по требованию» пользователя.

Сегодня есть множество фреймворков, реализующих принцип SPA. Но сейчас я буду использовать библиотеку jComponent, в которую отдельно включена библиотека jRouting library, которая позволяет реализовать навигацию по SPA приложению.


Шаг 1

Перед созданием самого приложения, я сделал простую верстку. Для быстрого создания прототипа использовал фреймворк Bootstrap 3.2 У меня получилась следующая композиция. Сверху навигация, снизу блок,

<div id="body"></div>

именно в эту область мы будем подгружать динамические блоки в зависимости от нужного нам функционала.

Маршрутизациы Total.js
Первоначальная верстка SPA приложения

Шаг 2

В качестве фреймфорка для бэкенда будем использовать Totaljs. Для начала установим фреймворк следующей командой. Фреймворк из npm репозитария будет установлен в папку node-modules.

npm install total.js

После чего в этой же папке создадим папки, как на рисунке

Маршрутизациы Total.js
Структура SPA приложения

Немного о структуре папок, которые я создал на сервере.

  • conrollers - в этой папке размещаем описание маршрутов (рут)
  • definations - здесь размещаем описание определений для фреймворка, этим самым мы будем менять поведение фреймворка
  • modules - здесь находится различные функциональные модули, которые расширяют возможности нашего приложения
  • public - в этой папке будем размещать css, javascript, изображения, вообщем дополнительные ресурсы, которые нужны нашему приложению в браузере
  • views - в ней находятся шаблоны, которые необходимы фреймворку для рендеринга на стороне сервера, там будет находится всего один файл index.html, эта будет именно та страница, которую выдаст наш сервер при обращении.

Шаг 3

Разберемся, с тем как у нас будет работать сервер.

Маршрутизация

В папку 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.


Шаг 4

Теперь займемся самым интересным будем писать само приложение для клиента. Файл назовем 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.


P.S.

В моих примерах можно найти SPA приложение без использования серверной части. В дальнейшем я хочу на основе этого приложения сделать полноценное Electron приложение.


Ссылки:

Категории