همه ما میخواهیم برنامههایمان پایدار باشند، به طور کامل کار کنند و به همه حالتهای ممکن پاسخ دهند، آیا اینطور نیست؟ اما واقعیت این است که همه ما اشتباه میکنیم و هیچ کدی که بدون باگ نباشد وجود ندارد. هر چقدر هم دقت کنیم و هر تعداد تست خودکار هم بنویسیم، همیشه موقعیتی پیش خواهد آمد که چیزی بدتر از حد معمول رخ میدهد. اما نکته مهم پیشبینی این شرایط، محدود کردن آن به حداکثر اندازه ممکن و برخورد با آن ها به شیوهای مدیریت شده است، تا زمانی که به طور کامل قابل مدیریت باشد.
در این مقاله مدیریت خطا در React را بررسی می کنیم: اینکه چه کارهایی میتوانیم در این مواقع انجام دهیم، و چگونه میتوان آنها را کاهش داد. برای جزئیات بیشتر در مورد این مبحث و مباحث پیشرفته تر React می توانید در دوره جامع آموزش React شرکت کنید و یا سایر دوره های آموزش ری اکت را بررسی کنید.
چرا مدیریت خطاها در ری اکت اهمیت دارد؟
اما اولین چیزی که باید بدانیم این است که چرا داشتن یک راهحل برای مدیریت خطاها در ریاکت مهم است؟
پاسخ ساده است: از نسخه ۱۶ به بعد، هر خطایی که در ریاکت رخ می دهد، باعث از دسترس خارج شدن کل برنامه میشود. قبل از آن، اگر بخشی از اپلیکیشن دچار خطا می شد، متن خطا در کنسول لاگ می شد ولی سایر قسمت های اپلیکیشن کار می کرد. اکنون، یک خطای غیر منتظره در بخش بیاهمیتی از رابط کاربری یا حتی در یک کتابخانه خارجی که اغلب بر روی آن هیچ کنترلی وجود ندارد، میتواند کل صفحه را با مشکل مواجه کند و نتیجه آن نمایش یک صفحه خالی است.
مدیریت خطاها در جاوااسکریپت
در جاوااسکریپت یکی از امکانات ما برای مدیریت خطاها استفاده از 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
}
از try/catch می توان برای aysnchronous function ها نیز استفاده کرد:
try {
await fetch('/bla-bla');
} catch (e) {
// oh no, the fetch failed! We should do something about it!
}
یا، در صورت استفاده از promis، می توان از متد catch استفاده کرد. بنابراین، اگر مثال قبلی را با استفاده از روش مبتنی بر promise بازنویسی کنیم، به این صورت خواهد بود:
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 است ، فقط پیادهسازی آن متفاوت است، در ادامه مطالب از ساختار try/catch برای همه خطاها استفاده خواهیم کرد.
استفاده از try/catch در ری اکت
زمانی که یک خطا رخ می دهد، نیاز داریم که آن را مدیریت کنیم، درست است؟ بنابراین، علاوه بر ثبت آن در جایی، چه کارهای دیگری میتوانیم انجام دهیم؟ یا به عبارت دقیقتر، چه کاری میتوانیم برای کاربران خود انجام دهیم؟ نمایش یک صفحه خالی به هیچ عنوان راهکاری مناسبی برای مدیریت خطاها نیست.
راه حل بهتر این است در صورت مواجه شدن با خطا در بخش catch یک متن خطا به کاربر نشان دهیم تا از وجود خطا مطلع شود. خوشبختانه، میتوانیم هر کاری که بخواهیم در بلاک catch انجام دهیم، از جمله تغییر وضعیت (state) کامپوننت. بنابراین، میتوانیم به این شکل عمل کنیم:
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} />
}
در کد فوق با استفاده از fetch api یک درخواست به سرور ارسال کرده ایم، اگر این درخواست با خطا مواجه شود- hasError state را با true مقدار دهی می کنیم، سپس یک کامپوننت را با برخی اطلاعات اضافی برای کاربران مانند شماره تماس پشتیبانی را نمایش میدهیم.
این روش در شرایطی که سناریویی ساده داشته باشیم مانند همین حالت که ارسال یک درخواست به سرور است کاملا مناسب و منطقی است.
اما اگر میخواهید تمام خطاهایی که ممکن است در یک کامپوننت رخ دهد را کنترل کنید، با چالشها و محدودیتهای جدی مواجه خواهید شد.
محدودیت اول: useEffect hook مسئله ساز است
اگر useEffect را به شکل زیر در try/catch قرار دهیم کار نمی کند:
try {
useEffect(() => {
throw new Error('Hulk smash!');
}, [])
} catch(e) {
// useEffect throws, but this will never be called
}
این اتفاق به این دلیل رخ میدهد که useEffect به صورت ناهمزمان و پس از رندر فراخوانی میشود، بنابراین از دیدگاه try/catch هیچ خطایی رخ نداده است. چرا که تا زمانی که function داخل useEffect فراخوانی می شود try/catch اجرا و با موفقیت اجرا شده است.
به منظور اینکه به خطای داخل useEffect دسترسی داشته باشیم لازم است try/catch در داخل useEffect تعریف شود:
useEffect(() => {
try {
throw new Error('Hulk smash!');
} catch(e) {
// this one will be caught
}
}, [])
این موضوع برای هر هوکی که از useEffect استفاده میکند یا در واقع هر عملیات ناهمزمان دیگری اعمال میشود. به عبارت دیگر، به جای داشتن یک بلاک try/catch که همه چیز را درون خود قرار میدهد، باید آن را به بلاکهای متعددی تقسیم کنید: یک بلاک برای هر هوک.
محدودیت دوم: کامپوننت های فرزند
بلاک 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
}
}
در واقع تعریف در اینجا هم مشابه قبل کد با موفقیت و بدون خطا اجرا می شود. چرا که تعریف کامپوننت Child به معنی اجرا آن نیست. بلکه فقط یک تعریف است که ری اکت در آینده و با اجرای کامپوننت فرزند آن را اجرا می کند. در نتیجه تا زمانی که کامپوننت Child اجرا شود بلاک try/catch اجرا شده و با موفقیت هم اجرا شده در صورتی که ممکن است در آینده و با اجرای کامپوننت Child به خطا برسیم.
محدودیت سوم: تغییر State در طول رندر مسئله ساز می شود
چنانچه در خارج از useEffect و function ها (در طول رندر کامپوننت) خطاها را مدیریت کنید، ممکن است مسئله ساز شود. به طور مثال در کامپوننت زیر چنانچه خطایی رخ دهد، کامپوننت به طور بی نهایت رندر مجدد می شود:
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 below with live example
setHasError(true);
}
}
البته در چنین شرایطی می توانیم به جای تغییر state، کامپوننتی که خطا را به کاربر نشان می دهد return کنیم:
const Component = () => {
try {
doSomethingComplicated();
} catch(e) {
// this allowed
return <SomeErrorScreen />
}
}
مشکل این روش این است در صورتی که در همین کامپوننت قطعه کدهای دیگری داشته باشیم مثل استفاده از چند useEffect یا لاجیک های متفاوت، لازم است در جاهای مختلف کامپوننت SomeErrorScreen را تعریف کنیم که نگهداری کد را به شدت تحت تاثیر قرار می دهد:
// 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} />
}
خلاصه اینکه اگر تنها به try/catch در ری اکت بسنده کنیم، اکثر خطاهایی که در کامپوننت های مختلف رخ می دهند را از دست می دهیم و نمی توانیم آن ها را مدیریت کنیم. از طرفی هم ممکن است در وضعیتی قرار بگیریم که ناسازگاری های متعددی به وجود بیاید که این خود باعث خطا می شود.
خوشبختانه روش بهتری برای مدیریت خطاها در ری اکت وجود دارد
کامپوننت ErrorBoundary
برای کاهش محدودیتهایی که در بالا ذکر شد، React به ما راهکاری به عنوان ErrorBoundary ارائه میدهد: یک API ویژه که یک کامپوننت معمولی را به صورتی شبیه try/catch تبدیل میکند. استفاده معمولی از این کامپوننت می تواند به این شکل باشد:
const Component = () => {
return (
<ErrorBoundary>
<SomeChildComponent />
<AnotherChildComponent />
</ErrorBoundary>
)
}
حالا، اگر در هر کدام از این کامپوننتها یا کامپوننتهای فرزندشان در طول فرآیند رندر خطایی رخ دهد، خطا گرفته و مدیریت می شود
اما نکته ای که وجود دارد این است که کامپوننت ErrorBoundary فقط یک کامپوننت است بدون هیچ پیاده سازی. در واقع ما باید پیاده سازی این کامپوننت را انجام دهیم. اما چگونه. یه روش پیاده سازی به این شکل است:
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;
}
}
در اینجا ما یک کلاس کامپوننت معمولی ایجاد کرده ایم(در اینجا از هیچ گونه هوکی برای ErrorBoundary استفاده نمیکنیم) و متد getDerivedStateFromError را پیادهسازی میکنیم. در واقع از طریق این متد چنانچه خطای رخ دهد state مرتبط با خطا رو آپدیت می کنیم.
یک مورد مهم دیگر در مدیریت خطاها، لاگ خطاست . برای این منظور، می توانیم از متد componentDidCatch به شکل زیر استفاده کرد:
class ErrorBoundary extends React.Component {
// everything else stays the same
componentDidCatch(error, errorInfo) {
// send error to somewhere here
log(error, errorInfo);
}
}
پس از تعریف ErrorBoundary می توانیم به راحتی متن خطای مرتبط رو از طریق یک Props به کامپوننت ارسال کرد. به فرض مثال می توانیم متن خطا رو به این شکل تعریف کنیم:
render() {
// if error happened, return a fallback component
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
و برای استفاده از آن داریم:
const Component = () => {
return (
<ErrorBoundary fallback={<>Oh no! Do something!</>}>
<SomeChildComponent />
<AnotherChildComponent />
</ErrorBoundary>
)
}
محدودیت های کامپوننت ErrorBoundary
یکی از محدودیت های اساسی ای کامپوننت این است که این کامپوننت خطاهایی که در Promise ها یا به طور کلی asynchronous operation رخ می دهد را نمی تواند تشخیص دهد. در چنین شرایطی باید به شکل صریح با آن ها برخورد کنیم وگرنه نمی توانیم آن ها را مدیریت کنیم.
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>
)
}
در این چنین شرایطی تنها راه استفاده از try/catch برای این نوع خطاها است. بنابراین ، میتوانیم مانند زیر عمل کنیم:
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>
)
}
اما ما به مرحله اول بازگشتیم: هر کامپوننت باید وضعیت "خطا" خود را حفظ کند و مهمتر، باید تصمیم بگیرد برخورد آن با این خطا چگونه باشد.
البته، میتوانیم به جای مدیریت این خطاها در سطح کامپوننت، آنها را از طریق props یا Context به والدین بالاتر که دارای ErrorBoundary است، منتقل کنیم. به این ترتیب حداقل میتوانیم یک کامپوننت "پشتیبان" را در یک مکان تعیین کنیم:
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>
)
}
اما این همه کد اضافی است! در واقع با این روش باید این کار را برای هر کامپوننت فرزندی انجام دهیم. در حقیقت دو وضعیت خطا را هم اکنون نگه میداریم: اول در کامپوننت والدین و دوم در ErrorBoundary. بنابراین در واقع مدیریت خطا در دو محل انجام می شود
سوال این است آیا نمی توانیم خطاهایی که در async code ها رخ می دهند را با استفاده از ErrorBoundary مدیریت کنیم؟
استفاده از کامپوننت ErrorBoundary برای مدیریت خطاهای async
با تکنیکی که در ادامه توضیح داده می شود می توان از ErrorBoundary برای مدیریت خطاهای async استفاده کرد.
تکنیک این است که ابتدا با استفاده از try/catch خطاها را گرفته و سپس در دستور catch مقدار state را تغییر می دهیم و در داخل function مرتبط با آن یک بار دیگر خطا تولید می کنیم تا رندر مجدد اتفاق بیوفتد و سپس آن خطاها را به عنوان خطاهای معمولی React مجدداً هندل می کنیم. به این ترتیب ErrorBoundary میتواند آنها را مانند هر خطای دیگری گرفته و کنترل کند.کد زیر این تکنیک را نشان می دهد:
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;
})
}
}
}
تنها مشکلی که باقی می ماند این است که در حالت فوق باید برای هر کامپوننت یک state اضافه تعریف کنیم که بتوان از آن در catch استفاده کرد. برای حل این مشکل میتوانیم در اینجا خلاقیت کنیم و یک hook ایجاد کنیم که به ما یک مکانیزم برای مدیریت خطاهای async ارائه دهد:
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)
})
})
}
خلاصه
- try/catch نمی تواند خطاهای داخل useEffect یا کامپوننت های فرزند را تشخیص دهد
- در نتیجه برای اینکار باید از ErrorBoundary استفاده کرد. اما ErrorBoundary هم نمی تواند خطاهایی که در کدهای async رخ می دهند را شناسایی کند.
- برای حل این مشکل نیاز است ابتدا خطاها را در try/catch شناسایی کرد و سپس با re-throw کردن آن ها و استفاده از ErrorBoundary آن ها را مدیریت کرد.
مدیریت خطاها یکی از مباحث خیلی مهم ری اکت. برای بررسی دقیق تر می توانید به دوره رایگان آموزش React مراجعه کنید. همین طور دوره پیشرفته React برای جزئیات کامل این مبحث پیشنهاد می شود