Pull to refresh

Формируем стратегию работы с ошибками в React

Reading time8 min
Views15K

Как сделать падение мягким?




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

Проблематика и постановка целей


Понедельник утро, вы спокойненько попиваете кофе и хвастаетесь, что пофиксили больше багов, чем на прошлой неделе и тут прибегает менеджер и машет руками — “у нас прод свалился, все очень печально, мы теряем деньги”. Вы бежите и открываете свой Mac, заходите на продакшн версию вашего SPA, делаете пару кликов для воспроизведения бага, видите белый экран и только всевышний знает, что там произошло, лезем в консоль, начинаем копать, внутри компонента t есть компонент с говорящим именем b, в котором произошла ошибка cannot read property getId of undefined. N часов исследований и вы с победоносным кличем несетесь катить hotfix. Такие набеги происходят с некоторой периодичностью и стали уже нормой, но что, если я скажу, что все может быть по-другому? Как сократить время на отладку ошибок и построить процесс таким образом, чтобы клиент практически не заметил просчетов при разработке, которые неизбежны?

Рассмотрим по порядку проблемы, с которыми мы столкнулись:


  1. Даже если ошибка незначительна или локализирована в пределах модуля, в любом случае неработоспособным становится все приложение
    До 16 версии React у разработчиков не было единого стандартного механизма перехвата ошибок и случались ситуации, когда повреждение данных приводило к падению рендеринга только на следующих шагах или странному поведению приложения. Каждый разработчик обрабатывал ошибки, так как он привык, а императивная модель с try/catch в целом плохо ложилась на декларативные принципы React.

 В 16 версии появился инструмент Error Boundaries, который попытался решить эти проблемы, мы рассмотрим как его применить.
  2. Ошибка воспроизводится только в продакшн среде или не воспроизводится без дополнительных данных


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

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

  1. Повысить удобство работы пользователя с приложением в случаях возникновения ошибок;
  2. Сократить время между попаданием ошибки в продакшн и ее обнаружением;
  3. Ускорить процесс поиска и отладки проблем в приложении для разработчика.

Какие задачи необходимо решить?

  1. Обрабатывать критические ошибки при помощи Error Boundary
    Для повышения удобства работы пользователя с приложением мы должны перехватывать критические ошибки UI и обработать их. В случае, когда приложение состоит из независимых компонентов, такая стратегия позволит пользователю работать с остальной частью системы. Так же мы можем попробовать предпринять шаги для восстановления приложения после сбоя, если это возможно.
  2. Сохранять расширенную информацию об ошибках

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

Обработка критических ошибок

Начиная с 16 версии React изменил стандартное поведение обработки ошибок. Теперь исключения, которые не были пойманы при помощи Error Boundary будут приводить к размонтированию всего React дерева и, как следствие к неработоспособности всего приложения. Это решение аргументированно тем, что лучше не показывать ничего, чем дать пользователю возможность получить непредсказуемый результат. Более подробно можно почитать в официальной документации React.



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

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

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

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

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



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

В следствии чего у нас будет по ErrorBoundary на каждый компонент в который можно перейти через навигационное меню и общий ErrorBoundary, информирующий о падении всего приложения, в том случае, когда ошибка произошла в компоненте header, nav panel или внутри ErrorBoundary, но мы решили ее не обрабатывать и выкинуть дальше.

Рассмотрим листинг всего приложения, которое завернуто в ErrorBoundary

const AppWithBoundary = () => (
  <ErrorBoundary errorMessage="Application has crashed">
    <App/>
  </ErrorBoundary>
)

function App() {
  return (
    <Router>
      <Layout>
        <Sider width={200}>
          <SideNavigation />
        </Sider>
        <Layout>
          <Header>
            <ActionPanel />
          </Header>
          <Content>
            <Switch>
              <Route path="/link1">
                <Page1
                  title="Link 1 content page"
                  errorMessage="Page for link 1 crashed"
                />
              </Route>
              <Route path="/link2">
                <Page2
                  title="Link 2 content page"
                  errorMessage="Page for link 2 crashed"
                />
              </Route>
              <Route path="/link3">
                <Page3
                  title="Link 3 content page"
                  errorMessage="Page for link 3 crashed"
                />
              </Route>
              <Route path="/">
                <MainPage
                  title="Main page"
                  errorMessage="Only main page crashed"
                />
              </Route>
            </Switch>
          </Content>
        </Layout>
      </Layout>
    </Router>
  );
}

Никакой магии в ErrorBoundary нет, это всего лишь классовый компонент, в котором определен метод componentDidCatch, то есть любой компонент можно сделать ErrorBoundary, в случае если в нем определить данный метод.

class ErrorBoundary extends React.Component {
  state = {
    hasError: false,
  }

  componentDidCatch(error) {
    // Здесь можно отправлять данные в сервис сбора ошибок
    this.setState({ hasError: true });
  }

  render() {
    if (this.state.hasError) {
      return (
        <Result
          status="warning"
          title={this.props.errorMessage}
          extra={
            <Button type="primary" key="console">
              Some action to recover
            </Button>
          }
  />
      );
    }
    return this.props.children;
  }
};

Вот так выглядит ErrorBoundary для компоненты Page, которая будет рендериться в блок Content:

const PageBody = ({ title }) => (
  <Content title={title}>
    <Empty className="content-empty" />
  </Content>
);

const MainPage = ({ errorMessage, title }) => (
  <ErrorBoundary errorMessage={errorMessage}>
    <PageBody title={title} />
  </ErrorBoundary>

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

ВАЖНО: ErrorBoundary умеет ловить ошибки только в компонентах, которые находятся ниже него в дереве.

В листинге ниже ошибка не будет перехвачена локальным ErrorBoundary, а будет выкинута и перехвачена обработчиком выше по дереву:

const Page = ({ errorMessage }) => (
  <ErrorBoundary errorMessage={errorMessage}>
    {null.toString()}
  </ErrorBoundary>
);

А здесь ошибка перехватиться локальным ErrorBoundary:

const ErrorProneComponent = () => null.toString();

const Page = ({ errorMessage }) => (
  <ErrorBoundary errorMessage={errorMessage}>
    <ErrorProneComponent />
  </ErrorBoundary>
);

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

const PageBody = ({ title, steps }) => (
  <Content title={title}>
    <Steps current={2} direction="vertical">
      {steps.map(({ title, description }) => (<Step title={title} description={description} />))}
    </Steps>
  </Content>
);

const Page = ({ errorMessage, title }) => (
  <ErrorBoundary errorMessage={errorMessage}>
    <PageBody title={title} />
  </ErrorBoundary>
);



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



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

Сохранение информации об ошибках

Теперь, когда мы разместили достаточно ErrorBoundary в нашем приложении, необходимо сохранять информацию об ошибках, чтобы как можно быстрее их обнаружить и поправить. Самый простой способ — это использовать SaaS сервисы, например, такие как Sentry или Rollbar. Они обладают очень схожим функционалом, так что можно использовать любой сервис мониторинга ошибок.

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

Для подключения необходимо зарегистрироваться на их официальном сайте и пройти quick start guide, который сразу направит вас после регистрации.

В нашем приложении добавляем всего пару строк и все взлетает.

import * as Sentry from '@sentry/browser';
Sentry.init({dsn: “https://12345f@sentry.io/12345”}); 

Снова переходим по ссылке /link3 в нашем приложении и получаем экран ошибки, после чего переходим в интерфейс sentry, видимо, что произошло событие и проваливаемся внутрь.



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



и последние действия пользователя перед ошибкой (breadcrumbs).



Даже при такой простой конфигурации мы можем накапливать и анализировать информацию об ошибках и использовать ее при дальнейшей отладке. В данном примере ошибка посылается от клиента в development mode, поэтому мы можем наблюдать полную информацию о компоненте и ошибках. Для того, чтобы получать аналогичную информацию из production mode необходимо дополнительно настроить синхронизацию данных о релизе с Sentry, которая будет хранить в себе sourcemap, таким образом позволяя сохранить достаточно информации, без увеличения размера bundle. Мы не будем рассматривать такую конфигурации в рамках этой статьи, но я постараюсь рассказать о подводных камнях такого решения в отдельной статье после его внедрения.

Итог:

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

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

P.S. Вы можете попробовать различные варианты конфигурации ErrorBoundary или подключить Sentry в приложение самостоятельно в ветке feature_sentry, заменив ключи на полученные при регистрации на сайте.

Демо-приложение на git-hub
Официальная документация React по Error Boundary
Tags:
Hubs:
+16
Comments15

Articles

Change theme settings