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.
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 😅
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.
try/catch
đơn giản trong React: cách thực hiện và những điều cảnh báoKhi 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.
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.
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
.
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.
Để 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ứ.
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?
Đá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.
Đố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àoErrorBoundary
có thể bắt chúng, nhưng nó sẽ không bắt lỗi trong mã async và xử lý sự kiệnErrorBoundary
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» Tin mới nhất:
» Các tin khác: