ข้ามไปยังเนื้อหาหลัก

Public API

Public API คือ สัญญา (Contract) ระหว่างกลุ่มของ Modules (เช่น Slice) กับโค้ดที่จะมาใช้งานมัน มันยังทำหน้าที่เป็นประตู (Gate) ที่อนุญาตให้เข้าถึง Object บางอย่างเท่านั้น และต้องผ่านทาง Public API นั้นๆ เท่านั้น

ในทางปฏิบัติ มักจะถูก Implement ด้วยไฟล์ Index ที่มีการ Re-export:

pages/auth/index.js
export { LoginPage } from "./ui/LoginPage";
export { RegisterPage } from "./ui/RegisterPage";

อะไรที่ทำให้ Public API ดี?

Public API ที่ดีจะทำให้การใช้งานและการ Integrate Slice นั้นเข้าไปในโค้ดอื่น สะดวกและเชื่อถือได้ ซึ่งสามารถทำได้โดยตั้งเป้าหมาย 3 ข้อนี้:

  1. ส่วนอื่นๆ ของ Application ต้องได้รับความคุ้มครองจากการเปลี่ยนแปลงโครงสร้างภายใน Slice เช่น การทำ Refactoring
  2. การเปลี่ยนแปลงสำคัญในพฤติกรรมของ Slice ที่ทำลายความคาดหวังเดิม ควรส่งผลให้เกิดการเปลี่ยนแปลงใน Public API
  3. เฉพาะส่วนที่จำเป็นของ Slice เท่านั้นที่ควรถูกเปิดเผย

ข้อสุดท้ายมีนัยสำคัญในทางปฏิบัติ มันอาจจะน่าดึงดูดใจที่จะสร้าง Wildcard re-exports ของทุกอย่าง โดยเฉพาะในช่วงแรกของการพัฒนา Slice เพราะ Object ใหม่ๆ ที่คุณ Export จากไฟล์ของคุณจะถูก Export ออกจาก Slice โดยอัตโนมัติ:

Bad practice, features/comments/index.js
// ❌ BAD CODE BELOW, DON'T DO THIS
export * from "./ui/Comment"; // 👎 don't try this at home
export * from "./model/comments"; // 💩 this is bad practice

สิ่งนี้ทำร้ายความสามารถในการค้นพบ (Discoverability) ของ Slice เพราะคุณไม่สามารถบอกได้ง่ายๆ ว่า Interface ของ Slice นี้คืออะไร การไม่รู้ Interface หมายความว่าคุณต้องขุดลงไปลึกในโค้ดของ Slice เพื่อทำความเข้าใจว่าจะ Integrate มันอย่างไร อีกปัญหาคือคุณอาจเผลอเปิดเผยไส้ในของ Module โดยไม่ได้ตั้งใจ ซึ่งจะทำให้การ Refactor ยากขึ้นถ้ามีใครเริ่มมาพึ่งพามัน

Public API สำหรับ Cross-imports

Cross-imports คือสถานการณ์ที่มี Slice หนึ่ง Import จากอีก Slice หนึ่งที่อยู่ใน Layer เดียวกัน โดยปกติเรื่องนี้ถูกห้ามโดย กฎการ Import บน Layers แต่บ่อยครั้งก็มีเหตุผลที่สมควรที่จะทำ Cross-import ตัวอย่างเช่น Business entities ในโลกความจริงมักจะอ้างอิงถึงกันและกัน และมันดีที่สุดที่จะสะท้อนความสัมพันธ์เหล่านี้ในโค้ดแทนที่จะพยายามอ้อมค้อม

เพื่อจุดประสงค์นี้ จึงมี Public API ชนิดพิเศษ หรือที่รู้จักในนาม @x-notation ถ้าคุณมี Entities A และ B, และ Entity B จำเป็นต้อง Import จาก Entity A, ดังนั้น Entity A สามารถประกาศ Public API แยกต่างหากสำหรับ Entity B โดยเฉพาะ

  • 📂 entities
    • 📂 A
      • 📂 @x
        • 📄 B.ts — Public API พิเศษสำหรับโค้ดภายใน entities/B/
      • 📄 index.ts — Public API ปกติ

จากนั้นโค้ดภายใน entities/B/ สามารถ Import จาก entities/A/@x/B:

import type { EntityA } from "entities/A/@x/B";

Notation A/@x/B ตั้งใจให้อ่านว่า "A crossed with B"

note

พยายามรักษา Cross-imports ให้น้อยที่สุด และ ใช้ Notation นี้เฉพาะบน Entities layer เท่านั้น ซึ่งเป็นที่ที่การกำจัด Cross-imports มักจะไม่สมเหตุสมผล

ปัญหาเกี่ยวกับ Index files

Index files เช่น index.js, หรือที่รู้จักในนาม Barrel files เป็นวิธีที่พบบ่อยที่สุดในการกำหนด Public API มันสร้างง่าย แต่เป็นที่รู้กันว่าก่อให้เกิดปัญหากับ Bundlers และ Frameworks บางตัว

Circular imports

Circular import คือเมื่อไฟล์สองไฟล์หรือมากกว่า Import กันเองเป็นวงกลม

Three files importing each other in a circleThree files importing each other in a circle

ในภาพ: สามไฟล์ fileA.js, fileB.js, และ fileC.js Import กันและกันเป็นวงกลม

สถานการณ์เหล่านี้มักยากสำหรับ Bundlers ที่จะจัดการ และในบางกรณีอาจนำไปสู่ Runtime errors ที่ยากต่อการ Debug

Circular imports สามารถเกิดขึ้นได้โดยไม่มี Index files แต่การมี Index file เป็นการเปิดโอกาสให้สร้าง Circular import โดยไม่ตั้งใจได้ง่ายขึ้น มักเกิดขึ้นเมื่อคุณมีสอง Objects ที่เปิดเผยใน Public API ของ Slice เช่น HomePage และ loadUserStatistics และ HomePage ต้องการเข้าถึง loadUserStatistics แต่ทำแบบนี้:

pages/home/ui/HomePage.jsx
import { loadUserStatistics } from "../"; // importing from pages/home/index.js

export function HomePage() { /* … */ }
pages/home/index.js
export { HomePage } from "./ui/HomePage";
export { loadUserStatistics } from "./api/loadUserStatistics";

สถานการณ์นี้สร้าง Circular import เพราะ index.js Import ui/HomePage.jsx, แต่ ui/HomePage.jsx Import index.js

เพื่อป้องกันปัญหานี้ พิจารณาหลักการสองข้อนี้ ถ้าคุณมีสองไฟล์ และไฟล์หนึ่ง Import อีกไฟล์หนึ่ง:

  • เมื่อพวกมันอยู่ใน Slice เดียวกัน ให้ใช้ Relative imports และเขียน Full import path เสมอ
  • เมื่อพวกมันอยู่ต่าง Slice กัน ให้ใช้ Absolute imports ตัวอย่างเช่น ใช้อเลียส (Alias)

Bundle ขนาดใหญ่และ Tree-shaking ที่พังใน Shared

Bundlers บางตัวอาจมีปัญหาในการทำ Tree-shaking (ลบโค้ดที่ไม่ได้ถูก Import) เมื่อคุณมี Index file ที่ Re-export ทุกอย่าง

โดยปกติเรื่องนี้ไม่ใช่ปัญหาสำหรับ Public API ระดับ Slice เพราะเนื้อหาของ Module มักเกี่ยวข้องกันอย่างใกล้ชิด ดังนั้นคุณแทบจะไม่ค่อยต้องการ Import อย่างหนึ่งแล้ว Tree-shake อีกอย่างทิ้งไป อย่างไรก็ตาม มีสองกรณีที่พบบ่อยมากที่กฎปกติของ Public API ใน FSD อาจนำไปสู่ปัญหา — shared/ui และ shared/lib

สองโฟลเดอร์นี้เป็นคอลเลกชันของสิ่งที่ไม่เกี่ยวข้องกันและมักไม่ได้ถูกใช้พร้อมกันในที่เดียว ตัวอย่างเช่น shared/ui อาจมี Modules สำหรับทุก Component ใน UI library:

  • 📂 shared/ui/
    • 📁 button
    • 📁 text-field
    • 📁 carousel
    • 📁 accordion

ปัญหานี้แย่ลงเมื่อหนึ่งใน Modules เหล่านี้มี Heavy dependency เช่น Syntax highlighter หรือ Drag'n'drop library คุณคงไม่อยากดึงสิ่งพวกนั้นเข้ามาในทุกๆ หน้าที่ใช้แค่บางอย่างจาก shared/ui เช่น ปุ่ม (Button)

ถ้า Bundles ของคุณโตขึ้นจนไม่น่าพอใจเนื่องจาก Public API เดียวใน shared/ui หรือ shared/lib แนะนำให้แยก Index file สำหรับแต่ละ Component หรือ Library แทน:

  • 📂 shared/ui/
    • 📂 button
      • 📄 index.js
    • 📂 text-field
      • 📄 index.js

จากนั้นผู้ใช้ (Consumers) ของ Components เหล่านี้สามารถ Import พวกมันโดยตรงแบบนี้:

pages/sign-in/ui/SignInPage.jsx
import { Button } from '@/shared/ui/button';
import { TextField } from '@/shared/ui/text-field';

ไม่มีการป้องกันจริงจากการเลี่ยง Public API

เมื่อคุณสร้าง Index file สำหรับ Slice คุณไม่ได้ห้ามใครไม่ให้เลี่ยงการใช้มันแล้วไป Import โดยตรงจริงๆ นี่เป็นปัญหาโดยเฉพาะกับ Auto-imports เพราะมีหลายที่ที่ Object สามารถถูก Import ได้ ดังนั้น IDE จึงต้องตัดสินใจแทนคุณ บางครั้งมันอาจเลือก Import โดยตรง ซึ่งทำลายกฎ Public API บน Slices

เพื่อดักจับปัญหาเหล่านี้โดยอัตโนมัติ เราแนะนำให้ใช้ Steiger ซึ่งเป็น Architectural linter พร้อม Ruleset สำหรับ Feature-Sliced Design

ประสิทธิภาพของ Bundlers แย่ลงในโปรเจกต์ขนาดใหญ่

การมี Index files จำนวนมากในโปรเจกต์สามารถทำให้ Development server ช้าลงได้ ดังที่ TkDodo บันทึกไว้ใน บทความของเขา "Please Stop Using Barrel Files"

มีหลายสิ่งที่คุณทำได้เพื่อจัดการกับปัญหานี้:

  1. คำแนะนำเดียวกับในปัญหา "Bundle ขนาดใหญ่และ Tree-shaking ที่พังใน Shared" — แยก Index files สำหรับแต่ละ Component/Library ใน shared/ui และ shared/lib แทนที่จะมีไฟล์เดียวใหญ่ๆ

  2. หลีกเลี่ยงการมี Index files ใน Segments บน Layers ที่มี Slices ตัวอย่างเช่น ถ้าคุณมี Index สำหรับ Feature "comments", 📄 features/comments/index.js, ไม่มีเหตุผลที่จะมี Index อีกอันสำหรับ ui segment ของ Feature นั้นอย่าง 📄 features/comments/ui/index.js

  3. ถ้าคุณมีโปรเจกต์ที่ใหญ่มาก มีโอกาสสูงที่ Application ของคุณสามารถแยกเป็นก้อนใหญ่ๆ (Chunks) ได้หลายก้อน ตัวอย่างเช่น Google Docs มีความรับผิดชอบที่แตกต่างกันมากสำหรับ Document editor และสำหรับ File browser คุณสามารถสร้าง Monorepo setup ที่แต่ละ Package เป็น FSD root แยกต่างหาก โดยมีชุดของ Layers เป็นของตัวเอง บาง Packages อาจมีแค่ Shared และ Entities layers, บางอันอาจมีแค่ Pages และ App, บางอันอาจรวม Shared เล็กๆ ของตัวเอง แต่ยังคงใช้ Shared ใหญ่จากอีก Package ด้วยก็ได้