โฟลเดอร์มั่วเพราะอะไร — วิธีวินิจฉัยโครง 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.tsx | react-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 ฝั่ง client | SSR ต้องคิดใหม่เป็น 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 สุดท้ายให้ถือเป็นข้อเสนอที่ยังรอผลจริง
สรุป
สิ่งที่ควรทำก่อนย้ายไฟล์แม้แต่ไฟล์เดียว:
grepว่าแต่ละไฟล์ import อะไรจากภายนอก — คุณจะพบว่าโค้ดส่วนใหญ่สะอาดกว่าที่รู้สึกgrepหา coupling กับ bundler จริง ๆ — มันอาจหายไปแล้วโดยที่ไม่มีใครสังเกตgrepหา browser global — นี่คือตัวขวางจริงของการย้าย framework ไม่ใช่ bundlergrepหา peer import ในโฟลเดอร์เดียวกัน — จำนวนของมันคือคำตอบว่า “มั่วตรงไหน”
แล้วตัดสินด้วย 4 ข้อนี้:
| # | เกณฑ์ |
|---|---|
| 1 | แบ่งด้วย “ผูก framework / ไม่ผูก” ไม่ใช่ “app / lib” |
| 2 | index.* = ทางเข้า · ไฟล์อื่นเป็นใบไม้ ห้าม import กันเอง |
| 3 | ผู้บริโภคเป็นเจ้าของสัญญา ไม่ใช่ผู้ผลิต |
| 4 | ของที่ทุกคนต้องรู้จัก ต้องไม่รู้จักใครเลย (หรือดีกว่านั้น: ทำให้ไม่ต้องมี) |
และข้อสุดท้ายที่ไม่เกี่ยวกับโฟลเดอร์แต่สำคัญกว่า — เกณฑ์ทุกข้อข้างบนบังคับด้วยเครื่องได้
(dependency-cruiser, ESLint import/no-restricted-paths) ⇒ อย่าปล่อยให้มันเป็นแค่ข้อตกลงในหัว
เพราะข้อตกลงที่ไม่มีเครื่องบังคับ คือข้อตกลงที่จะถูกลืมในสามเดือน