Создаем одностраничный сайт 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
Истории из жизни айти и обсуждение кода.