Если вы используете React Native в производственной среде, это просто факт жизни, что в конечном итоге что-то заставит JS генерировать неперехваченное исключение. По умолчанию React Native в производственной среде фактически полностью выдает сбой вашего приложения, возвращая это исключение обратно до Objective-C. Это не идеально: пользователя просто бесцеремонно выкидывают обратно на домашний экран iOS, а вы, бедный разработчик приложения, возможно, даже не узнаете об этом. Если вы используете собственный инструмент для отчетов о сбоях, например New Relic или Google Analytics, все, что вы увидите, - это кучу бесполезных функций JavaScriptCore, возможно, с горсткой функций React: не так уж и полезно.

Разве не было бы неплохо, если бы мы могли регистрировать следы, которые мы обычно видим на Красном экране смерти ™, в какой-нибудь системе и улучшать работу пользователей, которые вызывают сбой?

Подход, основанный на использовании всего JavaScript

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

В вашем index.js используйте это, чтобы изменить глобальный обработчик ошибок:

const ErrorUtils = require('ErrorUtils');
ErrorUtils.setGlobalHandler((e, isFatal) => {
  if (__DEV__) {
    // In DEV, pass the error to the standard ExceptionsManager
    // This way you'll still get nice RSODs in DEV
    require('ExceptionsManager').handleException(e, isFatal);
  } else {
    // Not in DEV. Send the exception 'e' to your logger of choice
    sendErrorToCrashReporter(e.message);
  }
});

Однако что произойдет, если внутри этого блока кода что-то пойдет не так? Что, если исключение действительно плохо влияет на состояние вашей среды JavaScript, а ваш sendErrorToCrashReporter вызов не работает? Вы также теряете все удобное форматирование и синтаксический анализ, которые дает вам ExceptionsManager React Native. Также сложнее справиться с тем, что происходит после сбоя, если вы не согласны с тем, чтобы вернуть пользователя на главный экран iOS.

Как правило, в React Native вы всегда должны пытаться заставить что-то работать на чистом JavaScript везде, где это возможно. Однако в данном случае мы думали, что сможем улучшить взаимодействие как разработчика, так и пользователя, если внедрим что-то изначально.

Более надежный подход Objective-C

Это будет решение для iOS. Приносим свои извинения, если вам здесь нужна поддержка Android, ExceptionsManagerModule в React Native Android, похоже, не так полон функций, как RCTExceptionsManager, и, поскольку мы не используем React Native в Android, мы не приложили никаких усилий для обходного пути.

В React Native есть класс RCTExceptionsManager, в котором регистрируются исключения JS. У него есть необязательное свойство, в котором вы можете указать делегата. В идеале вам нужно установить этот делегат до выполнения любого кода JavaScript, чтобы вы могли быть уверены, что получите все ошибки, в том числе при запуске.

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

Если вы никогда не реализовывали RCTBridgeDelegate, первое, что вам нужно, это реализовать требуемый метод -sourceURLForBridge:

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
#if DEBUG
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
#else
  return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}

Теперь, когда это решено, вот полезный: -extraModulesForBridge:. Этот метод позволяет вам предварительно инициализировать некоторые собственные модули, чтобы вы могли вручную управлять временем жизни и семантикой инициализации этих модулей, вместо того, чтобы полагаться на React Native, чтобы лениво создавать их экземпляры через RCT_EXPORT_MODULE как обычно. Здесь мы инициализируем RCTExceptionsManager, но тот, который мы предоставляем, будет иметь настраиваемого делегата.

- (NSArray<id<RCTBridgeModule>> *)extraModulesForBridge:(RCTBridge *)bridge
{
  // Initialize delegate object to listen hard and soft exceptions
  MyCustomExceptionsManager *myExceptionsManager = [MyCustomExceptionsManager new];
  // Initialize RCTExceptionManager and assign myExceptionsManager as delegate
  RCTExceptionsManager *rctExceptionsManager = [[RCTExceptionsManager alloc] initWithDelegate: myExceptionsManager];
    
  // All three modules will be re-created upon reload.
  return @[rctExceptionsManager, myExceptionsManager];
}

Убедитесь, что ваш MyCustomExceptionsManager также является обычным RCTBridgeModule, чтобы вы могли получить доступ к мосту. Теперь, когда вы это сделали, ваш MyCustomExceptionsManager класс будет получать все фатальные и нефатальные исключения JS! Теперь вы можете делать с этим все, что вам заблагорассудится:

- (void)handleFatalJSExceptionWithMessage:(NSString *)message stack:(NSArray *)stack exceptionId:(NSNumber *)exceptionId
{
  [self sendFatalExceptionToBackendWithMessage: message stack:stack exceptionId:exceptionId];
}

- (void)handleSoftJSExceptionWithMessage:(NSString *)message stack:(NSArray *)stack exceptionId:(NSNumber *)exceptionId
{
  [self sendSoftExceptionToBackendWithMessage: message stack:stack exceptionId:exceptionId];
}

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

Вы можете реализовать эту функцию «перезапуска», просто вызвав [self.bridge reload] в своем пользовательском классе диспетчера исключений!

Следы стека

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

[
  {
    "lineNumber": 1,
    "file": "123.js",
    "methodName":"crashAllTheThings",
    "column": 1234
  },
  ...
]

И сообщения тоже не такие уж хорошие:

Не бойтесь, эти следы можно вернуть в здравомыслие. React Native использует упаковщик JavaScript под названием metro для компиляции множества файлов JavaScript в два файла: пакет JS и исходную карту. Этот пакет JS отправляется вашим клиентам, а исходная карта используется для сопоставления мест в окончательном пакете JS с исходным источником.

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

Если вы не используете пакеты ОЗУ (если не уверены, то, вероятно, нет):

node node_modules/react-native/local-cli/cli.js bundle --platform ios --entry-file index.js --dev false --reset-cache --bundle-output /dev/null --assets-dest /dev/null --sourcemap-output bundle.ios.map --sourcemap-sources-root "$(pwd)"

Если вы используете пакеты RAM:

node node_modules/react-native/local-cli/cli.js ram-bundle --platform ios --entry-file index.js --dev false --reset-cache --bundle-output /dev/null --assets-dest /dev/null --sourcemap-output  bundle.ios.map --sourcemap-sources-root "$(pwd)"

Я настоятельно рекомендую вашей системе CI / CD генерировать эти исходные карты с вашими производственными сборками и хранить их в безопасном месте. Убедитесь, что вы действительно доверяете системе, в которой храните ее, поскольку исходная карта содержит весь исходный код!

Metro создает карту источников, несовместимую со стандартными картами веб-источников, поэтому, скорее всего, ваш любимый инструмент сопоставления источников не будет работать с ними. Metro предоставляет собственный инструмент metro-symbolicate, который, к сожалению, не считывает эти трассировки стека на основе JSON: он ожидает, что они будут в текстовом формате JavaScriptCore. Однако, немного смазав локоть, его можно использовать как библиотеку для вашего собственного инструмента командной строки JavaScript ...

yarn add metro-symbolicate

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

const fs = require('fs');
const { SourceMapConsumer } = require('source-map');
const Symbolication = require('./node_modules/metro-symbolicate/src/Symbolication');
const sourceMap = fs.readFileSync(sourceMapPath, 'utf8');
const context = Symbolication.createContext(SourceMapConsumer,
  sourceMap, {
    nameSource: 'function_names',
    inputLineStart: 1,
    inputColumnStart: 0,
    outputLineStart: 1,
    outputColumnStart: 0
  }
);
const jsonStr = fs.readFileSync(stackJsonPath, 'utf8');
const json = JSON.parse(jsonStr);
function formatMappedPosition(pos) {
  if (!pos.source) {
    return "  at <unknown>";
  } else {
    const source = pos.source;
    if (pos.name) {
      return `  at ${pos.name} (${source}:${pos.line}:${pos.column})`;
    } else {
      return `  at ${source}:${pos.line}:${pos.column}`;
    }
  }
}
const stackStr = json
  .map(element => {
    return Symbolication.getOriginalPositionFor(
      element.lineNumber,
      element.column,
      Symbolication.parseFileName(element.file),
      context
    );
  )
  .map(formatMappedPosition)
  .join("\n");
console.log(`Stack: ${stackStr}`);

Пожалуйста, выполняйте аварийное завершение работы ответственно!