നിങ്ങളുടെ ആപ്പ് പത്ത് മിനിറ്റ് നേരം നന്നായി പ്രവർത്തിക്കും. പിന്നീട് സ്ക്രോളിംഗ് പതുക്കെയാകുന്നു (sticky). അരമണിക്കൂർ കഴിയുമ്പോൾ ടാബ് ഒരു ഗിഗാബൈറ്റ് ഉപയോഗം പിടിക്കുന്നു. ഒടുവിൽ, ഔട്ട്-ഓഫ്-മെമ്മറി (out-of-memory) എറർ വന്ന് പേജ് ക്രാഷ് ആകുന്നു, നിങ്ങൾക്ക് ഇതിന് കാരണമായ സ്റ്റാക്ക് ട്രാസ് (stack trace) പോലും ലഭിക്കില്ല.
ഇതൊരു റെൻഡർ പെർഫോമൻസ് (render performance) പ്രശ്നമല്ല. കമ്പോണന്റുകൾ എത്ര തവണ വീണ്ടും വരയ്ക്കുന്നു (redraw) എന്നതല്ല ഇവിടെ പ്രശ്നം, അതിനാൽ React DevTools Profiler നോക്കുമ്പോൾ പ്രശ്നമൊന്നും കാണില്ല. കമ്പോണന്റുകൾ അൺമൗണ്ട് (unmount) ചെയ്ത ശേഷവും എന്താണ് മെമ്മറിയിൽ അവശേഷിക്കുന്നത് എന്നതാണ് യഥാർത്ഥ പ്രശ്നം. JavaScript ഹീപ്പിലെ (heap) എവിടെയെങ്കിലും അവശേഷിക്കുന്ന ഒരു റഫറൻസ് (reference), DOM നോഡുകൾ, ക്ലോഷറുകൾ (closures), സ്റ്റേറ്റ് (state) എന്നിവയുടെ ഒരു വലിയ ശൃംഖലയെ തന്നെ പിടിച്ചുനിർത്തുന്നു. ബ്രൗസറിന് ഇവയിൽ ഒന്നും തിരികെ എടുക്കാൻ കഴിയില്ല, അതിനാൽ മെമ്മറി ഉപയോഗം വർദ്ധിച്ചുകൊണ്ടിരിക്കുകയും ഒടുവിൽ പ്രോസസ്സ് തകരാറിലാകുകയും ചെയ്യുന്നു.
നിങ്ങളുടെ സോഴ്സ് കോഡ് വായിച്ചതുകൊണ്ട് മാത്രം ഈ ലീക്ക് (leak) കണ്ടെത്താൻ കഴിയില്ല. നിങ്ങൾ അൺമൗണ്ട് ചെയ്തു എന്ന് കരുതുന്നതും ഗാർബേജ് കളക്ടർ (garbage collector) യഥാർത്ഥത്തിൽ കാണുന്നതും തമ്മിലുള്ള വ്യത്യാസത്തിലാണ് ഈ ബഗ് ഒളിഞ്ഞിരിക്കുന്നത്. റീറ്റൈനിംഗ് പാത്തുകൾ (retaining paths) ഇല്ലാത്ത ഒബ്ജക്റ്റുകളെ മാത്രമേ V8 ഫ്രീ ചെയ്യുകയുള്ളൂ. ഒരു അൺക്ലിയർ ചെയ്ത ഒബ്സർവർ (observer), അല്ലെങ്കിൽ ഒരു ലോങ്ങ്-ലിവിംഗ് ക്ലോഷർ (long-lived closure), അല്ലെങ്കിൽ ഒരു ഇവന്റ് ലിസണർ (event listener) എന്നിവയിൽ ഏതെങ്കിലും ഒന്ന് ഒരു ഫൈബറിനോ (fiber) DOM നോഡിനോ പോയിന്റർ നൽകുന്നുണ്ടെങ്കിൽ, ആ കമ്പോണന്റ് സബ്ട്രീ (subtree) മുഴുവനും മെമ്മറിയിൽ അവശേഷിക്കും. നിങ്ങൾ ഒരു മോഡൽ (modal) അൺമൗണ്ട് ചെയ്തേക്കാം, പക്ഷേ window-ലെ ഒരു ലിസണർ ആ മോഡലിനുള്ളിൽ നിർവചിച്ച ഒരു ഹാൻഡ്ലറിലേക്ക് (handler) ഇപ്പോഴും പോയിന്റ് ചെയ്യുന്നുണ്ടെങ്കിൽ, അതിന്റെ ഡിറ്റാച്ച്ഡ് നോഡുകൾ (detached nodes) മെമ്മറിയിൽ അവശേഷിക്കും.
ലീക്ക് എവിടെയാണെന്ന് തെളിയിക്കാൻ നിങ്ങൾ എഡിറ്റർ നോക്കിയാൽ പോരാ, ഹീപ്പ് (heap) പരിശോധിക്കണം.
എന്തുകൊണ്ടാണ് ഹീപ്പ് (Heap) സത്യം വെളിപ്പെടുത്തുന്നത്
ഗാർബേജ് കളക്ടർ എന്താണ് കാണുന്നത് എന്ന് നേരിട്ട് മനസ്സിലാക്കാൻ Chrome DevTools നിങ്ങളെ സഹായിക്കുന്നു. മെമ്മറി ടാബ് (Memory tab) ഉപയോഗിച്ച് ഹീപ്പ് സ്നാപ്ഷോട്ടുകൾ (heap snapshots) റെക്കോർഡ് ചെയ്യാം: അതായത് JavaScript മെമ്മറിയിൽ നിലവിൽ ഉള്ള ഓരോ ഒബ്ജക്റ്റ്, DOM നോഡ്, ക്ലോഷർ എന്നിവയുടെയും പൂർണ്ണമായ പട്ടിക. സംശയിക്കപ്പെടുന്ന ലീക്കിന് മുമ്പും ശേഷവുമുള്ള രണ്ട് സ്നാപ്ഷോട്ടുകൾ താരതമ്യം ചെയ്തുകൊണ്ട്, ഏത് ഒബ്ജക്റ്റുകളാണ് മെമ്മറിയിൽ നിന്ന് നീക്കം ചെയ്യപ്പെടാത്തതെന്ന് നിങ്ങൾക്ക് കൃത്യമായി കണ്ടെത്താം.
ഇതൊരു വെറും സിദ്ധാന്തമല്ല. ലീക്ക് ആയ ഒരു സിംഗിൾ React കമ്പോണന്റിന് ആയിരക്കണക്കിന് ഡിറ്റാച്ച്ഡ് HTMLElement ഒബ്ജക്റ്റുകളെ നിലനിർത്താൻ കഴിയും. ആ ഒബ്ജക്റ്റുകൾ ഇപ്പോൾ വിസിബിൾ ആയ ഡോക്യുമെന്റുമായി ബന്ധമില്ലെങ്കിലും, JavaScript റഫറൻസുകൾ അവയെ കളക്ട് ചെയ്യുന്നത് തടയുന്നു. ഇവ കമ്പാരിസൺ വ്യൂവിൽ (comparison view) Detached HTMLElement എന്ന കൺസ്ട്രക്റ്റർ പേര് (constructor name) കാണിച്ചുകൊണ്ട് പ്രത്യക്ഷപ്പെടും. അവയുടെ എണ്ണം വർദ്ധിക്കുന്നത് കാണുമ്പോൾ, നിങ്ങൾ ലീക്ക് കണ്ടെത്തിക്കഴിഞ്ഞു.
Chrome DevTools വർക്ക്ഫ്ലോ (Workflow)
വൃത്തിയായി തുടങ്ങുക. ആവശ്യമില്ലാത്ത ബ്രൗസർ ടാബുകൾ അടയ്ക്കുക, എക്സ്റ്റൻഷനുകൾ ഡിസേബിൾ ചെയ്യുക, നിങ്ങളുടെ ആപ്ലിക്കേഷൻ ഒരു സ്റ്റെഡി സ്റ്റേറ്റിൽ (steady state) എത്താൻ അനുവദിക്കുക. Chrome DevTools തുറന്ന് Memory ടാബിലേക്ക് മാറുകയും Heap snapshot തിരഞ്ഞെടുക്കുകയും ചെയ്യുക. 'Take snapshot' ക്ലിക്ക് ചെയ്യുക. ഇത് നിങ്ങളുടെ തുടക്കത്തിലെ മെമ്മറി ഉപയോഗം (memory footprint) രേഖപ്പെടുത്തുന്നു.
ഇനി, നിങ്ങൾ സംശയിക്കുന്ന ആ യൂസർ ആക്ഷൻ (user action) കൃത്യമായി ചെയ്യുക. ആ വലിയ മോഡൽ തുറക്കുകയും അടയ്ക്കുകയും ചെയ്യുക. വിഡ്ജറ്റ് മൗണ്ട് (mount) ചെയ്യുകയും അൺമൗണ്ട് (unmount) ചെയ്യുകയും ചെയ്യുക. ഒരു റൂട്ടിലേക്ക് പോയി തിരികെ വരിക. UI അതിന്റെ പഴയ അവസ്ഥയിലേക്ക് തിരിച്ചെത്തിയാൽ, Memory ടാബിലെ ട്രഷ് കാൻ (trash can) ഐക്കണിൽ ക്ലിക്ക് ചെയ്യുക. ഇത് ഗ്ലോബൽ ഗാർബേജ് കളക്ഷൻ (global garbage collection) നടത്താൻ നിർബന്ധിക്കുന്നു. റെൻഡർ സൈക്കിളിലെ താൽക്കാലിക ഒബ്ജക്റ്റുകൾ ഇതിലൂടെ നീക്കം ചെയ്യപ്പെടണം. അതിനുശേഷവും അവശേഷിക്കുന്നവ ലീക്കിന് സാധ്യതയുള്ളവയാണ്.
വീണ്ടും 'Take snapshot' ക്ലിക്ക് ചെയ്യുക. ഇപ്പോൾ നിങ്ങളുടെ പക്കൽ മെമ്മറിയുടെ രണ്ട് ചിത്രങ്ങളുണ്ട്. വ്യൂ 'Summary'-ൽ നിന്ന് 'Comparison'-ലേക്ക് മാറ്റുക. കമ്പാരിസൺ സ്കോപ്പ് (comparison scope) ആദ്യത്തെ സ്നാപ്ഷറ്റായി സെറ്റ് ചെയ്യുക. റൺടൈമിലെ (runtime) അനാവശ്യ വിവരങ്ങൾ ഒഴിവാക്കി, രണ്ട് സ്നാപ്ഷോട്ടുകൾക്കിടയിൽ എന്താണ് മാറിയതെന്ന് മാത്രം ഈ ടൂൾ കാണിച്ചുതരും.
'Delta' ഉപയോഗിച്ച് സോർട്ട് ചെയ്യുക. വർദ്ധിച്ചുവരുന്ന ഒബ്ജക്റ്റ് എണ്ണങ്ങൾ ശ്രദ്ധിക്കുക. Detached HTMLElement, Array, Function അല്ലെങ്കിൽ നിങ്ങളുടെ കോഡ്ബേസിലെ ക്ലാസ് ഇൻസ്റ്റൻസുകൾ (class instances) എന്നിവ പ്രത്യേകം ശ്രദ്ധിക്കുക. ഡെൽറ്റ (delta) കൂടുന്നുണ്ടെങ്കിൽ, നിങ്ങളുടെ ആക്ഷൻ സമയത്ത് ഒബ്ജക്റ്റുകൾ നിർമ്മിക്കപ്പെടുകയും പിന്നീട് അവ കളക്ട് ചെയ്യപ്പെടാതിരിക്കുകയും ചെയ്തു എന്നാണ് അർത്ഥം.
റീറ്റൈനിംഗ് പാത്ത് (Retaining Path) കണ്ടെത്തുക
ഒരു ലീക്ക് ആയ എലമെന്റ് കണ്ടെത്തിയാൽ അത് സെലക്ട് ചെയ്യുക. താഴെയുള്ള പാനലിൽ 'retaining path' കാണാം: അതായത് ഈ ഒബ്ജക്റ്റ് എന്തുകൊണ്ടാണ് ഇപ്പോഴും നിലനിൽക്കുന്നത് എന്ന് വിശദീകരിക്കുന്ന റഫറൻസുകളുടെ ഒരു ശൃംഖല. ഒരു ഡിറ്റാച്ച്ഡ് div-ൽ നിന്ന് തുടങ്ങി React-ന്റെ ഇന്റേണൽ പ്രോപ്പർട്ടികൾ വഴി ഒരു ക്ലോഷറിലേക്കും, ഒടുവിൽ നിങ്ങളുടെ കമ്പോണന്റുകളിൽ രജിസ്റ്റർ ചെയ്ത ഒരു ഇവന്റ് ലിസണറിലേക്കും ഈ ശൃംഖല നീളാം. ആ ശൃംഖലയിലെ അവസാനത്തെ ലിങ്ക് നിങ്ങളുടെ കോഡിലെ ലൈൻ നമ്പറാണ്.
ഇവിടെയാണ് നിങ്ങൾ പ്രശ്നനിർണ്ണയത്തിൽ നിന്ന് അതിന്റെ മൂലകാരണം (root cause) കണ്ടെത്തുന്നത്. റീറ്റൈനിംഗ് പാത്ത് window.addEventListener-ൽ അവസാനിക്കുന്നുണ്ടെങ്കിൽ, ഒരു ഗ്ലോബൽ ലിസണർ നിങ്ങളുടെ കമ്പോണന്റിനെ പിടിച്ചുനിർത്തുന്നു എന്ന് നിങ്ങൾക്ക് മനസ്സിലാക്കാം. അത് ഒരു IntersectionObserver ഇൻസ്റ്റൻസിൽ അവസാനിക്കുന്നുണ്ടെങ്കിൽ, ഗാർബേജ് കളക്ട് ചെയ്യേണ്ടിയിരുന്ന ഒരു നോഡിനെ ഒരു ഒബ്സർവർ ഇപ്പോഴും നിരീക്ഷിക്കുന്നുണ്ടെന്ന് നിങ്ങൾക്ക് അറിയാം.
React-ലെ സാധാരണ കാരണങ്ങൾ
React-ലെ മെമ്മറി ലീക്കുകൾ സാധാരണയായി മൂന്ന് രീതിയിലായിരിക്കും സംഭവിക്കുന്നത്.
ഒറ്റപ്പെട്ടുപോയ ഗ്ലോബൽ ലിസണറുകൾ (Orphaned global listeners). സ്ക്രോൾ പൊസിഷൻ, കീ പ്രസ്സ് അല്ലെങ്കിൽ റീസൈസ് ഇവന്റുകൾ എന്നിവ ട്രാക്ക് ചെയ്യാൻ ഒരു useEffect ഹുക്ക് window-ലോ document-ലോ കണക്ട് ചെയ്യുന്നു. ഈ എഫക്റ്റിൽ removeEventListener വിളിക്കുന്ന ഒരു ക്ലീനപ്പ് ഫംഗ്ഷൻ (cleanup function) തിരികെ നൽകുന്നില്ലെങ്കിൽ, ആ ലിസണർ പേജ് നിലനിൽക്കുന്നതുവരെ അവിടെത്തന്നെ ഉണ്ടാകും. ലിസണർ ഒരു ക്ലോഷർ ആയതുകൊണ്ട്, React കമ്പോണന്റ് അൺമൗണ്ട് ചെയ്തതിന് ശേഷവും അത് കമ്പോണന്റ് സ്കോപ്പ് മുഴുവനായും നിലനിർത്തുന്നു.
ക്ലിയർ ചെയ്യാത്ത ഒബ്സർവറുകൾ. IntersectionObserver, ResizeObserver എന്നിവ ശക്തമാണ്, എന്നാൽ അവ React-ന്റെ നിയന്ത്രണത്തിന് പുറത്തുള്ള നേറ്റീവ് റഫറൻസുകൾ (native references) സൃഷ്ടിക്കുന്നു. ഒരു കമ്പോണന്റിനുള്ളിൽ നിങ്ങൾ ഒരു ഒബ്സർവർ ഇൻസ്റ്റൻഷ്യേറ്റ് ചെയ്യുകയും ക്ലീനപ്പ് ഘട്ടത്തിൽ (cleanup phase) disconnect() വിളിക്കാൻ മറന്നുപോവുകയും ചെയ്താൽ, ഒബ്സർവർ ടാർഗെറ്റ് DOM നോഡിനെ പിടിച്ചുവെക്കുന്നു, ആ DOM നോഡ് വീണ്ടും React fibers, props, state എന്നിവയെ പിടിച്ചുവെക്കുന്നു.
ക്ലോഷർ ട്രാപ്പുകൾ (Closure traps). ഒരു കമ്പോണന്റിനുള്ളിൽ നിങ്ങൾ ഒരു ഫംഗ്ഷൻ നിർവചിക്കുകയും അത് ഒരു തേർഡ് പാർട്ടി ലൈബ്രറിക്കോ, ഗ്ലോബൽ ക്യാഷെയോ, അല്ലെങ്കിൽ setTimeout-നോ പാസ് ചെയ്യുകയും ചെയ്യുമ്പോൾ, ആ ഫംഗ്ഷൻ അതിന്റെ ലെക്സിക്കൽ സ്കോപ്പിലുള്ള (lexical scope) എല്ലാ വേരിയബിളുകളെയും ഉൾക്കൊള്ളുന്നു. ആ ഫംഗ്ഷനെ പുറത്തുള്ള ഒരു ഓണർ നിലനിർത്തുകയാണെങ്കിൽ, അത് നിങ്ങളുടെ കമ്പോണന്റ് സ്കോപ്പിനെ മുഴുവൻ കൂടെ നിലനിർത്തുന്നു.
ഫലപ്രദമായ ക്ലീനപ്പ് പാറ്റേണുകൾ (Cleanup Patterns That Actually Work)
ഒരു മെമ്മറി ലീക്ക് പരിഹരിക്കുക എന്നതിനർത്ഥം സ്നാപ്പ്ഷോട്ടിൽ നിങ്ങൾ കണ്ടെത്തിയ എല്ലാ റീറ്റൈനിംഗ് പാത്തുകളും (retaining paths) മുറിച്ചുമാറ്റുക എന്നതാണ്.
useEffect-ൽ നിന്ന് എപ്പോഴും ഒരു ക്ലീനപ്പ് ഫംഗ്ഷൻ റിട്ടേൺ ചെയ്യുക. എഫക്റ്റിൽ നിങ്ങൾ ഒരു ലിസണർ ചേർക്കുകയാണെങ്കിൽ, അവിടെത്തന്നെ അത് നീക്കം ചെയ്യുകയും വേണം.
DOM-ലോ window-ലോ നിങ്ങൾ ഘടിപ്പിക്കുന്ന ഏതൊരു ഹാൻഡ്ലറിനും (handler) useCallback ഉപയോഗിക്കുക. ഇത് ഉപയോഗിച്ചില്ലെങ്കിൽ, ഓരോ റെൻഡറിംഗും പുതിയൊരു ഫംഗ്ഷൻ റഫറൻസ് സൃഷ്ടിക്കും. നിങ്ങൾ ഒരു റഫറൻസ് ഉപയോഗിച്ച് addEventListener വിളിക്കുകയും പിന്നീട് മറ്റൊരു റഫറൻസ് ഉപയോഗിച്ച് removeEventListener വിളിക്കുകയും ചെയ്താൽ, ആ നീക്കം ചെയ്യൽ പരാജയപ്പെടും. യഥാർത്ഥ ലിസണർ window-ൽ എന്നെന്നേക്കുമായി അവശേഷിക്കും. add-ഉം remove-ഉം കൃത്യമായി ഒത്തുപോകുന്നതിനായി useCallback റഫറൻസിനെ സ്റ്റേബിൾ ആയി നിലനിർത്തുന്നു.
ഒബ്സർവറുകളെയും ഇതേ രീതിയിൽ കൈകാര്യം ചെയ്യുക. ഒബ്സർവർ ഇൻസ്റ്റൻസ് ഒരു ref-ലോ അല്ലെങ്കിൽ എഫക്റ്റിനുള്ളിലെ ലോക്കൽ വേരിയബിളിലോ സൂക്ഷിക്കുക. ക്ലീനപ്പ് ഫംഗ്ഷനിൽ observer.disconnect() വിളിക്കുക. കമ്പോണന്റ് അൺമൗണ്ട് (unmount) ചെയ്യുന്നത് ഒബ്സർവറിനെ ഇല്ലാതാക്കുമെന്ന് കരുതരുത്. അത് സംഭവിക്കില്ല.
നിങ്ങളുടെ കമ്പോണന്റ് എന്തെങ്കിലും ഒരു ഗ്ലോബൽ നെയിംസ്പേസിലേക്കോ (global namespace) സിംഗിൾറ്റൺ സർവീസിലേക്കോ (singleton service) നൽകുന്നുണ്ടെങ്കിൽ, അൺമൗണ്ട് ചെയ്യുമ്പോൾ ആ റഫറൻസുകൾ നീക്കം ചെയ്യുക. ഒരു ഒബ്ജക്റ്റ് പൂർണ്ണമായും അൺറീച്ചബിൾ (unreachable) ആകുമ്പോൾ മാത്രമേ V8 എഞ്ചിന് മെമ്മറി തിരികെ എടുക്കാൻ കഴിയൂ. window-ൽ ഒരു ഹുക്ക് (hook) വിട്ടേക്കുകയോ അല്ലെങ്കിൽ ഒരു മോഡ്യൂൾ ലെവൽ മാപ്പിൽ (module-level Map) ഒരു എൻട്രി നിലനിർത്തുകയോ ചെയ്യുന്നത് ഹീപ്പ് (heap) വളർന്നുകൊണ്ടിരിക്കാൻ കാരണമാകുന്ന ഒരു അദൃശ്യ പാലം സൃഷ്ടിക്കുന്നു.
യഥാർത്ഥ പാഠം (The Real Takeaway)
മെമ്മറി ലീക്കുകൾ നിങ്ങളുടെ ആപ്പിനെ ഉടൻ തന്നെ ക്രാഷ് ചെയ്യിക്കില്ല. നീണ്ട യൂസർ സെഷനുകൾക്കിടയിൽ ഓരോ തവണയും ഓരോ ഡിറ്റാച്ച്ഡ് നോഡുകൾ (detached nodes) വീതം അവ അടിഞ്ഞുകൂടുന്നു. ഇതിനുള്ള പരിഹാരം ഒരു ലൈബ്രറി അപ്ഗ്രേഡോ കംപൈലർ ഫ്ലാഗോ അല്ല. മറിച്ച്, ഹീപ്പ് സ്നാപ്പ്ഷോട്ടുകൾ (heap snapshots) ഉപയോഗിച്ച് നിങ്ങളുടെ ക്ലീനപ്പ് ലോജിക് ശരിയാണെന്ന് തെളിയിക്കുന്ന ശീലമാണ്.
ഒരു ബേസ്ലൈൻ (baseline) എടുക്കുക, സംശയിക്കുന്ന ഫ്ലോ (flow) പ്രവർത്തിപ്പിക്കുക, ഗാർബേജ് കളക്ഷൻ (garbage collection) നിർബന്ധമാക്കുക, എന്നിട്ട് താരതമ്യം ചെയ്യുക. വ്യത്യാസം (delta) വർദ്ധനവ് കാണിക്കുന്നുണ്ടെങ്കിൽ, റീറ്റൈനിംഗ് പാത്ത് പരിശോധിക്കുക, നിലനിൽക്കാൻ പാടില്ലാത്ത ലിസണറോ ഒബ്സർവറോ കണ്ടെത്തുക, ആ റഫറൻസ് മുറിച്ചുമാറ്റുക. ടെസ്റ്റ് വീണ്ടും ചെയ്യുക. വ്യത്യാസം മാറ്റമില്ലാതെ തുടരുമ്പോൾ, നിങ്ങൾ അത് ശരിയായി പരിഹരിച്ചു കഴിഞ്ഞു. നിങ്ങളുടെ ആപ്ലിക്കേഷൻ റെസ്പോൺസീവ് ആയിരിക്കും, കൂടാതെ ഫ്രീസ് ആയ ബ്രൗസർ ടാബ് കാരണം നിങ്ങളുടെ ഉപയോക്താക്കൾക്ക് അവരുടെ ജോലി നഷ്ടപ്പെടുകയുമില്ല.
