Создаем одностраничный сайт SPA. Разбираемся с History API

май 21 , 2017
Метки:
Следующая статья Демо Исходники

Одностраничные сайты или Single Page Applications (SPA) - это круто. Главный их профит в том, что SPA быстрее и отзывчивее на действия пользователей. Достигается это за счет переноса логики работы на клиентскую сторону и активного взаимодействия с сервером посредством ajax.

Бытует мнение, что SPA - это мощные приложения на ангуляре или реакте, ворочающие тоннами данных в какой-нибудь панели управления или в сложном сервисе. И в целом это так. Но я убежден, что есть смысл писать одностраничные приложения не только для таких сервисов, но и для обычных корпоративных сайтов-визиток.

Зачем это надо и как это сделать, приложив немного усилий? Об этом ниже. Поехали.


Итак, зачем это баловство?

Самое главное - это скорость работы.

Конечно, при разработке одностраничного сайта-визитки мы столкнемся с некоторыми проблемами:

  • 1. Как подступиться, с чего начать?
  • 2. Как разобраться с историей браузера, с History API?
  • 3. Какой фреймворк или библиотеку изучить: ангуляр, реакт? А мы ни одного не знаем...
  • 4. Как заставить поисковики индексировать одностраничный сайт?

Ответы на эти вопросы:

  • 1. Разберемся в этой же статье, на примере простого сайта
  • 2. Тоже расскажу ниже, это десяток строк кода
  • 3. Никакой, обойдемся нативным javascript-ом и jQuery в качестве помощника
  • 4. Про поисковики будет следующая статья из этой серии

Почему без ангуляра-реакта?
Конечно же, это очень нужные темы, рекомендую их изучать. Но для нашего примера они не понадобятся, нам достаточно обладать минимальными знаниями javascript-a.

Одностраничники - это тема не одной статьи. Это будет целый проект, серия статей минимум из трех штук. И затрагиваться в нем будут самые разные темы. Но уточню. Мы будем строить не сложную админку со взаимодействием с сервером посредством REST API (по крайней мере, в самом начале). В первых уроках наш одностраничный сайт будет обычной визиткой из десятка страниц. Но сайт будет работать без перезагрузки страниц и радовать наших пользователей скоростью и отзывчивостью интерфейса.


Идея сайта, как он устроен

Возьмем самый обычный корпоративный сайт: главная страница, раздел "О проекте", контакты и блог. В разделе "Блог" будет несколько ссылок на внутренние страницы. На каждой странице забьем какой-нибудь контент и вставим немного перекрестных ссылок.

На каждой странице сайта, как правило, есть повторяющийся контент. У нас это будет шапка и меню. И есть основное содержимое страницы, которое меняется. Мы сделаем так: загрузим страницу всего один раз, а потом кликая по ссылкам, будем динамически подгружать нужное содержимое аяксом. При этом мы будем менять заголовок страницы во вкладке браузера, url в адресной строке и запоминать историю в браузере, чтобы работала навигация через кнопки Назад/Вперед браузера.

Контент для каждой отдельной странице будет храниться в отдельном html-файле.

Можете сразу посмотреть, что у нас в итоге получится - Демо-сайт


Структура сайта и подготовительные работы

Структура файлов-папок такова. В корне проекта файл index.php и .htaccess. Почему именно php, а не html, расскажу позже. В папке css лежат стили в файле main.css. В папке js - библиотека jquery.js и главный файл приложения main.js. В папке pages лежат html-файлы с содержимым сайта - на каждую страницу по одному файлу.


Готовим контент

Я сделаю демо-сайт на примере своего проекта webdevkin. Набор страниц будет таким:

  • — main - Главная
  • — about - О проекте
  • — blog - Блог
    • — shop - Интернет-магазины
    • — frontend - Фронтенд
    • — mysql - База данных MySql
    • — widgets - Встраиваемые виджеты
  • — simpple - Проект Simpple
  • — contacts - Контакты

Соответственно, в папке pages будут лежать 9 html-файлов. Всю разметку для контента найдете в исходниках. Для примера приведу содержимое только одного файла - simpple.html

    

Проект Simpple вырос из блога webdevkin.ru. Идея проекта в том, чтобы делать простые и легко встраиваемые виджеты, помогающие взаимодействовать с посетителями Вашего сайта. Прямо сейчас уже есть виджет опросов, который легко создать и встроить на любой сайт.

Как видно, никаких head, body, html, script здесь нет - только разметка, относящаяся к конкретной странице.


index.php и .htaccess

Почему не index.html?
Дело в том, что на нашем сайте будет одна-единственная физическая страница - index. Но нас интересуют и такие адреса, как site.ru/about, site.ru/contacts и прочее. Но страниц about и contacts в корне нашего сайта нет. Папка pages с набором html-файлов - это не полноценные страницы, а просто куски html-кода, которые встраиваются внутрь общего каркаса.

Поэтому, чтобы при обращении к site.ru/about не посыпались 500, 403 и еще бог знает какие ошибки, мы должны все входящие запросы на сайт перенаправлять на index.php, который уже и будет эти запросы разруливать. Впрочем, пока что index.php у нас - это обычная html-разметка без единой строчки php-кода (но это только пока). А в .htaccess мы пропишем следующее. Возвращаться к нему придется редко.

    RewriteEngine On
    Options +SymLinksIfOwnerMatch
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-l
    RewriteRule ^(.+)$ index.php?q=$1 [L,QSA]    

В тему
Однажды я писал статью про Простой RESTful-сервис на нативном PHP. Там Вы найдете немного больше информации про такой способ перенаправления запросов.

html-код будет у нас очень простым. В head подключаются стили css/main.css. В подвале 2 js-файла: js/jquery.js и js/main.js. А в body будет следующее:

    

Сначала выводим меню. Дальше идет заголовок страницы. И ниже пустой div с id=content (не обращайте внимания на style="" - парсер когда-нибудь выбесит и я его заменю). В #content будут подгружаться динамически содержимое страниц из файлов pages/*.html. Пока ничего интересного.

Только обратите внимание на атрибуты data-menu и data-link="ajax" у ссылок. Они введены для того, чтобы отличать ссылки-навигации от обычных внешних ссылок, которые на нашем сайте тоже будут. data-link="ajax" означает, что при клике по этой ссылке мы перехватим стандартное поведение браузера и возьмем работу со ссылкой в свои руки. А data-menu означает, какой пункт главного меню будет выделен при клике на оную ссылку. Здесь data-menu дублируется с атрибутом href, но возможны и другие варианты. Например, когда мы залезем в раздел frontend блога, то мы укажем data-menu="blog".

2 примера для наглядности:

    simpple.ru

Это ссылка на страницу site.ru/simpple, при переходе на которую содержимое страницы подгрузится из папки pages/simpple.html, и при этом будет выделен пункт главного меню "simpple"

    Главная страница simpple.ru

А это обычная внешняя ссылка, кликнув на которую мы отправимся на внешний ресурс.


Стили main.css

Быстро пролистаем и скопипастим самое скучное - файл css/main.css.

    body {
        position: relative;
        font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial;
        font-size: 1em;
        font-weight: 400;
        color: #333;
    }
    
    a, a:visited {
        color: steelblue;
    }
    
    a:hover {
        color: navy;
    }
    
    .wrapper {
        width: 80%;
        margin: 0 10%;
    }
    
    .spa-title {
        font-size: 1.2em;
        text-align: center;
    }
    
    menu {
        margin-top: 2em;
        padding: 0;
        text-align: center;
    }
    
    menu a {
        display: inline-block;
        margin-right: 10px;
        padding: 5px 15px;
        text-decoration: none;
    }
    
    menu a:hover, menu a.active {
        background-color: steelblue;
        color: white;
    }
    
    .page-title {
        text-align: center;
    }
    
    ul li {
        list-style-type: circle;
    }    

А вот теперь самое интересное - javascript-код, который превратит наш набор отдельных файлов в одностраничный сайт.


javascript. Общий код и конфиги

Зададим каркас js-кода.

    var app = (function() {
    
        var config = {};
    
        var ui = {};
    
        // Привязка событий
        function _bindHandlers() {
            // ...
        }
    
        // Инициализация приложения
        function init() {
            // ...
            _bindHandlers();
        }
    
        // Возвращаем наружу
        return {
            init: init
        }
    })();
    
    // Запуск приложения
    $(document).ready(app.init);    

Мы имеем отдельный модуль app, который при загрузке страницы запускает свою функцию init. В ней навешиваем обработчики событий и выполняем еще некоторый код. Также видим 2 объекта: config и ui. В ui будут закэшированы все dom-элементы, которые понадобятся нам в работе.

    var ui = {
        $body: $('body'),
        $menu: $('#menu'),
        $pageTitle: $('#page-title'),
        $content: $('#content')
    };

$menu нам нужно, чтобы выделять отдельные пункты меню, $pageTitle будем менять динамически при переходе между страницами, а в $content будет подгружаться содержимое файлов pages/*.html

А вот config выглядит интереснее.

    var config = {
        siteTitle: 'Webdevkin SPA',
        mainPage: 'main',
        pages: {
            main: {
                title: 'Главная',
                menu: 'main'
            },
            about: {
                title: 'О проекте',
                menu: 'about'
            },
            blog: {
                title: 'Блог Webdevkin-a',
                menu: 'blog'
            },
            simpple: {
                title: 'Проект Simpple',
                menu: 'simpple'
            },
            contacts: {
                title: 'Контакты',
                menu: 'contacts'
            },
            shop: {
                title: 'Интернет-магазины',
                menu: 'blog'
            },
            frontend: {
                title: 'Статьи о фронтенде',
                menu: 'blog'
            },
            mysql: {
                title: 'База данных Mysql',
                menu: 'blog'
            },
            widgets: {
                title: 'Встраиваемые javascipt-виджеты',
                menu: 'blog'
            }
        }
    };

siteTitle: 'Webdevkin SPA' - заголовок сайта, используется в нескольких местах.
mainPage: 'main' - указываем стартовую страницу сайта, ту, которая откроется при заходе на site.ru. Сейчас это главная страница main, но Вам запросто может прийти в голову поставить стартовую, например, "О проекте" - about.

Самое важное во всем конфиге - это объект pages. Каждое поле объекта описывает одну страницу сайта. Сейчас нам понадобятся только 2 пункта: заголовок страницы и меню, к которому оная страница относится. Ключи объекта, то есть страницы, совпадают с названиями файлов в pages/*.html и атрибутами href во внутренних ссылках.

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


Подгрузка контента с помощью ajax. History API

Пойдем по порядку. Займемся функцией init, в которой есть привязывание нужных событий _bindHandlers

    // Привязка событий
    function _bindHandlers() {
        ui.$body.on('click', 'a[data-link="ajax"]', _navigate);
        window.onpopstate = _popState;
    }

В первой строке мы отлавливаем клики на внутренние ссылки a[data-link="ajax"] и отправляем их в функцию _navigate. Теперь мы окончательно убедились, что нужны отдельные атрибуты data-link="ajax" :-)

Далее window.onpopstate = _popState;
Из документации.
Событие popstate отсылается объекту window каждый раз, когда активная запись истории меняется между двумя записями истории для одного и того же документа.
Проще говоря, это срабатывание кнопок Назад/Вперед в браузере. Как мы видим, нативное поведение браузера тоже нужно перехватывать. Поэтому мы отдадим управление функции _popState.

Для лучшего восприятия приведу код сразу для обеих функций

    // Клик по ссылке
    function _navigate(e) {
        e.stopPropagation();
        e.preventDefault();

        var page = $(e.target).attr('href');

        _loadPage(page);
        history.pushState({page: page}, '', page);
    }

    // Кнопки Назад/Вперед
    function _popState(e) {
        var page = (e.state && e.state.page) || config.mainPage;
        _loadPage(page);
    }

При явном клике по ссылке _navigate мы останавливаем всплытие события клика и отменяем дефолтное поведение браузера (переход по ссылке). Затем мы определяем страницу, которую мы хотим загрузить (понимаем по атрибуту href), и вызываем новую функцию _loadPage. Она и сделает всю основную работу по загрузке контента, изменению заголовка и прочее-прочее. И в конце через history.pushState добавляем новую запись в истории браузера. Да, мы сами, явным образом создаем историю браузера. Тогда, когда считаем нужным. И сохраняем данные о загружаемой странице в объект {page: page}. Эти данные нам пригодятся в следующей функции _popState.

В _popState идея аналогична: ищем нужную страницу и запускаем ту же _loadPage.

    var page = (e.state && e.state.page) || config.mainPage;

e.state && e.state.page - вытаскивает нам страницу из объекта истории, которую мы предусмотрительно записали в _navigate. Если же объект e.state недоступен (например, когда мы первый раз зашли на сайт site.ru и еще не успели побродить по нему), то берем страницу, указанную главной в нашем конфиге - config.mainPage.
По совести говоря, функция history.pushState и тот факт, что в window.onpopstate доступен объект e.state с данными, записанными в pushState, - это все, что нам достаточно знать о History API. Для более любопытных товарищей, не сомневаюсь, что гугление поможет найти и другие хорошие способы работы с историей браузера.

Мы же глубокими изысканиями заниматься не будем, а напишем код главной функции _loadPage

    // Загрузка контента по странице
    function _loadPage(page) {
        var url = 'pages/' + page + '.html',
            pageTitle = config.pages[page].title,
            menu = config.pages[page].menu;

        $.get(url, function(html) {
            document.title = pageTitle + ' | ' + config.siteTitle;
            ui.$menu.find('a').removeClass('active');
            ui.$menu.find('a[data-menu="' + menu + '"]').addClass('active');
            ui.$pageTitle.html(pageTitle);
            ui.$content.html(html);
        });
    }

Выглядит он вполне заурядно. Сначала формируем url, то есть путь, по которому мы загрузим html для страницы. Потом вытаскиваем из конфига заголовок страницы и пункт меню, подлежащий выделению. Далее получаем html-содержимое страницы через банальный jQuery.get и выполняем еще ряд нехитрых действий.

а) Обновляем заголовок страницы
б) В 2 строки выделяем нужный пункт меню
в) Меняем заголовок уже на самой странице, в html-коде
г) Загружаем собственно html-содержимое страницы, полученное из url

Почти все! Остался маленький момент. Если мы сейчас перейдем, например, на страницу site.ru/blog и обновим ее, то загрузится не блог, а пустая страница. Потому что при инициализации мы _loadPage не вызываем. Чтобы это исправить, нам нужно дописать пару строк в функцию init, которая в итоге будет выглядеть так

    
    // Инициализация приложения
    function init() {
        var page = document.location.pathname.substr(1) || config.mainPage;
        _loadPage(page);

        _bindHandlers();
    }

Вот теперь точно все!


Подведем итоги и напомним ссылки

Итак, мы написали простой, но вполне рабочий одностраничный сайт, а также немного узнали, как работает History API. Честно говоря, чувствую себя режиссером плохого фильма, когда 80% времени ведут к какой-то грандиозной развязке, а в конце все оказывается намного проще, чем ожидалось.

С одной стороны, почти весь код - это просто подготовка, обвязка, написание конфигов и прочих скучных вещей. А действительно полезный код, выполняющий непосредственную работу, занимает 2 десятка строк.

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

Есть и третья сторона. Для не знакомых с одностраничными сайтами - это ни фига не простая вещь. И я думаю, это здорово, когда изначально кажущаяся сложной тема, при подробном рассмотрении оказывается вполне себе решаемой, причем без великих усилий с нашей стороны.

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

А пока напоминаю нужные ссылки.
Демо одностраничного сайта из статьи - spa.webdevkin.ru
Исходники демо-сайта

И небольшой опрос

Что еще почитать на сайте

Следующая статья Демо Исходники
Метки:
Заходите в группу в контакте - https://vk.com/webdevkin
Анонсы статей, обсуждения интернет-магазинов, vue, фронтенда, php, гита.
Истории из жизни айти и обсуждение кода.
Как Вам статья? Оцените!