Создаем одностраничный сайт SPA. Разбираемся с History API
Одностраничные сайты или 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.
Идея проекта в том, чтобы делать простые и легко встраиваемые виджеты, помогающие взаимодействовать с посетителями Вашего сайта.
Прямо сейчас уже есть виджет опросов, который легко создать и встроить на любой сайт.
- simpple.ru - Главная
- Панель управления Simpple с бесплатной регистрацией
- Пример виджета-опроса "Как Вы справляетесь с нервами?"
Как видно, никаких 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
Исходники демо-сайта
И небольшой опрос
Что еще почитать на сайте
- Корзина в интернет-магазине на javascript
- Сборка фронтенда
- Разбираемся с throttle и debounce
- 10+ способов оптимизации фронтенда
- Встраиваемый виджет на нативном javascript
- javascript-шаблонизация для начинающих на примере lodash template
Истории из жизни айти и обсуждение кода.