Dynamicznie wczytywana lista

Dzisiaj zajmiemy się stworzeniem dynamicznie wczytywanej listy na stronie. Omawiana technika może być wykorzystana np do wczytywania postów na stronie
wordpressa, dynamicznego pokazywania nowości, produktów itp.

Utworzenie bazy danych

Aby nasz przykład był bardziej realny, po stronie serwera dane będziemy pobierali z bazy danych.
Odpalamy serwer, tworzymy vhosta i rozpoczynamy pracę.
Zacznijmy od utworzenia przykładowej bazy danych z tabelą.
Możemy to zrobić ręcznie w phpMyAdmin, możemy też za pomocą skryptu. Ja wybrałem ta drugą metodę i stworzyłem plik populateDB.php:

<?php
$servername = "localhost";
$username = "root";
$password = "";
$databaseName = 'news_example';
$tableName = 'news';

$conn = new mysqli($servername, $username, $password);

//sprawdzamy połączenie z bazą
if ($conn->connect_error) {
    die("Connection failed: " . $conn->connect_error);
}

//tworzymy baze "news_example"
$sql = "CREATE DATABASE $databaseName";
if ($conn->query($sql) === TRUE) {
    echo 'Database "$databaseName" created successfully';
} else {
    echo $conn->error;
    echo '<br><br>';
}

//wybieramy baze
$conn->select_db($databaseName);

// tworzymy przykladowa tablicę "news"
$sql = "CREATE TABLE $tableName (
               id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY,
               news_title VARCHAR(255),
               news_url VARCHAR(255),
               news_date DATE,
               news_graphic_url VARCHAR(255),
               news_content TEXT
               ) ENGINE=MyISAM";

if ($conn->query($sql) === TRUE) { 
  echo 'Success creating table "$tableName"<br><br>';
} else {
  echo $conn->error;
  echo '<br><br>';
}

//wstawiamy kilka postów do tablicy
for ($i=0; $i<55; $i++) {
    $news_title = 'Przykladowy tytul '.($i+1);
    $news_date = date('Y-m-d');
    $news_graphicUrl = 'https://unsplash.it/300/200?image='.$i;;
    $news_content = 'Lorem ipsum '.($i+1).' dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
    $news_url = 'http://google.pl';

    $sql = "INSERT INTO news (news_title, news_url, news_date, news_graphic_url, news_content) VALUES ('$news_title', '$news_url', '$news_date', '$news_graphicUrl', '$news_content')";      

    if ($conn->query($sql) === TRUE) { 
      echo 'Wstawiono '.($i+1).' wpis do tabeli $tableName<br><br>';
    } else {
      echo $conn->error;
      echo '<br><br>';
    }
}

Odpalamy powyższy skrypt który stworzy nam bazę news_example z tablicą news. Jeżeli będziesz miał problemy z tym skryptem,
zawsze możesz wrócić do ręcznej metody i skorzystać z phpMyAdmin. W samym skrypcie są widoczne konkrente pola, więc nie powinieneś mieć problemów z tym co i jak ustawić.

Tworzymy kod listy

Nasza lista z elementami ma postać:

<section class="news-list">
    <h2>Nowości na stronie</h2>
    <div class="list">
        
        ...tutaj trafią wpisy
        
    </div>
    <button type="button" class="btn load-more" id="loadMorePost">
        <strong>Wczytaj więcej</strong>
    </button>
</section>

a jej stylowanie ma postać:

* {
    box-sizing:border-box;
}

body {
    font-family:sans-serif;
    margin:40px;
    color:#555;
}

.news-list {
    text-align: center;
    max-width:1140px;
    margin:0 auto;
}

/* pojedynczy element listy */
.news {
    display: inline-block;
    width:33.3333%;
    padding:10px;
}
@media screen and (max-width:860px) {
    .news {	width:50%; }
}
@media screen and (max-width:650px) {
    .news {	width:100%; }
}
.news > div {
    background: #fafafa;
    padding:5px;
    border-radius:3px;
    box-shadow:0 2px 1px rgba(0,0,0,.1);
}
.news .title {
    text-align: left;
    padding-left:20px;
    padding-right:20px;
    font-size:17px;
    margin-bottom:20px;
}
.news .title a {
    color:#444;
    text-decoration: none;
}
.news .graphic {
    text-align: center;
    display: block;
    overflow: hidden;
    position: relative;
    border-radius:3px;
}
.news .graphic img {
    width:100%;
    height:auto;
    display: block;
    transition:transform 0.4s;
    border-radius:3px;
}
.news .graphic:before {
    position: absolute;
    left:0;
    top:0;
    background: rgba(0,0,0,0);
    width:100%;
    height:100%;
    content:'';
    z-index: 1;
    transition:background 0.5s, box-shadow 0.5s;
    box-shadow:none;
}
.news .graphic:hover:before {
    background: rgba(0,0,0,0.5);
    box-shadow:inset 0 0 30px rgba(0,0,0,0.5);
}
.news .graphic:hover img {
    transform:scale(1.5, 1.5);
}
.news .date {
    text-align: left;
    padding-left:20px;
    padding-right:20px;
    color:#aaa;
    font-size:12px;
    margin:10px 0 15px;
    display: block;
}
.news .content {
    color:#555;
    font-size:13px;
    line-height:1.2em;
    padding:0 20px 20px;
    text-align:left;
}

/* przycisk do wczytywania danych */
.load-more {
    border:0;
    display:inline-block;
    background:transparent;
    margin:20px 0;
    position:relative;
    outline:none;
    cursor:pointer;
}
.load-more strong {
    display:block;
    clear: both;
    cursor: pointer;
    padding:15px 40px;
    border:0;
    background: #4C9ED9;
    color:#fff;
    border-radius:4px;
    font-weight:bold;
    position:relative;
    transition:0.5s opacity;
}
.load-more:after {
    opacity:0;
    visibility:hidden;
    transition:0.5s opacity;
    position:absolute;
    left:50%;
    top:50%;
    transform:translate(-50%, -50%);
    clear:both;
    display: block;
    width:20px;
    height:20px;
    border-radius: 50%;
    border:1px solid rgba(0,0,0,0.2);
    border-right-color:rgba(0,0,0,0.6);
    animation : spin 1s linear infinite;
    content:'';

}
.load-more.loading {
    cursor:default;
    pointer-events:none;
}
.load-more.loading strong {
    opacity:0.2;
}
.load-more.loading:after {
    opacity:1;
    visibility:visible;
}

@keyframes spin {
    from {transform: translate(-50%, -50%) rotate(0deg);}
    to {transform: translate(-50%, -50%) rotate(360deg);}
}

Kolejne wpisy będziemy pobierać za pomocą ajaxa i dynamicznie wstawiać do listy.
Każdy taki wpis po utworzeniu będzie miał postać:

<article class="news">
    <div>
        <a href="http://..." class="graphic">
            <img src="http://...">
        </a>
        <h3 class="title">
            <a href="http://...">Przykładowy wpis</a>
        </h3>
        <date class="date" datatime="2016-10-22">
            Data: 22 październik 2016
        </date>
        <div class="content">
            Lorem ipsum dolor sit amet, consectetur adipisicing elit...
        </div>
    </div>
</article>

Ale to za chwilę. Na początku napiszmy skrypt, który pobierze dane z serwera. Zaczynamy od php:

Kod php pobierający dane z bazy

<?php
sleep(1); //dla testów opuźniamy nieco nasz skrypt

$servername = "localhost";
$username = "root";
$password = "";
$databaseName = 'news_example';
$tableName = 'news';

$conn = new mysqli($servername, $username, $password);
if ($conn->connect_error) {
    die("Connection failed: " . $conn->connect_error);
}

$conn->select_db($databaseName);

$startIndex = (int)$_POST['startIndex']; //odkąd zaczynamy pobieranie
$postsPerPage = (int)$_POST['postsPerPage']; //ile pobieramy

//pobieramy ostatni rekord - posluzy nam to do przekazania czy tablica wpisow sie skonczyla
//nie mozemy tutaj polegac na tym, ze za pomocą js spradzimy czy pobrano stosowną liczbę wpisow
$sql = "SELECT id FROM $tableName ORDER BY id DESC LIMIT 1";
$lastRowId = false;
if ($result = $conn->query($sql)) {
    $lastRowId = $result->fetch_object()->id;
} else {
    die("Connection failed: " . $conn->connect_error);
}

$sql = "SELECT * FROM $tableName LIMIT $postsPerPage OFFSET $startIndex";


//jezeli pobranie elementow zakonczylo sie powodzeniem
if ($result = $conn->query($sql)) {
    $elements = array();
    $resultArray = array(
        'elements' => $elements,
        'endList' => $lastRowId
    );

    $i=$startIndex;

    while ($obj = $result->fetch_object()) {
        $i++;
        $element = array(
            'id'            => $obj->id,
            'newsUrl'       => $obj->news_url,
            'title'         => $obj->news_title,
            'date'          => $obj->news_date,
            'graphicUrl'    => $obj->news_graphic_url,
            'content'       => $obj->news_content
        );
        if ($obj->id == $lastRowId) {
            $resultArray['endList'] = true;
        }
        array_push($resultArray['elements'], $element);
    }

    echo json_encode($resultArray);
} else {
    echo $conn->error;
}

Zwracamy 2 elementową listę. Indeks elements zawiera listę zwracanych elementów. Drugi indeks endList zawiera tylko
wartość true/false w zależności czy nastąpił koniec listy (wczytaliśmy właśnie końcowe dane). Po co w ogóle zwracamy test czy nastąpił koniec listy?
Moglibyśmy spróbować wykryć koniec danych za pomocą pętli w JS sprawdzając liczbę zwróconych z serwera danych. Przyszła pusta odpowiedź? Znaczy że osiągneliśmy koniec danych.
Powodowało by to jednak pewien błąd. Ostatnia strona zwróciła by jakieś rekordy, a mimo zakończenia danych guzik wczytywania kolejnej strony widoczny byłby nadal. Dopiero ponowne jego kliknięcie spowodowało by wczytanie pustych danych i ukrycie guzika.

Kod JS obsługujący pobrane dane

Obsługa pobranych danych za pomocą JS jest bardzo prosta. Przekazujemy do PHP pozycję początkową (startIndex) i liczbę
pobieranych danych (postsPerPage). W odpowiedzi dostaniemy obiekt json zawirający dwie pozycje: elements będący tablicą
obiektów z nowościami i drugi endList zawierający prawdę/fałsz o zakończeniu danych w bazie.
Cała reszta to klasyczne wykorzystanie jquery $.ajax. Cały nasz kod napiszemy w formie modułu, kóry będzie miał ogólna postać:

var infiniteLoading = (function() {
    "use strict";

    var _privateMethod = function() {
        //...
    }

    var _init = function() {
        //...tutaj odpalimy metody prywatne
    }


    //tutaj zwracamy tylko te metody, które chcemy by były dostępne na zewnątrz.
    //W naszym przypadku zwracamy tylko init, czyli dostępne będzie wywołanie:
    //infiniteLoading.init()
    return {        
        init : _init
    }
})();

$(function() {
    infiniteLoading.init();
});

Zaczynamy od ustawienia liczby wpisów na stronie, oraz początkowej strony currentPage.
Po kliknięciu w przycisk wysyłamy ajaxem zapytanie do serwera. Serwer sobie coś tam mieli.
W tym czasie ustawiamy zmienną isPreviousEventComplete na false.
Dopiero po zakończonym pobieraniu danych i wstawianiu wpisów na stronę zmieniamy tą zmienną na true.
Dzięki temu unikamy sytuacji gdy użytkownik kliknie na przycisk w czasie gdy poprzednie pobieranie danych jeszcze się
nie zakończoło.

var infiniteLoading = (function() {
	"use strict";

	var postsPerPage = 6; //liczba wpisów na jedną na stronę
	var currentPage = 1; //obecna strona
	var isPreviousEventComplete = true; //czy poprzednie zapytanie o posty sie zakonczylo
	var isDataAvailable = true; //czy dane sa dostepne

	var _readPosts = function() {
        if (isPreviousEventComplete && isDataAvailable) {
            isPreviousEventComplete = false;

            $("#loadMorePost").addClass('loading');

            $.ajax({
                type: 'POST',
                url: '/getMorePosts.php',
                dataType: 'json',
                data: {
                    //pobieranie zaczynamy od indeksu. Czemu tutaj dajemy - 1? Bo dla pierwszej strony
                    //wpisy to 0-5, dla drugiej 6-11 itp
                    startIndex: 	postsPerPage * (currentPage - 1),
                    //liczba pobieranych wpisów
                    postsPerPage: 	postsPerPage
                },

                success: function (result) {
					var month = ['styczeń', 'luty', 'marzec', 'kwiecień', 'maj', 'czerwiec', 'lipiec', 'sierpień', 'wrzesień', 'październik', 'listopad', 'grudzień'];

					if (result.elements.length) {
                        //robimy pętlę po pobranych danych
						for (var i = 0; i < result.elements.length; i++) {
							var news = result.elements[i];

							var dateSplit = news.date.split('-');
                            var dateText = dateSplit[2] + ' ' + month[parseInt(dateSplit[1], 10)] + ' ' + dateSplit[0];

							...tutaj bedziemy tworzyć wstawiany element

							$(".news-list .list").append($element);
						}

						currentPage++;
						isPreviousEventComplete = true;
					} else {
						//wczytano puste dane - dane nie sa juz dostepne
						isDataAvailable = false;
						$('#loadMorePost').hide();
					}
					if (result.endList == true) {
                        //przekazano z serwera koniec listy
						isDataAvailable = false;
						$('#loadMorePost').hide();
					}
				},

                error: function (error) {
                    console.log(error);
                },

                complete: function () {
                    $("#loadMorePost").removeClass('loading');
                }
            });
		}
    };

    var _displayPageOnClickButton = function() {
        $('#loadMorePost').on('click', function(e) {
            e.preventDefault();
            _readPosts();
        });
    };

    var _init = function() {
        _displayPageOnClickButton();
    };

    return {
        init : _init
    }
})();

$(function() {
    infiniteLoading.init();
});

Tworzenie wpisu z wykorzystaniem szablonu

Najbardzie interesującą funkcją-callbackiem jest success. To właśnie tam odbieramy dane z serwera i na ich podstawie dynamicznie tworzymy kolejne wpisy.
Zaczynamy od zamiany dany w formacie 22-10-2012 na format 22 październik 2012. Kolejnym krokiem będzie utworzenie wnętrza naszego elementu.
Za pomocą czystego JS utworzenie wszystkich składowych takiego elementu było by nieco problematyczne, dlatego wykorzystamy do tego celu system szablonów. Na rynku istnieje tego od groma.
Jednym z przyjemniejszych jest http://handlebarsjs.com/, z którego skorzystamy. Dorzucamy plik z tą biblioteką do strony, a następnie
tworzymy szablon naszego newsa.

<script id="news-template" type="text/x-handlebars-template">
    <article class="news">
        <div>
            {{#if graphicUrl}}
            <a href="{{newsUrl}}" class="graphic">
                <img src="{{graphicUrl}}">
            </a>
            {{/if}}
            <h3 class="title"><a href="{{newsUrl}}">{{title}}</a></h3>
            <date class="date" datatime="{{date}}">Data: {{dateText}}</date>
            <div class="content">
                {{content}}
            </div>
        </div>
    </article>
</script>

Jak widzisz, jest to kod html, w którym umieszcamy w klamrach zmienne, które zaraz do takiego szablonu przekażemy.
Kod szablonów umieszczamy między znacznikami script, które mają atrybut text/x-handlebars-template.
Pozostaje nam obsługa tego szablonu we wcześniej napisanej funkcji success:

var infiniteLoading = (function() {
    ...
    var readPosts = function() {
        ...

        $.ajax({
            ...

            success: function (result) {
                var month = ['styczeń', 'luty', 'marzec', 'kwiecień', 'maj', 'czerwiec', 'lipiec', 'sierpień', 'wrzesień', 'październik', 'listopad', 'grudzień'];

                if (result.elements.length) {
                    //robimy pętlę po pobranych danych
                    for (var i = 0; i < result.elements.length; i++) {
                        var news = result.elements[i];

                        var dateSplit = news.date.split('-');
                        var dateText = dateSplit[0] + ' ' + month[dateSplit[1]] + ' ' + dateSplit[2];

                        //tworzymy template newsa
                        var theTemplateScript = $("#news-template").html();
                        var theTemplate = Handlebars.compile(theTemplateScript);

                        //ustawiamy dane, które przekażemy do szablonu. Dane te pobieramy w zmiennej result z serwera
                        var context = {
                            newsUrl: news.newsUrl,
                            title: news.title,
                            graphicUrl: news.graphicUrl,
                            date: news.date,
                            dateText: dateText,
                            content: news.content
                        };

                        //renderujemy html przekazując mu dane pobrane z serwera - zamieniamy szablon na html
                        var theCompiledHtml = theTemplate(context);
                        //zamieniamy html na obiekt jquery
                        var $element = $(theCompiledHtml);
                        $(".news-list .list").append($element);
                    }

                    //po wczytaniu danych i stworzeniu postów zwiekszamy obecną strone
                    currentPage++;
                    isPreviousEventComplete = true;
                } else {
                    //wczytano puste dane - dane nie sa juz dostepne
                    isDataAvailable = false;
                    $('#loadMorePost').hide();
                }
                if (result.endList == true) {
                    isDataAvailable = false;
                    $('#loadMorePost').hide();
                }
            }
        });
    }
    ...
})();

$(function() {
    infiniteLoading.init();
});

Demo 1

Bonus: obsługa historii

Bardzo często można spotkać dynamiczne listy, które nie dają możliwości zapamiętania naszej pozycji na kolejnych stronach listy. Tworząc dynamiczne listy, czy strony z tak zwanym nieskończonym przewijaniem warto
dodać obsługę historii przeglądarki, czyli automatycznej aktualizacji url strony.
Dzięki temu po przejściu na kolejną stronę użytkownikowi automatycznie zaktualizuje się url w oknie przeglądarki.

Na szczęście implementacja historii jest bardzo prostym zadaniem, ponieważ wystarczą tylko 2 linijki kodu i wykorzystanie metody pushState.
Wsparcie dla tej metody jest na szczęście całkiem dobre (jeżeli interesuje cię wsparcie dla starszych przeglądarek, zawsze możesz skorzystać z tego projektu)

...
success: function (result) {
    var month = ['styczeń', 'luty', 'marzec', 'kwiecień', 'maj', 'czerwiec', 'lipiec', 'sierpień', 'wrzesień', 'październik', 'listopad', 'grudzień'];

    if (result.elements.length) {
        for (var i = 0; i < result.elements.length; i++) {
            ...
        }

        currentPage++;
        isPreviousEventComplete = true;

        //aktualizujemy url w oknie przeglądarki
        var url = window.location.protocol + "//" + window.location.host + window.location.pathname;
        history.pushState(null, $('head > title').html(), url + '?page=' + currentPage);
    } else {
        //wczytano puste dane - dane nie sa juz dostepne
        isDataAvailable = false;
        $('#loadMorePost').hide();
    }
    if (result.endList == true) {
        isDataAvailable = false;
        $('#loadMorePost').hide();
    }
},
...

Aktualizację urla podczas wczytywania kolejnych danych mamy już obsłużoną.
Pozostaje obsłużyć wczytanie odpowiedniej strony danych tuż po wejściu na naszą stronę. Aby to zrobić musimy sprawdzić, czy po załadowaniu strony w adresie nie istnieje zmienna "page", którą aktualizowaliśmy przy aktualizacji urla strony.
Napiszmy funkcję getParameterByName, która sprawdzi, czy w query string urla znajduje się szukana zmienna:

var getParameterByName = function(name, url) {
	if (!url) url = window.location.href;
	name = name.replace(/[\[\]]/g, "\\$&");
	var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)");
	var results = regex.exec(url);
	if (!results) return null;
	if (!results[2]) return '';
	return decodeURIComponent(results[2].replace(/\+/g, " "));
};

Następnie korzystamy z tej funkcji do sprawdzenia czy w urlu istnieje już nasza zmienna. Jeżeli istnieje - ustawiamy na nią currentPage, po czym wczytujemy dane, tak jak poprzednio:

var infiniteLoading = (function() {
    ...

    var _displayPageOnPageEnter = function() {
		if (getParameterByName('page') != '' && getParameterByName('page') != null) {
			currentPage =  parseInt(getParameterByName('page'), 10);
			if (currentPage < 1) currentPage = 1;
		} else {
			currentPage = 1;
		}
		_readPosts();
	};

    var _init = function() {
        _displayPageOnPageEnter();
        _displayPageOnClickButton();
    }

    return {
        init : _init
    }
})();

$(function() {
    infiniteLoading.init();
});

Dodatkowo do tej pory w callbacku success po wygenerowaniu wczytanej listy wpisów zwiększaliśmy aktualną stronę.
Gdy wczytujemy wpisy od razu po wejściu na stronę, nie powinniśmy zwiększać aktualnej strony. Wystarczy przekazać do
funkci wczytującej dodatkowy atrybut loadOnPageEnter:

var _readPosts = function(loadOnPageEnter) {
		if (isPreviousEventComplete && isDataAvailable) {
			...
			$.ajax({
				...
				success: function (result) {
                    ...
					if (result.elements.length) {
						...
                        if (!loadOnPageEnter) {
                            currentPage++;
                            var url = window.location.protocol + "//" + window.location.host + window.location.pathname;
                            history.pushState(null, $('head > title').html(), url + '?page=' + currentPage);
                        }
					} else {
						...
					}
					...
				},
				...
			});
		}
	};

	var _displayPageOnPageEnter = function() {
		...
		_readPosts(true);
	};

	var _displayPageOnClickButton = function() {
		$('#loadMorePost').on('click', function(e) {
			e.preventDefault();
			_readPosts(false);
		});
	};

Demo 2

Więcej bonusów: automatyczne wczytywanie postów podczas przewijania strony

Wspomniałem, że zamiast klikania na przycisk, możemy kolejne strony z postami wczytywać gdy przewiniemy stronę do jej końca.

var infiniteLoading = (function() {
    ...

    var _displayPageOnScroll = function() {
        $(window).scroll(function () {
            if ($(document).height() - 50 <= $(window).scrollTop() + $(window).height()) {
                _readPosts(false);
            }
        });
    }

    var _init = function() {
        _displayPageOnPageEnter();
        _displayPageOnScroll();
       //wczytywanie po kliknięciu na przycisk sobie darujemy
       //pamiętaj też o usunięciu z kodu html tego przycisku
       //_displayPageOnClickButton();
    }
})();

$(function() {
    infiniteLoading.init();
});    

Sam guzik w tym przypadku usuńmy. Nie musimy go więc także ukrywać gdy dane się zakończyły.
Sam wskaźnik wczytywania będziemy dynamicznie dodawać do listy:

var _readPosts = function(loadOnPageEnter) {
    if (isPreviousEventComplete && isDataAvailable) {
        isPreviousEventComplete = false;

        $(".news-list").append($('<div class="loading-list"></div>'));

        success: function (result) {
            ...
            if (result.elements.length) {
                ...
            } else {
                //wczytano puste dane - dane nie sa juz dostepne
                isDataAvailable = false;
                $('#loadMorePost').hide(); //<-- to już nam jest niepotrzebne
            }
            if (result.endList == true) {
                isDataAvailable = false;
                $('#loadMorePost').hide(); //<-- to już nam jest niepotrzebne
            }
        },
        ...
        complete: function () {
            $(".news-list").find('.loading-list').remove();
        }
    }
};

Stylowanie wskaźnika wczytywania możemy zaporzyczyć z przygotowanego wcześniej przycisku:

.loading-list {
    display: block;
    min-height:30px;
    position: relative;
    margin:30px 0;
}
.loading-list:after {
    position:absolute;
    left:50%;
    top:50%;
    transform:translate(-50%, -50%);
    display: block;
    width:20px;
    height:20px;
    border-radius: 50%;
    border:1px solid rgba(0,0,0,0.2);
    border-right-color:rgba(0,0,0,0.6);
    animation : spin 1s linear infinite;
    content: '';
}

@keyframes spin {
    from {transform: translate(-50%, -50%) rotate(0deg);}
    to {transform: translate(-50%, -50%) rotate(360deg);}
}

Demo 3

Komentarze

  • Grzegorz Sowa

    MEGA! Jak zwykle świetny materiał!

  • FoxCode

    Teraz lepiej korzystać z ES6 niż handlebars?