Złożoność nieskończonego przewijania

TL;DR: ponownie wykorzystaj elementy DOM i usuń te, które znajdują się daleko od widoku. Użyj obiektów zastępczych, aby uwzględnić opóźnione dane. Oto prezentacjakod nieskończonego scrollera.

Nieskończone przewijanie pojawia się w całym internecie. Lista wykonawców w Google Music jest jedną z nich, podobnie jak linia czasu na Facebooku i strumień na żywo na Twitterze. Przewijasz stronę w dół i przed dotarciem do dołu nowe treści magicznie pojawiają się znikąd. Jest to wygodna opcja dla użytkowników, a pozew jest łatwy do sprawdzenia.

Jednak techniczne wyzwanie związane z nieskończonym scrollerem jest trudniejsze, niż się wydaje. Zakres problemów, które napotykasz, gdy chcesz zrobić to, co słuszne™, jest ogromny. Zaczyna się od prostych rzeczy, takich jak linki w stopce, które stają się praktycznie niedostępne, ponieważ treści ciągle przesuwają stopkę w dół. Ale zadania stają się trudniejsze. Jak obsłużyć zdarzenie zmiany rozmiaru, gdy ktoś przełączy telefon z orientacji pionowej na poziomą? Jak zapobiec temu, aby telefon nie zatrzymał się w boleśliwy sposób, gdy lista stanie się zbyt długa?

The right thing™

Uznaliśmy, że to wystarczający powód, aby opracować implementację referencyjną, która pokazuje, jak rozwiązać wszystkie te problemy w sposób umożliwiający ich ponowne wykorzystanie przy zachowaniu standardów wydajności.

Aby osiągnąć nasz cel, użyjemy 3 technik: recyklingu DOM, nagłówków i ankroch przewijania.

Nasz przykład będzie przedstawiać okno czatu podobne do Hangouts, w którym można przewijać wiadomości. Potrzebujemy nieskończonego źródła wiadomości czatu. Technicznie rzecz biorąc, żaden z dostępnych nieskończonych scrollerów nie jest naprawdę nieskończony, ale przy ilości danych, które można w nich wykorzystać, może się wydawać, że tak jest. W celu uproszczenia zaimplementujemy twardo kodowany zbiór wiadomości czatu i losowo wybierać będziemy wiadomości, autorów i dodatkowe załączniki z obrazami, stosując przy tym sztuczne opóźnienie, aby udawać prawdziwą sieć.

Zrzut ekranu aplikacji do obsługi czatu

Recykling DOM

Recykling DOM to mało wykorzystywana technika, która pozwala ograniczyć liczbę węzłów DOM. Zasada ogólna polega na tym, aby zamiast tworzyć nowe elementy DOM, używać już utworzonych elementów, które są poza ekranem. Same węzły DOM są tanie, ale nie są bezpłatne, ponieważ każdy z nich powoduje dodatkowe koszty związane z pamięcią, układem, stylem i renderowaniem. Urządzenia niskobudżetowe będą działać zauważalnie wolniej, a w najgorszym razie mogą stać się całkowicie nieużyteczne, jeśli witryna będzie miała zbyt duży DOM. Pamiętaj też, że każde ponowne rozmieszczanie i ponowne stosowanie stylów – proces, który jest uruchamiany za każdym razem, gdy klasa jest dodawana lub usuwana z węzła – staje się droższe wraz z większym DOM-em. Dzięki temu, że będziemy ponownie używać węzłów DOM, łączna liczba węzłów DOM będzie znacznie niższa, co przyspieszy wszystkie te procesy.

Pierwszym problemem jest przewijanie. Ponieważ w danym momencie będziemy mieć tylko niewielką podgrupę wszystkich dostępnych elementów w DOM, musimy znaleźć inny sposób, aby pasek przewijania przeglądarki prawidłowo odzwierciedlał ilość treści, która teoretycznie się tam znajduje. Użyjemy elementu strażnika o wymiarach 1 x 1 z transformacją, aby wymusić na elemencie zawierającym elementy (start), aby miał odpowiednią wysokość. Każdy element na pasie będzie przeniesiony do własnej warstwy, aby pas był całkowicie pusty. Brak koloru tła, nic. Jeśli warstwa pasa startowego nie jest pusta, nie kwalifikuje się do optymalizacji przeglądarki i musimy przechowywać na karcie graficznej teksturę o wysokości kilkuset tysięcy pikseli. Zdecydowanie nie jest to możliwe na urządzeniu mobilnym.

Podczas przewijania sprawdzamy, czy widok jest wystarczająco blisko końca pasa startowego. Jeśli tak, przedłużymy pas startowy, przesuwając element strażnika i przenosząc elementy, które opuściły obszar widoku, na dół pasa startowego, i wypełniając je nową zawartością.

Runway Sentinel Viewport

To samo dotyczy przewijania w drugą stronę. Nigdy jednak nie zmniejszymy pasa startowego w naszej implementacji, aby pozycja suwaka była spójna.

Nagrobki

Jak już wspomnieliśmy, staramy się, aby źródło danych zachowywało się jak coś z prawdziwego świata. Z opóźnieniami sieciowymi i wszystkim. Oznacza to, że jeśli użytkownicy będą przewijać stronę za pomocą gestów, mogą łatwo przewinąć do ostatniego elementu, na temat którego mamy dane. W takim przypadku umieścimy element nagrobkowy – element zastępczy, który zostanie zastąpiony elementem z rzeczywistą zawartością, gdy tylko dane się pojawią. Nagrobki są również odzyskiwane i mają osobny zbiór elementów DOM, które można ponownie wykorzystać. Potrzebujemy tego, aby płynnie przejść z tombstone'a do elementu wypełnionego treścią, co w przeciwnym razie mogłoby być dla użytkownika bardzo nieprzyjemne i mogłoby spowodować, że straci on skupienie.

Takie sąsie. Bardzo kamienny. Niesamowite.

Ciekawym wyzwaniem jest tu fakt, że rzeczywiste elementy mogą mieć większą wysokość niż element nagrobka z powodu różnej ilości tekstu na element lub załączonego obrazu. Aby rozwiązać ten problem, będziemy dostosowywać bieżącą pozycję przewijania za każdym razem, gdy otrzymamy dane i zastąpimy element Tombstone nad obszarem widoku, zabezpieczając pozycję przewijania do elementu zamiast wartości pikseli. Nazywa się to osadzaniem przewijania.

Blokowanie pozycji podczas przewijania

Nasze osadzanie przewijania będzie wywoływane zarówno podczas zastępowania nagłówków, jak i zmieniania rozmiaru okna (co dzieje się też, gdy urządzenie jest odwracane). Musimy określić, który element jest widoczny na górze widocznego obszaru. Ponieważ element może być widoczny tylko częściowo, zapiszemy też przesunięcie od góry elementu, gdzie zaczyna się widoczny obszar.

Diagram kotwiczenia podczas przewijania.

Jeśli rozmiar widoku zostanie zmieniony, a w wyniku tego zmieni się pas startowy, możemy przywrócić sytuację, która wizualnie będzie wyglądać identycznie dla użytkownika. Wygrana! Zmienione rozmiary okna oznaczają, że wysokość każdego elementu może się zmienić, więc jak mamy wiedzieć, jak daleko w dół należy umieścić zakotwiczone treści? Nie. Aby to sprawdzić, musielibyśmy ustawić każdy element nad elementem zakotwiczonym i dodać wszystkie ich wysokości. Mogłoby to spowodować znaczne opóźnienie po zmianie rozmiaru, a tego nie chcemy. Zamiast tego zakładamy, że każdy element powyżej ma taki sam rozmiar jak kamień nagrobny, i odpowiednio dostosowujemy pozycję przewijania. Gdy elementy są przewijane na pas, dostosowujemy pozycję przewijania, odkładając pracę związaną z układem na czas, gdy będzie ona rzeczywiście potrzebna.

Układ

Pominęliśmy jeden ważny szczegół: układ. Każde odświeżanie elementu DOM zwykle powoduje zmianę układu całego pasu startowego, co znacznie obniża liczbę klatek na sekundę poniżej docelowego poziomu 60 FPS. Aby tego uniknąć, przejmujemy odpowiedzialność za układ i używamy elementów z pozycji bezwzględnej z transformacjami. W ten sposób możemy udawać, że wszystkie elementy dalej na pasie startowym nadal zajmują miejsce, podczas gdy w rzeczywistości jest tam tylko pusta przestrzeń. Ponieważ układ jest tworzony przez nas, możemy przechowywać w pamięci podręcznej pozycje, w których znajduje się każdy element, i natychmiast wczytywać odpowiedni element z pamięci podręcznej, gdy użytkownik przewija stronę w dół.

W idealnej sytuacji elementy powinny być ponownie renderowane tylko raz, gdy są dołączane do DOM, oraz nie powinny być zmieniane przez dodawanie ani usuwanie innych elementów w runway. Jest to możliwe, ale tylko w przypadku nowoczesnych przeglądarek.

Ulepszenia

Niedawno w Chrome dodano obsługę ograniczeń CSS, czyli funkcji, która pozwala deweloperom określić przeglądarce, że element jest granicą układu i malowania. Ponieważ układamy tu sami, jest to idealne zastosowanie dla kontenera. Gdy dodajemy element do ścieżki, wiemy, że inne elementy nie muszą być dotknięte zmianą układu. Każdy element powinien być contain: layout. Nie chcemy też wpływać na pozostałą część naszej witryny, więc ta dyrektywa stylu powinna dotyczyć również samej wybiegu.

Rozważaliśmy też użycie IntersectionObservers jako mechanizmu do wykrywania, kiedy użytkownik przewinął się wystarczająco daleko, abyśmy mogli zacząć odzyskiwanie elementów i ładowanie nowych danych. Jednak interfejs IntersectionObserver ma dużą latencję (jak w przypadku requestIdleCallback), więc może się wydawać, że jest on mniej responsywny niż bez niego. Nawet nasze obecne rozwiązanie korzystające ze zdarzenia scroll ma ten problem, ponieważ zdarzenia przewijania są wysyłane „według najlepszej wiedzy”. Ostatecznie element kompozytorski Houdini (worklet) może być wysokiej jakości rozwiązaniem tego problemu.

Nie jest to jednak idealne rozwiązanie.

Nasze obecne podejście do recyklingu DOM nie jest idealne, ponieważ dodaje wszystkie elementy, które przechodzą przez widok, zamiast uwzględniać tylko te, które są na ekranie. Oznacza to, że gdy przewijasz bardzo szybko, nakład na Chrome musi wykonać tak dużo pracy nad układem i malowaniem, że nie nadąża. W rezultacie zobaczysz tylko tło. To nie koniec świata, ale zdecydowanie coś, co można poprawić.

Mamy nadzieję, że widzisz, jak proste problemy mogą stać się trudne, gdy chcesz połączyć wygodę użytkowników z wysokimi standardami wydajności. Progresywne aplikacje internetowe stają się podstawowym elementem interfejsu na telefonach komórkowych, dlatego znaczenie tego aspektu będzie rosło, a programiści będą musieli nadal inwestować w używanie wzorów, które uwzględniają ograniczenia wydajności.

Cały kod znajdziesz w naszym repozytorium. Dołożyliśmy wszelkich starań, aby można było go używać wielokrotnie, ale nie opublikujemy go jako biblioteki na npm ani jako osobnego repozytorium. Główne zastosowanie to edukacja.