«Бесконечная» лента записей с ajax дозагрузкой на Yii
На многих новых сайтах всё чаще встречается вывод списка новостей или других сущностей в виде бесконечно подгружающейся ленты. На некоторых сайтах подгрузка выполняется автоматически (на twitter.com или vk.com), на других – вручную, то есть в конце списка вместо стандартного переключателя страниц имеется кнопка «Показать ещё». Освежим в памяти работу с ClistView и попробуем реализовать подобный функционал на своём сайте.
Довольно часто такую бесконечную «стену» делают где ни попадя, не задумываясь, нужна она или нет. Но мы не будем касаться здесь этической стороны.
Итак, нам нужно подгружать записи используя Ajax. Сначала отвлечёмся на разбор работы Ajax обновлений стандартного списка ClistView.
Вывод списка записей с ClistView
В самом простом случае с использованием встроенных средств Yii Frmework мы можем выводить список постов с Ajax переходами по страницам так:
Контроллер controllers/PostController.php:
class PostController extends Controller { public function actionIndex() { $criteria = new CDbCriteria; $criteria->order = 't.create_datetime DESC'; $dataProvider = new CActiveDataProvider('Post', array( 'criteria'=>$criteria, 'pagination'=>array( 'pageSize'=>10, ), )); $this->render('index', array( 'dataProvider'=>$dataProvider, )); } }
Представление views/post/index.php:
<?php $this->pageTitle = 'Блог'; $this->breadcrumbs = array( 'Блог', ); <h1>Блог</h1> $this->widget('zii.widgets.CListView', array( 'dataProvider'=>$dataProvider, 'itemView'=>'_view', 'ajaxUpdate'=>true, 'template'=>"{items}\n{pager}", ));
Элемент списка views/post/_view.php:
<?php <article> <h2><?php echo Chtml::link($data->getUrl(), $data->title); </h2> echo $data->short; </article>
Здесь у нас всего одно действие actionIndex()
и одно представление index.php
для него. Но в блоге могут быть вывод записей из категории, записей по тегу, по дате и т.д. Все они будут использовать свои шаблоны index.php
, category.php
, tag.php
, date.php
, но одинаковый общий список. Целесообразно по принципу шаблонов Wordpress вынести формирование списка в отдельный файл _loop.php
.
Все шаблоны типа index.php теперь ссылаются на файл списка _loop.php
:
views/post/index.php:
<?php $this->pageTitle = 'Блог'; $this->breadcrumbs = array( 'Блог', ); <h1>Блог</h1> $this->renderPartial('_loop', array('dataProvider'=>$dataProvider));
views/post/_loop.php:
<?php $this->widget('zii.widgets.CListView', array( 'dataProvider'=>$dataProvider, 'itemView'=>'_view', 'ajaxUpdate'=>true, 'template'=>"{items}\n{pager}", ));
Теперь все наши списки нормально выводятся и переход по страницам происходит по Ajax. Но в этом простейшем способе кроются некоторые подводные камни.
Снижение нагрузки при обновлении по ajax
Ajax запрос обращается по ссылкам, которые прописаны в ячейках списка номеров страниц, то есть фактически к тому же actionIndex()
, который независимо от способа запроса рендерит полную страницу сайта в строке $this->render('index', array(...))
. Обработчик Ajax ответа только выделяет из всего HTML-содержимого код своего списка. Эта тема уже поднималась на Habrahabr. Теперь мы можем решить эту проблему.
При Ajax запросе наш контроллер должен возвращать не всю страницу в шаблоне, а только код списка. Так как мы вынесли список в отдельный файл _loop.php
, ничто не мешает генерировать только его вызывая $this->renderPartial('_loop', array(...))
:
class PostController extends Controller { public function actionIndex() { $criteria = new CDbCriteria; $criteria->order = 't.create_datetime DESC'; $dataProvider = new CActiveDataProvider('Post', array( 'criteria'=>$criteria, 'pagination'=>array( 'pageSize'=>10, ), )); if (Yii::app()->request->isAjaxRequest){ $this->renderPartial('_loop', array( 'dataProvider'=>$dataProvider, )); Yii::app()->end(); } else { $this->render('index', array( 'dataProvider'=>$dataProvider, )); } } }
Теперь при Ajax запросе будет возвращаться только список без генерации всего шаблона сайта. Этот способ подойдёт и для CGridView, где из файла admin.php
таблицу можно вынести в файл _grid.php
.
Добавляем подгружающуюся ленту
Действия нашего контроллера уже способны оптимизировать свою работу при Ajax запросах. Рассмотрим теперь непосредственно организацию ленты. Нам нужно:
- Создать на странице блок
<div id="listView"></div>
; - В блоке вывести стандартный список первых 10 новостей со навигацией по страницам;
- Если включен JavaScript и страниц больше одной, то скрыть навигатор и отобразить кнопку «Показать ещё»;
- Запомнить в JavaScript номер текущей страницы
- Навесить на эту кнопку обработчик, который бы по щелчку увеличивал номер текущей страницы на единицу и загружал новую порцию записей и добавлял в блок
#listView
; - Если записи закончились (
page>=pageCount
), то скрыть кнопку.
Если JavaScript у пользователя отключен, то наш скрипт не сработает и останется стандартный навигатор.
Также нам нужно добавить индикацию процесса загрузки (можно, например, показывать/скрывать анимированное изображение или дабавлять/удалять класс-примесь кнопке) и защиту от частых нажатий (выставлять флаг на всё время згрузки).
Представление views/post/_loop.php с учётом этого может быть примерно таким:
<div id="listView"> <?php $this->widget('zii.widgets.CListView', array( 'dataProvider'=>$dataProvider, 'itemView'=>'_view', 'ajaxUpdate'=>false, 'template'=>"{items}\n{pager}", 'pager'=>array( 'htmlOptions'=>array( 'class'=>'paginator' ) ), )); </div> if ($dataProvider->totalItemCount > $dataProvider->pagination->pageSize): <p id="loading" style="display:none"><img src=" echo Yii::app()->request->baseUrl; /images/loading.gif" alt="" /></p> <p id="showMore">Показать ещё</p> <script type="text/javascript"> /*<![CDATA[*/ (function($) { // скрываем стандартный навигатор $('.paginator').hide(); // запоминаем текущую страницу и их максимальное количество var page = parseInt(' echo (int)Yii::app()->request->getParam('page', 1); '); var pageCount = parseInt(' echo (int)$dataProvider->pagination->pageCount; '); var loadingFlag = false; $('#showMore').click(function() { // защита от повторных нажатий if (!loadingFlag) { // выставляем блокировку loadingFlag = true; // отображаем анимацию загрузки $('#loading').show(); $.ajax({ type: 'post', url: window.location.href, data: { // передаём номер нужной страницы методом POST 'page': page + 1, ' echo Yii::app()->request->csrfTokenName; ': ' echo Yii::app()->request->csrfToken; ' }, success: function(data) { // увеличиваем номер текущей страницы и снимаем блокировку page++; loadingFlag = false; // прячем анимацию загрузки $('#loading').hide(); // вставляем полученные записи после имеющихся в наш блок $('#listView').append(data); // если достигли максимальной страницы, то прячем кнопку if (page >= pageCount) $('#showMore').hide(); } }); } return false; }) })(jQuery); /*]]>*/ </script> endif;
Здесь мы отключили за ненадобностью встроенную поддержку ajax, так как будем делать переходы в своём скрипте.
Это представление уже не надо возвращать по Ajax. Оно должно выводиться единожды и подгружать только голый список. Для этого добавим представление loopAjax.php
с кодом подгружаемого списка без прочих лишних элементов:
views/post/_loopAjax.php
<?php $this->widget('zii.widgets.CListView', array( 'dataProvider'=>$dataProvider, 'itemView'=>'_view', 'ajaxUpdate'=>false, 'template' => "{items}", ));
Кроме переименования _loop
в _loopAjax
в контроллере необходимо произвести ещё несколько изменений. Все они касаются передачи номера необходимой страницы контроллеру.
Можно заметить, что номер страницы из нашего скрипта передаётся посредством переменной page
в POST запросе (а не в GET), а в классе CPagination
, используемом CActiveDataProvider
, для определения номера текущей страницы используется именно GET.
В зависимости от используемых роутов при различных построениях ЧПУ номер страницы в URL может находиться в любом месте, например
http://site.com/blog?page=2 http://site.com/blog/page-2 http://site.com/page/2 http://site.com/blog/2 http://site.com/page-2
Наш JavaScript код не может воспользоваться функцией Yii::app()->createUrl()
для генерации ссылок, поэтому номер необходимой страницы проще передать в POST запросе, отправленном на текущий адрес window.location.href
и в контроллере произвести подмену $_GET['page']=$_POST['page']
.
Кроме того, нужно явно указать имя параметра page
в поле pageVar
, иначе по умолчанию он будет использовать параметр по имени нашей модели, то есть Post_page
.
class PostController extends Controller { public function actionIndex() { $this->processPageRequest('page'); $criteria = new CDbCriteria; $criteria->order = 't.create_datetime DESC'; $dataProvider = new CActiveDataProvider('Post', array( 'criteria'=>$criteria, 'pagination'=>array( 'pageSize'=>10, 'pageVar' =>'page', ), )); if (Yii::app()->request->isAjaxRequest){ $this->renderPartial('_loopAjax', array( 'dataProvider'=>$dataProvider, )); Yii::app()->end(); } else { $this->render('index', array( 'dataProvider'=>$dataProvider, )); } } protected function processPageRequest($param='page') { if (Yii::app()->request->isAjaxRequest && isset($_POST[$param])) $_GET[$param] = Yii::app()->request->getPost($param); } }
Подмену значения $_GET['page']
мы производим в методе processPageRequest()
. Его можно при желании либо поднять в базовый контроллер, либо (чтобы избавиться от лишней строки $this->processPageRequest('page');
) вынести в отдельный фильтр.
После всех этих манипуляций мы получим «бесконечную» ленту, подгружающую записи и становящуюся всё длиннее и длиннее по щелчку мыщи. Для автоматической загрузки новых записей при прокрутке страницы нужно обработчик щелчка $('#showMore').click()
заменить на обработчик прокрутки страницы $('html').scroll()
с проверкой на появление нашего блока #showMore
в видимой части окна.
Для большего удобства можно вынести код кнопки и обработчика в отдельный настраиваемый виджет, которому бы передавались, например, $dataProvider
и CSS-идентификатор стандартного навигатора по страницам.
Опечатка: $this->processCurrentPage('page');
Надо: $this->processPageRequest('page');
Для автоматической подгрузки:
Хмм, не уверен, где разместить loadingFlag = true;, который вначале.
Спасибо, исправил.
у вас все равно $this->processCurrentPage('page'); стоит, такого метода нет в контроллере.
Сдейлайте в виде екстеншена
А какой формат расширения для этого лучше подойдёт?
вот на примере инфинит скролла: http://www.yiiframework.com/extension/inifinite-scroll-pager
Здравствуйте. Спасибо за отличный пример. А какой у Вас action для _view? Мне не совсем понятно с выборкой.
Это представление _view.php выводит каждую запись в _loop.php:
Тогда во _view должно быть так?
Почти:
Текущая модель передаётся в переменную $data, номер записи в $index, а this виджета CListView в $widget.
Спасибо, разобрался уже после того как спросил)
Здравствуйте. Прошу прощения, не слишком силен в yii. Допустим во время того, как пользователь осуществляет прокрутку ленты, добавляется несколько новых записей в базу. Будут ли при этом продублированы последние добавленные на страницу записи?
Да, новые будут сдвигать старые вниз. Это и обычного постраничного отображения касается.
Подскажите пожалуйста, а как решить эту проблему? Может необходимо при первой загрузке страницы сохранить в javascript переменной время первой загрузки, и потом передавать его контроллеру, а из базы выбирать данные, у которых время добавления не позже переданного?
Можно и так.
Спасибо)
Здравствуйте!
Спасибо за пример. Все сделал, работает. Данные подгружает, но Js не работает в подгруженных данных. Как побороть это?
У меня подгружаются картинки, с возможностью проголосовать за них, так вот это голосование не работает...
Навешивайте голосования live-привязкой через on():
вместо обычного click():
Спасибо! Попробую, отпишусь потом.
в views/post/_loop.php используется и jQuery и $
думаю стоит писать все в одном стиле
Исправил. Спасибо!
Спасибо за статью. Вопрос, почему когда нажимаю первый раз на "Показать ещё", он загружает новые 10 статей и по идеи в базе больше нет статей, но кнопка не исчезает и если нажать ещё то появляются те, к-е уже выводились.
Ещё показывает несколько раз "Показать ещё" после первого нажатия.
Подскажите, пожалуйста, почему JavaScript в Вашем примере не может воспользоваться Yii::app()->createUrl()?
Спасибо.
Yii::app()->createUrl() – это PHP. В JavaScript без ajax это сделать будет проблематично.
из Вашего же примера
Ну попробуйте.
Свою проблему решил. Вместо "_loopAjax", написал "_loop", поэтому не работало, исправил, теперь работает.
А я уж подумал, что я в $ переименовал не так.
:)
Отличный пример. Вообще у Вас на сайте замечательный материал, уже нашел для себя много полезного и интересного. В случаях когда делают "бесконечную ленту" делают еще и догрузку новых записей в начало списка, естественно с помощью AJAX. Было бы здорово если бы Вы описали и этот нюанс.
Спасибо :)
Например, элементы можно пометить:
Теперь в JavaScript по setTimeout() через каждые несколько секунд получать крайний идентификатор записи на странице:
и передавать его в Ajax запросе.
А контроллер должен найти записи с id больше last_id и сгенерировать ответ через renderPartial(). В success коллбэке Ajax запроса клиентский скрипт теперь получает этот ответ, добавляет элементы вверх через prepend() и запускает следующий setTimeout().
Пожалуй это будет оптимальный вариант. Еще был вариант посмотреть в сторону "WebSockets", но поскольку технология еще не всеми браузерами поддерживается (или не всеми корректно), решил пока не углубляться в них.
Спасибо за подсказку.
Добрый день!
у меня вопрос возник, а как реализовать pagination так что бы он при переходе на следующий страницу или при нажатии на любую из страничек делалэто без перезагрузки страницы?
У CListView или CGridView указать 'ajaxUpdate' => true.
Есть один плохой момент, каждый раз при добавлении новых записей вместо добавления в список items, генерируется новый список.
Да, так и есть. Но если это критично, то можно сделать озвлечение элементов из data и вставлять прямо в items:
Может можете подсказать, как извлечь из data только div item?
Достаточно как-то обозначить закрывающий блок в _view.php:
И на клиенте выбрать внутренности с помощью регулярного выражения вроде:
Грузит последние два элемента с базы данных. Видимо нет никакой проверки на то что эти записи уже выводились. Помогите пожалуйста!
Грузит последние два элемента с базы данных снова и снова при прокрутке вниз*. Видимо нет никакой проверки на то что эти записи уже выводились. Помогите пожалуйста!
А все исправил.
if (page >= pageCount)
loadingFlag = true;
написал совсем в другом месте
Здравствуйте, сделал ленту, отлично работает и автору респект!
Столкнулся с тем что необходимо добавить пользовательский поиск, чтобы была возможность ввода параметров для поиска и вывод данных сохраняя при этом ленту, делал сначала форму путем добавления модели и формы, но вот по нажатию поиска столкнулся с проблемой как сделать вывод на основе атрибутов этого самого поиска? какие идеи есть по этому поводу?
Реализовал, все работает. Для примера из этой статьи
Перед $dataProvider в контроллере добавляем
Далее в контроллере меняем
на следующий код
и во вьюшке в данном случае views/post/index.php добавляем форму поиска через виджет CActiveForm
Подскажите пожалуйста, а как сделать, чтобы вначале при загрузке страницы не показывало ни одного блока, а при нажатии на кнопку уже выводило по три блока (или столько, сколько указано в размере "page")?
Со стороны сервера сразу не скажу, а на клиенте можно сразу убрать:
И тогда page надо считать не с единицы, а с нуля.
Не работает. На странице не выводит ни одного элемента, при этом есть надпись "показать еще". При нажатии на нее подгружается лоад-гифка и всё.
Контроллер:
Представление:
Соврал. Только при подгрузке у меня подгружаются не элементы,а весь layout
А что внутри ответа с layout? Обычно он весь подгружется при выводе ошибки.
С этим разобрался. Сейчас возникли проблемы связать это все еще с аякс-фильтром. Запутался уже) Буду очень благодарен небольшому примеру. Сейчас у меня так.
_loop.php:
_loopAjax:
контроллер:
Вроде выборка правильная, но проблема в том, что если кнопка "Показать еще" была нажата, то она больше не появится при фильтрации
И кнопка появляется в начале всегда (даже если элемента 3, при нажатии добавляются они же)
Просто не забудьте учесть, что при фильтрации нужно получить новый pageCount и сбросить page=1.
Ваш метод инфинит скролла имеет баг
Conditions: limitPage = 10, countItems = 11
Вы передаете page=2, Вам по идеи должно вернуть 1 item, но за пару сек. до этого кто то постит в ленту несколько сообщений в итоге вам вернется 9, 10, 11[id]
9, 10 - вернулись в первом запросе, получаем дублирование контента, решать этот вопрос средствами frontend это костыль.
Эту проблему нужно решать на уровне backend, солюшен состоит в том что бы передавать id последнего item со страници
В кондишенах имеем
Спасибо!
Сразу интегрировал !!!!!!!!!!!!!1
Тут у меня проблема возникла, как вывести сообщения в обратном порядке после выборки из бд.
Вот критерии:
Как видно из критерии сортировка идёт по desc, а как мне при рендере отсортировать сообщения по ACS, что-бы новые сообщения отображались последними в списке?
Может быть есть что-либо из коробки?
Пока не придумал ничего умнее чем:
Ну если нужно именно после выборки отсортировать, то можно и так.
Дмитрий, большое спасибо за пример. Всё отлично работает!
Подскажите, как организовать добавку /page/номер_страницы в url при нажатии кнопки "Показать ещё"?
Используйте history API.
Спасибо!
Скажите, а на сколько увеличивается нагрузка на сервер при таком подходе? Может быть лучше передавать только данные о постах в JSON, а потом уже javascript-ом их обворачивать в нужные дивы и вставлять? У меня просто проект где нагрузка на сервер будет немаленькая и вот думаю, делать способом как у Вас или все же передавать только данные в JSON, а уже остальную их обработку делать на javascript-ом. По логике нагрузку на сервер я таким образом сделаю немного меньше, но получается нехороший код тогда. Нужно, например, помимо php шаблона вывода поста делать шаблон для вставки данных в js. При изменении одного из них - надо менять второй. Ну и скорее всего в абсолютном выражении такой подход будет медленнее, ибо придется делать много замен в цикле в js. Чтобы из конструкции а-ля
%post_title%
%post_date%
получилась конструкция с именем и датой поста, которые пришли в JSON с сервера. Как думаете, какие еще моменты я не учел для выбора? На что еще стоит обратить внимание? Чтобы Вы посоветовали?
Так как в контроллере сейчас есть условие:
то кроме _loop ничего лишнего из шаблона сервере рендериться не будет. А если делать возврат через JSON, то помимо самих записей нужно будет возвращать связанные модели и генерируемые поля (URL и т.п.). Так что разница будет небольшая.
Что ничего лишнего не рендерится это понятно. Видимо, мне нужно было сразу сказать, что выводятся у меня не точно такие же посты как у Вас, а совсем другие данные. Так вот в этих данных, если делать через renderPartial, больше html кода, чем самих данных. Один элемент(аналог Вашего поста) у меня занимает 23 строки(примерно одинакового размера). Из них только 3 строки это данные с сервера, а остальное верстка. Относительно нагрузки на сервер у меня для одного элемента получается выбор: либо в JSON-е отправить эти 3 строки и обернуть их в верстку js-ом, либо грузить все 23 строки сразу с сервера, подождав пока отработает рендеринг. Теперь понимаете, что я имею в виду?
Теперь понятно.
Добрый день,
В документации указано. что можно создавать Action, указав actionId:
http://www.yiiframework.com/doc/api/1.1/CController#createAction-detail
Но тут ничего не сказано о том, как передать туда параметры...
У меня 3 вопроса:
1. Можно ли сделать то, что я описал выше?
2. Есть ли более лояльный способ ajax переключения с actionCreate на actionUpdate. Обязательна поддержка загрузки картинок. Дело в том, что некоторые блоки формы должные открываться только после сохранения основной модели.
3. Возможно это лучше провернуть подменяя сценарии как то ?
Пока у меня такой быдлокод, но место , где я в isAjaxRequest блоке создаю action, не работает:
Нашел:
Yii::app()->runController('sadmin/hotels/update/id/1416');
Только почему то не работает в isAjaxRequest, только в основном блоке экшена.
Почему-то данный метод отрабатывает в основном теле экшена, но при isAjaxRequest ничего не генерируется(
Не пробовал, так что не знаю. Если так и не получится, то попробуйте вынести код из actionUpdate в обычный метод и вызывать его здесь.
Буду разбираться тогда, спасибо, что не прошли мимо!
Дополнил комментарий.
Благодарю, пробую нечто подобное сделать, хочется fullAjax на старости лет)
А есть ли способ собрать action create и action update в один action без костылей вроде input type="hidden" и js-a , который подменяет значение этого поля?
ajaxSubmitButton на success ничего не возвращает:
Сама кнопка точно отрабатывает, если подкинуть в isAjaxRequest какие нибудь die($value), они возвращаются
Добрый день. А как такое сделать на Yii2? Что-то не выходит никак сделать)
В что именно не выходит?
Не выходит спрятать пагинацию и добавлять вывод по клику.
возвращает NaN
Остальное, кажется, удалось победить
Все получилось, кроме вывода _loopAjax
Похоже, что вообще не срабатывает в контроллере if (Yii::$app->request->isAjax) - пробовал вывести var_dump и setFlash - тишина.
Страница передается, запрос формируется GET http://test.loc/shop/new?page=2
только гифка мигает... и кнопка пропадает. когда прокликиваю до конца.
И с токенами не разобрался - может в них дело?
Вот опять Я, привет Дмитрий.
Вопрос такой , мне нужен пример добавление комментариев с ajax , не обязательно древовидный.
Или статьи на эту тему, искал , но нашел не рабочие.
Подскажи где посмотреть наглядный пример
Пример jQuery.ajax?
Как сделать, чтобы подгрузка блоков происходила заранее, а не по достижению блоком нижнего края окна браузера, т.е, как в соцсетях?
Елена, если Вы не понимаете сути бесконечной подзагрузки.... она собственно в том и заключается, чтобы \по достижению блоком нижнего края\ грузился новый контент. "Заранее" итак грузится первая \портянка\ можете её сделать небольшой, записей 50 (самый частый запрос), потом подгружать по 100... как вариант.
Всё остальное это CSS, JS ... вы там можете играться как угодно. в конце концов дайте прелоадер пользователю, чтобы он видел - идёт загрузка.
(у всех разная скорость прокрутки + разный контент)
Спасибо, уже давно сделала) А как сделать, чтобы при загрузке лайтбокса грузились не все миниатюры в диафильм, а например 50?
https://girls-art.photos/baleriny/#bwg90/34583
Добрый день, Дмитрий! Поясните, пожалуйста, почему мы используем в ajax запросе метод post? Если использовать метод get, то мы можем не использовать processPageRequest...
Подскажите
csrf всегда нужно отправлять в ajax запросах?
Что будет если его не оправить?
Что с ним делать в контроллере?
в yii2.
В Yii2 он отправляется и принимается сам незаметно.
Здравствуйте. Вы не могли бы показать, как сделать загрузку без кнопки - при прокручивании до конца списка?
разобрался
Дмитрий, здравствуйте. Вопрос, если используется ArrayDataProvider, можно ли как-то сделать бесконечный скрол (если на странице - статический контент с массива в гриде) ?