Проблемы написания библиотеки React, которая хорошо работает в различных средах и сценариях

Эта тема пришла мне в голову, когда я просмотрел код своих коллег и понял, насколько сложно создать надежную библиотеку React. Удивительно, но даже код, найденный через поиск Google, которому мы часто доверяем, может иногда вызывать такие проблемы, как ошибка несоответствия гидратации NextJS.

Эта библиотека безопасна? Этот, казалось бы, простой вопрос на удивление сложен в сообществе React.

В этой статье я поделюсь некоторыми советами и рекомендациями по написанию безопасной библиотеки React, которая безопасна для рендеринга на стороне сервера (SSR), безопасна для параллельного рендеринга и имеет оптимальные зависимости. Чтобы проиллюстрировать эти принципы в действии, мы рассмотрим наивную реализацию хука useLocalStorage.

Отказ от ответственности: хотя я обычно идентифицирую себя как разработчик Vue, моя повседневная работа связана с поддержкой приложения Next.js. Пожалуйста, поправьте меня, если какая-либо информация в этой статье устарела или неверна.

Что такое useLocalStorage?

useLocalStorage — это настраиваемый хук, который позволяет вам читать и записывать в localStorage в компонентах React. localStorage – это API браузера, который позволяет хранить пары данных "ключ-значение" в браузере. Хук синхронизирует состояние компонента с данными, хранящимися в localStorage. Важно отметить, что для краткости этой статьи хук не обновляет представление, если изменения в localStorage инициируются другими компонентами или вкладками браузера.

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

// A naive custom hook that uses localStorage in React components
function useLocalStorage(key, initialValue) {
  // Use useState to store the value in local state
  const [storedValue, setStoredValue] = useState(() => {
    // Get the value from localStorage
    const item = localStorage.getItem(key);
    // Parse the value as JSON
    return item ? JSON.parse(item) : initialValue;
  });

  // Use useEffect to update localStorage when the value changes
  useEffect(() => {
    // Stringify the value as JSON
    const valueToStore = JSON.stringify(storedValue);
    // Set the value in localStorage
    localStorage.setItem(key, valueToStore);
  }, [key, storedValue]);

  // Return the value and a setter function from the hook
  return [storedValue, setStoredValue];
}

Приведенная выше реализация намеренно наивна. Мы рассмотрим его ограничения и улучшим его соответствующим образом.

Что означает безопасность SSR?

SSR означает рендеринг на стороне сервера, метод рендеринга компонентов React на сервере и отправки HTML в браузер. SSR может улучшить производительность, SEO и доступность вашего приложения. Однако это также требует особых соображений для авторов библиотек, таких как отказ от API-интерфейсов, специфичных для браузера, обработка гидратации и поддержка потоковой передачи.

Небезопасный код SSR может привести к ошибкам рендеринга Node.js на сервере или ошибкам несоответствия гидратации на клиенте. Эти ошибки могут привести к замедлению работы вашего приложения, прикреплению обработчиков событий к неправильным элементам или даже остановке работы вашего сервера.

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

  • Избегайте использования специфичных для браузера API, таких как window, document или localStorage. Эти API недоступны на сервере и могут вызывать ошибки или несоответствия. Вместо этого используйте обнаружение функций или резервные варианты для обработки различных сред. Например:
// Allow us to use window server-side
const safeWindow = (typeof window === 'undefined')
  ? {
      addEventListener() {},
      removeEventListener() {},
    }
  : window;
  • Избегайте рендеринга разных представлений на сервере и на клиенте. Иногда вам может понадобиться отображать различный контент на сервере и клиенте, как в случае с useLocalStorage. В таких ситуациях вы должны убедиться, что сервер и клиент отображают одно и то же исходное содержимое и что содержимое клиента соответствующим образом изменяется с помощью useEffect. Подробнее об этом можно узнать в Документации React по отображению разного контента на сервере и клиенте.

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

Во-вторых, если мы условно читаем из localStorage, это может привести к тому, что сервер и клиент будут отображать разное содержимое. Нам нужно решить эти проблемы, чтобы сделать SSR безопасным.

// SSR safe version
function useLocalStorage(key, initialValue) {
  // useState will always return initialValue consistently
  const [storedValue, setStoredValue] = useState(initialValue);

  // Get the value from localStorage in the effect handler
  useEffect(() => {
    const item = localStorage.getItem(key);
    if (item) {
      setStoredValue(JSON.parse(item));
    } else {
      setStoredValue(initialValue);
    }
  }, [key, initialValue]);

  // Use useEffect to update localStorage when the value changes
  useEffect(() => {
    // Stringify the value as JSON
    const valueToStore = JSON.stringify(storedValue);
    // Set the value in localStorage
    localStorage.setItem(key, valueToStore);
  }, [key, storedValue]);

  // Return the value and a setter function from the hook
  return [storedValue, setStoredValue];
}

Обратите внимание, что с появлением React Server Components (RSC) обеспечение безопасности SSR становится еще сложнее. Однако в этой статье мы не будем углубляться в вопросы безопасности RSC.

Что означает параллельный рендеринг безопасным?

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

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

  • Используйте функциональные компоненты и хуки вместо компонентов класса и методов жизненного цикла.
  • Используйте хук useEffect правильно, обеспечивая:
    1. Используйте хук useEffect для выполнения побочных эффектов.
    2. Очистите все ресурсы в функции возврата хука.
    3. Используйте useLayoutEffect при выполнении синхронного чтения DOM.
  • Избегайте использования глобальных или общих изменяемых состояний, таких как переменные, объекты или массивы. Вместо этого используйте локальное состояние с хуками useState или useReducer или используйте контекст с хуком useContext.
  • Не записывайте и не читайте из ref.current во время рендеринга, за исключением инициализации. Это может привести к непредсказуемому поведению компонента.

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

Первоначальная реализация обновляет localStorage только после обновления компонента и применения изменений к DOM, потому что обновление происходит внутри ловушки useEffect. Как автор библиотеки, предполагать, что код ваших пользователей безопасен для параллельного рендеринга, небезопасно.
В частности, код пользователя может считываться из localStorage во время рендеринга, что приводит к несоответствиям между кодом библиотеки и кодом пользователя. Чтобы решить эту проблему, нам нужно обновить localStorage перед вызовом setStoredValue.

Вторая проблема более тонкая: мы синхронно читаем значение из localStorage, но useEffect не гарантирует синхронных обновлений. Это может привести к неожиданному мерцанию содержимого в пользовательском коде. Поскольку одновременный рендеринг React может задерживать рендеринг в зависимости от производительности устройства и времени рендеринга, мерцание может воспроизводиться или не воспроизводиться. Автору библиотеки желательно обеспечить более надежный рендеринг.

// Concurrent rendering safe version
function useLocalStorage(key, initialValue) {
  // useState will always return initialValue consistently
  const [storedValue, setStoredValue] = useState(initialValue);

  // Get the value from localStorage in the layout effect handler
  useLayoutEffect(() => {
    const item = localStorage.getItem(key);
    if (item) {
      setStoredValue(JSON.parse(item));
    } else {
      setStoredValue(initialValue);
    }
  }, [key, initialValue]);

  // Use useEffect to update localStorage when the value changes
  useEffect(() => {
    // Stringify the value as JSON
    const valueToStore = JSON.stringify(storedValue);
    // Set the value in localStorage
    localStorage.setItem(key, valueToStore);
  }, [key]);

  // Use useEffect to update localStorage when the value changes
  const setValue = useCallback((value) => {
    // Stringify the value as JSON
    const valueToStore = JSON.stringify(value);
    // Set the value in localStorage
    localStorage.setItem(key, valueToStore);
    setStoredValue(value);
  }, [key, setStoredValue]);

  // Return the value and a setter function from the hook
  return [storedValue, setValue];
}

Наконец, давайте обсудим оптимальные зависимости.

Что такое оптимальные зависимости?

В React хуки имеют массив зависимостей, который отражает реактивные обновления значений во время рендеринга. Однако не все значения должны быть постоянно актуальными. Например, в случае useLocalStorage мы не хотим перерисовывать, если initialValue изменится. Это связано с тем, что initialValue представляет резервное значение, которое будет отображаться, когда key не существует в локальном хранилище. Если key остается прежним, нет необходимости перерисовывать компонент.

Высокопроизводительная библиотека должна пропускать ненужные обновления представления, оптимизируя зависимости. Эта концепция может напомнить опытным пользователям React известную запись в блоге Дэна Абрамова о том, как сделать setInterval декларативным с помощью хуков React.

Вот оптимизированная версия хука useLocalStorage, который пропускает зависимость, используя useRef для хранения последнего значения initialValue:

// Concurrent rendering safe and optimized version
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(initialValue);
  const initialValueRef = useRef(initialValue);

  useLayoutEffect(() => {
    const item = localStorage.getItem(key);
    if (item) {
      setStoredValue(JSON.parse(item));
    } else {
      setStoredValue(initialValueRef.current);
    }
  }, [key]);

  useEffect(() => {
    const valueToStore = JSON.stringify(storedValue);
    localStorage.setItem(key, valueToStore);
  }, [key, storedValue]);

  const setValue = useCallback((value) => {
    const valueToStore = JSON.stringify(value);
    localStorage.setItem(key, valueToStore);
    setStoredValue(value);
  }, [key, setStoredValue]);

  return [storedValue, setValue];
}

Заключение

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

Но это не конец публикации современной библиотеки JavaScript! Мы только коснулись поверхности, обратившись к проблемам, связанным с React. Еще предстоит решить другие важные вопросы, такие как совместимость с браузером, целостность данных и, возможно, самое неприятное, модульная система.

Спасибо за чтение! Если вы найдете это полезным, следуйте за мной на среднем!