در تیم فرانتاند کیوکامرس دیجیکالا، روی یکی از صفحههای پربازدید یکی از پروداکتهای مهممان به مشکلی خوردیم که در نگاه اول ساده به نظر میرسید. این صفحه از لیستی از ویجتها ساخته شده؛ اسکلت لیست همان لحظهی اول نمایش داده میشود، ولی دادهی هر ویجت جدا و با تأخیر میرسد؛ یکی زودتر، یکی دیرتر. راهحل اول همان چیزی بود که به ذهن هر کسی میرسد: یک state واحد (یا یک Context) که دادهی همهی ویجتها را نگه دارد و با رسیدن دادهی هرکدام آپدیت شود.
مشکل از همانجا شروع شد: با هر بار رسیدن دادهی یک ویجت، کل لیست دوباره رندر میشد؛ نه فقط همان ویجتی که دادهاش تغییر کرده بود. روی یک صفحهی کمترافیک با چند کارت ساده شاید این اصلاً به چشم نیاید، ولی در صفحهای که هر روز تعداد زیادی کاربر بازش میکنند و ویجتهایش هم سبک نیستند، هر رندر اضافه مستقیم روی تجربهی کاربر اثر میگذارد. همین شد که سراغ useSyncExternalStore رفتیم.
چرا این اتفاق میافتد
اگر دادهی همهی ویجتها را در یک Context نگه دارید، مشکل از جنس خودِ Context است، نه بیدقتی در نوشتن کد. مستندات ریاکت هم دقیقاً همین را میگوید: هر کامپوننتی که از یک Context مصرف میکند (useContext)، با هر تغییر مقدار آن Context دوباره رندر میشود؛ حتی اگر آن کامپوننت را داخل React.memo هم بپیچید. memo فقط propها را مقایسه میکند و useContext از این مقایسه عبور میکند. یعنی راهحل «فقط با memo درستش کن» اینجا جواب نمیدهد.
اگر هم بهجای Context از یک state واحد در کامپوننت والد استفاده کنید و آن را به بچهها پاس بدهید، باز هم مشکل حل نمیشود؛ چون هر آپدیت یک آبجکت تازه برای آن state میسازد و والد را دوباره رندر میکند، و تا وقتی بچهها memo نشدهاند، رندر والد یعنی رندر همهی بچهها.
useSyncExternalStore چه فرقی میکند
useSyncExternalStore هوکی است که در React 18 اضافه شد تا یک کامپوننت بتواند مستقیم به یک منبع دادهی بیرون از ریاکت subscribe شود؛ بیرون از state معمولی ریاکت، ولی همچنان با رندرینگ concurrent سازگار (بدون مشکل tearing). امضایش اینطور است:
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
- subscribe تابعی است که یک listener میگیرد و باید یک تابع cleanup برگرداند؛ هر وقت دادهی store تغییر کرد، باید همهی listenerها را صدا بزنید.
- getSnapshot مقدار فعلی را برمیگرداند. ریاکت این تابع را در هر رندر صدا میزند و نتیجه را با Object.is با مقدار قبلی مقایسه میکند؛ اگر یکسان بود، همان کامپوننت دوباره رندر نمیشود.
- getServerSnapshot برای SSR لازم است (این سایت هم با react-router روی سرور رندر میشود)، وگرنه هنگام hydrate با خطا مواجه میشوید.
نکتهی اصلی همین مقایسهی getSnapshot است. اگر store را طوری بسازید که هر ویجت مقدار خودش را جدا نگه دارد، وقتی دادهی ویجت A میرسد، فقط snapshot همان ویجت تغییر میکند. بقیهی کامپوننتها هم notify میشوند، اما چون getSnapshot خودشان همان مقدار قبلی را برمیگرداند، ریاکت رندرشان نمیکند.
یک مثال کوچک
کد اصلی پروداکت را نمیتوانم اینجا بیاورم، برای همین نسخهای سادهشده از همان الگو را برای این نوشته ساختم: یک store با Map برای نگهداشتن مقدار هر ویجت، بههمراه یک Set از listenerها.
class WidgetStore {
private values = new Map<string, number | null>();
private listeners = new Set<() => void>();
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (id: string) => this.values.get(id) ?? null;
set(id: string, value: number) {
this.values.set(id, value);
this.listeners.forEach((listener) => listener());
}
}
const store = new WidgetStore();
و یک کامپوننت ویجت که فقط به سهم خودش گوش میدهد:
const Widget = memo(function Widget({ id }: { id: string }) {
const getSnapshot = useCallback(() => store.getSnapshot(id), [id]);
const value = useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
return value === null ? <Skeleton /> : <span>{value}</span>;
});
هر وقت دادهی ویجتی رسید، کافی است store.set(id, value) صدا زده شود؛ خودِ ریاکت مشخص میکند کدام Widget باید رندر شود.
نکتهای که موقع ساختنش گیرم انداخت
موقع ساختن همین نسخهی سادهشده یک اشتباه واقعی کردم که ارزش گفتن دارد. نسخهی اول دمو را نوشتم، رندرها را شمردم، و عدد کل رندرها برای هر دو روش، هم Context و هم store، دقیقاً یکسان از آب درآمد: ۱۰۸ در برابر ۱۰۸. useSyncExternalStore هیچ فرقی نداشت.
دلیلش را وقتی فهمیدم که کامپوننت Widget را دوباره نگاه کردم: هیچجا آن را با React.memo نپیچیده بودم. useSyncExternalStore فقط جلوی رندری را میگیرد که subscribe خودش قرار بود ایجاد کند؛ اما وقتی کامپوننت والد (بهخاطر همان state مربوط به نسخهی Context) دوباره رندر میشود، ریاکت بهطور پیشفرض همهی بچهها را هم دوباره صدا میزند، مگر اینکه memo شده باشند. بدون memo، هر دو نسخه دقیقاً یک رفتار داشتند.
با اضافهکردن memo (همانطور که در کد بالا هست)، عدد رندرهای ستون store فوراً از ۱۰۸ به ۲۴ افت کرد؛ عدد ستون Context همچنان ۱۰۸ ماند، چون مشکلش هیچوقت آنجا نبود. یعنی نزدیک به ۴.۵ برابر تفاوت، فقط با اضافهکردن یک memo که راحت میشود فراموشش کرد.
یک نکتهی جانبی هم بگویم که موقع خواندن اعداد به کارتان میآید: چون این دمو زیر React.StrictMode اجرا میشود، در حالت توسعه هر رندر دوبار حساب میشود (خودِ ریاکت اینطور طراحی شده تا رندرهای ناخالص را زودتر لو بدهد). برای همین اعدادی که میبینید تقریباً دو برابر مقدار واقعیاند؛ نسبت بین دو ستون همان چیزی است که اهمیت دارد، نه عدد مطلق.
کِی واقعاً بهدردتان میخورد
برای یک لیست کوچک با چند آپدیت، فرقش را چشمی هم بهسختی میبینید. جایی این الگو واقعاً به کار میآید که تعداد ویجتها زیاد باشد یا دادهشان مدام (مثلاً از یک WebSocket) بهروز شود؛ آنجا هر رندر اضافه روی کارتی که تغییری نکرده، هزینهی واقعی دارد. در مورد ما هم دقیقاً همین ترکیب بود: ترافیک بالا، و ویجتهایی که هرکدام جدا و در زمان متفاوتی داده میگرفتند. اگر لیستتان ثابت است و فقط یکبار داده میگیرد، همان useState معمولی کافی است؛ نیازی به این پیچیدگی نیست.
اگر کنجکاوید زیر این هوک دقیقاً چه میگذرد، در ساختن هوکی شبیه useSyncExternalStore از صفر قدمبهقدم یک نسخهی مشابه را با useState و useEffect نوشتهام.
دموی زنده
برای اینکه فرقش دیده شود، دو نسخه را کنار هم گذاشتم: یکی با Context، یکی با useSyncExternalStore، هر دو با همان تأخیر و همان داده. عدد گوشهی هر کارت شمارندهی رندر همان کارت است. صفحه را باز کنید و ببینید موقع رسیدن دادهی یک ویجت، در ستون Context همهی اعداد بالا میروند، ولی در ستون useSyncExternalStore فقط یکی.
میتوانید این دو روش را اینجا امتحان کنید: صفحهی آزمایش useSyncExternalStore.