Всем привет! Я фуллстек, пишу код для сервиса TolstoyComments. В процессе работы был накоплен ценный опыт, как решать разного рода задачи на React. Этим опытом я хочу поделиться.

Бывало так, что после загрузки страницы, вам нужно сделать прокрутку экрана к заданному месту? Все ли всегда получалось с первого раза? И что делать если скролл все равно дергается в процессе загрузки страницы?

Ссылка на источник картинки https://donttakefake.com/otkuda-vzyalsya-mem-nelzya-prosto-tak-vzyat-i-rasskazyvaet-rezhisser-vlastelina-kolets/
Генератор мемов: iloveimg.com
Ссылка на источник картинки https://donttakefake.com/otkuda-vzyalsya-mem-nelzya-prosto-tak-vzyat-i-rasskazyvaet-rezhisser-vlastelina-kolets/ Генератор мемов: iloveimg.com

Это пример самой комментируемой статьи на хабре когда скролл не с первого раза показывает нужный комментарий

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

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

Почему же так происходит?

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

Разберем более детально пример в начале статьи. Сначала загружается HTML и делается доскролл до нужного комментария. Потом скролл теряется, так как высота содержимого над комментарием изменилась и скролл уже показывает не туда куда надо. После этого происходит коррекция скролла и он снова начинает показывать куда надо. Здесь я делаю предположение, что возможно разработчики хабра это предусмотрели и на всякий случай сделали костыль, который срабатывает как раз в том случае если скролл все таки теряется.

Почему высота содержимого меняется?

Всему виной этот тег:<img />! (но не всегда!)

Самый простой случай, когда содержимое верстки может менять свою высоту связано с выводом картинок. Картинки в верстке имеют ширину и высоту и чаще всего их вывод делается адаптивным за счет css стилей. Чаще всего пользуются вот таким сочетанием стилей:

img {
  height: auto;
	max-width: 100%;
}

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

Взглянем на верстку картинок в комментариях на Хабре:

Как можно видеть, атрибутов width и height тут нет, а значить браузер не сможет корректно определить высоту содержимого страницы до того, как начнется загрузка самой картинки.

Получается, чтобы обойти проблему с выводом картинок нужно:

  1. Либо заранее прописывать корректные значения width и height

  2. Либо вешать на картинку события onload onerror и обрабатывать эти события чтобы узнать о том когда картинка загрузится или не загрузится.

А по другому никак?

Да конечно, есть еще вариант как сделано на хабре, я кстати тоже так сделал при разработке комментариев на портале myslo.ru (пример ссылки на комментарий и отработки доскролла)

setTimeout и setInterval - решение на все случаи жизни!

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

Как избавиться от сайд эффектов?

Все просто, нужно избавиться от динамического контента! Шучу конечно, на практике такое невозможно и это нужно как-то решать.

Самый простой пример реализации скролла до нужного элемента на react:

import { useLayoutEffect, useRef } from "react";
import "./styles.css";

export default function App() {
  const h2ref = useRef(null);

  useLayoutEffect(() => {
    h2ref.current.scrollIntoView();
  }, []);

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>1. Start editing to see some magic happen!</h2>
      <p style={{ height: "300px" }}></p>
      <h2 ref={h2ref}>2. Start editing to see some magic happen!</h2>
      <p style={{ height: "300px" }}></p>
      <h2>3. Start editing to see some magic happen!</h2>
      <p style={{ height: "300px" }}></p>
      <h2>4. Start editing to see some magic happen!</h2>
      <p style={{ height: "300px" }}></p>
      <h2>5. Start editing to see some magic happen!</h2>
      <p style={{ height: "300px" }}></p>
    </div>
  );
}

Есть некоторый компонент, после инициализации которого происходит вызов useLayoutEffect и перемещение скролла. В этом примере у нас нет элементов, которые могут менять свою высоту в процессе рендеринга страницы. Поэтому доскролл будет работать корректно.

Пример доскролла до элемента на codesandbox.

Теперь добавим немного динамического контента

import { useLayoutEffect, useRef } from "react";
import "./styles.css";

export default function App() {
  const h2ref = useRef(null);

  const src = "https://hsto.org/webt/ow/wu/bm/owwubmq_kdfpalevoql9vhhryl8.jpeg";
  const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13].map(
    (n) => src + "?t=" + Math.random().toString().substring(2)
  );

  useLayoutEffect(() => {
    h2ref.current.scrollIntoView();
  }, []);

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>1. Start editing to see some magic happen!</h2>
      <p>
        {arr.map((n, i) => (
          <img key={i} src={n} alt="" />
        ))}
      </p>
      <h2 ref={h2ref}>2. Start editing to see some magic happen!</h2>
      <p style={{ height: "300px" }}></p>
      <h2>3. Start editing to see some magic happen!</h2>
      <p style={{ height: "300px" }}></p>
      <h2>4. Start editing to see some magic happen!</h2>
      <p style={{ height: "300px" }}></p>
      <h2>5. Start editing to see some magic happen!</h2>
      <p style={{ height: "300px" }}></p>
    </div>
  );
}

Пример с подгрузкой картинок на codesandbox

Если внимательно посмотреть что будет происходить в этом примере то можно заметить как скролл сразу отображается на нужном месте но полоса скролла как-то странно уменьшается. Что же тут происходит?

Сначала происходит полная загрузка HTML. Потом браузер делает фокус скролла на нужном нам элементе. А потом происходит подгрузка картинок выше места скролла. Но поскольку все современные браузеры умные, то они автоматически двигает скролл так чтобы содержимое не смещалось относительно левого верхнего угла. 

Браузер пытается любой ценой сохранить нужное нам положение скролла. Иногда у него это получается, иногда нет.

В случае использования scrollintoview браузер понимает на каком элементе нужно сохранить положение скролла до полной загрузки страницы, и поэтому положение скролла не теряется. Но что делать если у нас SPA и мы хотим реализовать восстановление положения скролла при переходе на шаг назад?

В этом случае, проблема в том что мы не можем использовать метод scrollintoview. Мы можем использовать window.scrollTo но этот метод уже не имеет такого умного поведения сохранения положения скролла.

Можно ли корректно восстановить скролл?

Для удобства я сделал вывод числового значения положения скролла и записал процесс обновления страницы в замедленном виде.

Страница загружается, потом происходит перемещение скролла на 1000 (как и нужно). Но потом скролл становится 1294 и даже 1589. Откуда такие значения скролла?

Исходный код:

import { useCallback, useEffect, useState } from "react";
import "./styles.css";
 
function DynamicBlock() {
 const src = useState(
   () => "https://hsto.org/webt/ow/wu/bm/owwubmq_kdfpalevoql9vhhryl8.jpeg" +
     "?t=" +
     Math.random().toString().substring(2)
 );
 
 return (
   <img src={src} alt="" />
 );
}
 
export default function App() {
 const [scroll, setScroll] = useState(0);
 const onScroll = useCallback(() => setScroll(Math.round(window.scrollY)), []);
 useEffect(() => {
   onScroll();
   window.addEventListener("scroll", onScroll);
   return () => window.removeEventListener("scroll", onScroll);
 }, [onScroll]);
 
 useEffect(() => window.scrollTo(0, 1000), []);
 
 useEffect(() => setTimeout(() => window.scrollTo(0, 1000), 2000), []);
 
 return (
   <div className="App">
     <h2>1. Start editing to see some magic happen!</h2>
     <p style={{ height: "300px" }}></p>
     <DynamicBlock />
     <h2>2. Start editing to see some magic happen!</h2>
     <p style={{ height: "300px" }}></p>
     <DynamicBlock />
     <h2>3. Start editing to see some magic happen!</h2>
     <p style={{ height: "300px" }}></p>
     <DynamicBlock />
     <h2>4. Start editing to see some magic happen!</h2>
     <p style={{ height: "300px" }}></p>
     <DynamicBlock />
     <h2>5. Start editing to see some magic happen!</h2>
     <p style={{ height: "300px" }}></p>
     <DynamicBlock />
     <div className="scroll">{scroll}</div>
   </div>
 );
}

Пример с выводом текущего положения скролла, решенный через костыль codesandbox

Разберем написанный код:

useEffect(() => window.scrollTo(0, 1000), []);

Эта строка должна восстанавливать положение скролла, но она не работает, так как на странице есть динамические компоненты с изменяемой высотой.

 useEffect(() => setTimeout(() => window.scrollTo(0, 1000), 2000), []);

Эта строка - костыль, призванный решить проблемы, которые может не решить строка выше. Здесь весь вопрос в том какой нужно выставить таймер. А если у вас медленный интернет и страница за 2 секунды целиком не загрузится? Или здесь можно двигать скролл постоянно пока пользователь не начнет сам двигать скролл.

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

Меня такое решение не устраивает!

Скролл дергается в процессе отрисовки страницы и это мягко говоря не красиво, а грубо говоря раздражает.

На этом месте уже можно сформулировать задачу:

  • Дано, страница с динамическими элементами, которые могут менять свою высоту в процессе отрисовки.

  • Нужно реализовать такой метод onRenderChildrenComplete чтобы он вызывался сразу после завершения отрисовки всех дочерних компонентов.

Самое просто решение - это реализовать во всех динамических компонентах событие onRenderComplete и передавать его через props. Это событие будет вызываться после полной отрисовки компонента. Но на практике можно столкнуться с множеством проблем так как в примере выше динамических компонентов может быть несколько. 

А если динамический компонент добавлен через условие? Здесь могут возникнуть сложности.

Формулируя выше описанные требования я решил все реализовать через контекст:

// RenderContext.js
import { createContext } from "react";
 
const RenderContext = createContext({
 onInit: () => {},
 onRender: () => {}
});
 
export default RenderContext;

Здесь описана реализация заглушки для контекста.

// RenderContextProvider.js
import { useCallback, useRef, useState } from "react";
import RenderContext from "./RenderContext";
 
export default function RenderContextProvider(props) {
 const { onRender } = props;
 const countInit = useRef(0);
 const countRender = useRef(0);
 const onInitCallback = useCallback(() => {
   countInit.current++;
 }, []);
 const onRenderCallback = useCallback(() => {
   countRender.current++;
   // если countInit совпадает с countRender то вызываем onRender
   if (countInit.current === countRender.current) {
     if (onRender) {
       onRender();
     }
   }
 }, [onRender]);
 
 // оборачиваем value через useState чтобы ссылка была постоянной
 const [value] = useState(() => ({
   onInit: onInitCallback,
   onRender: onRenderCallback
 }));
 
 return (
   <RenderContext.Provider value={value}>
     {props.children}
   </RenderContext.Provider>
 );
}

Компонент RenderContextProvider принимает параметр onRender (сокращение от onRenderChildrenComplete)  - событие завершения рендеринга всех дочерних компонентов.

// RenderUse.js
import { useCallback, useContext, useLayoutEffect, useRef } from "react";
import RenderContext from "./RenderContext";
 
export function useLayoutRender() {
 const { onInit, onRender } = useContext(RenderContext);
 
 useLayoutEffect(() => onInit(), [onInit]);
 
 const render = useRef(false);
 
 const onCallbackRender = useCallback(() => {
   // простая защита от повторного вызова onRender
   if (render.current === false) {
     render.current = true;
     onRender();
   }
 }, [onRender]);
 
 return () => onCallbackRender();
}

useLayoutRender - хук который нужно вызывать в динамическом компоненте. Этот хук вернет метод onRender - событие завершения рендеринга.

После подключения хука в нем будет вызван инкримент счетчика countInit. А после завершения рендеринга в компоненте будет вызвано событие onRender и тем самым значения счетчика countRender совпадет со значением счетчика countInit. При совпадении этих значений произойдет вызова события onRenderChildrenComplete.

Пример реализации:

import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import RenderContextProvider from "./RenderContextProvider";
import { useLayoutRender } from "./RenderUse";
import "./styles.css";
 
function DynamicBlock() {
 const src = useState(
   () =>
     "https://hsto.org/webt/ow/wu/bm/owwubmq_kdfpalevoql9vhhryl8.jpeg" +
     "?t=" +
     Math.random().toString().substring(2)
 );
 const onRender = useLayoutRender();
 
 const onLoadComplite = useCallback(
   (img) => {
     if (img.target.complete) {
       onRender();
     }
   },
   [onRender]
 );
 
 return <img src={src} onLoad={onLoadComplite} onError={onRender} alt="" />;
}
 
export default function App() {
 const [scroll, setScroll] = useState(0);
 const onScroll = useCallback(() => setScroll(Math.round(window.scrollY)), []);
 useEffect(() => {
   onScroll();
   window.addEventListener("scroll", onScroll);
   return () => window.removeEventListener("scroll", onScroll);
 }, [onScroll]);
 
 const onCallbackRender = useCallback(() => {
   window.scrollTo(0, 1000);
 }, []);
 
 return (
   <RenderContextProvider onRender={onCallbackRender}>
     <div className="App">
       <h2>1. Start editing to see some magic happen!</h2>
       <p style={{ height: "300px" }}></p>
       <DynamicBlock />
       <h2>2. Start editing to see some magic happen!</h2>
       <p style={{ height: "300px" }}></p>
       <DynamicBlock />
       <h2>3. Start editing to see some magic happen!</h2>
       <p style={{ height: "300px" }}></p>
       <DynamicBlock />
       <h2>4. Start editing to see some magic happen!</h2>
       <p style={{ height: "300px" }}></p>
       <DynamicBlock />
       <h2>5. Start editing to see some magic happen!</h2>
       <p style={{ height: "300px" }}></p>
       <DynamicBlock />
       <div className="scroll">{scroll}</div>
     </div>
   </RenderContextProvider>
 );
}

Финальный результат:

После загрузке всех картинок на странице происходит вызов метода, в котором запускается позиционирование скролла.

Конечно в реальных проектах могут быть совершенно разные динамические компоненты, намного сложнее приведенного примера с картинками. Здесь так же может идти речь о компонентах, которые нуждается в дополнительном fetch. Но тем не менее, описанный выше способ позволяет решить и эти проблемы.

Резюме

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

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

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


  1. hobogene
    15.11.2021 14:48

    Точно не должно быть complete везде, где сейчас complite?


    1. devlev Автор
      15.11.2021 14:52

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


      1. hobogene
        15.11.2021 14:55

        You're welcome. У всех бывает. Для того и комменты.


  1. nin-jin
    15.11.2021 16:49
    +1

    К сожалению, не все браузеры ещё умные. Ваша проблема скорее всего связана с тем, что не хватает scrollHeight для отскролла так, чтобы элементы выше нужного ушли из видимой области. Поэтому умный браузер в качестве якоря выбирает не тот элемент, что вам нужно, поэтому и происходит скачок. Тут я рассказывал как работают умные браузеры:

    https://github.com/nin-jin/slides/tree/master/virt


    1. devlev Автор
      15.11.2021 18:25

      Да, вы правы, проблема в том что я не знаю финального scrollHeight. А узнать его можно только после того как компоненты с изменяемой высотой будут отрендерены на странице. По сути scrollHeight можно узнать после вызова события onRenderChildrenComplete

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

      Статью по ссылке не видел, прочитаю, спасибо!


  1. aamonster
    15.11.2021 19:26

    1. Нельзя ли применить ResizeObserver/MutationObserver? (но вполне возможно, что это даст лишнюю нагрузку на проц)

    2. "если у нас SPA и мы хотим реализовать восстановление положения скролла при переходе на шаг назад" – а нет возможности разбить страницу на части и всё-таки задействовать scrollintoview? (в пределе – модифицируя url fragment так, чтобы соответствовал)


    1. devlev Автор
      15.11.2021 22:50

      ResizeObserver/MutationObserver

      Как узнать что изменений больше не будет?

      задействовать scrollintoview?

      Проблема с scrollintoview что с ним не получится восстановить скролл на том месте, на котором пользователь был ранее.