วิธีใช้ 'use memo' ให้ React Compiler ทำงานจริง

ในโหมด annotation ของ React Compiler คำสั่ง 'use memo' ต้องเป็นบรรทัดแรกในตัวฟังก์ชัน ถ้าวางไว้บนสุดของไฟล์จะไม่มีอะไร compile เลย และ compiler 1.0 ข้ามฟังก์ชันที่มี finally หรือ try ที่ไม่มี catch ไปเงียบ ๆ จึงต้องรัน compiler ดูเองว่าฟังก์ชันไหน compile จริง

เผยแพร่
เวลาอ่าน
5 นาที
แท็ก
#Next.js#React

หมวด 4 · Refactor ให้ปลอดภัยบทที่ 13 จาก 14

สารบัญบทเรียน บทที่ 13 จาก 14
ในบทนี้ 9 หัวข้อ
  1. React Compiler ทำอะไร
  2. เปิดใช้ใน Next.js 16
  3. 'use memo' ต้องอยู่ในตัวฟังก์ชัน
  4. compiler ข้ามฟังก์ชันที่ compile ไม่ได้แบบเงียบ ๆ
  5. รัน compiler ดูเองว่าฟังก์ชันไหน compile
  6. โค้ดแบบไหนที่ compiler ต้องการ
  7. เปิด compiler คือการเปลี่ยนพฤติกรรม ให้ทำเป็นงานแยก
  8. เมื่อไหร่ไม่ต้องใช้ 'use memo'
  9. จากงานจริง

React Compiler ช่วย memo ค่าและฟังก์ชันใน component ให้เอง แทนที่จะต้องเขียน useMemo, useCallback และ memo ด้วยมือ Next.js 16 เปิดใช้ได้จาก config บรรทัดเดียว และมีโหมดที่ให้เลือกเปิดทีละ component ด้วยคำสั่ง 'use memo'

กฎของบทนี้มีสองข้อ

  1. ในโหมด annotation 'use memo' ต้องเป็นบรรทัดแรกในตัวฟังก์ชัน วางไว้บนสุดของไฟล์แล้ว ไม่มีอะไร compile เลย
  2. อย่าเชื่อ directive ให้รัน compiler ดูเอง เพราะ compiler ข้ามฟังก์ชันที่ compile ไม่ได้ไปโดยไม่เตือนอะไร

ทั้งสองข้อเป็นเรื่องที่ไม่มีใครเห็นจนกว่าจะลองรัน compiler กับโค้ดของตัวเอง หน้าเว็บทำงานเหมือนเดิมทุกอย่าง แค่ไม่ได้เร็วขึ้นอย่างที่คิด

React Compiler ทำอะไร

ทุกครั้งที่ component render ใหม่ React จะคำนวณค่าในตัว component และสร้างฟังก์ชันที่ประกาศข้างในใหม่ทั้งหมด ถ้าส่งค่าหรือฟังก์ชันพวกนั้นต่อให้ child ก็ทำให้ child render ใหม่ตามไปด้วย แม้ข้อมูลจริงจะไม่เปลี่ยน

ที่ผ่านมาเราแก้ด้วย useMemo, useCallback และ React.memo ซึ่งต้องคิดเองว่าจุดไหนควรใส่ และต้องดูแล dependency array ให้ครบ React Compiler ทำงานนี้ตอน build แทน วิเคราะห์ว่าค่าไหนขึ้นกับอะไร แล้วใส่ cache ให้เอง โดยมีเงื่อนไขว่าโค้ดต้องทำตาม Rules of React เช่น ไม่แก้ props หรือ state ตรง ๆ และไม่อ่าน ref ระหว่าง render

เปิดใช้ใน Next.js 16

ติดตั้ง babel-plugin-react-compiler แล้วเปิดใน next.config.ts ได้สองแบบ

// next.config.ts แบบ compile ทุก component และ hook ที่ compiler รู้จัก
const nextConfig: NextConfig = {
  reactCompiler: true,
};

// แบบเลือกเปิดทีละฟังก์ชันด้วย 'use memo'
const nextConfig: NextConfig = {
  reactCompiler: { compilationMode: 'annotation' },
};

แบบแรกเหมาะกับโปรเจกต์ใหม่หรือโปรเจกต์ที่โค้ดสะอาดพอ แบบที่สองเหมาะกับโปรเจกต์เดิมที่ยังไม่แน่ใจว่าทุกไฟล์ทำตาม Rules of React ครบ เปิดทีละ component ที่วัดแล้วว่าคุ้ม ถ้าเปิดแบบแรกแล้วเจอ component ที่มีปัญหา ใช้ 'use no memo' ปิดเฉพาะตัวนั้นได้

Next.js ใช้ SWC เลือกเฉพาะไฟล์ที่มี JSX หรือ hook ส่งให้ compiler จึงไม่ได้ทำให้ build ช้าลงทั้งโปรเจกต์ แต่ก็ยังช้ากว่าไม่เปิด บางโปรเจกต์จึงเปิดเฉพาะตอน build production

'use memo' ต้องอยู่ในตัวฟังก์ชัน

directive ของ JavaScript อย่าง 'use client' และ 'use server' วางไว้บนสุดของไฟล์ แต่ 'use memo' ในโหมด annotation ทำงานอีกแบบ compiler 1.0 อ่านเฉพาะเมื่อเป็นคำสั่งแรกในตัวฟังก์ชัน

// ก่อน: ดูเหมือนเปิดแล้ว แต่ compile ไม่ได้สักฟังก์ชัน
'use memo';
'use client';

export function ProductGrid({ items }: Props) {
  const sorted = sortByPrice(items);
  return <Grid>{sorted.map((p) => <ProductCard key={p.id} product={p} />)}</Grid>;
}

export function ProductFilters({ value, onChange }: FilterProps) {
  // …
}

// หลัง: เปิดทีละฟังก์ชันที่ต้องการ
'use client';

export function ProductGrid({ items }: Props) {
  'use memo';
  const sorted = sortByPrice(items);
  return <Grid>{sorted.map((p) => <ProductCard key={p.id} product={p} />)}</Grid>;
}

export function ProductFilters({ value, onChange }: FilterProps) {
  'use memo';
  // …
}

แบบก่อนไม่มี error ไม่มี warning ทั้งตอน build และตอนรัน ทุกอย่างทำงานเหมือนไม่ได้เปิด compiler ส่วน custom hook ก็ใส่ 'use memo' เป็นบรรทัดแรกได้แบบเดียวกับ component

ถ้าใช้โหมดเปิดทั้งโปรเจกต์ (reactCompiler: true) เรื่องนี้ไม่เกิด เพราะ compiler ไม่ต้องรอ directive แต่โปรเจกต์ที่เริ่มจากโหมด annotation มักมี 'use memo' บนสุดไฟล์ติดมาจากการ copy ตัวอย่างหรือจากคนที่คิดว่าใช้เหมือน 'use client'

compiler ข้ามฟังก์ชันที่ compile ไม่ได้แบบเงียบ ๆ

เรื่องที่สองหาเจอยากกว่า compiler รองรับ JavaScript ไม่ครบทุก syntax ถ้าเจอฟังก์ชันที่ compile ไม่ได้ ค่าเริ่มต้นคือข้ามฟังก์ชันนั้นไปทั้งตัว ปล่อยเป็นโค้ดเดิม โดยไม่หยุด build

ตัวอย่างที่เจอบ่อยที่สุดใน React Compiler 1.0 คือ try ที่มี finally หรือ try ที่ไม่มี catch ซึ่งมักอยู่ใน handler ที่ต้องปิดสถานะ loading ไม่ว่าจะสำเร็จหรือไม่

// ก่อน: finally ใน handler ทำให้ทั้ง component ไม่ compile
export function SaveBar({ submit }: { submit: () => Promise<void> }) {
  'use memo';
  const [busy, setBusy] = useState(false);

  async function save() {
    setBusy(true);
    try {
      await submit();
    } finally {
      setBusy(false);
    }
  }

  return <button disabled={busy} onClick={save}>บันทึก</button>;
}

// หลัง: ย้ายส่วนที่มี finally ออกไปเป็นฟังก์ชันธรรมดานอก component
async function runWhileBusy(task: () => Promise<void>, setBusy: (b: boolean) => void) {
  setBusy(true);
  try {
    await task();
  } finally {
    setBusy(false);
  }
}

export function SaveBar({ submit }: { submit: () => Promise<void> }) {
  'use memo';
  const [busy, setBusy] = useState(false);
  return <button disabled={busy} onClick={() => runWhileBusy(submit, setBusy)}>บันทึก</button>;
}

ฟังก์ชันที่อยู่นอก component ไม่ได้ผ่าน compiler อยู่แล้ว จะเขียน finally ยังไงก็ได้ ส่วน component กลับมา compile ได้ อีกทางคือย้าย logic นี้ไปไว้ใน custom hook ที่ไม่ได้ใส่ 'use memo' หรือเขียนเป็น try/catch ที่ไม่มี finally แล้วปิดสถานะหลังบล็อก ถ้าได้พฤติกรรมเดิมจริง

ข้อจำกัดพวกนี้เปลี่ยนตามรุ่นของ compiler รุ่นถัดไปอาจรองรับ finally แล้ว จึงไม่ควรจำเป็นรายการ ให้รันดูกับรุ่นที่ใช้อยู่ตามหัวข้อถัดไป

รัน compiler ดูเองว่าฟังก์ชันไหน compile

babel-plugin-react-compiler รับ logger ที่บอกผลของแต่ละฟังก์ชันได้ สคริปต์สั้น ๆ นี้รันกับไฟล์ไหนก็ได้ในโปรเจกต์

// scripts/compiler-report.cjs
const babel = require('@babel/core');
const compiler = require('babel-plugin-react-compiler');

for (const file of process.argv.slice(2)) {
  const events = [];
  babel.transformFileSync(file, {
    babelrc: false,
    configFile: false,
    parserOpts: { plugins: ['jsx', 'typescript'] },
    plugins: [[compiler, {
      compilationMode: 'annotation',
      logger: { logEvent: (_file, e) => events.push(`${e.kind} ${e.fnName ?? ''}`) },
    }]],
  });
  console.log(file, events.length ? events : 'ไม่มีฟังก์ชันไหน compile');
}
node scripts/compiler-report.cjs components/product-grid.tsx components/save-bar.tsx
components/product-grid.tsx [ 'CompileSuccess ProductGrid', 'CompileSuccess ProductFilters' ]
components/save-bar.tsx [ 'CompileError ' ]

CompileSuccess ตามด้วยชื่อฟังก์ชันคือ compile ได้ CompileError คือ compiler ลองแล้วไม่ผ่าน (ในข้อมูลของ event จะมีเหตุผล เช่น "Handle TryStatement with a finalizer") ส่วนไฟล์ที่ไม่มี event เลยคือไม่มีฟังก์ชันไหนเปิดใช้ ซึ่งเป็นอาการของ 'use memo' บนสุดไฟล์ ถ้าใช้โหมดเปิดทั้งโปรเจกต์ ให้เปลี่ยน compilationMode เป็นค่าเดียวกับ config จริง

อีกทางที่ใช้ระหว่างพัฒนาคือ React DevTools ซึ่งแสดงป้าย "Memo ✨" ข้าง component ที่ compiler ทำงานแล้ว แต่ต้องดูทีละหน้า และถ้าโปรเจกต์เปิด compiler เฉพาะ production ก็จะไม่เห็นป้ายนี้ตอน dev เลย

โค้ดแบบไหนที่ compiler ต้องการ

compiler memo ได้อย่างปลอดภัยก็ต่อเมื่อ component เป็นฟังก์ชันที่ "บริสุทธิ์" ระหว่าง render คือรับ props กับ state เท่าเดิม ต้องได้ JSX เท่าเดิม และไม่ไปแก้อะไรข้างนอก React เรียกกติกานี้ว่า Rules of React สามแบบที่เจอบ่อยในโค้ดเก่า

// แก้ props ตรง ๆ ระหว่าง render
function CartLines({ lines }: { lines: Line[] }) {
  lines.sort((a, b) => a.name.localeCompare(b.name));          // แก้ array ของ parent
  return <ul>{lines.map((l) => <CartLine key={l.id} line={l} />)}</ul>;
}
// แก้: สร้าง array ใหม่
const sorted = lines.toSorted((a, b) => a.name.localeCompare(b.name));

// อ่าน ref ระหว่าง render
function Search() {
  const inputRef = useRef<HTMLInputElement>(null);
  const hasText = inputRef.current?.value !== '';             // ค่าที่ React ไม่รู้ว่าเปลี่ยน
  // …
}
// แก้: เก็บค่าที่ใช้แสดงผลเป็น state แล้ว update จาก onChange

// setState ใน effect เพื่อคำนวณค่าจาก props
function PriceLabel({ price, vat }: Props) {
  const [total, setTotal] = useState(0);
  useEffect(() => setTotal(price * (1 + vat)), [price, vat]);
  return <span>{total}</span>;
}
// แก้: คำนวณตอน render ได้เลย
const total = price * (1 + vat);

โค้ดพวกนี้ทำงานได้ตอนไม่มี compiler เพราะ component render ใหม่ทุกครั้งอยู่แล้ว พอ compiler cache ผลไว้ หน้าจอจะไม่ update ตามค่าที่แอบเปลี่ยน

ทางที่ง่ายที่สุดในการหาโค้ดแบบนี้คือ eslint-plugin-react-hooks ตั้งแต่รุ่น 6 ที่มีกฎซึ่งใช้ตัววิเคราะห์ของ compiler มาด้วย เช่น react-hooks/immutability, react-hooks/refs, react-hooks/purity และ react-hooks/set-state-in-effect เปิดผ่าน preset recommended ได้เลย ควรเปิด lint ให้เขียวก่อนเปิด compiler ในไฟล์ไหน ถ้ามีจุดที่ยังแก้ไม่ไหว ลดเป็น warn ไว้พร้อมรายการไฟล์ ดีกว่าปิดกฎทิ้ง

เปิด compiler คือการเปลี่ยนพฤติกรรม ให้ทำเป็นงานแยก

ข้อนี้สำคัญที่สุดตอน refactor component ที่ compiler ยังไม่เคย compile ทำงานแบบปกติมาตลอด ถ้าโค้ดแอบละเมิด Rules of React อยู่ เช่น แก้ object ที่เป็น props หรืออ่าน ref.current ระหว่าง render ก็ยังแสดงผลถูก พอ compiler เริ่ม compile ค่าที่ cache ไว้อาจไม่ update ตามที่เคย

ในโปรเจกต์ที่เปิด compiler เฉพาะ production ปัญหาแบบนี้จะไม่เห็นตอน dev เลย จึงต้องระวังสองกรณี

  • แยกไฟล์แล้ว compiler เริ่มทำงานโดยไม่ตั้งใจ เช่น ไฟล์เดิมมี 'use memo' บนสุดไฟล์ (ไม่ได้ทำงาน) แล้วตอนแยก component ย่อยออกไป มีคนใส่ 'use memo' ไว้ในฟังก์ชันใหม่ให้ถูกที่ ส่วนนั้นจะเริ่ม compile ทันทีใน production
  • ย้ายโค้ดแล้วเกิด finally ใหม่ใน component ส่วนที่เคย compile จะหยุด compile ผลคือช้าลงเงียบ ๆ

ทางที่ปลอดภัยคือรันสคริปต์ข้างบนกับไฟล์เก่าและไฟล์ใหม่ แล้วเขียนไว้ในคำอธิบายของ PR ว่าฟังก์ชันไหน compile ก่อนและหลัง ถ้าอยากเปิด compiler เพิ่ม ให้ทำเป็น PR ของตัวเอง ทดสอบใน build production แล้ววัดผล ไม่ปนกับงานย้ายโค้ด

เมื่อไหร่ไม่ต้องใช้ 'use memo'

  • component ที่ render ไม่บ่อยหรือเบาอยู่แล้ว การ memo มีต้นทุนของตัวเอง ทั้งขนาดโค้ดและหน่วยความจำ ถ้าไม่ได้วัดว่าช้า ไม่ต้องเปิดเพิ่ม
  • โปรเจกต์ที่เปิดโหมด compile ทั้งหมดอยู่แล้ว 'use memo' ไม่มีผลอะไรเพิ่ม ใช้ 'use no memo' เฉพาะตัวที่ต้องปิด
  • Server Component ไม่ได้ render ซ้ำบนเบราว์เซอร์ ไม่มีอะไรให้ memo การใส่ไว้ไม่เสียหาย แต่ไม่ได้อะไร

ส่วน useMemo และ useCallback ที่มีอยู่แล้วในไฟล์ที่ compile ได้ ไม่ต้องรีบลบ compiler ทำงานร่วมกับของเดิมได้ ลบได้ทีหลังเมื่อแน่ใจว่าไฟล์นั้น compile จริง

จากงานจริง

ในร้านค้าออนไลน์ Next.js 16 กับ CMS ที่เป็น Next.js admin ใน pnpm monorepo ที่เรา refactor ไป ฝั่ง CMS เปิด compiler แบบ annotation เฉพาะตอน build production เพื่อไม่ให้ dev ช้า ระหว่างตรวจงานแยกไฟล์ เราลองรัน compiler กับโค้ดเก่าและโค้ดใหม่เพื่อเทียบว่าไม่มีอะไรเปลี่ยน แล้วพบว่าไฟล์ใหญ่ราวสิบห้าไฟล์มี 'use memo' อยู่บนสุดไฟล์ รวมถึงหน้า editor ที่ใหญ่ที่สุดของระบบ ไฟล์พวกนี้ไม่เคย compile ใน production มาก่อนเลย และมีบางจุดที่การแยกไฟล์ไปทำให้ compiler เริ่มทำงานโดยไม่ได้ตั้งใจ ตั้งแต่นั้นทุก PR ที่ย้ายโค้ดต้องบอกว่าฟังก์ชันไหน compile ก่อนและหลัง

บทถัดไป

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →