Table of contents
Prerequistion
ก่อนที่จะเข้าเรื่องเนื้อหา useMemo เราคิดว่าคุณจำเป็นต้องรู้จักพื้นฐานการ Re-render ก่อนสักนิด ถ้าหากคุณยังไม่รู้จักกระบวนการดังกล่าว เราแนะนำให้อ่านเพิ่มเติมจากสรุปดังต่อไปนี้
การ Re-render
สาเหตุที่เราจำเป็นต้อง Re-render component เกิดจาก
- State / Props ของ Component นั้นเองมีการเปลี่ยนแปลง
- Parent มีการ Re-render
- Hook มีการเปลี่ยนแปลง
- มีการเรียกใช้ Context แล้ว Context มีการเปลี่ยนแปลง
ซึ่งกระบวนการดังกล่าวก็จะเป็นไปตามหลักการ ของ React. จากการ Re-render นั้น จะทำให้เกิดการแสดงผล UI ที่เปลี่ยนแปลงบนหน้าจอของผู้ใช้งาน ถ้าหากเราฝืนการทำงานตามกฏของ React ผลลัพธ์ที่ได้ออกมาจะก่อให้เกิดการแสดงผลที่ไม่ถูกต้อง หรือ เกิดปัญหาในเรื่องของ Performance
ขอแนะนำ React lifecycle timeline by Jules blom สำหรับผู้ที่อยากศึกษา Lifecycle แบบเจาะลึก
การ Re-render เป็นอย่างไร?
สมมิตว่าเรามีโค้ด React ซึ่งเป็นโค้ดของโปรแกรมนับเลขดังต่อไปนี้
import React, { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
return (
<div>
<h2>Counter: {count}</h2>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
);
}
export default Counter;จากโค้ดดังกล่าว ถ้าหากเรากดปุ่ม Increment หรือ Decrement จะทำให้เกิดการ Re-render เพื่อแสดงค่าตัวแปรที่เปลี่ยนแปลงไป.
ต่อมาหลังจากกดปุ่ม Increment หรือ Decrement แล้วจะทำให้เกิดการเรียกใช้ Handler function นั้น ๆ และไล่ทำงานจนจบ Scope ของมัน สุดท้ายก็จะทำให้เกิดการ Re-render ของ Component.
ในกระบวนการ Re-render นี้แหละที่จะมีการเปลี่ยนแปลงของ State จากการที่เราเคยเรียก setCount(count+1) เพื่อเปลี่ยนค่า State. React จะทำการเปรียบเทียบโดยใช้หลักการที่เรียกว่า ผ่านการเช็คจาก Object.is() ในการเปรียบเทียบ State ว่าควร Re-render หรือไม่ ดังนั้นภายหลังจากที่มันอัปเดต State แล้วมันคือการที่บอกให้ตัวแปรชี้ตำแหน่งของข้อมูลไปยังตำแหน่งใหม่ใน Memory นั่นแหละ เท่ากับว่าการ Re-assign ใหม่ก็ไม่แตกต่างจากการ Initialize ตัวแปรใหม่ในต่ละรอบทีเกิดการ Re-render
สำหรับ React life cycle นั้นจะมีลำดับการทำงานคร่าว ๆ ดังต่อไปนี้
- JavaScript Initialization
- State & Hooks initialization
- Return JSX
- Update DOM node
- Unset DOM
ref useLayoutEffectcleanups- Attach
ref useLayoutEffectexecutes- DOM paint
useEffectcleanupsuseEffectexecutes
สรุปสั้น ๆ จะทำการ Re-calculate DOM ใหม่ทั้งหมดสำหรับ Component ที่ต้องถูก Re-render. โดยให้มองว่าในแต่ละ state จะ Re-assign ค่าที่มัน Reference ไปเป็นค่าที่อัปเดตใหม่ แล้วจึงแสดงผล (ในหัวข้อของ React Life cycle ยังมีรายละเอียดอื่น ๆ อีกมากมายที่เรายังไม่พูดถึง แต่เราขอละไว้ในที่นี้ก่อน)
ทำความรู้จักกับ useMemo
สาเหตุที่ React มี hook สำหรับจัดการประสิทธิภาพอย่าง useMemo, useCallback, useDeferredValue, useTransition เนื่องจาก React มีการทำงานแบบ ที่ทำให้ React ต้อง Re-render ทุกครั้งที่มีการเปลี่ยนแปลงของ State หรือ Props ทำให้เกิดปัญหาเรื่องประสิทธิภาพของการทำงาน และ เพื่อลดปัญหานี้ React จึงมี hook ที่ช่วยในการจัดการประสิทธิภาพของการทำงาน โดยในแต่ละ hook จะมีวิธีการใช้งานและเหตุผลที่ต้องใช้แตกต่างกัน
useMemo คืออะไร
Built-in hook ที่ช่วยให้ตัวแปรสามารถรักษาตำแหน่งอ้างอิงใน Memory ระหว่างการ Re-render แต่ละ Cycle ได้. จากนิยามดังกล่าวถ้าพูดให้ยาวมากขึ้นก็คือ useMemo ช่วย Cache ตำแหน่งของข้อมูลที่ตัวแปรถือไว้อยู่ได้ ทำให้ React สามารถข้ามขั้นตอนการ Re-initialize ตัวแปรใหม่ในแต่ละ Cycle ได้
useMemo กับการ Re-render
ตามที่เราได้เขียนไว้ในหัวข้อ ของการ Re-render ก่อนหน้านี้ ว่าการ Re-render ใหม่ในแต่ละรอบมันก็คือการที่ React ต้องสร้างและกำหนดตัวแปรใหม่ทุกครั้ง ดังนั้นเราจึงมักจะใช้ useMemo เพื่อป้องกันการ Re-initialize ตัวแปรใหม่ในแต่ละ Cycle โดยการ Cache ตำแหน่งของข้อมูลที่ตัวแปรถือไว้อยู่นั่นเอง
การที่เราสามารถป้องกันการ Re-calculate หรือ Re-initialize ตัวแปรใหม่ในแต่ละ Cycle นั้นจะมีประโยชน์ดังต่อไปนี้
- ช่วยให้เราใช้ทรัพยากรในการทำงานลง ลดการใช้งานของ Memory
- ป้องกันการเกิด Re-render ของ Child component ที่เรียกใช้ตัวแปรดังกล่าวได้ (เดี๋ยวจะมีการพูดถึงต่อไป)
- เพิ่มประสิทธิภาพของการทำงานของเว็บไซต์เราได้
useMemo ใช้งานอย่างไร
const cachedValue = useMemo(calculateValue, dependencies);| ชื่อ | ประเภท | คำอธิบาย |
|---|---|---|
| calculateValue | any | ค่าของตัวแปรนั้น ๆ ที่จะถูก Cache โดยที่เราควรจะกำหนดไว้เป็นจำพวก Non-primitive data types เช่น Object, Array และ Function ที่ return ค่าใด ๆ |
| dependencies | any[] | Array ที่มีสมาชิกเป็นตัวแปรที่ต้องการดูว่ามีการเปลี่ยนแปลง ถ้าหากตัวแปรเหล่านี้มีการเปลี่ยนแปลง จะทำให้ตัวแปรที่เรียกใช้ useMemo ทำการ Re-calculate |
วิธีการใช้งาน useMemo ที่ถูกต้อง
เราได้รู้จัก useMemo กันไปแล้วว่าควรใช้ตอนไหน และ ใช้อย่างไร ใช้เพื่ออะไร แต่จุดที่ยากที่สุดคือ Practical implementation เพราะเราเคยเห็นนักพัฒนาหลาย ๆ คนยังคงใช้งานแบบผิด ๆ ทำให้เกิดปัญหาในเรื่องของประสิทธิภาพของการทำงาน และ แทนที่จะช่วยให้ดียิ่งขึ้นมันกลับทำให้หลาย ๆ อย่างแย่ลงไปมากกว่าเดิม
สิ่งที่อาจเกิดขึ้นจากการใช้ useMemo แบบผิด ๆ
- ประสิทธิภาพแย่ลงกว่าเดิม
- Maintainability ลดลง
- เกิด Bug ในเว็บไซต์ของเรา
การใช้งานเพื่อ Cache expensive calculation
ในกรณีที่เรามีการเก็บข้อมูลที่เกิดจากการคำนวณทางคณิตศาสตร์ซับซ้อนจำพวกการ Loop, Aggregate หรือการจัดการข้อมูล Array ที่มีจำนวนมาก ๆ ในทุกครั้งที่มีการ Re-render ข้อมูลเหล่านี้ก็จะเกิดการ Re-calculate ใหม่ จึงนำมาใช้ useMemo เพื่อ Cache ข้อมูลเหล่านี้ไว้
const expensiveFunction = () => {
let sum = 0;
while (sum < 1000000000) sum += 1;
return sum;
};
const App = () => {
const [count, setCount] = useState(0);
// useMemo จะไม่ถูก re-calculate
const expensiveValue = useMemo(() => {
return expensiveFunction();
}, []);
return (
<div>
{expensiveValue}
<button onClick={() => setCount((prev) => prev + 1)}>
Re-render
</button>
<span>{count}</span>
</div>
);
};ต่อมาเราก็จะเกิดคำถามที่ว่า แล้วเราจะรู้ได้อย่างไรว่าข้อมูลของเรามันเป็น Expensive calculation หรือไม่ เราจะทราบได้อย่างไร?
สำหรับคำตอบนี้ ถ้าเราไม่ได้มีการ Loop หรือคำนวณ Array ที่มีขนาดสมาชิกใหญ่กว่าหลายพันตัวมันก็คงไม่ได้มีความจำเป็นต้องใช้ useMemo ส่วนถ้าถามว่าเราจะตรวจสอบเป็น Report ออกมาได้อย่างไร ก็สามารถทำได้โดยการใช้ตัวอย่างดังต่อไปนี้ได้
// วิธีที่ 1
console.time("Time spent on calculation");
// คำนวณค่าค่า
console.timeEnd("Time spent on calculation");// วิธีที่ 2
let time = performance.now()
// คำนวณค่าค่า
time = performance.now() - time;
console.log("Time spent on calculation",time)ดังนั้นในหลาย ๆ กรณีเราไม่จำเป็นต้องใช้
useMemoก็ได้ถ้าข้อมูลไม่ได้ใหญ่มากจริง ๆ
การใช้งานเพื่อป้องกันการ Re-render
สมมติว่าเรามี Component อยู่ 3 ส่วนได้แก่
- FirstComponent
- SecondComponent
- App
และมี Code ตัวอย่างดังต่อไปนี้
import { useState } from "react";
const SecondComponent = () => {
console.log("Second component has been re-rendered");
return (
<div>
<h2>This is Second component</h2>
</div>
);
};
const FirstComponent: React.FC<React.PropsWithChildren> = ({
children,
}) => {
console.log("First component has been re-rendered");
return (
<div>
<h2>This is First component</h2>
<div>{children}</div>
</div>
);
};
const App = () => {
const [count, setCount] = useState(0);
return (
<>
<div>
<button
onClick={() => setCount((prev) => prev + 1)}
>
Increase
</button>
<p>{count}</p>
<FirstComponent>
<SecondComponent />
</FirstComponent>
</div>
</>
);
};เมื่อใดก็ตามที่เรากดปุ่ม Increase จะทำให้เกิดการเพิ่มขึ้นของจำนวน count ผ่านการเปลี่ยนแปลงของ State -> ก่อให้เกิดการ Re-render ของ Component -> ส่งผลให้ Object ที่อยู่ภายใต้ App component จะถูก Re-create ทั้งหมด ดังนั้น Child components ที่อยู่ภายใต้ App นี่จึงเป็นเหตุผลว่าถ้าหากเราสังเกตที่ Console ตามตัวอย่างด้านล่างถัดไป เราก็จะพบว่ามันมีการ Execute code ที่อยู่ภายใต้ของแต่ละ Component ทุกครั้งที่เรากดปุ่ม!
: First component has been re-rendered: Second component has been re-renderedCONSOLE
ซึ่งคุณเองก็คงจะคิดแบบเดียวกันว่า ในเมื่อมันไม่ใช่ส่วนที่มีการเปลี่ยนแปลง มันไม่ควรถูก Re-render แล้วเราจะป้องกันได้อย่างไร? สำหรับในส่วนนี้เราต้องเข้าใจก่อนว่าเงื่อนไขที่มันจะป้องกันการ Re-render ได้นั้นจะแบ่งออกเป็นกรณีดังต่อไปนี้
เพราะกรณีทั้งสองนั้นจะมีการป้องกันการ Re-render ที่แตกต่างกันไปครับ
1. Component ที่ไม่มีการส่ง Props ผ่านไป
สำหรับในกรณีนี้ เราจะขอไม่ลงลึกมากแต่จะยกตัวอย่างผ่าน ๆ เนื่องจากมันไม่ได้เกี่ยวข้องโดยตรง โดยเราป้องกันการ Re-render ได้ด้วยการเพิ่ม อย่าง React.memo() ตามตัวอย่างดังต่อไปนี้
const FirstComponent: React.FC<React.PropsWithChildren> = ({
children,
}) => {
console.log("First component has been re-rendered");
return (
<div>
<h2>This is First component</h2>
<div>{children}</div>
</div>
);
};
const MemoFirstComponent = React.memo(FirstComponent);
const App = () => {
const [count, setCount] = useState(0);
return (
<>
<div>
<button
onClick={() => setCount((prev) => prev + 1)}
>
Increase
</button>
<p>{count}</p>
<MemoFirstComponent />
</div>
</>
);
};2. Component ที่มีการส่ง Props ผ่านไป
และนี่คือกรณีที่ useMemo จะเข้ามามีบทบาทเนื่องจากว่าการใช้ React.memo เพียงอย่างเดียวกับ Child component จะไม่สามารถป้องกันการ Re-render เนื่องจากว่าในการ Re-render นั้น React จะทำการเปรียบเทียบ Props ที่ส่งผ่านมาด้วย Object.is() และ ในทุก ๆ ครั้งที่ Component ถูก Re-render จะทำให้ Reference ที่ชี้ไปยัง Object เปลี่ยนเป็นตำแหน่งใหม่ที่แตกต่างจากเดิม ส่งผลให้การส่งผ่าน props ที่เป็นพวก Non-primitive (ซึ่ง Function component เองก็เป็น) ถูกเข้าใจผิดว่ามันเปลี่ยนไปจากเดิมทำให้ Child component ปลายทางทำการ Re-render ตัวเองตามกันไป
คุณคงจะคิดว่าจาก Code ที่แสดงนั้นมีการส่ง props ไปอย่างไร เราไม่ได้ส่งอะไรไปเลยนะ... จริง ๆ แล้วเรากำลังถูก เนื่องจากการเขียน JSX ในรูปดังต่อไปนี้
<FirstComponent>
<SecondComponent />
</FirstComponent>นั้นไม่ได้แตกต่างจากการเขียนแบบนี้
<FirstComponent children={<SecondComponent />} />ทำให้จริง ๆ แล้วการที่เราแนบ Children ไปด้วยภายในก็ไม่แตกต่างจากการส่ง Prop children ที่เป็น Object ไปอยู่ดีนั่นเอง เราจึงใช้ useMemo ในการ Cache component ที่ถูกส่งไปเป็น Prop children เพื่อไม่ให้ React เข้าใจผิดในส่วนนี้
เราสามารถปรับ Code เป็นดังต่อไปนี้เพื่อป้องกันการ Re-render ที่ไม่จำเป็น
const SecondComponent = () => {
console.log("Second component has been re-rendered");
return (
<div>
<h2>This is Second component</h2>
</div>
);
};
const FirstComponent: React.FC<React.PropsWithChildren> = ({
children,
}) => {
console.log("First component has been re-rendered");
return (
<div>
<h2>This is First component</h2>
<div>{children}</div>
</div>
);
};
const MemoFirstComponent = React.memo(FirstComponent);
const App = () => {
const [count, setCount] = useState(0);
const secondComponent = useMemo(
() => <SecondComponent />,
[]
);
return (
<>
<div>
<button
onClick={() => setCount((prev) => prev + 1)}
>
Increase
</button>
<p>{count}</p>
<FirstComponent>
<SecondComponent />
</FirstComponent>
<MemoFirstComponent>
{secondComponent}
</MemoFirstComponent>
</div>
</>
);
};ซึ่งเมื่อคุณดูจากตัวอย่างข้างต้นแล้วก็อาจจะดูขัด ๆ กับ Mental model ที่เรามีต่อ React ไปสักหน่อย
ข้อผิดพลาดที่คนส่วนใหญ่ไม่ทราบ
การใช้ Non primitive data types ที่ dependencies
การใช้ Object หรือ Array ใน Dependency arrays นั้นจะไม่ได้ช่วยการ Cache ข้อมูลเนื่องจากเมื่อมีการ Re-render แล้ว React จะทำการเทียบว่าข้อมูลก่อน-หลัง มีการแปลี่ยนแปลงไปหรือไม่ แต่เนื่องจากว่า Object จะมี Reference ที่เปลี่ยนแปลงใหม่ตลอดเวลาทำให้การใช้ useMemo ในกรณีนี้จึงไม่ส่งผลช่วยอะไรทั้งสิ้น
const profile = {
fname: "Supakorn",
lname: "Netsuwan",
age: 20,
};
const data = useMemo(
() => ({
address: {
road: "xxxx",
district: "xxxx",
},
profile,
}),
[profile] // // ควรเปลี่ยนไปเป็น Primitive data type
);Component ที่มีการส่ง Props จำพวก Primitive data type
ถ้าหากมีการส่ง Props ผ่านไปที่ Child component ที่คุณต้องการแต่ว่าเป็น Primitive data types คุณไม่จำเป็นต้องใช้ useMemo กับข้อมูลเหล่านั้นที่ถูกส่งผ่านไปก็ได้ เช่น
type ProfileType = {
fname: string;
lname: string;
age: number;
};
const Profile: React.FC<ProfileType> = ({
fname,
lname,
age,
}) => {
console.log("Profile component has been re-rendered");
return (
<div>
<h2>User Profile</h2>
<div>
<p>
Name: {fname} {lname}
</p>
<p>Age: {age}</p>
</div>
</div>
);
};
const MemoProfile = React.memo(Profile);
const App = () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((prev) => prev + 1)}>
Increase
</button>
<p>{count}</p>
<MemoProfile fname="Supakorn" lname="Netsuwan" age={20} />
</div>
);
};จากตัวอย่างดังกล่าวคุณสามารใช้เพียง React.memo() กับ Child component ที่ถูกเรียกก็เพียงพอแล้ว เพราะทุกครั้งที่มีการ Re-render เราส่ง Primitive data type ไปซึ่งผลลัพธ์ของการเช็คจากตัว React เองมันสามารถรู้ได้ว่าข้อมูลที่ส่งผ่านมาไม่ได้มีการเปลี่ยนแปลงจากการใช้ Object.is()
