در دنیای بزرگ React هوک ها و ویژگی های بسیار زیادی وجود دارند که باید در مورد آن ها اطلاعات کسب کنیم تا بتوانیم یک اپلیکیشین بسیار سریع و کارآمد و بهینه را پیاده سازی کنیم. ما در این مقاله قصد بررسی اینکه React Concurrent Rendering چیست، هوک هایی مانند useTransition
و useDeferredValue
چه می کنند، مزایا و معایب استفاده از آنها چیست.
اگر در دو سال اخیر زیر یک سنگ زندگی نکرده باشید، احتمالاً کلمات جادویی "رندر همزمان" را اینجا و آنجا شنیده اید. React برای پشتیبانی از آن از ابتدا بازنویسی شد، این یک معماری کاملاً جدید است که به ما کنترل انتقال را از طریق هوک های useTransition
و useDeferredValue
میدهد، و قرار است یک تغییر دهنده بازی برای عملکرد تعاملات رابط کاربری ما باشد. حتی Vercel عملکرد خود را با انتقال بهبود می بخشد.
اما آیا واقعاً یک تغییر دهنده بازی است؟ هیچ نگرانی وجود ندارد، واقعاً؟ آیا می توانیم از این انتقال ها در همه جا استفاده کنیم؟ این ها برای شروع چه هستند و چرا به آنها نیاز داریم؟ بیا با هم تحقیق کنیم و بفهمیم.
پیاده سازی state به صورت کند
ابتدا، اجازه دهید چیزی واقعی را با یک مشکل عملکرد پیاده سازی کنیم. در داکیومنت، آنها از کامپوننت «tabs
» به عنوان مثالی استفاده میکنند که در آن useTransition
مفید است، بنابراین بیایید دقیقاً آن را پیادهسازی کنیم. بدون کپی پیست، بیایید این کار را از ابتدا انجام دهیم!
ما یک کامپوننت App
خواهیم داشت که سه برگه (issues
، projects
و reports
) را ارائه میکند و محتوای آن برگهها را بهصورت مشروط ارائه میکند.فهرستی از آخرین مسائل، پروژهها و گزارشها برای رقیب کوچک ما برای Jira و Linear. برنامه stateی را نگه میدارد که بین برگهها سوئیچ میکند و کامپوننت صحیح را ارائه میکند.
چیزی که الان داریم:
export default function App() {
const [tab, setTab] = useState('issues');
return (
<div className="container">
<div className="tabs">
<TabButton
onClick={() => setTab('issues')}
name="Issues"
/>
<TabButton
onClick={() => setTab('projects')}
name="Projects"
/>
<TabButton
onClick={() => setTab('reports')}
name="Reports"
/>
</div>
<div className="content">
{tab === 'issues' && <Issues />}
{tab === 'projects' && <Projects />}
{tab === 'reports' && <Reports />}
</div>
</div>
);
}
اکنون، مشکلی که transitions باید به آن کمک کند: اگر یکی از صفحات بسیار سنگین باشد و رندر آن کند باشد چه؟ فرض کنید صفحه پروژه ها لیستی از 100 پروژه اخیر را ارائه می دهد و اجزای آن لیست بسیار سنگین هستند و برای نصب هر کدام چیزی حدود 10 میلی ثانیه طول می کشد. 100 مورد از نظر تئوری غیر منطقی نیست. و اگرچه 10 میلیثانیه به ازای هر کامپوننت کمی بد است، اما همچنان میتواند اتفاق بیفتد، به خصوص در لپتاپهای کند. در نتیجه، 1 ثانیه طول می کشد تا صفحه پروژه ها را نصب کنید.
مثال را در سیستم خود پیاده سازی کنید و ببینید چگونه وقتی روی دکمه "پروژه ها" کلیک می کنید، رندر صفحه پروژه ها برای همیشه طول می کشد؟ اکنون سعی کنید به سرعت بین آن برگه ها حرکت کنید. اگر سعی کنم از Issues به Projects و سپس بلافاصله به Reports پیمایش کنم، نمی توانم این کار را انجام دهم: رابط پاسخگو نیست. این دقیقاً بهترین تجربه کاربری نیست: حتی اگر قرار است صفحه پروژهها آنقدر سنگین باشد و من اکنون نتوانم آن را بهینه کنم، حداقل کاری که باید برای کاربران انجام دهم این است که صفحه را از تعاملات دیگر مسدود نکنم.
این به این دلیل اتفاق میافتد که بهروزرسانی state، علیرغم باور عمومی، ناهمزمان نیست. راهاندازی آن معمولاً این است: ما آن را به صورت ناهمزمان از فراخوانی های مختلف به عنوان پاسخی به تعاملات کاربر انجام میدهیم. اما هنگامی که بروزرسانی state راه اندازی شد، React به طور همزمان روی محاسبه تمام بروزرسانی های لازم که باید انجام شوند کار می کند، همه کامپوننت هایی را که باید رندر شوند دوباره رندر می کند، آن تغییرات را در DOM انجام می دهد تا بتوانند روی صفحه ظاهر شوند، و تنها پس از آن به مرورگر اجازه دهید تا برود و متوجه شود که در زمانی که مشغول بود چه اتفاقی می افتاد.
اگر در حین آن روی یک دکمه تب کلیک کنیم، بروز رسانی state از کلیک در صفی از وظایف قرار می گیرد و پس از انجام وظیفه اصلی (بروزرسانی state کند) اجرا می شود. میتوانید این رفتار را در خروجی کنسول مثال کند مشاهده کنید: همه رندرهایی که با کلیک کردن روی برگهها فعال میکنید، ثبت میشوند، حتی اگر صفحه در آن زمان ثابت باشد.
وظایف و صف وظایف نحوه پردازش جاوا اسکریپت توسط مرورگر است.
Rendering همزمان و useTransition برای بهروزرسانیهای state کند
این واقعیت که یک بهروزرسانی state معمولی وظیفه اصلی را مسدود میکند، همان چیزی است که رندر همزمان با آن مقابله میکند. با آن، میتوانیم برخی بروزرسانیهای state و رندر مجدد ناشی از آنها را بهصراحت بهعنوان «غیر بحرانی» علامتگذاری کنیم. در نتیجه، React به جای مسدود کردن کار اصلی، این بهروزرسانیها را در «پسزمینه» محاسبه میکند. اگر اتفاقی «بحرانی» بیفتد (یعنی یک بهروزرسانی state عادی)، React رندر «پسزمینه» خود را متوقف میکند، بهروزرسانی کامل را اجرا میکند و سپس یا به وظیفه قبلی برمیگردد یا آن را به طور کامل رها میکند و کار جدیدی را شروع میکند.
البته "پس زمینه" فقط یک مدل ذهنی مفید است: جاوا اسکریپت تک رشته ای است. React فقط به صورت دوره ای صف اصلی را در حالی که مشغول کار "پس زمینه" است بررسی می کند. اگر چیز جدیدی در صف ظاهر شود، بر کار "پس زمینه" اولویت خواهد داشت.
اما کلمات کافی است، به نوشتن کد بازگردیم. در تئوری، state ما با یکی از تب ها که بسیار کند است و تعاملات کاربر را مسدود می کند، دقیقاً همان چیزی است که رندر همزمان می تواند به آن کمک کند. تنها کاری که باید انجام دهیم این است که رندر صفحه پروژه ها را به عنوان "غیر بحرانی" علامت گذاری کنیم.
ما می توانیم این کار را با هوک useTransition
انجام دهیم. یک بولین "بارگذاری" را به عنوان اولین آرگومان و یک تابع را به عنوان آرگومان دوم برمی گرداند. در داخل آن تابع، ما setTab('projects')
خود را فراخوانی می کنیم و از آن نقطه به بعد، بروزرسانی state در "پس زمینه" بدون مسدود کردن صفحه محاسبه می شود. بهعلاوه، میتوانیم از boolean isPending
برای اضافه کردن یک state بارگیری استفاده کنیم، در حالی که منتظر اتمام آن بهروزرسانی هستیم. برای نشان دادن اینکه چیزی در حال رخ دادن به کاربر است.
فقط سه کد به کد های قبلی اضافه می کنیم، به همین سادگی:
export default function App() {
const [tab, setTab] = useState('issues');
// add the useTransition hook
const [isPending, startTransition] = useTransition();
return (
<div className="container">
<div className="tabs">
...
<TabButton
// indicate that the content is loading
isLoading={isPending}
onClick={() => {
// call setTab inside a function
// that is passed to startTransition
startTransition(() => {
setTab('projects');
});
}}
name="Projects"
/>
...
</div>
...
</div>
);
}
وقتی روی دکمه تب«پروژهها» کلیک میکنم، نشانگر بارگذاری نمایش داده میشود و اگر روی «گزارشها» کلیک کنم، بلافاصله به آنجا هدایت میشوم.
قسمت تاریک useTransition و رندرهای مجدد
خب، حالا که پیمایش به صفحه پروژه ها و برگشت درست شد، اجازه دهید کمی جلوتر برویم. در زندگی واقعی، هر یک از این تب ها به طور بالقوه می تواند سنگین باشد. به خصوص صفحه گزارش ها. من تصور می کنم که در آنجا یک دسته نمودار بسیار سنگین خواهد داشت. چرا این مورد را از قبل پیش بینی نمی کنید، انتقال بین همه این برگه ها را به عنوان غیر بحرانی علامت گذاری نمی کنید، و state داخل startTransition
را برای همه آنها به روز نمی کنید؟
تنها کاری که باید انجام دهم این است که این transitions را به یک تابع آبسترک در بیاریم:
const onTabClick = (tab) => {
startTransition(() => {
setTab(tab);
});
};
و سپس به جای تنظیم مستقیم state، از این عملکرد روی همه دکمه ها استفاده کنید:
<div className="tabs">
<TabButton
onClick={() => onTabClick('issues')}
name="Issues"
/>
<TabButton
onClick={() => onTabClick('projects')}
name="Projects"
/>
<TabButton
onClick={() => onTabClick('reports')}
name="Reports"
/>
</div>
تمام بهروزرسانیهای stateی که از این دکمهها نشأت میگیرند اکنون بهعنوان «غیر بحرانی» علامتگذاری میشوند و اگر هر دو صفحه گزارشها و پروژهها سنگین باشند، رندر آنها رابط کاربری را مسدود نمیکند.
اگر با مثال زنده بازی کنید، خواهیم دید ک فقط صفحه رو بدتر کردیم.
اگر به صفحه پروژهها بروید و سپس سعی کنم از آن به سمت مسائل یا گزارشها حرکت کنم، دیگر فوراً اتفاق نمیافتد! من هیچ چیزی را در آن صفحات تغییر نداده ام، هر دو در حال حاضر فقط یک رشته را ارائه می دهند، اما هر دوی آنها طوری رفتار می کنند که انگار سنگین هستند. چه خبر است؟
مشکل اینجاست که اگر بهروزرسانی state را در یک Transition قرار دهم، React فقط بروزرسانی state را در «پسزمینه» راهاندازی نمیکند. این در واقع یک فرآیند دو مرحله ای است. ابتدا، یک رندر فوری «بحرانی» با state قدیمی فعال میشود، و Boolean isPending
است که از هوک useTransition
استخراج میکنیم از false
به true
حرکت میکند. این واقعیت که من می توانم از آن در خروجی رندر استفاده کنم باید یک سرنخ بزرگ باشد. تنها پس از اتمام آن رندر «سنتی» حیاتی، React با بهروزرسانی state غیر بحرانی شروع میشود.
به طور خلاصه، useTransition
باعث می شود به جای یک بار، دو رندر مجدد انجام شود. در نتیجه، رفتار را مانند مثال بالا می بینیم. اگر در صفحه پروژهها باشم و روی برگه Issues کلیک کنم، ابتدا رندر اولیه با state تب هنوز "پروژهها" فعال میشود. کامپوننت بسیار سنگین Projects در حالی که دوباره رندر می شود، وظیفه اصلی را به مدت 1 ثانیه مسدود می کند. تنها پس از انجام این کار، بروزرسانی state غیر بحرانی از "پروژه ها" به "مسائل" برنامه ریزی و اجرا می شود.
چگونه از useTransition استفاده کنیم؟
استفاده از useMemo
با حفظ کردن همه چیز می توانیم! چطور؟
برای رفع افت عملکرد از موارد فوق، باید مطمئن شویم که اولین رندر اضافی تا حد امکان سبک باشد. به طور معمول، به این معنی است که ما باید هر چیزی را که می تواند سرعت آن را کاهش دهد، به خاطر بسپاریم:
همه کامپوننت های سنگین باید در React.memo
قرار بگیرند و کامپوننت آنها با useMemo
و useCallback
به خاطر بسپارند.
تمام عملیات های سنگین با useMemo
به حافظه سپرده می شوند.isPending
به عنوان یک پایه یا وابستگی به موارد فوق منتقل نمی شود.
در مورد ما، فقط با بسته بندی کامپوننت صفحه ما باید این کار را انجام دهیم:
const IssuesMemo = React.memo(Issues);
const ProjectsMemo = React.memo(Projects);
const ReportsMemo = React.memo(Reports);
آنها هیچ ابزاری ندارند، بنابراین ما می توانیم آنها را ارائه دهیم:
<div className="content">
{tab === 'issues' && <IssuesMemo />}
{tab === 'projects' && <ProjectsMemo />}
{tab === 'reports' && <ReportsMemo />}
</div>
و مشکل برطرف شده است، رندر مجدد صفحه سنگین پروژهها دیگر کلیک روی برگهها را مسدود نمیکند.
اما این فقط نشان میدهد که useTransition
قطعاً ابزاری برای استفاده روزمره نیست: یک اشتباه کوچک در حافظهسازی، و شما برنامهتان را بدتر از قبل از useTransition
میکنید. و انجام درست یادداشت در واقع بسیار سخت است. به عنوان مثال، آیا میتوانید از ذهن خود بگویید که اگر برنامه به دلیل transitions اولیه دوباره رندر شود، آیا IssuesMemo
در اینجا دوباره رندر میشود؟
const ListMemo = React.memo(List);
const IssuesMemo = React.memo(Issues);
const App = () => {
// if startTransition is triggered, will IssuesMemo re-render?
const [isPending, startTransition] = useTransition();
return (
...
<IssuesMemo>
<ListMemo />
</IssuesMemo>
)
}
پاسخ در اینجا این است، بله، خواهد شد. دوباره رندر تمام راه! مسائل به درستی حفظ نمی شوند.
انتقال از هیچ به سنگین
راه دیگر برای اطمینان از اینکه این رندر اولیه اضافی تا حد امکان سبک است، استفاده از useTransition
فقط در هنگام انتقال از "هیچ چیز" به "موارد بسیار سنگین" است. یک مثال معمولی از آن ممکن است واکشی داده ها و قرار دادن آن داده ها در state
بعد باشد. یعنی این:
const App = () => {
const [data, setData] = useState();
useEffect(() => {
fetch('/some-url').then((result) => {
// lots of data
setData(result);
})
}, [])
if (!data) return 'loading'
return ... // render that lots of data when available
}
در این state، اگر داده ای وجود نداشته باشد، فقط یک state بارگذاری را برمی گردانیم که بعید است سنگین باشد. بنابراین اگر setData
را در startTransition
قرار دهیم، رندر اولیه ناشی از این بد نخواهد بود: آن را با state خالی و نشانگر بارگذاری مجددا رندر میکند.
در مورد useDeferredValue
چطور؟
یک هوک دیگر وجود دارد که به ما امکان می دهد از قدرت رندر همزمان استفاده کنیم: useDeferredValue
می باشد. این به طور مشابه با useTransition
کار می کند، به ما اجازه می دهد برخی از بروزرسانی ها را به عنوان غیر بحرانی علامت گذاری کنیم و آنها را به "پس زمینه" منتقل کنیم. معمولاً برای استفاده زمانی که به عملکرد بهروزرسانی state دسترسی ندارید، توصیه میشود. به عنوان مثال، وقتی مقدار از props می آید.
در مورد ما، اگر کامپوننتهای محتوای برگههای خود را در کامپوننت خودشان استخراج کنیم و برگه فعال را به عنوان پراپس ارسال کنیم، از آن استفاده میکنیم:
const TabContent = ({ tab }) => {
// mark the "tab" value as non-critical
const tabDeffered = useDeferredValue(tab);
return (
<>
{tabDeffered === 'issues' && <Issues />}
{tabDeffered === 'projects' && <Projects />}
{tabDeffered === 'reports' && <Reports />}
</>
);
};
با این حال، مشکل رندر دوگانه در اینجا نیز وجود دارد. آن را در سیستم خود بررسی کنید.
بنابراین راه حل در اینجا دقیقاً مانند useTransition
خواهد بود. فقط در موارد زیر بهروزرسانیها را بهعنوان غیر مهم علامتگذاری کنید:
1.هر چیزی که تحت تأثیر قرار می گیرد به حافظه سپرده می شود.
2.یا اگر در حال گذار از "هیچ" به "سنگین" هستیم و هرگز در جهت مخالف نیستیم.
آیا می توانم از useTransition
برای بازگرداندن استفاده کنم؟
مورد دیگری که گاهی اوقات برای useTransition
اینجا و آنجا نشان داده میشود، انحراف است. وقتی چیزی را سریع در یک فیلد ورودی تایپ میکنیم، نمیخواهیم با هر بار زدن کلید، درخواستهایی را به بک اند ارسال کنیم، ممکن است سرور ما از کار بیفتد. در عوض می خواهیم کمی تاخیر را معرفی کنیم تا فقط متن کامل ارسال شود.
به طور معمول، ما این کار را با چیزی مانند تابع debounce
از lodash
(یا معادل آن) انجام می دهیم:
function App() {
const [valueDebounced, setValueDebounced] = useState('');
const onChangeDebounced = debounce((e) => {
setValueDebounced(e.target.value);
}, 300);
useEffect(() => {
console.log('Value debounced: ', valueDebounced);
}, [valueDebounced]);
return (
<>
<input type="text" onChange={onChangeDebounced} />
</>
);
}
پاسخ فراخوانی onChange
در اینجا بازگردانده میشود، بنابراین setValueDebounced
تنها 300 میلیثانیه پس از توقف تایپ در فیلد ورودی فعال میشود.
اگر به جای کتابخانه خارجی از useTransition
استفاده کنم چه می شود؟ به اندازه کافی معقول به نظر می رسد: تنظیم state در داخل انتقال ها بنا به تعریف قابل وقفه است، این تمام هدف است. آیا چیزی شبیه به این کار خواهد کرد؟
function App() {
const [value, setValue] = useState('');
const [isPending, startTransition] = useTransition();
const onChange = (e) => {
startTransition(() => {
setValue(e.target.value);
});
};
useEffect(() => {
console.log('Value: ', value);
}, [value]);
return (
<>
<input type="text" onChange={onChange} />
</>
);
}
پاسخ این است: نه، نمی شود. یا، به طور دقیق، در این مثال اثر انحرافی رخ نخواهد داد. React خیلی سریع است، میتواند مقدار «پسزمینه» را بین فشار دادن کلید محاسبه را تعیین کند. در این مثال هر تغییر مقدار در خروجی کنسول را مشاهده خواهیم کرد.
نتیجه
امیدواریم اکنون کمی واضح تر شده باشد که رندر همزمان چیست، هوک های مربوط به آن چیست و چگونه از آنها استفاده کنیم.
اگر میخواهید فقط یک مورد از مقاله را به خاطر بسپارید، آن این است: هوکهای رندر همزمان باعث رندرهای مضاعف میشوند. بنابراین، هرگز از آنها برای تمام بروزرسانی های state استفاده نکنید. مورد استفاده آنها بسیار خاص است و نیاز به درک بسیار عمیقی از چرخه عمر React، رندرهای مجدد و حافظه گذاری دارد.
در مورد من، احتمالاً به این زودی از useTransition
یا useDeferredValue
استفاده نخواهم کرد. هنگامی که در مورد عملکرد صحبت می کنیم، چیزهای زیادی وجود دارد که باید به خاطر داشته باشید تا آن را به درستی انجام دهید. هزینه اشتباهات بسیار زیاد است: آخرین چیزی که به آن نیاز دارم این است که به طور تصادفی عملکرد را بدتر کنم. و برای انحراف، خیلی غیرقابل پیش بینی است.