Chào mừng độc giả thân yêu đến với Cafedev! Trong hành trình lập trình, việc xử lý lỗi là một kỹ năng quan trọng. Bài viết hôm nay sẽ đưa bạn qua một hướng dẫn đầy đủ về cách xử lý lỗi trong React. Chúng ta sẽ khám phá những thách thức, giải pháp và chiến lược để giữ ứng dụng của bạn ổn định. Với sự chăm sóc đặc biệt từ đội ngũ Cafedev, chúng ta sẽ cùng nhau khám phá cách tạo ra code nguồn sáng tạo và dễ bảo trì. Hãy bắt đầu hành trình lập trình không lỗi cùng Cafedev ngay bây giờ!”

Chúng ta đều muốn ứng dụng của mình ổn định, hoạt động hoàn hảo và đáp ứng mọi tình huống biên. Nhưng thực tế buồn là chúng ta là con người (ít nhất là đối với giả định của mình), chúng ta đều mắc phải sai lầm và không có khái niệm về mã không lỗi. Dù chúng ta có cẩn thận đến đâu hoặc viết bao nhiêu bài kiểm thử tự động đi chăng nữa, luôn sẽ có tình huống khi mọi thứ diễn ra tồi tệ. Quan trọng nhất, khi nói đến trải nghiệm người dùng, là dự đoán điều kinh khủng đó, giới hạn nó càng nhiều càng tốt và xử lý một cách tinh tế cho đến khi nó thực sự được sửa.

Vậy nên hôm nay, hãy xem xét về cách xử lý lỗi trong React: chúng ta có thể làm gì nếu có lỗi xảy ra, những điều cảnh báo của các phương pháp tiếp cận khác nhau đối với việc bắt lỗi, và cách giảm nhẹ chúng.

1. Tại sao chúng ta nên bắt lỗi trong React

Nhưng trước hết, tại sao lại quan trọng đối với React có một giải pháp bắt lỗi?

Câu trả lời đơn giản: bắt đầu từ phiên bản 16, một lỗi xảy ra trong vòng đời React sẽ khiến toàn bộ ứng dụng gỡ bỏ chính nó nếu không ngừng lại. Trước đó, các thành phần sẽ được bảo tồn trên màn hình, ngay cả khi chúng biến dạng và không ổn định. Bây giờ, một lỗi không may xảy ra trong một phần không quan trọng của giao diện người dùng, hoặc thậm chí trong một thư viện bên ngoài mà bạn không kiểm soát, có thể phá hủy toàn bộ trang và hiển thị màn hình trống trơn cho mọi người.

Chưa bao giờ những người phát triển frontend có quyền lực phá hủy như vậy 😅

2. Nhớ cách bắt lỗi trong javascript

Khi đến việc bắt những điều bất ngờ khó chịu trong JavaScript thông thường, các công cụ khá đơn giản.

Chúng ta có cấu trúc try/catch statement , khá là dễ hiểu: thử làm điều gì đó, và nếu có lỗi – catch lỗi đó và thực hiện một số biện pháp để giảm nhẹ nó:

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
}

Điều này cũng sẽ hoạt động với hàm async với cú pháp tương tự:

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

Hoặc, nếu chúng ta theo đuổi promises kiểu cũ, chúng ta có một phương thức catch dành riêng cho chúng. Vì vậy, nếu chúng ta viết lại ví dụ fetch trước đó với API dựa trên promises, nó sẽ trông như thế này:

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!
})

Đó là cùng một khái niệm, chỉ khác một chút trong việc thực hiện, nên trong phần còn lại của bài viết, mình sẽ chỉ sử dụng cú pháp try/catch cho tất cả các lỗi.

3.try/catch đơn giản trong React: cách thực hiện và những điều cảnh báo

Khi một lỗi được bắt được, chúng ta cần phải làm gì đó với nó, đúng không? Vậy, chúng ta có thể làm gì, ngoài việc ghi log nó ở đâu đó? Hoặc, để chính xác hơn: chúng ta có thể làm gì cho người dùng của chúng ta? Chỉ để họ với một màn hình trống hoặc giao diện bị hỏng không chắc đã là thân thiện với người dùng.

Câu trả lời rõ ràng và dễ hiểu nhất có lẽ là hiển thị một cái gì đó trong cấu trúc catch, bao gồm việc thiết lập trạng thái. Vì vậy, chúng ta có thể làm một cái gì đó như thế này:

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

  useEffect(() => {
    try {
      // do something like fetching some data
    } catch(e) {
      // oh no! the fetch failed, we have no data to render!
      setHasError(true);
    }
  })

  // something happened during fetch, lets render some nice error screen
  if (hasError) return <SomeErrorScreen />

  // all's good, data is here, let's render it
  return <SomeComponentContent {...datasomething} />
}

Chúng ta đang cố gửi một yêu cầu fetch, nếu nó thất bại — thiết lập trạng thái lỗi, và nếu trạng thái lỗi là true, sau đó chúng ta hiển thị một màn hình lỗi với một số thông tin bổ sung cho người dùng, như số liên hệ hỗ trợ.

Phương pháp này khá đơn giản và hoạt động tốt cho các trường hợp sử dụng đơn giản, dự đoán và hẹp như việc bắt lỗi fetch thất bại.

Nhưng nếu bạn muốn bắt tất cả các lỗi có thể xảy ra trong một thành phần, bạn sẽ đối mặt với một số thách thức và hạn chế nghiêm trọng.

3.1 Hạn chế 1: bạn sẽ gặp khó khăn với hook useEffect.

Nếu chúng ta bọc useEffect bằng try/catch, nó sẽ không hoạt động:

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

Điều này xảy ra vì useEffect được gọi bất đồng bộ sau lúc render, vì vậy từ góc nhìn try/catch, mọi thứ đều diễn ra thành công. Đây là câu chuyện giống như với bất kỳ Promise nào: nếu chúng ta không đợi kết quả, thì JavaScript sẽ tiếp tục công việc của mình, quay lại khi promise hoàn thành và chỉ thực thi những gì ở bên trong useEffect (hoặc sau của một Promise). Khối try/catch sẽ được thực hiện và kết thúc lâu trước đó.

Để bắt lỗi bên trong useEffect, try/catch cũng phải được đặt bên trong như sau:

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

Tận hưởng với ví dụ này để xem điều đó.

Điều này áp dụng cho bất kỳ hook nào sử dụng useEffect hoặc bất cứ thứ gì là bất đồng bộ thực sự. Do đó, thay vì chỉ có một khối try/catch bao trọn mọi thứ, bạn sẽ phải chia nó thành nhiều khối: một cho mỗi hook.

2.Hạn chế 2: các thành phần con.

try/catch sẽ không thể bắt bất cứ điều gì xảy ra bên trong các thành phần con. Bạn không thể chỉ làm như thế này:

const Component = () => {
  let child;

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

  return child;
}

Hoặc thậm chí như thế này:

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

Thử nghiệm với ví dụ này để thấy điều đó.

Điều này xảy ra vì khi chúng ta viết , chúng ta thực sự không render thành phần này. Điều chúng ta đang làm là tạo ra một Element của thành phần, mà không gì khác ngoài định nghĩa của thành phần. Đó chỉ là một đối tượng chứa thông tin cần thiết như loại thành phần và props, sẽ được React sử dụng sau đó, và nó sẽ thực sự kích thích quá trình render của thành phần này sau khi khối try/catch được thực hiện thành công, chính xác như câu chuyện với promises và hook useEffect.

3.Hạn chế 3: thiết lập trạng thái trong quá trình render là không được phép

Nếu bạn đang cố bắt lỗi bên ngoài useEffect và các gọi lại khác (tức là trong quá trình render của thành phần), thì xử lý chúng một cách đúng đắn không còn dễ dàng nữa: cập nhật trạng thái trong quá trình render không được phép.

Mã đơn giản như sau sẽ chỉ gây ra một vòng lặp vô hạn của việc render lại nếu có lỗi:

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

  try {
    doSomethingComplicated();
  } catch(e) {
    // don't do that! will cause infinite loop in case of an error
    // see codesandbox with live example below
    setHasError(true);
  }
}

Kiểm tra nó tại codesandbox .

Tất nhiên, chúng ta có thể đơn giản trả lại màn hình lỗi ở đây thay vì thiết lập trạng thái:

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

Như bạn có thể tưởng tượng, đó là một chút phức tạp và sẽ buộc chúng ta phải xử lý lỗi trong cùng một thành phần theo cách khác nhau: trạng thái cho useEffect và các callback, và trả về trực tiếp cho tất cả những thứ khác.

// while it will work, it's super cumbersome and hard to maitain, don't do that
const SomeComponent = () => {
  const [hasError, setHasError] = useState(false);

  useEffect(() => {
    try {
      // do something like fetching some data
    } catch(e) {
      // can't just return in case of errors in useEffect or callbacks
      // so have to use state
      setHasError(true);
    }
  })

  try {
    // do something during render
  } catch(e) {
    // but here we can't use state, so have to return directly in case of an error
    return <SomeErrorScreen />;
  }

  // and still have to return in case of error state here
  if (hasError) return <SomeErrorScreen />

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

Để tóm tắt phần này: nếu chúng ta chỉ dựa vào try/catch trong React, chúng ta sẽ hoặc bỏ qua hầu hết các lỗi, hoặc biến mỗi thành phần thành một mớ mã khó hiểu có thể gây lỗi bản thân.

May mắn, có một cách khác.

4.Thành phần React ErrorBoundary

Để giảm nhẹ những hạn chế từ trên, React cung cấp cho chúng ta cái được biết đến như “Error Boundaries” : một API đặc biệt biến một thành phần thông thường thành một câu lệnh try/catch, chỉ cho mã khai báo React. Sử dụng điển hình mà bạn có thể thấy trong mọi ví dụ ở đó, bao gồm cả tài liệu React, sẽ như sau:

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

Bây giờ, nếu có điều gì đó không ổn trong bất kỳ thành phần nào hoặc ở những thành phần con của chúng trong quá trình render, lỗi sẽ được bắt và xử lý.

Nhưng React không cung cấp cho chúng ta thành phần được chính nó, nó chỉ cung cấp cho chúng ta một công cụ để triển khai nó. Triển khai đơn giản nhất có thể là như sau:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    // initialize the error state
    this.state = { hasError: false };
  }

  // if an error happened, set the state to true
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    // if error happened, return a fallback component
    if (this.state.hasError) {
      return <>Oh no! Epic fail!</>
    }

    return this.props.children;
  }
}

Chúng ta tạo một thành phần class thông thường (quay lại phong cách cũ ở đây, không có hook nào cho các ranh giới lỗi), và triển khai phương thức getDerivedStateFromError – chuyển thành phần thành ranh giới lỗi chính thức.

Một điều quan trọng khác cần làm khi xử lý lỗi là gửi thông tin lỗi đến một nơi nào đó có thể thông báo cho tất cả những người đang trực. Đối với điều này, ranh giới lỗi cung cấp phương thức componentDidCatch:

class ErrorBoundary extends React.Component {
  // everything else stays the same

  componentDidCatch(error, errorInfo) {
    // send error to somewhere here
    log(error, errorInfo);
  }
}

Sau khi ranh giới lỗi được thiết lập, chúng ta có thể làm bất cứ điều gì với nó, giống như bất kỳ thành phần nào khác. Chúng ta có thể, ví dụ, làm cho nó tái sử dụng hơn và truyền fallback qua một prop:

render() {
  // if error happened, return a fallback component
  if (this.state.hasError) {
    return this.props.fallback;
  }

  return this.props.children;
}

Và sử dụng nó như sau:

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

Hoặc bất cứ điều gì khác mà chúng ta có thể cần, như đặt lại trạng thái khi nhấn nút, phân biệt giữa các loại lỗi hoặc đẩy lỗi đó đến một ngữ cảnh nào đó.

Xem ví dụ đầy đủ tại codesandbox này.

Tuy nhiên, có một điều cảnh báo trong thế giới không lỗi này: nó không bắt được mọi thứ.

5. Thành phần ErrorBoundary: hạn chế

Ranh giới lỗi chỉ bắt lỗi xảy ra trong suốt vòng đời React. Những thứ xảy ra bên ngoài nó, như promises đã giải quyết, mã async với setTimeout, các gọi lại và xử lý sự kiện khác, sẽ chỉ biến mất nếu không được xử lý một cách rõ ràng.

const Component = () => {
  useEffect(() => {
    // this one will be caught by ErrorBoundary component
    throw new Error('Destroy everything!');
  }, [])

  const onClick = () => {
    // this error will just disappear into the void
    throw new Error('Hulk smash!');
  }

  useEffect(() => {
    // if this one fails, the error will also disappear
    fetch('/bla')
  }, [])

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

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

Khuyến nghị phổ biến ở đây là sử dụng try/catch thông thường cho loại lỗi đó. Và ít nhất ở đây, chúng ta có thể sử dụng trạng thái một cách an toàn (hơn hoặc ít hơn): các gọi lại của xử lý sự kiện chính là những nơi chúng ta thường đặt trạng thái. Vì vậy, theo kỹ thuật, chúng ta có thể kết hợp hai phương thức và làm một cái gì đó như sau:

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

  // most of the errors in this component and in children will be caught by the ErrorBoundary

  const onClick = () => {
    try {
      // this error will be caught by catch
      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>
  )
}

Nhưng. Chúng ta quay trở lại điểm xuất phát: mỗi thành phần cần duy trì trạng thái “lỗi” của mình và quan trọng hơn là đưa ra quyết định về điều gì cần làm với nó.

Tất nhiên, thay vì xử lý những lỗi đó ở mức thành phần, chúng ta có thể đẩy chúng lên phía cha có ErrorBoundary qua props hoặc Context. Ít nhất ở đó, chúng ta có thể có một thành phần “fallback” chỉ ở một nơi:

const Component = ({ onError }) => {
  const onClick = () => {
    try {
      throw new Error('Hulk smash!');
    } catch(e) {
      // just call a prop instead of maintaining state here
      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>
  )
}

Nhưng đó là thêm rất nhiều mã nguồn! Chúng ta sẽ phải làm điều này cho mỗi thành phần con trong cây render. Chưa kể rằng chúng ta thực sự đang duy trì hai trạng thái lỗi bây giờ: trong thành phần cha và trong ErrorBoundary chính nó. Và ErrorBoundary đã có tất cả các cơ chế cần thiết để truyền lỗi lên cây, chúng ta đang làm công việc kép ở đây.

Liệu có thể chúng ta chỉ đơn giản là bắt những lỗi từ mã async và các xử lý sự kiện với ErrorBoundary không?

6. Bắt lỗi async với ErrorBoundary

Đáng chú ý là — chúng ta có thể bắt tất cả chúng với ErrorBoundary! Dan Abramov, người được nhiều người yêu thích, chia sẻ với chúng ta một kỹ thuật tuyệt vời để đạt được chính xác điều đó: Dan Abramov chia sẻ một phương thức thú vị để đạt được chính xác điều đó: Throwing Error from hook not caught in error boundary · Issue #14981 · facebook/react.

Mẹo ở đây là bắt những lỗi đó trước với try/catch, sau đó bên trong câu lệnh catch kích hoạt việc render lại bình thường của React, và sau đó ném lại những lỗi đó vào vòng đời render lại. Như vậy, ErrorBoundary có thể bắt chúng giống như bất kỳ lỗi nào khác. Và vì cập nhật trạng thái là cách kích hoạt việc render lại, và hàm đặt trạng thái thực sự có thể chấp nhận một hàm cập nhật làm đối số, giải pháp này thật sự là phép màu:

const Component = () => {
  // create some random state that we'll use to throw errors
  const [state, setState] = useState();

  const onClick = () => {
    try {
      // something bad happened
    } catch (e) {
      // trigger state update, with updater function as an argument
      setState(() => {
        // re-throw this error within the updater function
        // it will be triggered during state update
        throw e;
      })
    }
  }
}

Xem ví dụ đầy đủ tại codesandbox này.

Bước cuối cùng ở đây là trừu tượng hóa cách đó, để chúng ta không phải tạo ra các trạng thái ngẫu nhiên trong mỗi thành phần. Chúng ta có thể sáng tạo ở đây và tạo một hook cung cấp cho chúng ta một người ném lỗi async:

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

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

Và sử dụng nó như sau:

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

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

Hoặc, chúng ta có thể tạo một wrapper cho các callback như sau:

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

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

Và sử dụng nó như sau:

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

  const onClickWithErrorHandler = useCallbackWithErrorHandling(onClick);

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

Hoặc bất cứ điều gì khác mà trái tim bạn mong muốn và ứng dụng yêu cầu. Không có giới hạn! Và không còn lỗi nào trốn thoát nữa.

Xem ví dụ đầy đủ tại codesandbox này.

7. Tôi có thể chỉ sử dụng react-error-boundary thay vì không?

Đối với những người trong số bạn ghét việc phải phát minh lại bánh xe hoặc chỉ đơn giản là ưa thích thư viện cho các vấn đề đã được giải quyết, có một thư viện tốt triển khai một thành phần ErrorBoundary linh hoạt và có một số tiện ích hữu ích tương tự như những gì đã được mô tả ở trên: GitHub — bvaughn/react-error-boundary

Việc sử dụng nó hay không chỉ là vấn đề của sở thích cá nhân, phong cách lập trình và tình hình độc đáo trong thành phần của bạn.

Đó là tất cả cho ngày hôm nay, hy vọng từ giờ trở đi nếu có điều gì xấu xảy ra trong ứng dụng của bạn, bạn sẽ có thể giải quyết tình huống một cách dễ dàng và lịch sự.

Và nhớ:

  • try/catch sẽ không bắt lỗi trong hooks như useEffect và trong bất kỳ thành phần con nào
  • ErrorBoundary có thể bắt chúng, nhưng nó sẽ không bắt lỗi trong mã async và xử lý sự kiện
  • Tuy nhiên, bạn có thể khiến ErrorBoundary bắt chúng, bạn chỉ cần bắt chúng với try/catch trước và sau đó ném lại chúng vào vòng đời React

Sống lâu và không lỗi! ✌🏼

Chúc mừng bạn đã hoàn thành hành trình khám phá cách xử lý lỗi trong React với hướng dẫn đầy đủ từ Cafedev! Chúng tôi hy vọng rằng bài viết đã mang lại cho bạn những thông điệp hữu ích và chiến lược thực tế để làm cho ứng dụng của bạn trở nên ổn định hơn. Tại Cafedev, chúng tôi cam kết tiếp tục chia sẻ kiến thức và thông tin mới nhất để đồng hành cùng cộng đồng lập trình. Hãy duyệt qua trang web của chúng tôi để khám phá thêm nhiều bài viết hấp dẫn khác và đón nhận những cập nhật đáng chú ý từ thế giới phần mềm!”

Các nguồn kiến thức MIỄN PHÍ VÔ GIÁ từ cafedev tại đây

Nếu bạn thấy hay và hữu ích, bạn có thể tham gia các kênh sau của Cafedev để nhận được nhiều hơn nữa:

Chào thân ái và quyết thắng!

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!