วิธีแยกฟังก์ชันล้วนออกจาก component React ให้ test ได้

การคำนวณ การจัดรูปแบบ การสร้าง URL และการตรวจข้อมูลที่ฝังอยู่ใน component ให้ย้ายออกมาเป็นฟังก์ชันธรรมดาที่รับค่าเป็น argument แล้วคืนค่า ไม่อ่าน hook ไม่อ่าน ref ไม่เรียก t() ข้างใน จะได้ unit test ที่ไม่ต้อง render อะไรเลย และ React Compiler ก็จัดการโค้ดแบบนี้ได้ดีที่สุด

เผยแพร่
เวลาอ่าน
4 นาที

หมวด 3 · Componentบทที่ 10 จาก 14

สารบัญบทเรียน บทที่ 10 จาก 14
ในบทนี้ 8 หัวข้อ
  1. ฟังก์ชันล้วนคืออะไร
  2. อาการที่บอกว่ามีฟังก์ชันซ่อนอยู่
  3. ก่อนและหลัง
  4. เขียน test ได้ทันที
  5. อะไรบ้างที่ควรย้ายออกมา
  6. React Compiler ชอบโค้ดแบบนี้
  7. เมื่อไหร่ไม่ต้องแยก
  8. จากงานจริง

ในบทแยก custom hook เราย้าย state กับ effect ออกจาก component ไปไว้ใน hook บทนี้เป็นอีกครึ่งหนึ่ง คือโค้ดที่ไม่มี state เลย แค่รับค่าเข้าไปแล้วคำนวณออกมา โค้ดแบบนี้ไม่ควรอยู่ทั้งใน component และใน hook

ฟังก์ชันล้วนคืออะไร

ฟังก์ชันล้วน (pure function) มีสองข้อ

  1. ใส่ค่าเดิม ได้ผลเดิมทุกครั้ง ผลขึ้นกับ argument อย่างเดียว ไม่ขึ้นกับเวลา ไม่ขึ้นกับตัวแปรข้างนอก
  2. ไม่ไปเปลี่ยนอะไรข้างนอก ไม่แก้ object ที่รับมา ไม่เรียก API ไม่เขียน localStorage

React เองก็ขอให้ component เป็นแบบนี้ Keeping Components Pure อธิบายว่า component ควรคืน JSX เดิมทุกครั้งที่ได้ props และ state เดิม ฟังก์ชันที่เราแยกออกมาจาก component ก็ใช้กฎเดียวกัน แค่คืนค่าธรรมดาแทน JSX

อาการที่บอกว่ามีฟังก์ชันซ่อนอยู่

  • useMemo ยาวสามสิบบรรทัด ที่ข้างในเป็นแค่การคำนวณ ไม่ได้มี state อะไร
  • ฟังก์ชันที่เขียนแล้วเรียกทันทีในตัว component หรือใน JSX แบบ {(() => { ... })()}
  • helper ที่อ่าน ref หรือเรียก t() ข้างใน ทำให้ต้องอยู่ใน component เพราะต้องใช้ของที่มีแค่ใน component
  • logic ที่ test ได้ทางเดียวคือ render component แล้วหาข้อความบนจอ เช่น อยากรู้ว่าค่าส่งของพื้นที่ห่างไกลคิดถูกไหม ต้อง render ทั้งหน้าตะกร้า

ข้อสุดท้ายเป็นตัววัดที่ดีที่สุด ถ้าจะ test กฎทางธุรกิจข้อหนึ่งแล้วต้อง mock hook สามตัว แปลว่ากฎข้อนั้นอยู่ผิดที่

ก่อนและหลัง

ตัวอย่างนี้เป็นสรุปยอดในตะกร้า ค่าส่งฟรีเมื่อยอดถึง 1,000 บาท ไม่อย่างนั้นคิด 60 บาท หรือ 120 บาทถ้าเป็นพื้นที่ห่างไกล

// ก่อน: คำนวณค่าส่งอยู่ใน render และอ่าน t() จาก closure
function CartSummary({ lines, zone }: CartSummaryProps) {
  const t = useT();
  const shipping = (() => {
    const subtotal = lines.reduce((sum, line) => sum + line.price * line.qty, 0);
    if (subtotal >= 1000) return { fee: 0, label: t('cart.freeShipping') };
    return { fee: zone === 'remote' ? 120 : 60, label: t('cart.standardShipping') };
  })();

  return <SummaryRow label={shipping.label} value={shipping.fee} />;
}

// หลัง: ฟังก์ชันล้วนรับทุกอย่างที่ต้องใช้เป็น argument และคืนค่าที่ไม่ผูกกับภาษา
// cart/shipping-for.ts
export type ShippingQuote = { fee: number; kind: 'free' | 'standard' };

export function shippingFor(lines: CartLine[], zone: Zone): ShippingQuote {
  const subtotal = lines.reduce((sum, line) => sum + line.price * line.qty, 0);
  if (subtotal >= 1000) return { fee: 0, kind: 'free' };
  return { fee: zone === 'remote' ? 120 : 60, kind: 'standard' };
}

// cart/cart-summary.tsx
function CartSummary({ lines, zone }: CartSummaryProps) {
  const t = useT();
  const shipping = shippingFor(lines, zone);

  return <SummaryRow label={t(`cart.${shipping.kind}Shipping`)} value={shipping.fee} />;
}

ที่เปลี่ยนมีสามจุด

ฟังก์ชันออกมาอยู่ไฟล์ของตัวเอง วางไว้ในโฟลเดอร์ของ feature ข้าง component ตามที่ว่าไว้ในบทจัดโฟลเดอร์ตาม feature ไม่ต้องไปอยู่ใน utils/ กองกลาง จนกว่าจะมี feature ที่สองต้องใช้

ฟังก์ชันไม่รู้เรื่องภาษา แทนที่จะคืนข้อความที่แปลแล้ว ฟังก์ชันคืน kind เป็นค่าคงที่ ให้ component เป็นคนแปลง kind เป็นข้อความ แบบนี้ test ไม่ต้องสนใจว่าตอนนี้ภาษาอะไร และถ้าวันหนึ่งข้อความเปลี่ยน test ก็ไม่ต้องแก้

component เหลือแค่เรียกฟังก์ชันแล้วแสดงผล อ่านแล้วรู้ทันทีว่าค่าส่งมาจากไหน ส่วนรายละเอียดว่าคิดยังไงไปดูในไฟล์เดียว

เขียน test ได้ทันที

พอเป็นฟังก์ชันล้วน test ก็ไม่ต้อง render ไม่ต้อง mock อะไรเลย ใส่ค่าเข้าไปแล้วดูค่าที่ออกมา

// cart/shipping-for.test.ts
import { describe, expect, it } from 'vitest';
import { shippingFor } from './shipping-for';

const line = (price: number, qty = 1) => ({ price, qty });

describe('shippingFor', () => {
  it('ส่งฟรีเมื่อยอดถึง 1,000 บาท', () => {
    expect(shippingFor([line(500, 2)], 'remote')).toEqual({ fee: 0, kind: 'free' });
  });

  it('คิด 60 บาทในพื้นที่ปกติเมื่อยอดยังไม่ถึง', () => {
    expect(shippingFor([line(999)], 'standard')).toEqual({ fee: 60, kind: 'standard' });
  });

  it('คิด 120 บาทในพื้นที่ห่างไกลเมื่อยอดยังไม่ถึง', () => {
    expect(shippingFor([line(999)], 'remote')).toEqual({ fee: 120, kind: 'standard' });
  });
});

test สามตัวนี้ครอบทุกทางที่ฟังก์ชันเดินได้ คือส่งฟรี พื้นที่ปกติ และพื้นที่ห่างไกล และรันเสร็จในเสี้ยววินาที ลองเทียบกับการ test ผ่าน component ที่ต้อง render ตะกร้า mock ระบบแปลภาษา แล้วหาตัวเลขบนจอ

ถ้า test ครอบแค่บางทาง เช่นมีแค่ test ส่งฟรี ก็ให้ความมั่นใจผิด ๆ ว่าครอบแล้ว ให้ดูว่าฟังก์ชันมี if กี่ทาง แล้วเขียนให้ครบทุกทาง

อะไรบ้างที่ควรย้ายออกมา

ที่เจอบ่อยมีห้ากลุ่ม

กลุ่ม ตัวอย่าง
จัดรูปแบบ ราคาเป็นบาท วันที่แบบ พ.ศ. เบอร์โทรใส่ขีด
สร้าง URL ลิงก์ไปหน้าสินค้าพร้อม query ของตัวกรอง
ตรวจข้อมูล รหัสไปรษณีย์ห้าหลัก เลขผู้เสียภาษีสิบสามหลัก
ค่าที่คำนวณจากข้อมูล ยอดรวม สถานะของคำสั่งซื้อจากหลาย field สิทธิ์ที่ผู้ใช้เห็นปุ่มไหน
ตำแหน่งและขนาด ตำแหน่งของ tooltip ความกว้างของคอลัมน์

กลุ่มที่สี่เป็นกลุ่มที่คุ้มที่สุด เพราะเป็นกฎทางธุรกิจที่มักผิดแบบเงียบ ๆ เช่น "ปุ่มยกเลิกแสดงเมื่อคำสั่งซื้อยังไม่จัดส่งและยังไม่เกิน 24 ชั่วโมง" ถ้าเขียนเป็นเงื่อนไขยาว ๆ ใน JSX จะไม่มีใครเห็นว่าผิด จนลูกค้ากดยกเลิกคำสั่งซื้อที่ส่งไปแล้ว

ของที่เปลี่ยนตามเวลา ส่งเข้าไปเป็น argument

ฟังก์ชันที่เรียก new Date() ข้างในไม่ใช่ฟังก์ชันล้วน เพราะเรียกวันนี้กับพรุ่งนี้ได้ผลต่างกัน และ test ก็ต้องไป mock นาฬิกา ให้ส่งเวลาปัจจุบันเข้าไปแทน

// ฟังก์ชันรับเวลาปัจจุบันเป็น argument test ใส่เวลาไหนก็ได้
export function canCancel(order: Order, now: Date): boolean {
  const hours = (now.getTime() - order.placedAt.getTime()) / 3_600_000;
  return order.status === 'pending' && hours < 24;
}

ค่าที่สุ่ม ค่าจาก localStorage หรือ config ของผู้ใช้ก็ใช้หลักเดียวกัน อ่านค่าที่ขอบ คือใน component หรือ hook แล้วส่งเข้าฟังก์ชัน

React Compiler ชอบโค้ดแบบนี้

React Compiler ช่วย memoize ค่าให้อัตโนมัติ คือจำผลที่คำนวณไว้แล้วไม่คำนวณซ้ำถ้าค่าที่ใช้ไม่เปลี่ยน แต่ทำได้เฉพาะโค้ดที่ทำตาม Rules of React โค้ดที่อ่าน ref ระหว่าง render หรือแก้ object ที่รับมา compiler จะข้ามไป ฟังก์ชันล้วนที่รับค่าแล้วคืนค่าจึงเป็นโค้ดที่ compiler จัดการได้ง่ายที่สุด และเราก็ไม่ต้องเขียน useMemo เอง รายละเอียดว่า compiler ข้ามอะไรบ้างอยู่ในบท'use memo'

เมื่อไหร่ไม่ต้องแยก

  • บรรทัดเดียวที่อ่านเข้าใจทันที เช่น items.length === 0 ไม่ต้องห่อเป็นฟังก์ชัน isEmpty(items) ให้ต้องกดเข้าไปดู
  • แยกเป็นการ refactor ไม่ใช่การย้าย ตัวอย่างข้างบนไม่ได้แค่ย้ายโค้ด แต่เปลี่ยนจากคืนข้อความเป็นคืน kind ด้วย การเปลี่ยนแบบนี้ควรอยู่ใน commit ของตัวเองพร้อม test ไม่ปนกับ commit ที่ย้ายโค้ดอย่างเดียว คนรีวิวจะได้ดูทีละเรื่อง
  • อย่าแยกจนต้องส่ง argument สิบตัว ถ้าฟังก์ชันต้องรับค่าเยอะขนาดนั้น แปลว่ามันทำหลายเรื่อง ให้แบ่งเป็นสองสามฟังก์ชันตามเรื่องที่ทำ

จากงานจริง

ร้านค้าออนไลน์ Next.js 16 กับ CMS ที่เป็น Next.js admin ใน pnpm monorepo ที่เราแยก component ใหญ่ไปราว 70 ไฟล์ การย้ายแบบนี้เกิดขึ้นในราว 20 ไฟล์ ทั้งการคำนวณราคาแต่ละแถว ชื่อที่แสดงของ block แต่ละชนิด รายชื่อคนที่กำลังแก้หน้าเดียวกัน และการคิดว่าผู้ใช้มีสิทธิ์ในขอบเขตไหน เราทำแบบนี้เป็นขั้นแรกของการแยกทุกไฟล์ เพราะทำให้ไฟล์สั้นลงได้มาก ความเสี่ยงต่ำ และทุกฟังก์ชันที่ย้ายออกมามี test ติดมาด้วย ก่อนจะไปแตะ hook หรือ component ย่อยที่ยากกว่า

บทถัดไป

อ่านต่อ

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

ล่าสุด

ดูทั้งหมด →