如何在 react 中處理報錯

本文爲 360 奇舞團前端工程師翻譯 

原文地址:https://www.developerway.com/posts/how-to-handle-errors-in-react

    我們都希望我們的應用能穩定、完美運行,並且能夠考慮到每一個邊緣情況。但是現實情況是,我們是人,是人就會犯錯,並且也不存在沒有 bug 的代碼。無論我們多麼小心或者編寫了多少自動化測試,總會有出現嚴重錯誤的情況。重要的是,當錯誤影響到用戶體驗時,儘可能地定位它,並能以優雅的方式處理它,直到它真正被修復。
     所以今天,讓我們來看看 React 中的錯誤處理:如果發生錯誤,我們可以做什麼,不同的錯誤捕捉方法的注意事項是什麼,以及如何減小錯誤的影響。

爲何要捕獲 react 中的錯誤

     那麼第一件事:爲什麼在 React 中擁有一些錯誤捕獲解決方案是極其重要的?
    這個答案很簡單:從 16 版開始,在 React 生命週期中拋出的錯誤,如果不停止的話,將導致整個應用自行卸載。在此之前,組件會被保留在屏幕上,即使是樣式錯誤和交互錯誤的。現在,在 UI 的一些無關緊要的部分,甚至是一些你無法控制的外部庫中,一個未被捕獲的錯誤也可以使整個頁面掛掉,給用戶呈現白屏。
    在此之前,前端開發人員從來沒有過這樣的破壞力。

還記得在 js 中是如何捕獲錯誤信息的嗎?

    在 javascript 裏如何捕獲錯誤?衆所周知,我們可以使用 try/catch 語句:在 try 裏做一些事情,在它們執行失敗的時候 catch 這些錯誤來減少影響

try {
  // if we're doing something wrong, this might throw an error
  doSomething();
} catch (e) {
  // if error happened, catch it and do something with it without stopping the app
  // like sending this error to some logging service
}

相同的語法也適用於 async 函數

try {
  await fetch('/bla-bla');
} catch (e) {
  // oh no, the fetch failed! We should do something about it!
}

    如果我們正在使用舊的 promises 規範,它有專門的方法來捕獲錯誤。我們可以基於 promise 的 API 來重寫 fetch 例子,像下面這樣:

fetch('/bla-bla').then((result) ={
  // if a promise is successful, the result will be here
  // we can do something useful with it
}).catch((e) ={
  // oh no, the fetch failed! We should do something about it!
})

    以上兩種是相同的概念,只是實現方式稍有不同,因此在接下來的文章中,我將只對 try/catch 錯誤使用語法。

在 React 中的 try/catch:如何操作和注意事項

    當一個錯誤被捕獲時,我們需要對它做些什麼?除了把它記錄在某個地方之外,我們還能做什麼?更準確地說:我們能爲我們的用戶做什麼?僅僅讓他們面對一個空屏幕或者一個不友好的界面。
    最明顯和最直觀的答案是在等待我們修復的時候渲染一些東西。幸運的是,我們可以在這個 catch 語句中做任何我們想做的事情,包括設置狀態。所以我們可以做一些事情像這樣:

const SomeComponent = () ={
  const [hasError, setHasError] = useState(false);

  useEffect(() ={
    try {
    } catch(e) {
      setHasError(true);
    }
  })

  if (hasError) return <SomeErrorScreen />

  return <SomeComponentContent {...datasomething} />
}

    我們試圖發送一個獲取請求,如果請求失敗了 -- 設置錯誤狀態,如果錯誤狀態爲真,那麼我們就渲染一個錯誤反饋的 UI,爲用戶提供一些額外的信息,比如支持聯繫號碼。
    這種方法非常簡單,非常適合簡單、可預測且範圍狹窄的用例,例如捕獲失敗的 fetch 請求。
    但是如果你想捕捉一個組件中可能發生的所有錯誤,你將面臨一些挑戰和嚴格的限制。

限制 1:你會在使用 useEffect 鉤子時遇到困難

如果我們用 try/catch 包住 useEffect,hook 就失效了。

try {
  useEffect(() ={
    throw new Error('Hulk smash!');
  }[])
} catch(e) {
  // useEffect throws, but this will never be called
}

    發生這種情況是因爲 useEffect 是在渲染後被異步調用的,所以從 try/catch 的角度來看,一切都很順利。這和任何 Promise 都是一樣的:如果我們不等待結果,那麼 javascript 就會繼續它的工作,在承諾完成後返回,並且只執行 useEffect(或 Promise)中的內容。
    爲了讓在 useEffect 中的錯誤被捕獲,try/catch 應該被放在裏面。

useEffect(() ={
  try {
    throw new Error('Hulk smash!');
  } catch(e) {
    // this one will be caught
  }
}[])

    看一下這個例子就知道了:https://codesandbox.io/s/try-catch-and-useeffect-28h3ux?from-embed
    這適用於所有使用 useEffect 的鉤子或異步事情的場景。因此,你不能用一個 try/catch 包裹所有代碼,而是將其拆分到每個 hook 中

限制 2:子組件。

try/catch 不能捕捉子組件內發生的任何事情。你不能像下面這樣做:

const Component = () ={
  let child;

  try {
    child = <Child />
  } catch(e) {
    // useless for catching errors inside Child component, won't be triggered
  }

  return child;
}

甚至是這樣

const Component = () ={
  try {
    return <Child />
  } catch(e) {
    // still useless for catching errors inside Child component, won't be triggered
  }
}

    可以看一下這個例子 https://codesandbox.io/s/try-catch-for-children-doesnt-work-5elto1?from-embed
    發生這種情況是因爲當我們寫時,我們實際上並沒有渲染這個組件。我們所做的是創建一個組件元素,這只是一個組件的定義。它只是一個包含必要信息的對象,比如組件類型和道具,以後會被 React 本身使用,它將實際觸發這個組件的渲染。它將在 try/catch 塊成功執行後發生,與 promises 和 useEffect 鉤子的情況完全一樣。
    如果你想更詳細地瞭解元素和組件的工作原理,這裏有一篇文章適合你:React 元素、子代、父代和重排的奧祕(https://www.developerway.com/posts/react-elements-children-parents)

限制 3:在渲染過程中設置 state 是不可取的

    如果你想在 useEffect 和各種回調之外捕獲錯誤 (也就是說在組件的渲染過程中),那麼正確處理它們就不再簡單了,因爲渲染過程中的狀態更新是允許的。
比如像這樣簡單的的代碼,如果發生錯誤,就會導致重新渲染無限循環。

const Component = () ={
  const [hasError, setHasError] = useState(false);

  try {
    doSomethingComplicated();
  } catch(e) {
    setHasError(true);
  }
}

當然,我們可以在這裏直接返回錯誤組件,而不是設置錯誤狀態。

const Component = () ={
  try {
    doSomethingComplicated();
  } catch(e) {
    return <SomeErrorScreen />
  }
}

但是,正如你想的,這有點麻煩,而且會迫使我們對同一組件的錯誤進行不同的處理:對 useEffect 和回調進行狀態處理,而對其他的直接返回錯誤組件

const SomeComponent = () ={
  const [hasError, setHasError] = useState(false);

  useEffect(() ={
    try {
      // do something like fetching some data
    } catch(e) {
      setHasError(true);
    }
  })

  try {
    // 
  } catch(e) {
    return <SomeErrorScreen />;
  }

  if (hasError) return <SomeErrorScreen />

  return <SomeComponentContent {...datasomething} />
}

    總結一下本節的內容:如果在 React 中僅僅依靠 try/catch,要麼會錯過大部分的錯誤,要麼會把每個組件變成難以理解的混亂代碼而造成錯誤
幸運的是,還有其他方法。

React ErrorBoundary component

    爲了減輕上面的限制,React 給我們提供了 “錯誤邊界”:一種特殊的 API,它以某種方式將普通組件轉換爲 try/catch 語句,但是僅適用於 React 聲明的代碼。你可以在下面的示例中看到的經典用法,包括 React 文檔。

const Component = () ={
  return (
    <ErrorBoundary>
      <SomeChildComponent />
      <AnotherChildComponent />
    </ErrorBoundary>
  )
}

    現在,如果這些組件或者他們的子組件在渲染中出現錯誤,這個錯誤會被捕獲並處理。
    但是 React 並沒有提供原生組件,只是給我們提供了一個工具來實現它。最簡單的實現是這樣子的:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    // 初始化error的狀態
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return <>Oh no! Epic fail!</>
    }

    return this.props.children;
  }
}

    我們創建了一個普通的類組件,並實現了 getDerivedStateFromError 方法 -- 這個方法可以讓組件擁有錯誤邊界。
在處理錯誤時,另一個重要的事情是將錯誤信息傳遞到某個地方,讓它能夠觸發所有監聽者。爲此,錯誤邊界提供了 componentDidCatch 方法

class ErrorBoundary extends React.Component {
  componentDidCatch(error, errorInfo) {
    log(error, errorInfo);
  }
}

在錯誤邊界設置後,我們可以像使用其他組件一樣使用它。比如,我們可以將其優化得更便於重用,並將fallback做爲 props 來傳遞

render() {
  if (this.state.hasError) {
    return this.props.fallback;
  }

  return this.props.children;
}

可以像下面這樣使用:

const Component = () ={
  return (
    <ErrorBoundary fallback={<>Oh no! Do something!</>}>
      <SomeChildComponent />
      <AnotherChildComponent />
    </ErrorBoundary>
  )
}

    或者其他我們可能需要的東西,比如點擊按鈕時重置狀態,區分錯誤類型,或者將錯誤傳遞到某個上下文中。查看完整的例子:https://4ldsun.csb.app/
不過,這裏有一個注意事項:它並不能捕獲一切錯誤

錯誤邊界組件的限制

    錯誤邊界只捕捉髮生在 React 生命週期中的錯誤。在生命週期之外發生的事情,如resolved promise、帶有setTimeout的異步代碼、各種回調和事件監聽函數,如果沒有被不明確處理,將不能捕獲。

const Component = () ={
  useEffect(() ={
    throw new Error('Destroy everything!');
  }[])

  const onClick = () ={
    throw new Error('Hulk smash!');
  }

  useEffect(() ={
    fetch('/bla')
  }[])

  return <button onClick={onClick}>click me</button>
}

const ComponentWithBoundary = () ={
  return (
    <ErrorBoundary>
      <Component />
    </ErrorBoundary>
  )
}

    這裏的建議是使用常規的 try/catch 來處理這類錯誤。而且至少在這裏我們可以安全地使用 state:事件監聽函數的回調正是我們通常setState的地方。所以從技術上講,我們可以把兩種方法結合起來,做這樣的事情。

const Component = () ={
  const [hasError, setHasError] = useState(false);

  const onClick = () ={
    try {
      throw new Error('Hulk smash!');
    } catch(e) {
      setHasError(true);
    }
  }

  if (hasError) return 'something went wrong';

  return <button onClick={onClick}>click me</button>
}

const ComponentWithBoundary = () ={
  return (
    <ErrorBoundary fallback={"Oh no! Something went wrong"}>
    <Component />
    </ErrorBoundary>
  )
}

    但是。我們又回到了原點:每個組件都需要維持它的 "錯誤" 狀態,更重要的是 -- 決定如何處理它。
    當然,我們可以不在組件層面上處理這些錯誤,而只是通過 props 或 Context 將它們傳遞到擁有 ErrorBoundary 的父級。這樣的話,我們只需要在一個地方設置一個 "fallback" 組件。

const Component = ({ onError }) ={
  const onClick = () ={
    try {
      throw new Error('Hulk smash!');
    } catch(e) {
      onError();
    }
  }

  return <button onClick={onClick}>click me</button>
}

const ComponentWithBoundary = () ={
  const [hasError, setHasError] = useState();
  const fallback = "Oh no! Something went wrong";

  if (hasError) return fallback;

  return (
    <ErrorBoundary fallback={fallback}>
    <Component onError={() => setHasError(true)} />
    </ErrorBoundary>
  )
}

    但這裏有很多冗餘代碼,我們必須對渲染樹的每一個子組件都這樣做。更不用說我們現在還要維護兩個錯誤狀態:父組件,以及 ErrorBoundary 本身。而 ErrorBoundary 已經實現了一套捕獲錯誤的機制,我們在這裏做了重複的工作。
那麼,我們就不能用 ErrorBoundary 從異步代碼和事件處理程序中捕捉這些錯誤嗎?

ErrorBoundary 捕捉異步錯誤

    有趣的是 -- 我們可以用 ErrorBoundary 把它們都捕獲!大家最喜歡的 Dan Abramov 與我們分享了一個很酷的黑客技術。正是爲了實現這一點:Throwing Error from hook not caught in error boundary · Issue #14981 · facebook/react. (https://github.com/facebook/react/issues/14981#issuecomment-468460187)
    這裏的技巧是先用 try/catch 捕捉這些錯誤,然後在 catch 語句中觸發正常的 React 重渲染,然後把這些錯誤重新拋回重渲染的生命週期。這樣,ErrorBoundary 就可以像捕獲其他錯誤一樣捕捉它們。由於 state 變化是觸發重新渲染的方式,而 setState 實際上可以接受一個更新函數作爲參數,這個解決方案是純粹的黑魔法。

const Component = () ={
  const [state, setState] = useState();

  const onClick = () ={
    try {
      // something bad happened
    } catch (e) {
      setState(() ={
        throw e;
      })
    }
  }
}

    完整例子在這裏:https://codesandbox.io/s/simple-async-error-in-error-boundary-r8l22g?from-embed
    這裏的最後一步將其抽象化,所以我們不必在每個組件中創建隨機狀態。我們可以在這裏發揮創意,實現一個鉤子用來將異步錯誤拋出。

const useThrowAsyncError = () ={
  const [state, setState] = useState();

  return (error) ={
    setState(() => throw error)
  }
}

像這樣使用它:

const Component = () ={
  const throwAsyncError = useThrowAsyncError();

  useEffect(() ={
    fetch('/bla').then().catch((e) ={
      // throw async error here!
      throwAsyncError(e)
    })
  })
}

或者,我們可以像下面這樣爲回調做一層封裝:

const useCallbackWithErrorHandling = (callback) ={
  const [state, setState] = useState();

  return (...args) ={
    try {
      callback(...args);
    } catch(e) {
      setState(() => throw e);
    }
  }
}

像下面這樣使用它:

const Component = () ={
  const onClick = () ={
    // do something dangerous here
  }

  const onClickWithErrorHandler = useCallbackWithErrorHandling(onClick);

  return <button onClick={onClickWithErrorHandler}>click me!</button>
}

完整的例子在這裏:https://codesandbox.io/s/simple-async-errors-utils-for-error-boundary-fzg5zv?from-embed

可以用 react-error-boundary 來代替嗎?

    對於那些討厭重新造輪子的人,或者喜歡用庫來解決已經解決的問題的人,有一個很好的庫,它實現了一個靈活的 ErrorBoundary 組件,並且有一些類似於上述的有用的工具:https://github.com/bvaughn/react-error-boundary
    是否使用它,只是個人喜好、編碼風格和組件特殊性的問題。
    今天就說到這裏,希望從現在開始,當你的應用程序發生了報錯,你都能夠輕鬆而優雅地處理這些情況。
請記住:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pTroZRX21ixJyMeg_SzvWQ