สวัสดีครับ ผมสร้าง prewire ไลบรารี DI แบบ build time สำหรับฟรอนต์เอนด์ TypeScript
แรงจูงใจที่ทำให้ผมสร้างไลบรารีนี้มาจากโครงสร้างของฟรอนต์เอนด์ monorepo ที่ผมดูแลอยู่
ฟรอนต์เอนด์ของหลายธุรกิจแชร์โค้ด core/kit เดียวกัน และแอปในปัจจุบันใช้ Next.js อยู่ ในอนาคต บางธุรกิจอยากลองใช้ TanStack Router แต่ผมก็ไม่อยากให้ core ที่แชร์กันต้องพึ่งพา next/navigation หรือ @tanstack/react-router โดยตรง
จึงต้องการโครงสร้างที่แต่ละธุรกิจเลือก implementation ที่ต่างกันได้ โดยไม่ต้องแก้ core ที่แชร์กัน
ใน prewire โค้ดที่แชร์กันจะประกาศ port และ token ก่อน
export interface RouterPort {
href(to: string): string
push(to: string): void
}
export const ROUTER = new InjectionToken<RouterPort>('router')
แต่ละแอปจะ implement port นี้ด้วย framework ที่ตัวเองใช้
export const tanstackRouter = injectable(
{ logger: LOGGER },
({ logger }): RouterPort => ({
href: (to) => to,
push: (to) => {
logger.info(`navigate → ${to}`)
throw redirect({ to })
},
}),
{ provides: ROUTER },
)
ฝั่ง consumer รวมถึงโค้ดที่แชร์ ไม่จำเป็นต้องรู้ว่า implementation มาจากที่ไหน แค่ import จาก path เดียวกัน
import { router } from '#prewire'
prewire codegen จะวิเคราะห์ declaration ของ injectable() ในแอปและ shared package แบบ static แล้วสร้าง composition root ของ TypeScript ธรรมดาตามลำดับ dependency
export const logger = consoleLoggerBinding.factory({})
export const router = tanstackRouterBinding.factory({ logger })
ไม่ใช้ runtime container, reflect-metadata หรือ decorator ปัญหาอย่าง binding ที่ขาดหาย, dependency วนเป็นวงกลม, binding ซ้ำ จะไม่ถูกจัดการตอนรัน แต่จะกลายเป็น build error ในขั้นตอนสร้างโค้ด โค้ดที่สร้างขึ้นอ่านได้โดยตรง และถ้าจำเป็นก็สามารถแยกออกมาใช้เป็น composition root แบบเขียนเองได้
เพื่อไม่ให้แอป override implementation ของ shared package ได้ตามใจ ค่า override จึงถูกปิดไว้เป็นค่าเริ่มต้น เฉพาะ binding ที่โค้ดแชร์ระบุ default: true เท่านั้นที่แอปจะแทนที่ได้ แนวคิดคล้ายกับ open ของ Kotlin
ยังมีแกน environment แยกไว้ต่างหากด้วย ผู้ใช้สามารถสร้าง root ที่ต่างกันตามเกณฑ์ที่ต้องการ เช่น live/test, server/client หรือชื่อแอปของแต่ละธุรกิจ ตัวอย่างเช่น binding เฉพาะ server ไม่ใช่แค่ไม่ถูกรันใน client root แต่ import ของมันจะไม่ถูกใส่ไว้ในโค้ดที่สร้างขึ้นเลย
ตัวอย่างใน repository ปัจจุบันมีการเชื่อมต่อ shared kit เดียวกันกับสามแอปต่อไปนี้ในรูปแบบที่ต่างกัน
- Next.js
- TanStack Start
- React Router
สำหรับการผสานกับ build มี unplugin สำหรับ Vite·webpack·rspack และ withPrewire() สำหรับ Next.js
อย่างไรก็ตาม ยังไม่ได้เอาไปใช้ในโค้ดผลิตภัณฑ์จริง ตอนนี้เป็นขั้นที่แยกออกมาเป็นไลบรารีและตัวอย่างเพื่อพิสูจน์การออกแบบแล้วเปิดเผยต่อสาธารณะ ปัจจุบันเผยแพร่บน npm เป็นเวอร์ชัน 0.1.1 และเนื่องจากเป็นเวอร์ชัน 0.x ช่วงต้น API อาจเปลี่ยนแปลงได้
นอกจากนี้ ถ้าเป็นแอปเดียว environment เดียว และมี binding ไม่กี่ตัว ก็ไม่มีเหตุผลมากนักที่จะใช้ prewire สำหรับโปรเจกต์แบบนั้น การเขียนไฟล์ composition root หนึ่งไฟล์เองน่าจะเรียบง่ายกว่า เป้าหมายหลักที่ผมคิดไว้คือกรณีที่ต้องเชื่อมต่อโค้ดแชร์ชุดเดียวกันให้แตกต่างกันในหลายแอป หลาย environment และหลายชุดทดสอบ
ผมพัฒนา backend เป็นหลักมาโดยตลอด และระหว่างดูแลฟรอนต์เอนด์ monorepo ก็แก้ปัญหานี้ด้วย DI และการสร้างโค้ดในช่วง build time ดังนั้นผมเองก็รู้สึกว่าแนวทางนี้ค่อนข้างมีกลิ่นอายแบบ backend
ผมอยากรู้ว่าคนที่ทำงานฟรอนต์เอนด์เป็นหลัก โดยปกติแล้วแก้ปัญหาแบบที่หลายแอปใช้ core ร่วมกัน แต่มีแค่ implementation ตาม framework เช่น router ที่ต่างกันอย่างไร DI แบบ build time อย่าง prewire ดูเหมาะสมไหม หรือถ้ามีแนวทางที่เรียบง่ายกว่า หรือคุ้นเคยกว่าใน ecosystem ฟรอนต์เอนด์ ก็อยากฟังความคิดเห็นครับ
GitHub: https://github.com/clroot/prewire
npm: https://www.npmjs.com/package/@prewire/core
เป็น MIT License เนื่องจากยังอยู่ในช่วงต้น ยินดีรับฟีดแบ็กเชิงวิจารณ์เกี่ยวกับการออกแบบและ API ด้วยครับ
ยังไม่มีความคิดเห็น