โฟลเดอร์มั่วเพราะอะไร — วิธีวินิจฉัยโครง frontend app shell ก่อนจะลงมือย้ายไฟล์

โฟลเดอร์มั่วเพราะอะไร — วิธีวินิจฉัยโครง frontend app shell ก่อนจะลงมือย้ายไฟล์

TL;DR — “โครงมั่ว” เป็นความรู้สึก ไม่ใช่ข้อมูล. วัดมันด้วย grep 4 คำสั่งก่อน แล้วคุณจะพบว่า สิ่งที่คิดว่าเป็นปัญหามักไม่ใช่ปัญหา. ในเคสนี้: โปรเจกต์ ไม่ได้ผูกกับ Vite เลยสักบรรทัด (ที่คิดว่าผูกคือคอมเมนต์) · ผูกกับ router แค่ 4 ไฟล์ · แต่ แตะ browser global 8 ไฟล์ ซึ่งคือตัวขวางจริงตอนย้ายไป SSR — และไม่มีโครงโฟลเดอร์ไหนแก้ข้อนั้นได้

เรื่องเริ่มจากประโยคเดียว

“ทุกอย่างกองรวมกันใน composition/ ทั้งหมด และชื่อไฟล์ก็ไม่ consistency กัน ทั้งที่เป็นไฟล์ประเภทเดียวกัน ดูมั่วและเข้าใจยาก”

โฟลเดอร์ที่ถูกพูดถึงมีหน้าตาแบบนี้ — 11 ไฟล์ วางแบน:

src/composition/
├── activities-api.ts
├── api-client.ts
├── browser-adapters.ts
├── config.ts
├── deps.ts
├── google-identity-adapter.ts
├── liff-adapter.ts
├── navigation.ts
├── router.ts
├── routes.tsx
└── session-store.ts

อ่านแล้วรู้สึกว่ามั่วจริง แต่ถ้าถามว่า “มั่วตรงไหน” คำตอบที่ได้มักเป็น “ก็มันดูไม่เป็นระเบียบ” ซึ่งเอาไปทำอะไรต่อไม่ได้ — และอันตราย เพราะมันนำไปสู่การย้ายไฟล์ตามความรู้สึก

ขั้นที่ 1 — เปลี่ยนความรู้สึกให้เป็นตัวเลข

ก่อนย้ายอะไรสักไฟล์ ให้ตอบ 4 คำถามนี้ด้วยคำสั่ง ไม่ใช่ด้วยการอ่านผ่าน ๆ

คำถามที่ 1: แต่ละไฟล์ import อะไรจากภายนอกบ้าง

for f in src/composition/*.ts src/composition/*.tsx; do
  printf "%-32s %s\n" "$(basename $f)" \
    "$(grep -hoE "from '[^.][^']*'" "$f" | sed "s/from '//;s/'//" | sort -u | tr '\n' ' ')"
done

ผลที่ได้เปลี่ยนบทสนทนาทั้งหมด:

ไฟล์import จากภายนอก
config.ts— (ไม่มีเลย)
session-store.ts— (ไม่มีเลย)
activities-api.ts · api-client.ts · liff-adapter.ts · google-identity-adapter.ts · browser-adapters.ts · navigation.ts · deps.tsเฉพาะ lib ของโปรเจกต์เอง
router.ts · routes.tsxreact-router

9 จาก 13 ไฟล์ไม่ผูกกับ framework อะไรเลย — ซึ่งแปลว่าคนเขียนทำถูกมาตลอด ปัญหาไม่ใช่โค้ด แต่เป็นการจัดวาง

คำถามที่ 2: ผูกกับ bundler จริงไหม

grep -rn "import\.meta\|from 'vite'\|vite/client" src/

ผลลัพธ์: 1 บรรทัด และมันเป็นคอมเมนต์

* เดิมอ่าน `import.meta.env.VITE_*` ซึ่งถูก bake ลง bundle ตอน build

คอมเมนต์เล่าประวัติว่า “เคยใช้ ตอนนี้เลิกแล้ว” — งานที่เปลี่ยน config จาก build-time เป็น runtime (ทำไปด้วยเหตุผลคนละเรื่อง คือไม่อยากให้ staging กับ prod ได้ artifact คนละก้อน) ปลด coupling กับ bundler ออกไปหมดโดยไม่มีใครตั้งชื่อว่างั้น

🔑 นี่คือเหตุผลที่ต้องวัด — ถ้าเชื่อความทรงจำ เราจะวางแผน “ปลด Vite” ทั้งที่มันปลดไปแล้ว

คำถามที่ 3: แตะ browser global ตรงไหนบ้าง

grep -rlnE "\bwindow\.|\bdocument\.|\bnavigator\.|\blocalStorage\b" src/

8 ไฟล์ — และนี่คือตัวเลขที่สำคัญที่สุดในบทความนี้

เพราะถ้าเป้าหมายคือ “ย้ายไป Next.js หรือ Remix ได้” ทั้งสองตัวเป็น SSR-first ⇒ โค้ดต้องรันบน server ได้ ⇒ ของพวกนี้พังทันที:

ของที่พังทำไม
window.__APP_CONFIG__server ไม่มี window — ต้องเปลี่ยนเป็น server env แล้วส่งลง client
SDK ฝั่ง client (LIFF, Google Identity)client-only ทั้งดุ้น ⇒ ต้อง 'use client' / <ClientOnly>
session store ที่เก็บใน memory ฝั่ง clientSSR ต้องคิดใหม่เป็น cookie-based ไม่งั้น hydration mismatch

⚠️ ตัวขวางการย้าย framework ไม่ใช่ bundler และไม่ใช่ router แต่คือ rendering model — คือคำถามว่า “โค้ดนี้รันที่ไหน” ไม่ใช่ “ใครเป็นคน build” ไม่มีโครงโฟลเดอร์ไหนทำให้ข้อนี้ฟรีได้ ทำได้แค่ให้ความเสียหายอยู่ในกล่องเดียวที่ทิ้งได้ทั้งกล่อง

คำถามที่ 4: ไฟล์ในโฟลเดอร์เดียวกัน import กันเองกี่จุด

grep -n "from '\./" src/composition/*.ts src/composition/*.tsx

11 จุด — และนี่คือคำตอบตรง ๆ ของคำถาม “มั่วตรงไหน”

deps.ts     → import เพื่อนบ้าน 8 ตัว
routes.tsx  → import เพื่อนบ้าน 1 ตัว
router.ts   → import เพื่อนบ้าน 2 ตัว

โฟลเดอร์ที่ไฟล์ระดับเดียวกัน import กันเองเป็นทอด ๆ มีลำดับชั้นอยู่จริง แต่มองไม่เห็น:

router.ts               ชั้น 3 — entry จริง (ชื่อไม่ได้บอก)
├─ deps.ts              ชั้น 2 — ตัวรวม adapter
│   └─ adapter 8 ตัว    ชั้น 1 — ใบไม้
└─ routes.tsx           ชั้น 2 — ตัวรวม route
    └─ route components ชั้น 1 — ใบไม้

3 ชั้นอัดอยู่ในโฟลเดอร์แบนเดียว ⇒ เปิดมาแล้วไม่มีทางรู้ว่าเริ่มอ่านที่ไหน

ขั้นที่ 2 — ปัญหาที่โผล่ตอนอ่านโค้ดจริง (ไม่ใช่แค่อ่านชื่อ)

พอไล่อ่านทีละไฟล์ เจอของที่ตัวเลขบอกไม่ได้:

router.ts ไม่ใช่ router

export function createAppRouter(): AppRouter {
  const holder: { current?: AppRouter } = {};
  const deps = composeApp((path) => { holder.current.navigate(path); });   // ← ประกอบ deps
  holder.current = createBrowserRouter(appRoutes(deps));                    // ← ประกอบ router
  return holder.current;
}

ไฟล์นี้แก้ปัญหาไก่กับไข่ (deps ต้องการ navigation → navigation ต้องการ router → router ต้องการ deps) ⇒ มันคือ composition root ตัวจริงของแอป ไม่ใช่ deps.ts อย่างที่ชื่อโฟลเดอร์ชวนให้เข้าใจ

คอมเมนต์ที่ระบุไฟล์ผิด

navigation.ts เขียนไว้ว่า:

“ไฟล์นี้คือ ที่เดียว ที่รู้จักยี่ห้อ router”

แต่โค้ดจริงไม่ import router เลยสักบรรทัด:

import { LOGIN_PATH, type AppRoute, type NavigationPort } from '@app/feature-auth';

export function createNavigation(navigate: (path: string) => void): NavigationPort {
  return { go(route) { navigate(pathOf(route)); } };
}

มันรับ navigate เข้ามาทาง parameter ⇒ agnostic สมบูรณ์ คนที่รู้จักยี่ห้อ router จริงคือ router.ts ที่ import { createBrowserRouter } แล้วส่ง navigate เข้าไป

🔴 เจตนาถูก แต่ระบุไฟล์ผิด — ใครเชื่อคอมเมนต์นี้แล้วจะเปลี่ยน router จะไปแก้ผิดไฟล์

logic ที่อยู่ผิดฝั่ง

export function pathOf(route: AppRoute): string {
  switch (route.name) {
    case 'login': return LOGIN_PATH;
    // ...
  }
}

ฟังก์ชันนี้ใช้แต่ของจาก feature-auth (AppRoute + LOGIN_PATH) ไม่ต้องรู้อะไรจากแอปเลย และ switch เป็น exhaustive บน union

⇒ เพิ่ม route ใหม่ใน feature แล้ว TypeScript ฟ้องที่แอป ไม่ใช่ที่ feature ⇒ คนแก้ feature ไม่รู้ว่าทำแอปพังจนกว่าจะ build แอป

ขั้นที่ 3 — เกณฑ์ตัดสิน 4 ข้อ

หลังวัดครบและอ่านโค้ดจบ เกณฑ์ที่ใช้ตัดสินได้จริงมี 4 ข้อ:

1. แกนที่ใช้แบ่งคือ “ผูก framework / ไม่ผูก” ไม่ใช่ “app / lib”

คำถามที่มักได้ยินคือ “งั้นย้ายทุกอย่างออกไปเป็น lib เลยไหม จะได้ agnostic”

การย้ายเข้า lib ไม่ได้ทำให้ agnostic — ย้าย routes.tsx เข้า lib มันก็ยัง import 'react-router' อยู่ดี แค่เปลี่ยนที่อยู่ แถมได้ lib ที่ผูก framework ซึ่งแย่กว่าเดิม เพราะ lib ควรเป็นของที่ยืมใช้ได้

เกณฑ์ที่ใช้ได้จริงคือ “ไฟล์นี้ import อะไรจากภายนอกบ้าง” — ซึ่งเป็นเกณฑ์ที่ วัดด้วยเครื่องได้ และบังคับด้วย dependency-cruiser ได้ตรง ๆ:

{
  name: 'shared-web-must-stay-agnostic',
  severity: 'error',
  from: { path: '^libs/frontend/shared-web/' },
  to:   { path: 'node_modules/(react-router|next|@remix-run)' },
}

2. index.* = หน้าตาของโฟลเดอร์ · ไฟล์อื่น = ใบไม้

กฎข้อเดียวที่แก้ทั้ง “ไม่รู้ว่าไฟล์ไหนเป็น entry” และ “peer import กันเองจนอ่านไม่ออก”:

index.* เป็นไฟล์เดียวที่ import ไฟล์อื่นในโฟลเดอร์เดียวกันได้ · ไฟล์อื่นห้าม import กันเอง

เปิดโฟลเดอร์ไหนก็ตอบได้ทันทีว่าเริ่มอ่านที่ไหน — และบังคับด้วยเครื่องได้อีกเหมือนกัน

ผลพลอยได้ที่ไม่คาดคิด: ไฟล์ที่เคยชื่อ route-table.tsx แล้วดูแปลกว่าทำไมอยู่ใน routes/ พอถามว่า “มันทำอะไร” คำตอบคือ “รวมทุก route เข้าเป็นตารางเดียวให้คนนอกใช้” ⇒ นั่นคือนิยามของ index พอดี ⇒ มันไม่ได้วางผิดที่ แค่ตั้งชื่อผิด

3. ผู้บริโภคเป็นเจ้าของสัญญา ไม่ใช่ผู้ผลิต

ตอนแรกโครงที่คิดไว้คือ:

composition/
├── index.ts
├── app-deps.type.ts     ← ประกาศ AppDeps ที่นี่
└── compose-deps.ts

ปัญหาที่ถูกทักคือ: ถ้า app-deps.type.ts อยู่ใน composition/ แปลว่ามันเป็นของภายในโฟลเดอร์นั้น แต่ route ข้างนอกดันต้องเอื้อมเข้าไปหยิบ ⇒ โฟลเดอร์ไม่ได้บอกขอบเขตจริง

และมันสวนกับหลักที่ใช้อยู่ทุกที่ในโปรเจกต์เดียวกัน — feature/types/*.port.ts คือ ผู้ใช้ประกาศว่าต้องการอะไร แล้ว adapter ข้างนอกมาทำให้ได้ ไม่ใช่ adapter ประกาศแล้วผู้ใช้ไปอ่าน

ทางที่ถูกคือ กลับทิศความเป็นเจ้าของ:

// routes/index.tsx — ผู้บริโภคเป็นคนตั้งสัญญา
export type RouteDeps = { activitiesApi: ActivitiesApiPort; authApi: AuthApiPort; /* ... */ };
export function appRoutes(deps: RouteDeps): RouteObject[] { /* ... */ }
// composition/index.ts — ผู้ผลิตอ่านสัญญาจากผู้บริโภค
import { appRoutes, type RouteDeps } from '../routes/index.js';
const deps: RouteDeps = composeDeps(navigate);   // ← TS ฟ้องทันทีถ้าผลิตไม่ครบ

ผลคือ composition/ ไม่ export type ให้ใครนอกโฟลเดอร์เลย = เป็น scope ปิดจริง

4. ของที่ทุกคนต้องรู้จัก ต้องเป็นของที่ไม่รู้จักใครเลย

ถ้ายังจำเป็นต้องมี type กลางที่หลายฝ่ายใช้ มันต้องเป็นใบไม้ — ไม่ import อะไรในระบบเดียวกันเลย

เหตุผลเป็นเรื่องกราฟล้วน ๆ ถ้าเอา type ไว้ในไฟล์ที่เป็น entry ด้วย:

composition/index.ts ──→ routes/index.tsx ──→ routes/activities.route.tsx
        ↑                                              │
        └──────────────── import type ─────────────────┘        🔴 ครบวง

แต่ถ้าแยกเป็นไฟล์ที่ไม่ชี้ต่อ:

composition/index.ts ──→ routes/index.tsx ──→ routes/activities.route.tsx
        │                      │                        │
        └──────────┬───────────┴────────────────────────┘
                   ↓
             app-deps.type.ts     🍃 ไม่ import อะไรเลย

ทุกลูกศรชี้เข้าหาจุดเดียวและจุดนั้นไม่ชี้กลับ ⇒ เดินยังไงก็ไม่วน

💡 วิธีที่ดีกว่า คือทำให้ไม่ต้องมี type กลางตั้งแต่แรก — ให้ route component รับเฉพาะ prop ที่ตัวเองใช้ แทนที่จะรับ dependency object ทั้งก้อน ในเคสนี้วัดแล้วพบว่าไม่มี route ไหนใช้เกิน 4 จาก 9 field ⇒ ส่งทั้งก้อนคือให้ทุก route รู้จักของที่มันไม่ใช้ 5–7 ตัว

ผลลัพธ์: app shell เหลือ 2 โฟลเดอร์ และไม่มี adapter เลย

apps/member--web/vite/src/
├── main.tsx                        entry ของ bundler
├── app.tsx                         root component
│
├── routes/                         ทุกไฟล์ในนี้ = route จริง
│   ├── index.tsx                   🚪 สารบัญ path → route + เจ้าของ type RouteDeps
│   ├── login.route.tsx             🍃
│   ├── activities.route.tsx        🍃
│   └── ...
│
└── composition/                    ที่ประกอบของจริงเข้าด้วยกัน — ที่เดียว
    ├── index.ts                    🚪 createAppRouter()
    └── compose-deps.ts             🍃

ส่วนที่เหลือย้ายออกไปเป็น lib โดยแบ่งด้วยเกณฑ์ “ใช้กี่แอป”:

libs/frontend/
├── shared-web/          🟢 agnostic + หลายแอปใช้
│   ├── http/            http-client.ts
│   ├── config/          web-config.ts          ← รับค่าทาง param ไม่อ่าน window เอง
│   ├── state/           session.store.ts
│   └── adapters/        google-identity · clipboard · share · navigation
│
└── member/
    ├── feature-auth/
    │   ├── types/navigation.port.ts
    │   └── logic/route-path.ts                 ← pathOf() ย้ายมาอยู่ข้าง port ของมัน
    └── adapters/        liff · activities-api · auth-api

เกณฑ์วัดผลที่จับต้องได้: ย้ายไป Next.js เมื่อไร ให้ ทิ้งทั้งโฟลเดอร์ apps/member--web/vite/ แล้วเขียน apps/member--web/nextjs/ ใหม่ โดยไม่ต้องแตะ libs/ สักไฟล์ — ไม่ใช่ไล่ copy ไฟล์ทีละตัว

⚠️ ข้อยกเว้นเดียวคือ web-config.ts ที่ต้องรับค่าทาง parameter ไม่อ่าน window เอง (Vite อ่านจาก window, Next อ่านจาก server env — ให้แอปเป็นคนหาค่ามาให้) ซึ่งเป็นกฎเดียวกับที่ฝั่ง backend บังคับอยู่แล้วว่า lib ห้ามอ่าน process.env เอง

สิ่งที่บทความนี้ยังพิสูจน์ไม่ได้

ตรง ๆ เลย: โครงข้างบนยังไม่ได้ลงมือทำ ณ เวลาที่เขียน

สิ่งที่วัดแล้วคือ สภาพปัจจุบัน (ตัวเลข import ทั้งหมด) และ เหตุผลที่ทางเลือกแต่ละทางตก สิ่งที่ยังไม่มีคือตัวเลขก่อน/หลัง และหลักฐานว่า “ทิ้งได้ทั้งกล่อง” จริง

⇒ ถ้าคุณจะเอาไปใช้ เอาส่วน “วิธีวินิจฉัย” ไปก่อน (grep 4 คำสั่ง + เกณฑ์ 4 ข้อ) ส่วน tree สุดท้ายให้ถือเป็นข้อเสนอที่ยังรอผลจริง

สรุป

สิ่งที่ควรทำก่อนย้ายไฟล์แม้แต่ไฟล์เดียว:

  1. grep ว่าแต่ละไฟล์ import อะไรจากภายนอก — คุณจะพบว่าโค้ดส่วนใหญ่สะอาดกว่าที่รู้สึก
  2. grep หา coupling กับ bundler จริง ๆ — มันอาจหายไปแล้วโดยที่ไม่มีใครสังเกต
  3. grep หา browser global — นี่คือตัวขวางจริงของการย้าย framework ไม่ใช่ bundler
  4. grep หา peer import ในโฟลเดอร์เดียวกัน — จำนวนของมันคือคำตอบว่า “มั่วตรงไหน”

แล้วตัดสินด้วย 4 ข้อนี้:

#เกณฑ์
1แบ่งด้วย “ผูก framework / ไม่ผูก” ไม่ใช่ “app / lib”
2index.* = ทางเข้า · ไฟล์อื่นเป็นใบไม้ ห้าม import กันเอง
3ผู้บริโภคเป็นเจ้าของสัญญา ไม่ใช่ผู้ผลิต
4ของที่ทุกคนต้องรู้จัก ต้องไม่รู้จักใครเลย (หรือดีกว่านั้น: ทำให้ไม่ต้องมี)

และข้อสุดท้ายที่ไม่เกี่ยวกับโฟลเดอร์แต่สำคัญกว่า — เกณฑ์ทุกข้อข้างบนบังคับด้วยเครื่องได้ (dependency-cruiser, ESLint import/no-restricted-paths) ⇒ อย่าปล่อยให้มันเป็นแค่ข้อตกลงในหัว เพราะข้อตกลงที่ไม่มีเครื่องบังคับ คือข้อตกลงที่จะถูกลืมในสามเดือน

Supawut Thomas

Supawut Thomas

Software Developer

มีประสบการณ์พัฒนา Software ระดับ Enterprise มากกว่า 10 ปี ผ่านงานจริงหลากหลายโปรเจกต์องค์กร — เชื่อว่าความรู้ที่ดีที่สุดคือความรู้ที่มาจากประสบการณ์จริง และอยากแบ่งปันสิ่งเหล่านั้นให้เพื่อน Developer ทุกคนได้นำไปพัฒนาตัวเองได้ดีขึ้นในทุกๆ วัน