Почему React Suspense изменит правила разработки веб-приложений?

внешний интерфейс React.js

Оригинальный адрес:medium.com/react-in-…

Оригинальный автор:Julian Burr

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

Краткое введение в саспенс

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

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

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

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

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

Идея Suspense состоит в том, чтобы дать компонентам возможность «приостанавливать» рендеринг, например, когда компоненту необходимо загрузить дополнительные данные из внешнего ресурса. React не будет пытаться повторно визуализировать компонент, пока данные не будут загружены.

React использует Promises для этого. Компонент может генерировать Promise при вызове метода рендеринга (или любого метода, вызываемого при рендеринге компонента, например, новые статические методыgetDerivedStateFromProps). React поймает обещания и переместится вверх по дереву компонентов, чтобы найти ближайшийSuspenseкомпонент, и он действует как своего рода граница.SuspenseКомпонент получаетfallbackprop, пока любой компонент в его поддереве находится в состоянии паузы,fallbackКомпоненты будут отображаться немедленно.

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

Основные понятия саспенса играница ошибкиочень похожий. Хотя границы ошибок были введены в React 16, чтобы иметь возможность перехватывать неперехваченные исключения в любом месте приложения, они снова обрабатываются путем размещения компонентов в дереве (в данном случае любого компонента с методом жизненного цикла componentDidCatch). Все исключения создаются из-под этого компонента. По совпадению,SuspenseКомпоненты перехватывают любые промисы, брошенные дочерними компонентами, разница в том, что нам не нужен конкретный компонент, который действует как граница, потому чтоSuspenseКомпонент сам по себе, он позволяет нам определитьfallbackчтобы определить резервный компонент средства визуализации.

1

Такая функциональность значительно упрощает наше представление о состоянии загрузки нашего приложения и приближает нашу ментальную модель как разработчиков к дизайнерам UX и UI.

Дизайнеры обычно не думают об источниках данных, а больше думают о логической организации и информационной иерархии пользовательского интерфейса или приложения. Знаете, кого еще не волнуют источники данных? Ответ — пользователи. Никто не любит тысячи счетчиков при загрузке, и некоторые из них будут мигать всего несколько миллисекунд, а содержимое страницы будет прыгать вверх и вниз при загрузке данных.

Почему Suspense называют огромным прорывом?

вопрос

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

Самый примитивный способ — хранить всю необходимую информацию в локальном состоянии, код может выглядеть так:

class DynamicData extends Component {
  state = {
    loading: true,
    error: null,
    data: null
  };

  componentDidMount () {
    fetchData(this.props.id)
      .then((data) => {
        this.setState({
          loading: false,
          data
        });
      })
      .catch((error) => {
        this.setState({
          loading: false,
          error: error.message
        });
      });
  }

  componentDidUpdate (prevProps) {
    if (this.props.id !== prevProps.id) {
      this.setState({ loading: true }, () => {
        fetchData(this.props.id)
          .then((data) => {
            this.setState({
              loading: false,
              data
            });
          })
          .catch((error) => {
            this.setState({
              loading: false,
              error: error.message
            });
          });
      });
    }
  }

  render () {
    const { loading, error, data } = this.state;
    return loading ? (
      <p>Loading...</p>
    ) : error ? (
      <p>Error: {error}</p>
    ) : (
      <p>Data loaded 🎉</p>
    );
  }
}

Это выглядит многословно, не так ли?

Мы загружаем данные и сохраняем их в локальном состоянии при монтировании компонента. Кроме того, мы отслеживаем ошибки и состояние загрузки через локальное состояние. Это выглядит знакомо, не так ли? Даже если вы используете не состояние, а какую-то абстракцию, есть вероятность, что в вашем приложении все равно будет много загрузочных троек.

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

1. 👎 уродливая тройка → плохой DX

Состояния загрузки и ошибки определяются тройками в методе рендеринга, что излишне усложняет наш код. Мы не описываем функцию рендеринга, мы описываем дерево компонентов.

2. 👎 шаблонный код → плохой DX

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

3. 👎 Ограниченные данные и состояние загрузки → плохой DX и UX

Мы обнаружим, что обработка состояния и хранение находятся в одном компоненте, а это означает, что в приложении будет много других счетчиков, которым нужно загружать данные.Если у нас есть разные компоненты, которые зависят от одних и тех же данных, будет много несоответствия. Необходимый код вызова API. Это восходит к тому, что я сказал ранее, что ментальная модель зависимости состояния загрузки от источника данных не кажется правильной. Таким образом, мы обнаруживаем, что состояние загрузки тесно связано с загрузкой данных и компонентов, что ограничивает нас решением проблемы внутри компонента (или его взломом) и невозможностью использовать его в более широком диапазоне сценариев приложений.

4. 👎 Обновить данные → плохой DX

После изменения идентификатора логика, которая должна повторно получить данные, является очень избыточной реализацией. мы обаcomponentDidMountИнициализируйте данные в дополнительномcomponentDidUpdateПроверьте, не изменился ли идентификатор.

5. 👎 мигающий спиннер → плохой DX

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


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

Итак, теперь, когда мы определили проблемы, как нам их исправить?

Улучшение контекста

Redux долгое время был решением вышеуказанных проблем. Но с новой версией React 16 "Context API«Выпуск, у нас есть еще один отличный инструмент, который помогает нам определять и предоставлять данные глобально, имея при этом возможность легкого доступа к ним в глубоко вложенных деревьях компонентов. Поэтому для простоты мы будем использовать последний здесь.

Во-первых, мы преобразуем все данные, изначально хранящиеся в состоянии компонента, в поставщика контекста, чтобы другие компоненты могли совместно использовать данные. Мы также можем открыть метод загрузки данных через провайдера, так что нашему компоненту нужно будет только активировать этот метод и прочитать загруженные данные через потребителя контекста. Последний выпуск React 16.6contextTypeДелает его более элегантным и менее громоздким.

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

const DataContext = React.createContext();

class DataContextProvider extends Component {
  // 我们想在该 provider 中储存多种数据
  // 因此我们用唯一的 key 作为每个数据集对象的键名
  // 加载状态
  state = {
    data: {},
    fetch: this.fetch.bind(this)
  };

  fetch (key) {
    if (this.state[key] && (this.state[key].data || this.state[key].loading)) {
      // 数据要么已经加载完成,要么正在加载中,因此没有必要再次请求数据
      return;
    }

    this.setState(
      {
        [key]: {
          loading: true,
          error: null,
          data: null
        }
      },
      () => {
        fetchData(key)
          .then((data) => {
            this.setState({
              [key]: {
                loading: false,
                data
              }
            });
          })
          .catch((e) => {
            this.setState({
              [key]: {
                loading: false,
                error: e.message
              }
            });
          });
      }
    );
  }

  render () {
    return <DataContext.Provider value={this.state} {...this.props} />;
  }
}

class DynamicData extends Component {
  static contextType = DataContext;

  componentDidMount () {
    this.context.fetch(this.props.id);
  }

  componentDidUpdate (prevProps) {
    if (this.props.id !== prevProps.id) {
      this.context.fetch(this.props.id);
    }
  }

  render () {
    const { id } = this.props;
    const { data } = this.context;

    const idData = data[id];

    return idData.loading ? (
      <p>Loading...</p>
    ) : idData.error ? (
      <p>Error: {idData.error}</p>
    ) : (
      <p>Data loaded 🎉</p>
    );
  }
}

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

Но это все еще проблематично, потому что толькоAsyncDataМетод загрузки данных запускается в первый раз, когда компонент начинает рендеринг. Конечно, мы могли бы поднять метод загрузки данных выше по дереву вместо запуска внутри этого компонента, но это на самом деле не решило проблему, а просто переместило его в другое место. Это также влияет на удобочитаемость и ремонтопригодность кода.AsyncDataКомпоненты зависят от других компонентов для загрузки данных для них. Такие зависимости не являются ни четкими, ни правильными. В идеале вы должны сделать так, чтобы компоненты работали как можно более независимо, чтобы вы могли размещать их где угодно, не завися от других компонентов в определенных местах окружающего их дерева компонентов.

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

Чтобы лучше понять это, давайте взглянем на исходную задачу:

1. 👎 уродливые тройки

не изменилось, все, что мы можем сделать, это переместить тройки в другое место, но это не решает проблему DX

2. 👍 шаблонный код

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

3. 👍 Ограниченные данные и статус загрузки

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

4. 👎 Обновить данные

не решил проблему.

5. 👎 Мигающий счетчик

То же не решило проблему.


Я думаю, мы все можем согласиться с тем, что это серьезное улучшение, но оно все еще оставляет некоторые нерешенные проблемы.

Появление саспенса

Как мы можем использовать Suspense, чтобы работать лучше?

Во-первых, мы можем сначала удалить контекст, обработка данных и кеширование будет выполняться поставщиком кеша, это может быть что угодно. Context, localStorage и простые объекты (даже Redux, если вам это нужно) и т. д. Все эти провайдеры просто помогают нам хранить запрошенные данные. При каждом запросе данных он сначала проверяет кэш. Если есть, прочитайте его напрямую, если нет, сделайте запрос данных и одновременно сгенерируйте промис. Прежде чем промис разрешится, он сохраняет резервную информацию в любом содержимом, используемом для кэширования. Как только компонент React запускает повторный рендеринг , все можно использовать. Очевидно, что все становится сложнее с более сложными вариантами использования, когда принимаются во внимание проблемы с аннулированием кеша и SSR, но это общая суть.

Такая неразрешимая проблема с кэшированием также является одной из причин, по которой Suspense в виде загрузки данных не был добавлен в текущую стабильную версию React. Если вам это интересно, вы можете использовать экспериментальныйreact-cachepackage, но он нестабилен и в будущем определенно будет сильно переработан.

Кроме того, мы можем удалить все триплеты состояния загрузки. Что еще более важно, Suspense будет условно загружать данные при рендеринге компонента, приостанавливая рендеринг компонента, если данные не кэшированы, а не загружая данные во время монтирования и обновления. Это может показаться анти-шаблоном (в конце концов, нам говорят этого не делать), но учтите, что если данные уже находятся в кеше, провайдер просто возвращает их, и рендеринг может продолжаться.

import createResource from './magical-cache-provider';
const dataResource = createResource((id) => fetchData(id));

class DynamicData extends Component {
  render () {
    const data = dataResource.read(this.props.id);
    return <p>Data loaded 🎉</p>;
  }
}

Наконец, мы можем разместить ограничивающий компонент и визуализировать тот, который мы определили ранее, при загрузке данных.fallbackкомпоненты. Мы можем размещать компоненты приостановки в любом месте, и, как объяснялось ранее, эти ограничивающие компоненты способны перехватывать промисы, всплывающие из всех своих дочерних элементов.


class App extends Component {
  render () {
    return (
      <Suspense fallback={<p>Loading...</p>}>
        <DeepNesting>
          <ThereMightBeSeveralAsyncComponentsHere />
        </DeepNesting>
      </Suspense>
    );
  }
}
// 我们可以具体地使用多个边界组件。
// 它们不需要知道哪个组件被暂停渲染
// 或是为什么,它们只是捕获任何冒泡上来的 Promise
// 然后按预期处理。
class App extends Component {
  render () {
    return (
      <Suspense fallback={<p>Loading...</p>}>
        <DeepNesting>
          <MaybeSomeAsycComponent />
          <Suspense fallback={<p>Loading content...</p>}>
            <ThereMightBeSeveralAsyncComponentsHere />
          </Suspense>
          <Suspense fallback={<p>Loading footer...</p>}>
            <DeeplyNestedFooterTree />
          </Suspense>
        </DeepNesting>
      </Suspense>
    );
  }
}

Я думаю, что это определенно сделает код чище, а логический поток данных сверху вниз — более читаемым. Теперь давайте посмотрим, какие проблемы были решены?

1. ❤️ Уродливая тройка

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

2. ❤️ шаблонный код

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

3. ❤️ Ограниченные данные и статус загрузки

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

4. ❤️ Повторная выборка данных

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

5. 👎 Мигающий счетчик

Это все еще открытый вопрос. 🤔


Это огромные улучшения, но у нас осталась одна нерешенная проблема...

Однако, поскольку мы используем Suspense, у React есть еще одна хитрость, которая поможет нам его использовать.

Окончательное решение: параллельный режим

Режим параллелизма, ранее известный как асинхронный React, — это еще одна будущая функция, которая позволяет React обрабатывать несколько задач одновременно и переключаться между ними на основе определенного приоритета, эффективно позволяя React выполнять несколько задач. Эндрю Кларк на React Conf 2018отличная речь, который содержит прекрасный пример его воздействия на пользователей. Я не хочу углубляться здесь, потому что уже естьстатьяОчень подробно объяснил.

Однако, добавляя режим параллелизма в наше приложение, Suspense получит новую функциональность, управляемую через реквизиты компонентов. Если мы пройдем черезmaxDurationсвойство, граничный компонент будет задерживать отображение счетчика до тех пор, пока не пройдет установленное время, что позволяет избежать ненужного мерцания счетчика. В то же время он также гарантирует, что счетчик отображается в течение как можно более короткого времени, принципиально решая ту же проблему и делая работу пользователя максимально удобной.

// 不需要这行代码
ReactDOM.render(<App />, document.getElementById('root'));

// 我们只需通过这行代码就可以切换到并发模式
ReactDOM.createRoot(document.getElementById(‘root’)).render(<App />);

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

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