แท็บเบราว์เซอร์ของผู้ใช้ค้างหลังจากผ่านไป 30 นาที UI เริ่มกระตุก จากนั้นเบราว์เซอร์ก็แครชด้วยข้อผิดพลาด out-of-memory
คุณไล่ดูโค้ดของคอมโพเนนต์ทีละบรรทัดแต่กลับไม่พบสิ่งผิดปกติใดๆ นั่นคือส่วนที่น่าหงุดหงิดที่สุดของปัญหา memory leak ใน React บั๊กไม่ได้อยู่ในไวยากรณ์ JSX หรือตรรกะของ hook ของคุณ แต่มันอยู่ในช่องว่างระหว่างคอมโพเนนต์ของคุณกับ garbage collector ของเบราว์เซอร์ event listener ที่หลงเหลืออยู่หรือ closure ที่มีอายุยืนยาวเกินไปทำให้ collector ไม่สามารถคืนหน่วยความจำได้ คุณ unmount คอมโพเนนต์ไปแล้ว แต่การอ้างอิง (reference) เพียงจุดเดียวที่ยังค้างอยู่ทำให้ tree ทั้งหมดถูกเก็บไว้ใน heap คุณไม่สามารถหา leak เหล่านี้ได้จากการอ่านโค้ดเพียงอย่างเดียว ปัญหายังคงซ่อนอยู่ใต้พื้นผิว มองไม่เห็นด้วยตาเปล่า และไม่แสดงอาการใดๆ ใน unit tests
ทำไม Leak ถึงซ่อนอยู่ในที่แจ้ง
JavaScript engine อย่าง V8 จัดการหน่วยความจำโดยอัตโนมัติ เมื่อไม่มีเส้นทางการอ้างอิง (reference path) จาก root ไปยัง object นั้นๆ engine จะทำเครื่องหมายว่า object นั้นเป็นขยะ (garbage) และคืนพื้นที่ว่าง กระบวนการนี้ทำงานได้ดีจนกระทั่งมีการอ้างอิงที่ซ่อนอยู่ซึ่งคงอยู่ยาวนานกว่าที่คุณตั้งใจไว้
ใน React อันตรายมักเกิดขึ้นที่รอยต่อระหว่างคอมโพเนนต์และ DOM คุณอาจจะแนบ resize listener เข้ากับ window ภายใน modal หรือสมัครใช้งาน (subscribe) WebSocket ใน dashboard widget เมื่อผู้ใช้ปิด modal หรือเปลี่ยนหน้า คอมโพเนนต์จะ unmount หากการ subscription ยังคงอยู่ engine จะมองเห็นการอ้างอิงที่ถูกต้องจาก global window object ลงไปยัง handler ของคุณ และจาก handler กลับเข้าไปยัง closure ของคอมโพเนนต์ คอมโพเนนต์, props, state และ subtree ของ DOM nodes ทั้งหมดจะถูกตรึงไว้ในหน่วยความจำ เมื่อมีการโต้ตอบหลายร้อยครั้ง วัตถุที่ถูกตรึงเหล่านี้จะสะสมมากขึ้นเรื่อยๆ การใช้งานหน่วยความจำจะพุ่งสูงขึ้นเป็นรูปแบบฟันเลื่อย (sawtooth pattern) ที่ไม่เคยลดลงมาถึงระดับเดิมได้เลย
ปัญหาของ Enterprise Dashboard
สิ่งนี้เกิดขึ้นบ่อยที่สุดใน enterprise dashboard ที่ผู้ใช้จะอยู่บนหน้าเดิมเป็นเวลาหลายชั่วโมง ลองนึกถึงแผงควบคุมการตรวจสอบ (monitoring panels), มุมมองการวิเคราะห์ (analytics views) หรือระบบจัดการตั๋ว (ticketing systems) ผู้ใช้อาจเปิด modal รายละเอียด, กรองชุดข้อมูลขนาดใหญ่ หรือสลับแท็บภายใน single-page app การโต้ตอบแต่ละครั้งดูเหมือนจะไม่มีปัญหา แต่เมื่อเวลาผ่านไป orphaned nodes และ detached listeners จะสะสมมากขึ้น แอปพลิเคชันจะช้าลง ไม่ใช่เพราะการ render ที่กินทรัพยากรสูงเพียงครั้งเดียว แต่เป็นเพราะ heap ขยายใหญ่จนกระตุ้นให้เกิดการหยุดชะงัก (pauses) ของ garbage collection บ่อยครั้งและกินทรัพยากรสูง
เลิกเดาสุ่มเกี่ยวกับ useEffect hooks ของคุณได้แล้ว วิธีเดียวที่จะรู้ว่ามี leak หรือไม่ คือการวัดค่า heap โดยตรง Chrome DevTools จะช่วยให้คุณมองเห็นสิ่งนั้นได้
การตามล่า Leak ด้วย Chrome DevTools
คุณต้องมีลำดับขั้นตอนที่ทำซ้ำได้และใช้เวลาจดจ่อเพียงไม่กี่นาที เปิดแอปพลิเคชันของคุณใน Chrome, เปิด DevTools และไปที่แท็บ Memory
Record a baseline. เลือก Heap snapshot และคลิก Take snapshot สิ่งนี้จะบันทึกทุก object ที่อยู่ใน JavaScript heap ในขณะนั้น และแสดงหน่วยความจำเริ่มต้นให้คุณเห็น ให้ทำหลังจากที่หน้าเว็บเข้าสู่สถานะ idle เริ่มต้นแล้ว ไม่ใช่ระหว่างการโหลดครั้งแรก เพื่อให้คุณวัดเฉพาะการเติบโตที่เกิดจากการกระทำของผู้ใช้เท่านั้น
Trigger the action. ทำการโต้ตอบกับ UI ในรูปแบบเดียวกับที่คุณสงสัยว่าเป็นสาเหตุของ leak เปิดและปิด modal, สลับการแสดงผล chart ที่ซับซ้อน หรือเปลี่ยน route แล้วย้อนกลับมา เมื่อเสร็จแล้ว ให้คืนค่าแอปพลิเคชันกลับสู่สถานะการแสดงผลเดิม ขั้นตอนนี้สำคัญมาก คุณต้องการให้ UI ดูเหมือนกับตอนที่ทำ baseline ทุกประการ หาก heap ขยายใหญ่ขึ้นทั้งที่ UI ดูเหมือนจะว่างเปล่า แสดงว่าคุณมีหลักฐานที่ชัดเจนว่าเกิด leak ขึ้นแล้ว
Force garbage collection. คลิกไอคอนถังขยะในแท็บ Memory สิ่งนี้จะกระตุ้นให้เกิด full GC cycle และล้าง object ชั่วคราวที่ควรจะคงอยู่จนกว่าจะถึงการเก็บกวาดครั้งถัดไป สิ่งที่เหลืออยู่คือ leak ที่แท้จริง ซึ่งก็คือ object ที่ควรจะถูกเก็บกวาดไปแล้ว แต่ยังคงถูกตรึงไว้ด้วยการอ้างอิงที่ไม่ได้ตั้งใจ
Take a second snapshot. คลิก Take snapshot อีกครั้ง ตอนนี้คุณจะมี "ภาพถ่าย" ของ heap สองภาพที่ถ่ายภายใต้เงื่อนไข UI เดียวกัน
Compare results. เปลี่ยนมุมมองจาก Summary เป็น Comparison ตั้งค่า baseline เป็น snapshot แรก และ snapshot ที่ต้องการเปรียบเทียบเป็น snapshot ที่สอง มุมมอง Comparison จะแสดงรายการหมวดหมู่ของ object ทั้งหมดและแสดงค่า delta ซึ่งก็คือการเปลี่ยนแปลงสุทธิของจำนวน object ระหว่างการบันทึกทั้งสองครั้ง
Sort by Delta. มองหาหมวดหมู่ที่มีจำนวนเพิ่มขึ้นอย่างมีนัยสำคัญ เน้นไปที่ Detached HTMLElement และ React fiber nodes เป็นพิเศษ Detached HTML element คือ DOM node ที่ไม่ได้เชื่อมต่อกับ active document tree แล้ว แต่ยังมีการอ้างอิงจาก JavaScript บางอย่างที่ถือมันไว้ สิ่งเหล่านี้คือหลักฐานมัดตัว (smoking guns) ซึ่งไม่ควรมีอยู่หลังจากที่คุณปิด modal หรือ unmount คอมโพเนนต์ไปแล้ว
การอ่าน Retaining Path
เมื่อคุณเลือก detached element ใน snapshot ตัว Chrome จะแสดง retaining path ในแผงด้านล่าง เส้นทางนี้คือลำดับการอ้างอิง (chain of references) จาก root ลงมายัง object ที่เลือกไว้ ให้ไล่ดูอย่างระมัดระวัง คุณมักจะพบ event listener, IntersectionObserver, ID ของ setInterval หรือ closure ที่ชี้ไปยังบรรทัดเฉพาะใน component ของคุณ
มองหาชื่อที่คุณคุ้นเคย หากคุณเห็น listener ที่ผูกติดกับ window พร้อมกับชื่อฟังก์ชันจาก codebase ของคุณ แสดงว่าคุณได้พบ "ตัวยึด" (anchor) แล้ว Object ที่ถือ listener นั้นอยู่กำลังทำให้ component ทั้งหมดของคุณยังคงทำงานอยู่ (alive) ในบางครั้ง เส้นทางการอ้างอิงอาจผ่าน third-party library ในกรณีนั้น ให้ตรวจสอบว่า library ดังกล่าวต้องการการเรียกใช้ teardown แบบชัดเจนที่คุณอาจลืมเรียกใน cleanup function หรือไม่
การแก้ไขที่ต้นเหตุ
เมื่อคุณระบุ retaining path ได้แล้ว การแก้ไขมักจะเป็นเรื่องเชิงเทคนิคที่ทำตามขั้นตอนได้ แต่ต้องอาศัยวินัยของคนในทีม
ใช้ cleanup functions ให้ return cleanup function ใน useEffect เสมอเมื่อคุณเพิ่ม listener ให้กับ window หรือ document หาก effect ของคุณมีการ subscribe ต่อ resize event ให้ลบการ subscription นั้นออกก่อนที่ component จะ unmount การ cleanup จะทำงานเมื่อ React ทำการ tear down component ซึ่งช่วยให้คุณมี hook ที่รับประกันได้ในการตัดการเชื่อมต่อจากภายนอก
ทำให้ reference มีความเสถียร (Stabilize references) ให้ครอบ handler ของคุณด้วย useCallback เพื่อให้แน่ใจว่าคุณส่ง function reference ตัวเดิมเป๊ะๆ ไปยัง removeEventListener เหมือนกับที่คุณส่งให้ addEventListener ในตอนแรก หากคุณลงทะเบียน inline function เช่น window.addEventListener('resize', () => { ... }) แล้วพยายามจะลบมันออกด้วย inline function อีกตัวหนึ่ง reference จะไม่ตรงกัน ทำให้ listener ยังคงติดอยู่ และ closure ภายในนั้นจะทำให้ component state ของคุณยังคงทำงานอยู่ การใช้ useCallback พร้อมกับ dependency array ที่เสถียรจะช่วยป้องกันปัญหา identity mismatch นี้ได้
ตัดการเชื่อมต่อกับ global จำไว้ว่า event target objects ของ browser เช่น window และ document จะมีอายุอยู่ตลอดช่วงเวลาที่หน้าเว็บเปิดอยู่ การอ้างอิงใดๆ จาก object เหล่านี้ไปยัง component ของคุณจะทำหน้าที่เป็น global anchor การลบ listener จะช่วยทำลาย anchor นั้น และปล่อยให้ V8 engine ทำการกวาดล้าง (sweep away) component state และ DOM nodes ทิ้งไปในรอบ garbage collection ถัดไป
ลองพิจารณา modal ที่มีการติดตามความกว้างของ window หากไม่มีการ cleanup ทุกครั้งที่ผู้ใช้เปิด modal จะมีการแนบ listener ตัวใหม่เข้าไปเสมอ ส่วน listener ตัวเก่าจะไม่ถูกถอดออกเพราะ instance ของ component ที่มันสังกัดอยู่นั้นหายไปแล้ว แต่ตัวฟังก์ชันเองกลับเป็นแบบ anonymous และสูญหายไป การใช้ named handler ที่ครอบด้วย useCallback ร่วมกับ cleanup function ที่เรียก removeEventListener จะช่วยปิดลูปนี้ได้อย่างสะอาดหมดจด
บทสรุปที่นำไปใช้ได้จริง
Memory leaks ใน React มักจะไม่แจ้งเตือนด้วย error message ที่ชัดเจน แต่มันจะแสดงตัวผ่าน tab ที่เริ่มทำงานหนักขึ้นเรื่อยๆ ยิ่งเปิดทิ้งไว้นาน อย่าเสียเวลาคาดเดาว่า hook ตัวไหนเป็นต้นเหตุ ให้เปิด Memory tab, สั่ง force garbage collection และเปรียบเทียบ snapshots ให้ heap profiler แสดง retaining path ที่แม่นยำให้คุณเห็น จากนั้นจึงเขียน cleanup function, ทำให้ callback reference มีความเสถียร และตัดการเชื่อมต่อกับ global ทิ้งไป ผู้ใช้ของคุณอาจจะไม่สังเกตเห็นการแก้ไขนี้โดยตรง แต่พวกเขาจะสังเกตเห็นว่า dashboard ยังคงทำงานได้อย่างราบรื่นจนจบวัน
