Статьи

Бесконечная прокрутка с рельсами, на практике

no_load_more

В предшественнике этой статьи мы создали очень простой блог с демонстрационными публикациями и реализовали бесконечную прокрутку вместо простой нумерации страниц. Мы использовали will_paginate и немного javascript для решения этой задачи.

Рабочую демонстрацию можно найти на Heroku .

Исходный код можно найти на GitHub .

Сегодня давайте реализуем кнопку «Загрузить еще» вместо бесконечной прокрутки. Это решение может пригодиться, когда, например, у вас есть некоторые ссылки внутри нижнего колонтитула, и бесконечная прокрутка заставляет его «убегать», пока все записи не будут загружены.

Чтобы продемонстрировать, как это можно сделать, внесите следующие изменения в PostsController :

posts_controller.rb

 def index get_and_show_posts end def index_with_button get_and_show_posts end private def get_and_show_posts @posts = Post.paginate(page: params[:page], per_page: 15).order('created_at DESC') respond_to do |format| format.html format.js end end 

И добавьте маршрут:

конфиг / routes.rb

 get '/posts_with_button', to: 'posts#index_with_button', as: 'posts_with_button' 

Теперь есть две независимые страницы, которые демонстрируют две концепции.

index_with_button.html.erb

 <div class="page-header"> <h1>My posts</h1> </div> <div id="my-posts"> <%= render @posts %> </div> <div id="with-button"> <%= will_paginate %> </div> <% if @posts.next_page %> <div id="load_more_posts" class="btn btn-primary btn-lg">More posts</div> <% end %> 

По большей части, вид такой же. Я только изменил идентификатор оболочки для разбивки на страницы (мы будем использовать его позже, чтобы написать правильное условие) и добавил блок #load_more_posts который будет отображаться в виде кнопки с помощью классов Bootstrap. Мы хотим, чтобы эта кнопка отображалась, только если доступно больше страниц. Представьте себе ситуацию, когда в блоге только один пост — зачем нам рендерить кнопку «Загрузить больше»?

Сначала эта кнопка не должна быть видна — мы покажем ее с помощью JavaScript. Таким образом, отклик на поведение по умолчанию, если JS отключен:

application.css.scss

 #load_more_posts { display: none; margin-bottom: 10px; /* Some margin to separate it from the footer */ } 

Пришло время изменить код на стороне клиента:

pagination.js.coffee

 if $('#with-button').size() > 0 $('.pagination').hide() loading_posts = false $('#load_more_posts').show().click -> unless loading_posts loading_posts = true more_posts_url = $('.pagination .next_page a').attr('href') $this = $(this) $this.html('<img src="/assets/ajax-loader.gif" alt="Loading..." title="Loading..." />').addClass('disabled') $.getScript more_posts_url, -> $this.text('More posts').removeClass('disabled') if $this loading_posts = false return 

Здесь мы скрываем блок разбиения на страницы, вместо этого показываем кнопку «Загрузить еще» и привязываем к ней обработчик события click . Кроме того, флаг loading_posts используется для предотвращения отправки нескольких запросов, если пользователь нажимает кнопку более одного раза.

Внутри обработчика событий мы используем ту же концепцию, что и раньше: извлекаем URL следующей страницы, добавляем «загрузочное» изображение, отключаем кнопку и отправляем AJAX-запрос на сервер. Мы также добавили функцию обратного вызова, которая срабатывает при получении ответа. Этот обратный вызов восстанавливает исходное состояние кнопки и устанавливает флаг в значение false .

А теперь мнение:

index_with_button.js.erb

 $('#my-posts').append('<%= j render @posts %>'); <% if @posts.next_page %> $('.pagination').replaceWith('<%= j will_paginate @posts %>'); $('.pagination').hide(); <% else %> $('.pagination, #load_more_posts').remove(); <% end %> 

Опять же, мы добавляем новые сообщения на страницу. Если есть еще сообщения, новая нумерация страниц отображается, а затем скрывается. В противном случае кнопка нумерации страниц удаляется.

Ссылка на определенную страницу

Теперь вы знаете, как создать бесконечную прокрутку или кнопку «Загрузить еще» вместо классической нумерации страниц. Одна вещь, которую вы, вероятно, должны рассмотреть, как пользователь может поделиться ссылкой на определенную страницу? В настоящее время нет способа сделать это, потому что мы не меняем URL при загрузке новых страниц.

Давайте попробуем добиться этого, изменив search часть внутри URL (та, которая начинается с символа ? ) С помощью javascript:

 window.location.search = 'page' + page_number 

К сожалению, это мгновенно перезагружает страницу, а это не то, что нам нужно. На нашей второй попытке вместо этого измените часть hash (ту, которая начинается с символа # ). Действительно, это работает хорошо. Страница
не перезагружается. Однако есть и третье, более элегантное решение — History API . С помощью этого API мы можем напрямую манипулировать историей браузера.

В этом конкретном случае мы хотим добавить некоторые записи в историю, используя метод pushState .

Прежде всего, давайте загрузим библиотеку History.js Бенджамина Артура Луптона, которая обеспечивает межбраузерную поддержку API истории и состояния HTML 5. Для jQuery вы, вероятно, захотите использовать скрипт, расположенный в scripts/bundled/html4+html5/jquery.history.js .

Теперь давайте напишем простую функцию, которая будет $.getScript после завершения загрузки ресурса $.getScript :

pagination.js.coffee

 page_regexp = /\d+$/ pushPage = (page) -> History.pushState null, "InfiniteScrolling | Page " + page, "?page=" + page return $.getScript more_posts_url, -> # ... pushPage(more_posts_url.match(page_regexp)[0]) 

Не забывайте, что more_posts_url содержит ссылку на следующую страницу, где выбирается номер страницы. Внутри функции pushPage мы используем History.js для управления историей браузера и, в основном, для изменения URL (с последним параметром). Второй параметр изменяет заголовок окна. Первый параметр ( null ) может использоваться для хранения некоторых дополнительных данных, если это необходимо. Обратите внимание, что после изменения URL-адреса пользователь может нажать кнопку «Назад» в своем браузере, чтобы перейти на предыдущую страницу. Довольно круто.

Последнее, о чем нужно беспокоиться, это устаревшие браузеры: IE 9 и менее, если быть точным, которые не поддерживают History API. У этих архаичных зверей результирующий URL будет выглядеть следующим образом: http://example.com#http://example.com?page=2 вместо http://example.com?page=2 . Итак, мы должны добавить поддержку для этого случая.

pagination.js.coffee

 [...] hash = window.location.hash if hash.match(/page=\d+/i) window.location.hash = '' # Otherwise the hash will remain after the page reload window.location.search = '?page=' + hash.match(/page=(\d+)/i)[1] [...] 

Этот блок кода выполняется при загрузке страницы. Здесь мы сканируем хэш URL для page= , Если найдено, search часть URL обновляется с соответствующим номером страницы, после чего страница перезагружается.

Это хорошая идея, чтобы немного изменить вид так, чтобы нумерация страниц отображалась только тогда, когда доступна следующая страница (как мы делали с кнопкой «Загрузить еще»). В противном случае, когда пользователь вводит URL-адрес, чтобы перейти непосредственно к последней странице, нумерация страниц все равно будет отображаться, а обработчик событий javascript будет по-прежнему привязан.

index.html.erb

 <% if @posts.next_page %> <div id="infinite-scrolling"> <%= will_paginate %> </div> <% end %> 

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

Другой способ — объединить базовую нумерацию страниц, отображаемую в верхней части страницы, с бесконечной прокруткой. Это решает еще одну проблему: что если наш посетитель захочет перейти на последнюю или, скажем, 31-ю страницу? Прокрутка вниз и вниз (или нажатие кнопки «Загрузить еще» 30 раз) будет очень раздражать. Мы должны либо представить способ перехода на нужную страницу, либо внедрить некоторые фильтры (по дате, категории, количеству просмотров и т. Д.).

Нумерация страниц и бесконечная прокрутка

Давайте реализуем «комбинированное» решение, объединяющее бесконечную прокрутку и базовую нумерацию страниц. Это также будет работать с отключенным JavaScript, наш пользователь увидит пагинацию в двух местах, что неплохо.

Во-первых, добавьте еще один блок разбиения на страницы к представлениям (в следующем разделе мы будем работать с оболочкой static-pagination ):

index.html.erb и index_with_button.html.erb

 <div class="page-header"> <h1>My posts</h1> </div> <div id="static-pagination"> <%= will_paginate %> </div> [...] 

После этого мы должны немного изменить сценарии, чтобы ссылаться только на один блок разбиения на страницы (я разместил комментарии рядом с измененными строками):

pagination.js.coffee

 [...] if $('#infinite-scrolling').size() > 0 $(window).bindWithDelay 'scroll', -> more_posts_url = $('#infinite-scrolling .next_page a').attr('href') # <-------- if more_posts_url && $(window).scrollTop() > $(document).height() - $(window).height() - 60 $('#infinite-scrolling .pagination').html( # <-------- '<img src="/assets/ajax-loader.gif" alt="Loading..." title="Loading..." />') # <-------- $.getScript more_posts_url, -> window.location.hash = more_posts_url.match(page_regexp)[0] return , 100 if $('#with-button').size() > 0 # Replace pagination $('#with-button .pagination').hide() # <-------- loading_posts = false $('#load_more_posts').show().click -> unless loading_posts loading_posts = true more_posts_url = $('#with-button .next_page a').attr('href') # <-------- if more_posts_url $this = $(this) $this.html('<img src="/assets/ajax-loader.gif" alt="Loading..." title="Loading..." />').addClass('disabled') $.getScript more_posts_url, -> $this.text('More posts').removeClass('disabled') if $this window.location.hash = more_posts_url.match(page_regexp)[0] loading_posts = false return [...] 

index.js.erb

 $('#my-posts').append('<%= j render @posts %>'); $('.pagination').replaceWith('<%= j will_paginate @posts %>'); <% unless @posts.next_page %> $(window).unbind('scroll'); $('#infinite-scrolling .pagination').remove(); // <-------- <% end %> 

Внутри index.js.erb мы не модифицируем 2-ю строку, потому что мы хотим, чтобы разбиение на страницы обновлялось в обоих местах.

index_with_button.js.erb

 $('#my-posts').append('<%= j render @posts %>'); $('.pagination').replaceWith('<%= j will_paginate @posts %>'); <% if @posts.next_page %> $('#with-button .pagination').hide(); // <-------- <% else %> $('#with-button .pagination, #load_more_posts').remove(); // <-------- <% end %> 

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

Шпион прокрутки!

Мы достигли последней и, возможно, самой сложной части. На этом этапе мы обновляем URL-адрес и выделяем текущую страницу, когда пользователь прокручивает страницу вниз и загружается больше постов. Однако что, если наш пользователь решит прокрутить назад (вверх)? Конечно, ни URL, ни нумерация страниц не будут обновлены, и это может быть довольно запутанным!

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

Начнем с разделителей.

index.html.erb и index_with_button.html.erb

 [...] <div id="my-posts"> <div class="page-delimiter first-page" data-page="<%= params[:page] || 1 %>"></div> <%= render @posts %> </div> [...] 

Здесь data-page содержит фактический номер страницы. Мы либо выбираем его из параметра GET, либо устанавливаем в 1, если номер страницы не был указан. Обратите внимание на класс first-page который мы вскоре будем использовать.

Мы также должны обновить скрипты.

index.js.erb и index_with_button.js.erb

 var delimiter = $('<div class="page-delimiter" data-page="<%= params[:page] %>"></div>'); $('#my-posts').append(delimiter); $('#my-posts').append('<%= j render @posts %>'); [...] 

Прямо сейчас эти разделители будут невидимы для пользователя.

Наконец, реализуйте фактический шпионаж прокрутки. Для этого мы можем использовать библиотеку Waypoints для jQuery, созданную Caleb Troughton. Есть некоторые другие библиотеки, которые предоставляют аналогичную функциональность, но эта позволяет отслеживать, прокручивается ли пользователь вверх или вниз, что пригодится в нашем случае.

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

pagination.js.coffee

 jQuery -> page_regexp = /\d+$/ window.preparePagination = (el) -> el.waypoint (direction) -> $this = $(this) unless $this.hasClass('first-page') && direction is 'up' page = parseInt($this.data('page'), 10) page -= 1 if direction is 'up' page_el = $($('#static-pagination li').get(page)) unless page_el.hasClass('active') $('#static-pagination .active').removeClass('active') pushPage(page) page_el.addClass('active') return [...] 

Здесь код проверяет, что пользователь не прокручивает вверх и не достиг первой страницы. Затем он выбирает номер страницы из атрибута data-page и уменьшает его на 1, если направление вверх. Это потому, что наши разделители помещаются перед сообщениями на соответствующей странице, поэтому, когда пользователь прокручивает этот разделитель и проходит мимо него, он фактически покидает эту страницу и переходит на предыдущую.

Селектор #static-pagination указывает на блок с базовой пагинацией. Он возвращает элемент li с номером текущей страницы и назначает ему active класс (удаляя этот класс из другого li ). Обратите внимание, что нумерация страниц начинается с 1, тогда как индексация элементов, возвращаемых $('#static-pagination li') начинается с 0, но мы не уменьшаем page на 1. Это потому, что первый li в Блок пагинации всегда содержит ссылку «Предыдущая страница», поэтому мы просто пропускаем ее. Наконец, мы также изменим хеш в URL.

Также обратите внимание, что функция preparePagination прикреплена к window . Это так, что мы вызываем его не только из этого файла, но и из наших представлений *.js.erb . CoffeeScript оборачивает код внутри каждого файла с помощью самовызывающейся анонимной функции, чтобы предотвратить загрязнение глобальной области (что на самом деле хорошо). В этом случае, однако, если мы не прикрепим функцию к window , она будет невидима извне.

Теперь мы можем применить это.

pagination.js.coffee

 [...] if $('#infinite-scrolling').size() > 0 preparePagination($('.page-delimiter')) [...] if $('#with-button').size() > 0 preparePagination($('.page-delimiter')) [...] 

index.js.erb и index_with_button.js.erb

 var delimiter = $('<div class="page-delimiter" data-page="<%= params[:page] %>"></div>'); $('#my-posts').append(delimiter); $('#my-posts').append('<%= j render @posts %>'); $('.pagination').replaceWith('<%= j will_paginate @posts %>'); preparePagination(delimiter); [...] 

Последнее, что нужно сделать, это удалить $(window).unbind('scroll'); из index.js.erb , потому что Waypoints полагаются на это событие, и мы должны слушать его все время.

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

 #static-pagination { position: fixed; top: 30px; opacity: 0.7; &:hover { opacity: 1; } } 

Теперь блок нумерации страниц всегда будет отображаться сверху и будет полупрозрачным. Когда пользователь наводит курсор на этот элемент,
его непрозрачность будет установлена ​​на 1.

Вывод

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