Aplikasi anda berjalan lancar selama sepuluh minit. Kemudian, skrol menjadi tersekat-sekat. Selepas setengah jam, tab tersebut mencecah satu gigabait. Akhirnya, halaman tersebut mati dengan ralat out-of-memory dan anda tidak mempunyai sebarang stack trace untuk ditunjukkan.

Ini bukan masalah prestasi render. React DevTools Profiler akan kelihatan tenang kerana masalahnya bukan tentang kekerapan komponen dilukis semula (redraw). Masalahnya adalah apa yang kekal aktif selepas ia di-unmount. Satu rujukan yang tersasar di suatu tempat dalam JavaScript heap mengunci keseluruhan pokok nod DOM, closures, dan state. Pelayar tidak dapat menuntut semula mana-mana bahagian daripadanya, jadi penggunaan memori meningkat sehingga proses tersebut runtuh.

Membaca kod sumber anda tidak akan mendedahkan kebocoran tersebut. Pepijat ini wujud dalam jurang antara apa yang anda fikir telah di-unmount dan apa yang sebenarnya dilihat oleh garbage collector. V8 hanya membebaskan objek yang mempunyai sifar laluan pengekalan (retaining paths). Jika satu event listener yang tidak terkawal, pemerhati (observer) yang tidak dibersihkan, atau closure yang berumur panjang memegang walaupun satu penunjuk (pointer) ke arah fiber atau nod DOM, keseluruhan subpokok komponen akan terus kekal. Anda melakukan unmount pada satu modal, tetapi nod-nod yang terpisah (detached nodes) tetap berada dalam memori kerana satu listener pada window masih menunjuk ke arah handler yang ditakrifkan di dalam modal tersebut.

Untuk membuktikan di mana kebocoran itu berlaku, anda perlu melihat pada heap, bukan pada editor.

Mengapa Heap Memberitahu Kebenaran

Chrome DevTools memberikan anda tetingkap terus kepada apa yang dilihat oleh garbage collector. Tab Memory boleh merakam heap snapshots: inventori lengkap bagi setiap objek, nod DOM, dan closure yang sedang dipegang dalam memori JavaScript. Dengan membandingkan dua snapshot—satu sebelum kebocoran yang disyaki dan satu selepasnya—anda boleh mengasingkan dengan tepat objek mana yang gagal dihapuskan.

Ini bukan teori abstrak. Satu komponen React yang bocor boleh mengekalkan beribu-ribu objek HTMLElement yang terpisah (detached). Objek-objek tersebut tidak lagi terikat pada dokumen yang kelihatan, tetapi rujukan JavaScript menghalangnya daripada dikumpul. Ia muncul dalam paparan perbandingan dengan nama pembina (constructor) Detached HTMLElement. Apabila anda melihatnya semakin banyak, anda telah menemui kebocoran anda.

Aliran Kerja Chrome DevTools

Mulakan dengan bersih. Tutup tab pelayar yang tidak berkaitan, nyahaktifkan sambungan (extensions) yang tidak berkaitan, dan biarkan aplikasi anda mencapai keadaan stabil. Buka Chrome DevTools, tukar ke tab Memory, dan pilih Heap snapshot. Klik Take snapshot. Garis dasar (baseline) ini merakam jejak memori permulaan anda.

Sekarang, lakukan tindakan pengguna yang anda syaki. Buka dan tutup modal yang berat itu. Mount dan unmount widget tersebut. Navigasi ke laluan (route) tersebut dan kembali semula. Sebaik sahaja UI telah kembali ke keadaan visual asalnya, klik ikon tong sampah dalam tab Memory. Ini memaksa satu kitaran garbage collection global. Objek sementara daripada kitaran render sepatutnya hilang. Apa-apa sahaja yang kekal adalah calon sebenar bagi kebocoran.

Klik Take snapshot sekali lagi. Anda kini mempunyai dua "foto" memori. Tukar paparan daripada Summary kepada Comparison. Tetapkan skop perbandingan kepada snapshot pertama. Alat ini akan menunjukkan kepada anda hanya apa yang berubah antara dua rakaman tersebut, dengan membuang gangguan (noise) daripada masa larian (runtime).

Susun mengikut Delta. Cari bilangan objek yang meningkat. Berikan perhatian khusus kepada pembina (constructors) seperti Detached HTMLElement, Array, Function, atau malah instans kelas bernama daripada kod sumber anda sendiri. Delta yang meningkat bermakna objek telah dicipta semasa tindakan anda dan tidak dikumpul selepas itu.

Menjejaki Laluan Pengekalan (Retaining Path)

Apabila anda mengesan elemen yang bocor, pilih ia. Panel bawah memaparkan retaining path: satu rantaian rujukan yang menjelaskan mengapa objek ini masih aktif. Rantaian tersebut mungkin bermula daripada div yang terpisah, melalui sifat dalaman React, masuk ke dalam closure, dan akhirnya mendarat pada satu event listener yang didaftarkan di dalam salah satu komponen anda. Pautan terakhir dalam rantaian itu adalah nombor baris kod anda.

Di sinilah anda beralih daripada diagnosis kepada punca utama. Jika retaining path berakhir pada window.addEventListener, anda tahu satu listener global sedang menyandera komponen anda. Jika ia berakhir pada instans IntersectionObserver, anda tahu pemerhati (observer) masih memerhatikan nod yang sepatutnya telah dikumpul oleh garbage collector.

Punca Biasa dalam React

Kebocoran memori dalam React biasanya terbahagi kepada tiga corak.

Listener global yang terbiar. Satu useEffect menyambung (hooks) ke window atau document untuk menjejak kedudukan skrol, tekanan kekunci, atau acara saiz (resize events). Jika kesan (effect) tersebut tidak mengembalikan fungsi pembersihan (cleanup function) yang memanggil removeEventListener, listener tersebut akan kekal sepanjang hayat halaman tersebut. Oleh kerana listener itu adalah satu closure, ia mengekalkan keseluruhan skop komponen tetap aktif lama selepas React melakukan unmount pada komponen tersebut.

Pemerhati yang tidak dibersihkan. IntersectionObserver dan ResizeObserver adalah berkuasa, tetapi ia mencipta rujukan asli di luar kawalan React. Jika anda melakukan instansiasi pemerhati di dalam komponen dan terlupa untuk memanggil disconnect() dalam fasa pembersihan, pemerhati tersebut akan memegang nod DOM sasaran, dan nod DOM tersebut akan memegang fiber, props, dan state React.

Perangkap closure. Apabila anda mendefinisikan fungsi di dalam komponen dan menyerahkannya kepada perpustakaan pihak ketiga, cache global, atau pun setTimeout, fungsi tersebut akan menutup (closes over) setiap pemboleh ubah dalam skop leksikalnya. Jika pemilik luaran mengekalkan fungsi tersebut, ia juga akan mengekalkan keseluruhan skop komponen anda bersamanya.

Corak Pembersihan yang Benar-benar Berkesan

Membaiki kebocoran bermaksud memutuskan setiap laluan pengekalan (retaining path) yang anda temui dalam snapshot.

Sentiasa pulangkan fungsi pembersihan daripada useEffect. Jika anda menambah pendengar (listener) dalam kesan (effect) tersebut, buangkannya di sana.

Gunakan useCallback untuk sebarang pengendali (handler) yang anda pasangkan pada DOM atau window. Tanpanya, setiap render akan mencipta rujukan fungsi yang baharu. Jika anda memanggil addEventListener dengan satu rujukan dan kemudian memanggil removeEventListener dengan rujukan yang berbeza, proses pembuangan akan gagal secara senyap. Pendengar asal akan kekal pada window selama-lamanya. useCallback mengekalkan rujukan yang stabil supaya proses tambah dan buang sepadan dengan tepat.

Kendalikan pemerhati dengan disiplin yang sama. Simpan instans pemerhati dalam ref atau pemboleh ubah tempatan di dalam kesan tersebut. Dalam fungsi pembersihan, panggil observer.disconnect(). Jangan andaikan bahawa proses unmounting komponen akan menghentikan pemerhati tersebut. Ia tidak berbuat demikian.

Jika komponen anda menerbitkan apa-apa ke ruang nama global atau perkhidmatan singleton, padamkan rujukan tersebut semasa unmount. Enjin V8 hanya boleh menuntut semula memori apabila sesuatu objek benar-benar tidak dapat dicapai. Meninggalkan hook pada window atau entri dalam Map pada tahap modul akan mencipta jambatan halimunan yang menyebabkan heap terus berkembang.

Intipati Sebenar

Kebocoran memori tidak menyebabkan aplikasi anda terhenti (crash) serta-merta. Ia terkumpul satu demi satu nod yang terputus (detached node) semasa sesi pengguna yang panjang. Penyelesaiannya bukanlah naik taraf perpustakaan atau bendera pengkompil (compiler flag). Ia adalah tabiat membuktikan logik pembersihan anda dengan heap snapshots.

Ambil garis dasar (baseline), cetuskan aliran yang mencurigakan, paksa pengumpulan sampah (garbage collection), dan bandingkan. Jika delta menunjukkan pertumbuhan, periksa laluan pengekalan, cari pendengar atau pemerhati yang sepatutnya tidak wujud, dan putuskan rujukan tersebut. Jalankan ujian sekali lagi. Apabila delta kekal mendatar, anda sebenarnya telah menyelesaikannya. Aplikasi anda akan kekal responsif, dan pengguna anda tidak akan kehilangan kerja mereka disebabkan tab pelayar yang membeku.