یک گزارش باگ دریافت شد که تمام غریزههای عیبیابی را به چالش میکشید. کاربران گوشیهای اندرویدی ضعیف میگفتند اپلیکیشن به سادگی ناپدید میشود. نه در هنگام اجرا، نه در حین یک ضربه یا کشیدن خاص. تقریباً بیست دقیقه پس از شروع یک نشست (session)، صفحه فریز میشد و فرآیند متوقف میشد. لاگها کاملاً پاک بودند. تیم QA نمیتوانست آن را روی سختافزارهای قدرتمند خود بازتولید کند. هیچ مرحلهای برای دنبال کردن وجود نداشت. پس از سه ساعت تحلیل حافظه (memory profiling)، بالاخره تصویر روشن شد. یک شنونده رویداد (event listener) واحد درون یک React hook قرار داشت. آن شنونده متغیرهای یک مجموعه داده بزرگ را در خود محصور کرده بود (closed over). کامپوننت از حالت فعال خارج شد (unmounted)، اما شنونده باقی ماند. مجموعه داده نیز در حافظه ماند. در دستگاهی با ۲ گیگابایت رم، این انباشت باعث پر شدن هیپ (heap) میشد و سیستمعامل اپلیکیشن را میکشت. این یک خطای سینتکسی یا نقص منطقی نبود؛ یک باگ مربوط به محدوده (scope bug) بود و مرگبار بود.
چگونه یک Closure به نشت حافظه تبدیل میشود
بیشتر آموزشها، scope را به عنوان یک معمای آکادمیک درباره اینکه یک متغیر کجا قابل مشاهده است، تدریس میکنند. در محیط عملیاتی (production)، scope قراردادی درباره طول عمر حافظه است. وقتی یک تابع JavaScript یک متغیر را در خود محصور میکند (closes over)، موتور جاوااسکریپت تا زمانی که خودِ closure قابل دسترسی باشد، آن متغیر را زنده نگه میدارد. در یک کامپوننت React، این بدان معناست که دادههای شما مدتها پس از اینکه کاربر از آن صفحه خارج شد و گره UI از بین رفت، همچنان زنده میمانند.
یک hook را در نظر بگیرید که یک شنونده را روی شیء window ثبت میکند. کامپوننت رندر میشود، شنونده را متصل میکند و بعداً unmount میشود. اگر مرحله پاکسازی (cleanup) وجود نداشته باشد یا ناقص انجام شود، شنونده باقی میماند. هر بار که کامپوننت دوباره mount میشود، یک کپی شبحوار دیگر از دادههای محصور شده به RAM اضافه میشود. در یک ایستگاه کاری توسعهدهنده با حافظه فراوان، ممکن است هرگز متوجه این تورم نشوید. اما در یک گوشی ارزانقیمت که Android Go را اجرا میکند، بیست دقیقه استفاده معمولی برای پر شدن هیپ (heap) موجود کافی است. سیستمعامل وارد عمل شده و فرآیند را متوقف میکند. هیچ استثنایی (exception) برای ثبت در لاگ وجود ندارد؛ سیستم به سادگی دوشاخه را میکشد.
به همین دلیل است که scope همان مدیریت حافظه است. محیط لغوی (lexical environment) یک مرز فلسفی نیست، بلکه یک گراف نگهداری (retention graph) است. هر متغیری که درون یک closure جمعآورینشده رها کنید، آجری است در دیواری که در نهایت اپلیکیشن شما را محصور میکند.
سه روشی که Scope باعث از کار افتادن اپلیکیشنهای عملیاتی میشود
مشکلات scope همگی شبیه هم نیستند. برخی حافظه را به آرامی تخلیه میکنند و برخی دیگر بلافاصله منفجر میشوند. در اینجا الگوهایی را میآوریم که به طور قابل اطمینانی اپلیکیشنها را از کار میاندازند.
آلودگی محدوده سراسری (Global Scope Pollution)
معماریهای Micro-frontend به تیمها اجازه میدهند به طور مستقل محصول عرضه کنند، اما همه آنها از شیء window مشترک استفاده میکنند. وقتی یک اپلیکیشن متغیر سراسری مانند window.config را تنظیم میکند یا یک ابزار مشترک را روی window اعمال میکند، در انزوا زندگی نمیکند. اپلیکیشن تیم دیگر ممکن است به ساختار متفاوتی برای همان متغیر سراسری وابسته باشد، یا در طول فرآیند بوتاسترپ خود آن را بازنویسی کند. نتیجه، یک تداخل ویژگی (feature collision) است که با بزرگ شدن سازمان شما، گسترش مییابد. توسعهدهندهای در یک مخزن (repository) نمیداند که میانبر او، یک تغییر مخرب (breaking change) برای تیم دیگر است. با افزایش سطح تماس، این متغیرهای سراسری به مینهای ناسمی تبدیل میشوند که در خاک مشترک دفن شدهاند.
نشت حافظه ناشی از Closure
اپلیکیشنهای تکصفحهای (SPA) برای ساعتها اجرا شدن ساخته شدهاند. دقیقاً به همین دلیل است که نشتهای ناشی از closure سمی میشوند. این الگو به طرز فریبندهای رایج است: یک useEffect یک callback را در یک event bus سراسری، یک هندلر WebSocket یا خودِ DOM ثبت میکند. اگر آرایه وابستگی (dependency array) ناپایدار باشد یا حذف شده باشد، مرحله پاکسازی هرگز با اشتراک اصلی مطابقت نمییابد. closure هر چیزی را که در محیط لغوی (lexical scope) خود دارد، محصور میکند که میتواند شامل آرایههای بزرگ تجزیهشده، دادههای JSON دریافتی یا ارجاعاتی به درختهای DOM باشد. هر ناوبری (navigation) وزن بیشتری اضافه میکند. کاربر نمیداند چرا تب مرورگرش ۸۰۰ مگابایت فضا اشغال کرده است؛ او فقط میداند که اپلیکیشن کند شده و در نهایت از کار میافتد.
این موضوع به ویژه زمانی خطرناک است که آرایههای وابستگی در هر رندر تغییر میکنند. در هر چرخه، یک ارجاع تابع جدید متولد میشود، با یک شنونده ثبت میگردد و قدیمی هرگز رها نمیشود. نتیجه، موزهای از closureهای مرده است که هر کدام دادههایی را که با آنها متولد شدهاند، ذخیره میکنند.
خطاهای TDZ در ماژولهای پویا
ناحیه مرده زمانی (Temporal Dead Zone) صرفاً یک مورد استثنایی تئوری نیست. وقتی قبل از اجرای اعلانِ یک let یا const به آن دسترسی پیدا میکنید، موتور برنامه یک ReferenceError پرتاب میکند. در مونورپوهای (monorepos) بزرگ با وابستگیهای چرخشی و ایمپورتهای پویا، ترتیب دقیق اجرا اغلب ضمنی است. ماژول A ماژول B را ایمپورت میکند، که خود به صورت پویا چانکی (chunk) را ایمپورت میکند که دوباره به ماژول A وابسته است. اگر یکی از شاخهها به متغیری دسترسی پیدا کند که هنوز مقداردهی اولیه آن تمام نشده باشد، اپلیکیشن در هنگام بارگذاری کرش میکند. این شکستها کلافهکننده هستند زیرا به زمانبندی وابسته میباشند. یک تغییر کوچک در نقاط تقسیم باندلر (bundler)، یک تأخیر شبکه در بارگذاری کد، یا تغییری در کش کردن چانکها میتواند ترتیب اجرا را دقیقاً به اندازهای تغییر دهد که TDZ را فعال کند. این کرش غیرقابل پیشبینی است و استک تریس (stack trace) معمولاً به خطی از کد اشاره میکند که کاملاً بیگناه است.
تاکتیکهای دفاعی
شما نمیتوانید برای نجات از باگهای مربوط به محدوده (scope)، تنها به استک تریسها تکیه کنید. شما به پیشگیری و تشخیص نیاز دارید.
با تحلیل استاتیک (static analysis) شروع کنید. ESLint را برای اعمال مرزهای سختگیرانه پیکربندی کنید. قوانینی مانند no-implicit-globals و no-shadow گناهان آشکار را شناسایی میکنند. سایهگذاری (Shadowing) بهویژه فریبنده است، زیرا شما را به این فکر میاندازد که در حال تغییر یک متغیر محلی هستید، در حالی که در واقع در حال ساختن یک کلوژر (closure) روی یک متغیر بیرونی یا ایجاد یک کپی تصادفی هستید. این قوانین، قصد و نیت صریح را تحمیل کرده و برخوردهای خاموش را از بین میبرند.
حافظه خود را با همان انضباطی که در تستهای واحد (unit tests) به کار میبرید، پروفایل کنید. Chrome DevTools را باز کنید، در مسیر شروع خود یک اسنپشات هیپ (heap snapshot) بگیرید، پنج دقیقه در اپلیکیشن خود پیمایش کنید و اسنپشات دیگری بگیرید. این دو را با هم مقایسه کنید. فیلتر را روی "Closure" قرار دهید و به دنبال تعدادهایی بگردید که بدون محدودیت رشد میکنند. به دنبال گرههای DOM جدا شده (detached DOM nodes) بگردید که هنوز شنوندههای رویداد (event listeners) را حفظ کردهاند. اگر اسنپشات دوم نشاندهنده هزاران ورودی Closure جدید باشد در حالی که تعداد کاربران شما ثابت مانده است، شما توابعی را گرفتار کردهاید که دادههای گرفتار شده را نگه داشتهاند. این همان نشت حافظه (leak) شماست.
از نظر معماری، از دسترسی به شیء سراسری window برای تنظیمات خودداری کنید. تنظیمات را به عنوان props یا از طریق یک context تایپشده پاس دهید. تزریق وابستگی (Dependency injection) در اینجا یک اصطلاح پرزرقوبرق سازمانی نیست؛ بلکه تمرینِ دادنِ هر آنچه یک تابع نیاز دارد از طریق آرگومانهاست، به جای اینکه اجازه دهید تابع محدوده سراسری را بو بکشد. نتیجه، کدی است که میتوانید بدون شیمهای مرورگر (browser shims) تست کنید و ماژولهایی که وقتی چندین اپلیکیشن در یک شل (shell) واحد سوار میشوند، با هم تداخل پیدا نمیکنند.
در نهایت، مرحله پاکسازی (cleanup phase) را بیرحمانه رعایت کنید. هر addEventListener به یک removeEventListener متناظر در پاکسازیِ effect نیاز دارد. برای کارهای ناهمگام (asynchronous)، از یک AbortController استفاده کنید و سیگنال آن را به fetch پاس دهید تا درخواستهای در جریان هنگام از بین رفتن کامپوننت، لغو شوند. این عادتها مستقیماً کنترل میکنند که یک محدوده (scope) تا چه زمانی زنده میماند. اینها کدهای تکراری (boilerplate) نیستند؛ اینها مدیریت حافظه هستند.
این موضوع برای تیم شما چه معنایی دارد
محدوده (Scope) یک ترفند نمایشی برای امتحان کردن کاندیداها در طول مصاحبه نیست. در محیط تولید، scope یعنی مدیریت حافظه. هر متغیری که تعریف میکنید، یک گروگان بالقوه است. هر کلوژر، قولی است که موتور برنامه باید به آن وفادار بماند. وقتی فراموش میکنید یک شنونده را آزاد کنید، فقط یک چراغ روشن رها نکردهاید؛ بلکه در واقع وزنه سنگینی را به اپلیکیشن خود زنجیر کرده و آن را در اقیانوس رها کردهاید. در سختافزارهای قدرتمند، اپلیکیشن همچنان شنا میکند، اما برای کاربران با دستگاههای ضعیف، غرق میشود. شروع کنید به برخورد با scope به عنوان یک منبع محدود. کاربران شما و جلسات سه ساعته عیبیابی شما، از شما سپاسگزار خواهند بود.
