Доброго времени суток!

Относительно недавно появившийся html5 HistoryAPI уже стал довольно популярным. В интернете можно найти много статей о том, как поднять у себя работу HistoryAPI, но при этом они по большей части однообразны и есть два нюанса:

  1. Они организованы так, что обрабатывают одинаково все ссылки;
  2. Можно отстрелить себе ногу и не понять — почему.

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

Итак, что же предлагают большинство статей:


Предположим, что у нас есть самый стартовый вариант разметки — несколько навигационных ссылок и блок контента:

<!DOCTYPE html>
<html>
 <head>
  <...>
 </head>
 <body>
  <nav>
   <a href="//<?=$_SERVER['HTTP_HOST']?>">Главная</a>
   <a href="/about">О проекте</a>
   <a href="/contact">Обратная связь</a>
  </nav>
  <div id="content">
   Lorem ipsum dolor sit amet, consectetur adipisicing elit. Voluptatibus, odio.
  </div>
 </body>
</html>

И такой же усреднённый минимальный предлагаемый js:

$(document).ready(function(){
 $('a').click(function(e){
  e.preventDefault();
  var url = $(this).attr('href');
  $.ajax({
   url: url,
   data: 'ajax=true',
   success: function(data){
    //Самый усреднённый вариант, но можно и передать полученную информацию в собственный обновлятель страницы
    $('#content').html(data.content);
   }
  });
  window.history.pushState(null, null, url);
  return false;
 });
 $(window).bind('popstate', function(){
  $.ajax({
   url: history.location,
   data: 'ajax=true',
   success: function(data){
    $('#content').html(data.content);
   }
  });
 });
});

Что здесь уже плохо:


  1. Этот js одинаково обрабатывает ВСЕ ссылки: внешние, локальные и даже ссылки, которые никуда не перенаправляют (допустим, вы сделали ссылку, открывающую модальное окно). А на дворе, на минуточку, 2017 год. Ссылок, которые никуда не ведут, на разных сайтах очень много; внешние ссылки и вовсе принято (хороший тон) открывать в новой вкладке.
  2. Как только у вас в изменяемом блоке появится локальная ссылка — вы обречены. Потому что она не будет обрабатываться вашим скриптом, и вы даже не будете понимать — почему.

Что же делать?


Проблема 1 довольно легко решается: нужно отлавливать только ссылки с атрибутом href (я для ссылок на модальные окна, например, использую конструкцию вида <a modal_url="/url" modal_header=«Header» title=«Title»>) и с помощью регулярки понять, локальная ссылка или нет; и в зависимости от этого обрабатывать её по-разному.

С проблемой 2 я столкнулся, когда занимался поднятием HistoryAPI на одном из сайтов. К слову, это online-радио, поэтому путешествие по сайту без обновления страницы — принципиально важная задача. В чем заключалась проблема: Первый переход по ссылке после обновления страницы работал прекрасно. А вот дальше начиналась какая-то дьявольщина: одни ссылки продолжали работать как надо, а другие приводили к перезагрузке страницы. Только спустя 2 месяца поисков решения я наконец обнаружил, что сатанеют только те ссылки, которые не были на странице изначально, а оказались там после подгрузки страницы. И сатанеют они, потому что на них не вешается прослушка события клика.

//Вот, где проблема:
$(document).ready(function(){
 $('a[href]').click(function(e){

Как работает этот код:

1. Вы зашли на сайт
2. Скрипт ждёт, когда прогрузится страница
3. Сканирует её и вешает на все ссылки с атрибутом href обработчик события клика
4. ВСЁ. На этом его работа окончена, и он умирает.

Внезапно, правда? Но ведь нам нужно обрабатывать ВСЕ ссылки ВСЕГДА.

Первая идея — повесить в функцию-обновлятель-страницы повторное сканирование — но это ни к чему не приводит, да и дополнительный код.

Вторая идея — вернуться к идее onclick=«foo(this)», но мы уже решили, что не стоит так делать.

Быстрое свидание с гуглом, и у нас появляется решение:


$(document).ready(function(){
 $(document).on('click', 'a[href]', function(e){

Этот код работает чуть-чуть по-другому: здесь скрипт ищет в документе… документ. И дальше будет отлавливать все клики, реагируя только на клики по сслыкам с атрибутом href. Звучит странно и необъяснимо похоже на первый вариант, но оно работает.

Итоговый вариант:


Данное решение позволит вам 1 раз написать обработчик ссылок и забыть о необходимости добавления атрибутов ссылкам при создании новых постов; и от необходимости объяснять всем, кто может создавать новые записи на сайте, как правильно вставлять ссылки.

$(document).ready(function(){
 var pattern = new RegExp("^(https:\/\/"+location.host+"\/|http:\/\/"+location.host+"\/|\/\/"+location.host+"\/|"+location.host+"\/|\/(?!\/))"), // "^\/(?!\/)" - "начинается с /, но дальше - не /"
     pattern_protocol = new RegExp("^(http:\/\/|https:\/\/|\/\/)"), // да, "просто двойной слеш" тоже здесь
     pattern_lochost  = new RegExp("^("+location.host+")");
 $(document).on('click', 'a[href]', function(e){
  e.preventDefault();
  if(!$(this).attr('href')){console.log('no href'); return false;}
  var url = $(this).attr('href'),
      isLocal = (pattern.test(url)) ? true : false;
  if(isLocal){
   console.log('Local link: '+url);
   if(pattern_protocol.test(url)){url = url.replace(pattern_protocol, '');}
   if(pattern_lochost.test(url)){url = url.replace(pattern_lochost, '');}
   //На выходе получаем ссылку без протокола, двойного слеша и домена. Т.е., например, "https://domain.com/page" -> "/page".
   //Это нужо делать, ибо если у нас сылка вида domain.com/page, то она честно отдаёт isLocal,
   //но открывается через пятую точку - domain.com/domain.com/page
   $.ajax({
    //У меня логика построена так: я получаю с сервера объект
    //string data.title
    //string data.url
    //  bool data.isErrorPage
    //  bool data.hideSidebar
    //и отправляю его в обновлятель страницы, который с помощью $('selector').load(data.url+' selector') меняет содержимое нескольких элементов.
    //Но вы можете и по-другому организовать работу.
    url: url,
    data: 'ajax=true',
    success: function(data){
     reload_page($.parseJSON(data));
    }
   });
   window.history.pushState(null, null, url);
   return false;
  }else{
   console.log('External link: '+url);
   //Если нет протокола или хотя бы двойного слеша, то нужно обязательно добавить, иначе откроется в новом окне, но как location.href/url (например, domain.com/google.com)
   //Добавляем http://. В последствие вторая сторона, если имеет https:// - сама перенаправит.
   url = (pattern_protocol.test(url)) ? url : 'http://'+url;
   window.open(url, '_blank');
  }
 });
 $(window).bind('popstate', function(){
  $.ajax({
   url: location.pathname+location.search,
   data: 'ajax=true',
   success: function(data){reload_page(data)}
  });
 });
});

Если у вас могут попасться ссылки на ftp:// или ssh:// и т.д. — нужно позаботиться об обработке этих ссылок. Я не заботился, т.к. они у меня попадаться не будут.

На этом всё. Надеюсь, было полезно.

Комментарии (12)


  1. ilyaplot
    13.11.2017 17:17

    Разве href — это необязательный аттрибут? А если a href="#"?
    Думаю, вся проблема только от того, что у вас неверный подход к решению задачи.

    data: 'ajax=true', Есть же замечательная штука, называется HTTP заголовок.


    1. psFitz
      13.11.2017 17:19
      -2

      href="#" уже костыль


      1. ilyaplot
        13.11.2017 17:24

        href="#ancor" тоже костыль? Это же нормальное явление. Сейчас на любом langing page такие ссылки.


        1. psFitz
          13.11.2017 17:43

          Я говорил конкретно за href="#" как и вы написали выше.
          За анкор никто ничего не говорил.


          1. ilyaplot
            16.11.2017 12:52

            Видимо, я в первом комментарии неясно выразился. Я имел в виду как раз анкоры, которые не предусмотрены автором.


  1. AiZen_13
    13.11.2017 18:40

    Как оказалось проблема-то вовсе не хисториАпи, а в умении обращаться с жквери


  1. Hazrat
    13.11.2017 18:46

    Марти — «В какой мы год попали нажав эту ссылку?»
    Док — «2008, Марти...»


  1. andreymal
    13.11.2017 18:47

    Надо же, так кто-то ещё делает.


    На самом деле там ещё наскребётся десяток-другой проблем, на которые автор, видимо, просто ещё не наткнулся; после сбора всех костылей для них у меня в итоге получилась библиотека аж в полтысячи строк


    1. AiZen_13
      13.11.2017 18:51

      Судя по всему у автора опыт работы с хистори апи 2 месяца, с жквери — 2,5
      Оттуда и «главная боль»


  1. justboris
    14.11.2017 02:04

    Вместо того чтобы матчить урлы руками (регулярками) можно воспользоваться браузерным API. Кроме свойства href у ссылок так же есть protocol, host, pathname и много других — все как у глобального window.location.


  1. Ashot
    14.11.2017 10:55

    Как написать один раз, и чтобы голова не болела

    Найти любой проверенный клиентский роутер на github. И вообще ничего болеть не будет. Судя по вашему итоговому варианту, вам подошёл бы page.js


  1. RubaXa
    14.11.2017 13:59

    Lenald, проще надо быть


    const R_HOSTNAME = new RegExp('^' + location.protocol + '//' + location.hostname);
    
    document.addEventListener('click', function pilotClickListener(evt) {
        let el = evt.target;
    
        do {
            const url = el.href;
    
            if (
                url &&
                R_HOSTNAME.test(url) && // проверяем домен
                !evt.defaultPrevented && // проверяем, что действие уже не отменено
                !(evt.metaKey || evt.ctrlKey) // юзер не хочет открыть в новом окне
                // можно ещё и `hash` учесть
            ) {
                evt.preventDefault();
                history.pushState(null, null, url);
            }
        } while (el = el.parentNode);
    });