گلوگاه DOM که هیچ‌کس درباره‌اش صحبت نمی‌کند

یک داشبورد پشتیبانی را تصور کنید که ده هزار ورودی لاگ را فراخوانی می‌کند. یا یک سیستم CRM که سعی دارد تمام مخاطبان را در یک جدول اسکرول‌شونده نمایش دهد. در React، کدِ ساخت این بخش به اندازه کافی بی‌خطر به نظر می‌رسد. شما روی یک آرایه map می‌زنید، مقداری JSX برمی‌گردانید و اجازه می‌دهید فریم‌ورک کارش را انجام دهد. در محیط توسعه با صد ردیف، همه چیز به خوبی کار می‌کند. اما وقتی داده‌های محیط عملیاتی (production) وارد می‌شوند، صفحه به شدت سنگین و کند می‌شود.

مرورگر تنبل نیست. دقیقاً همان کاری را انجام می‌دهد که از آن خواسته‌اید و مشکل همین‌جاست. هر ردیف به یک گره (node) در DOM تبدیل می‌شود. هر گره استایل‌دهی، چیدمان (layout)، رنگ‌آمیزی (paint) و ردیابی در حافظه را تجربه می‌کند. وقتی اسکرول می‌کنید، مرورگر موقعیت کل درخت را دوباره محاسبه می‌کند، نه فقط آن بخشی را که می‌بینید. شنونده‌های رویداد (event listeners) روی هم انباشته می‌شوند. مصرف حافظه به شدت بالا می‌رود. در نهایت، رشته اصلی (main thread) چنان تحت فشار قرار می‌گیرد که رابط کاربری دیگر به کلیک‌ها، فشردن کلیدها یا حتی خودِ اسکرول پاسخ نمی‌دهد. اپلیکیشن از نظر فنی کرش نکرده است، اما برای کاربری که مقابل آن نشسته، تجربه کاربری کاملاً از کار افتاده است.

این اتفاق به این دلیل می‌افتد که مرورگر سعی می‌کند تمام عناصر را به طور همزمان در حافظه فعال نگه دارد. ممکن است React در ایجاد توصیفات مجازی از رابط کاربری (UI) شما کارآمد باشد، اما زمانی که آن توصیفات به گره‌های واقعی در سند تبدیل می‌شوند، هزینه‌شان دقیقاً مانند HTML نوشته شده با دست است. هیچ راه فراری در خودِ فریم‌ورک وجود ندارد. شما به یک تغییر ساختاری در نحوه ارسال لیست به DOM نیاز دارید.

معنای واقعی مجازی‌سازی (Virtualization) چیست

مجازی‌سازی همان تغییر ساختاری است. به جای اینکه از React بخواهید کل آرایه را رندر کند، فقط مواردی را رندر می‌کنید که در محدوده دید (viewport) جا می‌شوند، به اضافه‌ی یک مقدار اندک (buffer) در بالا و پایین. با اسکرول کردن کاربر، اپلیکیشن گره‌هایی را که از دید خارج می‌شوند حذف کرده و گره‌های جدیدی را که از لبه‌ی مقابل وارد می‌شوند، ایجاد می‌کند. برای کاربر، همچنان مانند یک لیست پیوسته به نظر می‌رسد، زیرا ارتفاع کل قابل اسکرول، معمولاً از طریق یک عنصر نگهدارنده بلند یا یک فاصله‌انداز (spacer) دقیقاً محاسبه‌شده، حفظ می‌شود. آیتم‌های قابل مشاهده صرفاً پنجره‌ای هستند که روی مجموعه داده‌ها می‌لغزد.

آن را مانند یک نوار فیلم تصور کنید که از دریچه پروژکتور عبور می‌کند. تماشاگر حرکت روان را می‌بیند، اما دستگاه فقط فریم‌ای را روشن می‌کند که در حال حاضر در موقعیت قرار دارد. باقی نوار روی قرقره‌های ورودی و خروجی قرار دارد، نه در مسیر نور. لیست‌های مجازی‌شده نیز به همین صورت کار می‌کنند. مجموعه داده همان نوار فیلم است و viewport همان دریچه پروژکتور.

این به معنای سنتیِ بارگذاری تنبل (lazy loading) نیست. بارگذاری تنبل، واکشی داده‌ها را تا زمانی که کاربر به آن‌ها نزدیک شود، به تعویق می‌اندازد. مجازی‌سازی فرض را بر این می‌گذارد که شما از قبل داده‌ها را دارید، اما در مورد اینکه کدام بخش‌ها به عناصر واقعی DOM تبدیل شوند، گزینشی عمل می‌کنید. این دو تکنیک می‌توانند در کنار هم کار کنند، اما مشکلات متفاوتی را حل می‌کنند.

چرا تفاوت آن بلافاصله احساس می‌شود

مزایا در چهار بخش ظاهر می‌شوند که همگی به یک عامل اصلی مربوط هستند: شما دیگر هزینه‌ی چیزی را نمی‌پردازید که کاربر نمی‌تواند ببیند.

زمان بارگذاری اولیه سریع‌تر. وقتی مرورگر صفحه را باز می‌کند، شاید به جای پانزده هزار ردیف، فقط پانزده ردیف را رنگ‌آمیزی (paint) می‌کند. اولین رنگ‌آمیزی معنادار (first meaningful paint) زودتر اتفاق می‌افتد. زمان تعامل‌پذیری (time-to-interactive) کاهش می‌یابد، زیرا موتور JavaScript زمان کمتری را صرف ایجاد گره‌ها و اتصال آن‌ها به سند می‌کند.

مصرف حافظه کمتر. یک گره DOM شیء سنگینی است. هر گره شامل ارجاعاتی به قوانین استایل، معیارهای چیدمان (layout metrics) و اتصال‌های رویداد (event bindings) است. اگر تعداد گره‌های فعال را به چند ده عدد کاهش دهید، میزان اشغال حافظه (memory footprint) به شدت کم می‌شود. در دستگاه‌های ضعیف یا نشست‌های (sessions) طولانی، همین موضوع به تنهایی می‌تواند از بسته شدن تب توسط سیستم‌عامل جلوگیری کند.

عملکرد اسکرول روان. با گره‌های کمتر در درخت، مرورگر در طول رویدادهای اسکرول، زمان کمتری را در مراحل چیدمان و رنگ‌آمیزی صرف می‌کند. رشته‌ی ترکیب‌کننده (compositor thread) می‌تواند حرکت را بدون محاسبه مداوم هندسه‌ی محتوای پنهان مدیریت کند. نتیجه، اسکرولی است که به نرخ نوسازی (refresh rate) مانیتور نزدیک‌تر می‌ماند.

نرخ فریم پایدار. از آنجایی که رشته اصلی دیگر در کارهای مربوط به چیدمان غرق نیست، فضای کافی برای فعالیت‌های دیگر وجود دارد. انیمیشن‌ها روان می‌مانند. پاسخ‌های شبکه می‌توانند پردازش شوند. وقتی داده‌های جدید می‌رسند، رابط کاربری منجمد نمی‌شود، زیرا مسیر رندر دیگر یک گلوگاه نیست.

اجرای صحیح

در اکوسیستم React، کتابخانه‌هایی مانند react-window و کتابخانه‌ی سنگین‌تر react-virtualized ابزارهای لازم برای این الگو را فراهم می‌کنند. ایده اصلی ثابت است: شما یک رندرکننده‌ی آیتم تعریف می‌کنید، تعداد کل آیتم‌ها را می‌فرستید و کتابخانه محاسبات مربوط به پنجره‌بندی (windowing) را مدیریت می‌کند. اما جزئیات باعث می‌شود افراد دچار اشتباه شوند.

اول، کانتینر باید ارتفاع مشخصی داشته باشد. اگر لیست درون والد (parent) قرار بگیرد که با بزرگ شدن فرزندانش منبسط می‌شود، مجازی‌سازی (virtualization) نمی‌تواند محاسبه کند که کدام آیتم‌ها قابل مشاهده هستند، زیرا مرز ویوپورت (viewport) وجود ندارد. شما باید لیست را در یک ارتفاع ثابت یا یک کانتینر flex با محدودیت‌های مشخص قفل کنید.

دوم، اندازه آیتم‌ها اهمیت بسیار زیادی دارد. ردیف‌های با ارتفاع ثابت ساده‌ترین حالت هستند. کتابخانه ارتفاع ردیف را در ایندکس ضرب می‌کند و دقیقاً می‌داند هر عنصر را در کجا قرار دهد. محتوای با ارتفاع متغیر، مانند پیام‌های چت با تصاویر گنجانده شده یا رشته‌های کامنت، کتابخانه را مجبور می‌کند تا پس از mount اندازه‌گیری انجام دهد و در لحظه تنظیمات را اعمال کند. این مرحله‌ی اندازه‌گیری اگر خیلی دیر اتفاق بیفتد، می‌تواند باعث لرزش اسکرول (scroll jitter) شود. اگر داده‌های شما اجازه می‌دهند، ارتفاع‌های یکسان یا حداقل ارتفاع را اعمال کنید. در غیر این صورت، از یک virtualizer با ارتفاع متغیر استفاده کنید و پیچیدگی اضافی را بپذیرید.

سوم، overscanning دوست شماست. رندر کردن دقیقاً آنچه در صفحه جا می‌گیرد، هنگام اسکرول سریع کاربر، نوارهای سفید خالی ایجاد می‌کند. اکثر کتابخانه‌ها به شما اجازه می‌دهند چند آیتم اضافی در بالا و پایین ناحیه قابل مشاهده رندر کنید. دو یا سه ردیف overscan معمولاً برای پنهان کردن درزها بدون سنگین کردن دوباره DOM کافی است.

چهارم، ویژگی key را نادیده نگیرید. در یک لیست مجازی‌شده، با اسکرول کردن، آیتم‌ها از گره‌های (nodes) DOM دوباره استفاده می‌کنند. کلیدهای پایدار (stable keys) از حدس‌های اشتباه React در طول فرآیند reconciliation و از نابود شدن وضعیت (state) در داخل کامپوننت‌های ردیف جلوگیری می‌کنند. اگر ردیف‌های لیست شما شامل inputها، toggleها یا بخش‌های بازشونده باشند، کلیدهای نامناسب وضعیت UI را به گونه‌ای خراب می‌کنند که شبیه باگ در لایه داده به نظر می‌رسد، اما در واقع اشتباهات رندرینگ هستند.

یک تله ظریف، قابلیت جستجوی مرورگر در صفحه (find-in-page) است. از آنجایی که آیتم‌های پنهان در DOM وجود ندارند، کادر جستجوی مرورگر آن‌ها را نخواهد دید. اگر کاربران شما برای یافتن متن در یک لیست بزرگ به Ctrl+F متکی هستند، باید یک جستجوی سفارشی بسازید که روی مجموعه داده (dataset) عمل کند، نه روی سند (document). صفحه‌خوان‌ها (screen readers) نیز اگر معناشناسی (semantics) لیست با دقت مدیریت نشود، ممکن است بافت (context) را از دست بدهند؛ بنابراین با فناوری‌های کمکی تست کنید و اضافه کردن اعلان‌های live region را برای بارگذاری پویا در نظر بگیرید.

چه زمانی باید از آن صرف‌نظر کنید

مجازی‌سازی رایگان نیست. این کار باعث اضافه شدن وزن وابستگی‌ها، محاسبات مختصات و سربار محدودیت‌ها می‌شود. اگر لیست شما در حدود پنجاه یا صد آیتم محدود می‌شود، مرورگر می‌تواند بدون کمک از پس آن بربیاید. کل آن را رندر کنید و از آن بگذرید. همین موضوع در مورد آیتم‌هایی که به تنهایی بسیار پیچیده هستند نیز صدق می‌کند. مجازی‌سازی شما را از هزاران گره نجات می‌دهد، اما نمی‌تواند شما را از یک گره که حاوی یک نمودار عظیم یا عنصر ویدیو است نجات دهد. ابتدا مشکل سنگینی (bloat) آیتم‌ها را حل کنید.

همچنین زمانی که لیست اسکرول نمی‌شود، از مجازی‌سازی خودداری کنید. اگر با دکمه‌های بعدی و قبلی صفحه‌بندی (pagination) می‌کنید و در هر صفحه فقط بیست آیتم را نشان می‌دهید، چیزی برای پنجره‌بندی (windowing) وجود ندارد. این تکنیک تنها زمانی ارزش خود را نشان می‌دهد که کاربر انتظار دارد در یک توالی پیوسته و بزرگ اسکرول کند.

نتیجه‌گیری اصلی

مجازی‌سازی کمتر یک انتخاب کتابخانه و بیشتر یک طرز فکر است. این کار شما را مجبور می‌کند بپذیرید که DOM یک منبع محدود است، نه یک بوم بی‌نهایت. قبل از اضافه کردن آن، Chrome DevTools را باز کنید، یک پروفایل عملکرد (performance profile) ضبط کنید و تأیید کنید که آیا زمان layout یا paint واقعاً مقصر است یا خیر. وقتی دانستید که DOM گلوگاه (bottleneck) است، به محدودیت‌ها پایبند باشید. ارتفاع‌ها را ثابت نگه دارید، مراقب کلیدها باشید، overscan را در حد معقول انجام دهید و دسترسی‌پذیری (accessibility) خود را تست کنید. اگر درست انجام شود، یک لیست مجازی‌شده، یک دیوار داده‌ی غیرقابل استفاده را به چیزی تبدیل می‌کند که به اندازه یک نمای اسکرول بومی (native scroll view) سبک به نظر می‌رسد. مرورگر دیگر تقلا نمی‌کند، کاربران شما دیگر منتظر نمی‌مانند و اپلیکیشن بالاخره مانند رابط کاربری سریعی که قصد ساختنش را داشتید، رفتار می‌کند.