Персональный сайт Дмитрия Журавлева

Связь: dmitriyzhuravlev@yandex.ru

SPA сайт с разными адресами страниц
Сделал свой первый настоящий SPA:
/spa/webmaster/

Под словом "настоящий" понимается, что страниц как бы много, хотя на самом деле она одна. :)
Т.е. в браузере в адресной строке визуально меняется текущий адрес, без символа #, т.е. по-настоящему. В истории браузера добавляются страницы, по которым можно перемещаться кнопками вперёд-назад истории браузера.

Реализовать это можно благодаря методу history.pushState(state, title [, url]). Ну и важно продумать структуру сайта.

Мне моя реализация нравится, что SEO-friendly. Контент показывается, если отключен Javascript.

Один скрипт отвечает за отдачу контента - index.php:
<?php

// Определяю, есть ли страница для отображения
$pagesArr = file("pages.txt");
$pageToShow = null;

$requestUrl = substr($_SERVER['REQUEST_URI'], strlen("/spa/webmaster"));

for ($i = 0; $i < count($pagesArr); $i ++)
{
	$pageArr = explode("\t", $pagesArr[$i]);
	if ( $requestUrl === $pageArr[0])
	{
		$pageToShow = $pageArr[0];
		$title = trim($pageArr[2]);
		$pageFile = $pageArr[1];
		break;
	}
}

if ($pageToShow === null)
{
	header("HTTP/1.0 404 Not Found");
	echo "404 Not Found";
	exit; 
}

echo "
<!DOCTYPE html>
<html>
<head>
<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
";

echo"<title>".$title."</title>";

echo "
<link href=\"/spa/webmaster/spa-webmaster.css\" rel=\"stylesheet\" type=\"text/css\">
<link href=\"https://dmitriyzhuravlev.ru/style.css\" rel=\"stylesheet\" type=\"text/css\">

</head>

<body>
";

// Верх страницы
require "header.html";

echo "<h1 class=\"spa\">".$title."</h1>";

echo "<div class=\"spa pageContent\">";

if (file_exists ( "pages/".$pageFile.".html" ))
{
	require "pages/".$pageFile.".html";
}
else
{
	echo "Ошибка: для этой страницы не найдено файла с контентом.";
}

echo "</div>";

// Низ страницы
require "bottom.html";

?>
Все запросы идут на него, поэтому такой .htaccess в каталоге /spa/webmaster/:
RewriteEngine On

RewriteRule ^[a-zA-Z0-9-/]*$  index.php
Обратите внимание, что в regexp нет точки, поэтому запросы с точкой, т.е., например, к файлам *.png пройдут как обычные запросы к файлам.

Скрипт смотрит какую страницу запрашивают и сверяется с файлом pages.txt, где каждая строка - это одна возможная страница, её "технические" значения разделены табуляторами:
/	000	Что это за SPA?
/javascript/operator-precedence/	001	Приоритет операторов в Javascript
/javascript/navigator-mediadevices-getusermedia/	002	Метод navigator.mediaDevices.getUserMedia()
/javascript/truthy-falsy/	003	Truthy и falsy значения
/css/backdrop-filter/	004	CSS-свойство backdrop-filter
Для удобства всё хранится в текстовых файлах, БД не используется.

А Javascript делает POST-запросы к файлу getpagespa.php:
<?php
// Этот PHP скрипт отдаёт JSON-объект с контентом запрошенной страницы после запроса методом POST.

$postBody = file_get_contents('php://input');

if ($postBody === false) { exit; }

$data = json_decode($postBody, true); // второй аргумент (true) означает, что надо вернуть массив (ассоциативный), а не объект


if ( array_key_exists("page", $data) === false)
{
	echoAnswer ("PHP скрипт: получен некорректный JSON-объект без ключа page", false);
	exit;
}

$pageFromRequest = $data["page"];

$pagesArr = file("pages.txt");
$pageToShow = null;

for ($i = 0; $i < count($pagesArr); $i ++)
{
	$pageArr = explode("\t", $pagesArr[$i]);
	if ( $pageFromRequest === $pageArr[0])
	{
		$pageToShow = $pageArr[0];
		$title = trim($pageArr[2]);
		$pageFile = $pageArr[1];
		break;
	}
}

if ($pageToShow === null)
{
	echoAnswer("Запрошенной страницы нет в pages.txt", false);
	exit; 
}

if (file_exists ( "pages/".$pageFile.".html" ))
{
	$content = file_get_contents("pages/".$pageFile.".html");
}
else
{
	echoAnswer("Запрошенная страница есть в pages.txt, но отстутствует файл ".$pageFile, false);
	exit; 
}

echoAnswer($content, true);

function echoAnswer($text, $allOk) {
	global $title, $pageFromRequest;
	$answer = [
		"text" => $text,
		"allOk" => $allOk,
		"title" => $title,
		"page" => $pageFromRequest,
	];
	$text = json_encode( $answer );
	echo $text;
}

?>
Ну, а это сам JS-код:
// Полифил метода closest
// На самом деле он не нужен, ведь в этом коде клики в старых браузерах работают как клики по обычному сайту, а не SPA.

window.Element&&!Element.prototype.closest&&(Element.prototype.closest=function(e){var t,o=(this.document||this.ownerDocument).querySelectorAll(e),n=this;do{for(t=o.length;--t>=0&&o.item(t)!==n;);}while(t<0&&(n=n.parentElement));return n});

document.addEventListener('click', handleSPAClick);

window.addEventListener('popstate', handlePopstate);

function handleSPAClick(e) {
	if ( !fetch ) { return; } // старые браузеры - обработка клика как по обычному сайту, а не SPA
	
	var linkElem = e.target.closest('a'); // ищу ссылку (тег a) по дереву вверх
	
	if ( linkElem )
	{
		// определяю, какую страницу хочет открыть пользователь
		var pageForUser = null;
		var text = 'spa/webmaster';
		var pos = linkElem.href.indexOf(text);
		if (pos !== - 1)
		{
			pageForUser = linkElem.href.substr(pos + text.length);
		}
		
		if (pageForUser !== null)
		{
			e.stopPropagation();
			e.preventDefault();
			loadPage(pageForUser);
		}
	}
}

function loadPage(page, doNotPushState) {
	// Функция подготавливает объект с контентом  открываемой пользователем страницы
	
	// page - string - страница, которую надо открыть
	// doNotPushState - необязательный boolean - определяет нужно ли добавить в объект свойство, которое потом запрещает вызывать history.pushState в функции updateSPA
	
	// console.log('Пользователь хочет открыть страницу:');
	// console.log(page);
	
	var bodyData = JSON.stringify( {page: page} ); // тело для POST запроса к PHP-скрипту
	
	// console.log('Запрашиваю через fetch контент.');
	fetch('/spa/webmaster/getpagespa.php',
	{
		method: 'POST',
		headers: {'Content-Type': 'application/json;charset=utf-8'},
		body: bodyData,
	})
	.then(ifFulfilled)
	.catch(ifRejected);
	
	function ifRejected(e)
	{
		console.log('Ошибка при выполнении fetch.');
		console.log(e);
	}

	function ifFulfilled(response)
	{
		// console.log('Ответ получен.');
		if (response.ok) { response.text().then( useResponse ); }
		else
		{
			console.log('Проблема: response.ok равен ' + response.ok + ', а response.status равен ' + response.status);
		}
	}

	function useResponse(answer)
	{
		answer = JSON.parse(answer); // строка превращается в объект
		if (doNotPushState === true) 
		{
			answer.doNotPushState = true;
		}
		updateSPA(answer);
	}
	
}

function updateSPA (data) {
	// Функция обновляет страницу: меняет содержимое тегов title, h1 и div с css-классом pageContent
	// После этого меняет историю браузера (если требуется).
	if (data.allOk !== true)
	{
		// Значение allOk заполняет PHP-скрипт
		console.log( 'Возникла какая-то проблема. В ответе у allOk не стоит значение true.' );
		return;
	}
	document.title = data.title;
	document.querySelector('h1.spa').innerHTML = data.title;
	document.querySelector('.pageContent.spa').innerHTML = data.text;
	if (data.doNotPushState !== true) // Текущая функция запускается, если пользователь жмёт назад/вперёд в истории браузера, поэтому нельзя в таком случае добавлять в историю элемент.
	{
		data.doNotPushState = true;
		history.pushState( {spaPageData: data}, 'Title', '/spa/webmaster' + data.page); 
	}
}

function handlePopstate(e) {
	// Функция сработает при использованнии Вперёд/Назад истории браузера.
	
	if (e.state && e.state.spaPageData)
	{
		// Используется старый объект state, "закешированный" браузером.
		// Из-за этого может показываться старый контент, если контент за время просмотра успел обновиться.
		// При желании можно исользовать код из блока else, который заново запрашивает содержимое страницы.
		updateSPA(e.state.spaPageData);
	}
	else
	{
		// Сюда попадёт пользователь, когда кнопкой назад вернётся на самую первую страницу сайта, загруженную без SPA-технологии
		// console.log('Нет event.state.');
		var pageForUser = null;
		var text = 'spa/webmaster';
		var pos = location.href.indexOf(text);
		if (pos !== - 1)
		{
			pageForUser = location.href.substr(pos + text.length);
		}
		
		if (pageForUser !== null)
		{
			loadPage(pageForUser, true); // второй параметр равный true означает, что не надо добавлять в историю браузера новый элемент
		}
	}
}
Ну и как обычно, в моём варианте SPA показывает всё нормально старым браузерам.

SPA-сайт - это, конечно, интересный вариант построения сайта со своим плюсами и минусами.
Раздел: JavaScript

Комментарии
(из-за чертовых спамеров урлы в коментах теперь писать нельзя)

Имя:

 
Комментарий:

 

Антиспам. Сколько будет единица плюсик пять?
Напишите только число:




Комментариев нет