Bài này nói về kiến trúc frontend mức senior cho phỏng vấn: mẫu component, vài tên GoF map sang Vue, SOLID như bài test cho thay đổi, micro-frontend như nước đi tổ chức, thư mục theo feature, và design system khác component của app. Người phỏng vấn không chấm Animal / Dog. Họ muốn biết bạn cấu trúc app Vue 3 thế nào để team còn đổi được.
Đọc 15.1–15.3 trước (composition, pattern có tên, đường cắt SOLID). Rồi 15.5 cho thư mục và 15.6 cho thứ thuộc design system. 15.4 là bẫy: tám dev Vue không cần Module Federation. Stack mặc định: Vue 3 + TypeScript + Pinia, Nuxt khi SSR hoặc BFF quan trọng. Trả lời bằng một quyết định, một ràng buộc, một kịch bản lỗi, và cách kiểm chứng.
Tóm tắt: Component Vue 3 render. Composable chịu trách nhiệm một use-case (fetch, permissions, form). Cặp container/presentational là workaround React-Redux 2015. Bạn vẫn dùng phần compound khi chúng chia state và caller phải compose DOM, và headless / renderless chỉ khi DOM phải khác nhau rất nhiều.
Vì sao quan trọng: Một teammate muốn UserListContainer.vue + UserListView.vue; người khác muốn DataProvider renderless slot mười hai binding. Họ hỏi bạn chia hành vi thế nào: slot khác renderless khác composable, và bạn còn bịa cặp container/view trong <script setup> không.
- Cách hoạt động: Presentational/container tách fetch “smart” khỏi markup “dumb” vì
connect()rò Redux.<script setup>kèm composable đã tách nhìn khỏi hành vi:useUserList()trảitems/pending; SFC vẽ. Tách composable ở consumer thứ hai hoặc khi SFC không còn đọc được, không phải ở bốn mươi dòng theo nguyên tắc. - Compound:
FilterBar.Search/.Facet/.Clearchia state quaprovide/injectcó type, không phải$parent. Caller compose DOM. Headless / renderless: composable (useListbox) hoặc primitive kiểu Reka UI phụ trách bàn phím và state; app phụ trách markup.DataProvider.vueslot mười hai binding là shape Vue 3 sai: đó là composable. - Mixin xong rồi: đụng tên,
datangầm, không type. Routing sống ở trang (mỏng), trang gọi composable của feature, không phải trên badge. Analog React: hook kèm composition; cùng rule “đừng bịa Container.js”. Scoped slot tạo closure mới mỗi render thường update child: ổn trừ khi bạn đã đo.
<!-- Compound: state ở root qua provide; phần không phải widget độc lập. -->
<script setup lang="ts">
const q = ref("")
provide(filterBarKey, { q, clear: () => { q.value = "" } })
</script>
<template>
<FilterBar>
<FilterBar.Search v-model="q" />
<FilterBar.Facet field="status" />
<FilterBar.Clear />
</FilterBar>
</template>
<!-- Shape Vue 3 sai: <DataProvider v-slot="{ data, loading, error, refetch, ... }"> -->Điều gì xảy ra: Bar giữ q. Search, Facet và Clear đọc qua injection key. Caller vẫn quyết thứ tự DOM. DataProvider mười hai slot lẽ ra là useFilterBar().
| Thói cũ | Mặc định Vue 3 | Giữ khi |
|---|---|---|
UserListContainer + view | useUserList() trong trang feature | Không bao giờ dùng cặp SFC |
| Mixin | Composable với return tường minh | Không bao giờ: đụng tên |
| HOC / render-props | Slot + composable | DOM compound mà caller phải compose |
| Renderless slot 12 binding | useListbox / Reka / useAsyncData | Markup phải khác theo brand |
Góc nhìn senior
- Composition, composable và thư mục feature là mặc định. Component render; composable chịu trách nhiệm use-case.
- Compound
provide/injectkhi phần chia state và caller phải compose DOM. Key có type, không phải$parent. - Headless là composable hoặc primitive.
DataProvider.vuelà shape sai trong Vue 3. - Tách ở consumer thứ hai, không phải ở bốn mươi dòng. Routing ở lại trên trang mỏng.
- Mixin xong rồi. Analog React: hook, không phải cặp Container.js.
Lỗi hay gặp
- Triệu chứng:
UserListContainer.vue+UserListView.vue. Nguyên nhân: thói React-Redux. Cách xử lý:useUserList()trong trang.- Triệu chứng: SFC 400 dòng fetch, validate và vẽ chart. Nguyên nhân: không cắt ở consumer thứ hai. Cách xử lý: composable theo lý do đổi.
- Triệu chứng:
DataProvidervới mười hai slot prop. Nguyên nhân: theo đám renderless. Cách xử lý: một composable.- Triệu chứng: đụng
datamixin. Nguyên nhân: mixin. Cách xử lý: return composable tường minh.
Nhà tuyển dụng hay hỏi gì
Presentational khác container: bạn còn làm vậy không? Họ muốn composable, phần compound, và khi nào headless là thật, không phải một cặp SFC.
Trả lời khi phỏng vấn
Mình không bịa cặp container/view. Vue 3 đã tách nhìn khỏi hành vi bằng script setup và composable: component render, composable chịu trách nhiệm use-case. Mình vẫn dùng compound khi phần chia state và caller phải compose DOM, implement bằng provide/inject có type, không phải parent. Headless là composable hoặc primitive như listbox; DataProvider slot mười hai binding là shape sai. Mình tách ở consumer thứ hai, không phải ở bốn mươi dòng. Mixin xong rồi. Routing sống ở trang mỏng. Trên sản phẩm admin FilterBar là compound; useUserList nằm cạnh feature orders, không phải trong SFC Container.
Nhớ nhanh
- Component render. Composable chịu trách nhiệm use-case. Không cặp Container/View.
- Compound
provide/injectkhi caller phải compose DOM. DataProvider.vuelà composable. Mixin xong rồi.- Tách ở consumer thứ hai. Routing ở lại trên trang mỏng.
Câu hỏi nối tiếp thường gặp
- Mixin khác composable? Đụng tên, data ngầm, không type: mixin xong rồi.
- Routing sống ở đâu? Trang phụ trách composable, không phải badge.
- Khi nào tách SFC 40 dòng? Ở consumer thứ hai, hoặc khi không còn đọc được.
- Compound provide khác prop
itemstrên Tabs? Tab theo data muốnitems. Chrome do caller compose muốn compound.
Tóm tắt: Nêu rõ Observer, Strategy, Adapter, Facade như quyết định Vue, không phải sơ đồ lớp GoF. Observer là reactivity và invalidation query: Vue 3 cố ý gỡ $on/$off. Strategy là map hàm. Adapter bọc SDK third-party. Facade là BFF hoặc module API nhỏ. Singleton là module scope hoặc Pinia, per-request trên Nuxt SSR.
Vì sao quan trọng: “Bạn dùng design pattern nào ở frontend?” Sơ đồ lớp Singleton nghe như junior cầm sách. Họ muốn thanh toán, block CMS, và cart không được rò giữa user SSR, nêu như quyết định Vue.
- Cách hoạt động: Observer là props xuống, emit lên, Pinia,
provide/inject, và invalidation query. Không phải busmittglobal giữaOrderTablevàHeaderCart. Strategy làRecord<PaymentKind, PayFn>để Apple Pay là hàm mới, không phảiswitch200 dòng trongCheckout.vue. - Adapter bọc Stripe hoặc client SOAP legacy sau
PaymentGateway. Phần còn lại của app không bao giờ thấy type SDK. Facade là Nitroserver/api/checkout.post.tshoặcorders.api.ts: một shape cho UI. Factory là whitelisthero/faq→() => import(...); import tĩnh mọi block CMS là main 3 MB. - Pinia là singleton theo app trên client và per-request trên Nuxt SSR.
class Foo { static instance }rò data user xuyên request.refmức module là cùng chỗ rò. Analog React: Observer là state + Query; Strategy là cùng map.
// Strategy: thêm Apple Pay mà không sửa switch 200 dòng của Checkout.vue.
const processors: Record<PaymentKind, PayFn> = {
card: payWithCard,
paypal: payWithPaypal,
apple: payWithApple, // kind mới = hàm mới, không phải nhánh mới trong SFC
}
export async function pay(kind: PaymentKind, input: PayInput) {
const fn = processors[kind]
if (!fn) throw new Error(`unsupported payment ${kind}`)
return fn(input)
}
// Adapter: Checkout nói với PaymentGateway, không bao giờ với type Stripe.js.
export const stripeGateway: PaymentGateway = {
charge: (cents) => stripe.paymentIntents.create({ amount: cents, currency: "usd" }),
}Điều gì xảy ra: Checkout gọi pay("apple", input). Map điều phối. Stripe ở sau PaymentGateway. Thêm kind không đụng SFC. Bus global cố ý không có trong ví dụ này.
| Mẫu | Shape Vue | Từ chối |
|---|---|---|
| Observer | Reactivity, Pinia, invalidation query | $bus / mitt global giữa feature |
| Strategy | Record<Kind, Fn> | switch 200 dòng trong SFC |
| Adapter / Facade | Type gateway; BFF / *.api.ts | Type SDK trong mọi component |
| Singleton | Module api hoặc Pinia (per-request trên SSR) | static instance rò xuyên user Nuxt |
Góc nhìn senior
- Chỉ nêu tên pattern khi nó làm rõ quyết định. Observer là ngôn ngữ; bạn không cần
class EventBus. - Thông báo giữa các feature: domain event ở BFF, action Pinia, hoặc query invalidation, không phải
mitt. - Pinia là per-request trên Nuxt SSR.
refở module vàstatic instancerò data user. - Factory cho block CMS phải whitelist +
import(). Import tĩnh mọi block là main 3 MB. EventTargetdành cho API browser, không phải feature trong app nói với nhau.
Lỗi hay gặp
- Triệu chứng:
OrderTablenói vớiHeaderCartquamitt. Nguyên nhân: Observer giả. Cách xử lý: action Pinia hoặc query invalidation.- Triệu chứng: user B thấy cart của user A trên Nuxt. Nguyên nhân:
static instance/refở module. Cách xử lý: Pinia per-request.- Triệu chứng: main 3 MB sau một trang CMS. Nguyên nhân: factory import tĩnh mọi block. Cách xử lý: whitelist +
import().- Triệu chứng: keep-alive đăng listener bus hai lần. Nguyên nhân: event bus. Cách xử lý: đừng dùng bus.
Nhà tuyển dụng hay hỏi gì
Bạn dùng design pattern nào ở frontend? Sơ đồ lớp Singleton là câu junior. Họ muốn Observer, Strategy, Adapter, Facade như những quyết định Vue.
Trả lời khi phỏng vấn
Mình dùng ngôn ngữ, không phải sơ đồ lớp GoF. Observer là reactivity của Vue và query invalidation: Vue 3 gỡ on/off có chủ đích, nên OrderTable không nói với HeaderCart qua mitt. Strategy là map hàm thanh toán, mình thêm Apple Pay mà không cần switch hai trăm dòng. Adapter bọc Stripe sau PaymentGateway. Facade là BFF hoặc một module api nhỏ. Singleton là module scope hoặc Pinia, và trên Nuxt thì per-request: static instance sẽ rò user. Factory cho block CMS là whitelist kèm import. Ở admin nội bộ team mình thêm kind thanh toán như một hàm, không phải nhánh mới trong Checkout.vue.
Nhớ nhanh
- Observer là reactivity và query invalidation, không phải bus global.
- Strategy là map hàm. Adapter giấu type SDK.
- Pinia là per-request trên Nuxt SSR.
static instancerò user. - Factory CMS: whitelist +
import(). Đừng import tĩnh mọi block.
Câu hỏi nối tiếp thường gặp
- Dùng
EventTargetthật ở đâu? API browser, không phải feature trong app nói với nhau xuyên cây. - Test map strategy thế nào? Một bảng kind → mock gateway kỳ vọng.
- Vì sao
static instancerò trên Nuxt SSR? Một isolate, nhiều request. Pinia là per-request. - Khi nào
mittchấp nhận được? Hầu như không bao giờ giữa các feature. Dùng action Pinia hoặc event BFF thì hơn.
Tóm tắt: SOLID trên frontend là bài test cho thay đổi, không phải chuyện động vật. S tách composable khi có lý do đổi thứ hai. O mở qua slot và registry, không phải extends BaseButton. Liskov: MenuButton vẫn hành xử như button. Interface: props nhỏ. D phụ thuộc OrderApi, không phải new AxiosClient() trong composable.
Vì sao quan trọng: “Áp SOLID vào Vue”. Nếu bạn nhắc động vật, họ ngừng nghe. Checkout.vue fetch cart, validate VAT, nói với Stripe, và vẽ stepper: đó là bốn lý do để đổi. Bạn cắt ở đâu, và đâu là chỗ SOLID bị áp quá đà (20 file cho một badge)?
- Cách hoạt động: SRP:
useCart,useVat(country),usePayment(strategy), leafOrderSummary. Vuex như god store là fail SRP; store Pinia theo domain mới là cách xử lý. OCP: slot, registry variant;v-ifladder là mùi; Options APIextendscopy data và cãi nhau với TS. - LSP:
MenuButtonphải kích hoạt được bằng bàn phím và có state disabled. Interface dùng chung mà chỉ một implementer cần là nói dối. ISP: propsPick; đừng bắt mọiListItemimplement drag. DIP:useOrders(api = defaultApi)để Storybook và test không bao giờ đụng network. DI container là Singleton trên server thì cùng chỗ rò SSR như 15.2. - Tách ở consumer thứ hai hoặc test đầu tiên bị đau. Hai mươi file cho một badge là YAGNI đội lốt SOLID. Analog React: cùng đường cắt là hook kèm client được inject.
export function useOrders(api: OrderApi = defaultApi) {
return useQuery({
queryKey: ["orders"],
queryFn: () => api.listOrders(), // DIP: test truyền fakeApi
})
}
// Checkout.vue render OrderSummary. Không nói với Stripe hay VAT.
export function useVat(country: MaybeRef<string>, rates: VatRates = defaultRates) {
return computed(() => rates[toValue(country)] ?? 0)
}
// Nuxt: đừng container.get("api") như singleton cả process.Điều gì xảy ra: Test gọi useOrders(fakeApi). Storybook không bao giờ đụng network. VAT là lý do đổi thứ hai, nên không nằm trong Checkout.vue. Client mặc định là module, không phải singleton cả process.
| Chữ | Ví dụ FE | Áp quá đà |
|---|---|---|
| S | Một composable, một lý do đổi | 20 file cho một badge |
| O | Slot, registry, variant | extends BaseButton; v-if mỗi client |
| L | MenuButton vẫn là button | Interface chỉ một implementer cần |
| I / D | Props nhỏ; inject OrderApi | God props; new Axios() trong composable |
Góc nhìn senior
- Coi SOLID như bài test cho thay đổi.
Checkout.vuekhông nên fetch, validate VAT, nói với Stripe, và vẽ stepper. - Abstraction là hàm hoặc interface, không phải abstract class. Action store gọi api được inject.
- Vuex như god store là vi phạm SRP. Đó là lý do có store Pinia theo domain.
- Tách ở consumer thứ hai. Hai mươi file cho một badge là câu SOLID sai.
- Component chỉ chạy trên
windowmà dùng trong trang SSR thì đã gãy D (và hydration). Hãy inject IO.
Lỗi hay gặp
- Triệu chứng: động vật trong câu trả lời. Nguyên nhân: SOLID giáo khoa. Cách xử lý: cắt
Checkout.vue.- Triệu chứng: 20 file cho một badge. Nguyên nhân: SOLID thành tôn giáo. Cách xử lý: tách ở consumer thứ hai.
- Triệu chứng: test đụng network. Nguyên nhân:
new AxiosClient()tronguseOrders. Cách xử lý: injectOrderApi.- Triệu chứng: Options
extendscopy data và gãy TS. Nguyên nhân: OCP giả. Cách xử lý: slot / composition.
Nhà tuyển dụng hay hỏi gì
Áp SOLID vào Vue: Checkout.vue làm bốn việc. Họ muốn đường cắt, và muốn bạn từ chối hai mươi file cho một badge.
Trả lời khi phỏng vấn
Mình coi SOLID như bài test cho thay đổi, không phải chuyện động vật. Checkout.vue không nên fetch cart, validate VAT, nói với Stripe, và vẽ stepper: đó là bốn lý do đổi, nên mình cắt useCart, useVat, usePayment, và leaf OrderSummary. Mình inject API client để Storybook và test không bao giờ đụng network. Mình mở rộng bằng slot và registry, không phải extends BaseButton. MenuButton vẫn hành xử như button. Mình tách ở consumer thứ hai; hai mươi file cho một badge là YAGNI. Vuex như god store là fail SRP mà store Pinia theo domain sửa. Ở admin nội bộ, VAT thành useVat khi quốc gia thứ hai xuất hiện, không phải ngày đầu.
Nhớ nhanh
- SOLID là bài test cho thay đổi. Cắt
Checkout.vuethành composable. - Mở qua slot và registry, không phải
extends BaseButton. - Inject
OrderApi.new Axios()trong composable làm gãy test. - Tách ở consumer thứ hai. Hai mươi file cho một badge là YAGNI.
Câu hỏi nối tiếp thường gặp
- Tương tác với Pinia thế nào? Action store gọi api được inject, không phải
fetchtrong action. - Vuex có vi phạm SRP? God store thì có. Đó là lý do có store Pinia theo domain.
- Khi nào 20 file cho một badge là câu SOLID sai? Một consumer, không test đau: YAGNI.
- Component chỉ chạy trên
windowtrong trang SSR, chữ nào? D. Inject IO; đừng đọcwindowtrong setup.
Tóm tắt: Micro-frontend tách một product thành lát UI deploy riêng (Module Federation, web component, iframe, hoặc điều hướng cứng). Đó là giải pháp tổ chức, kèm cái giá về performance và UX, không phải pattern scale cho tám dev Vue. Ưu tiên monorepo với package cho đến khi deploy độc lập và ranh giới team buộc phải tách.
Vì sao quan trọng: “Có nên làm micro-frontend không?” Tám dev Vue, một product. Staff engineer muốn Module Federation để các team deploy độc lập. User vẫn có một document, một thứ tự tab, một cookie auth, một ngôn ngữ design. Tách build nhân những vấn đề đó lên.
- Cách hoạt động: Module Federation (Webpack/Rspack; Vite qua plugin) load remote lúc runtime và share
vuenếusharedđúng, hoặc nhân đôi Vue nếu không. Web component cô lập style tốt hơn và vẫn chung một thế giới JS; a11y và form thì vụng. iframe cô lập crash và CSS, và đây là lựa chọn tệ nhất về UX/a11y/SEO/auth. Điều hướng cứng sang SPA khác thì ổn cho product tách hẳn (/careerskhác/app). - Chỉ nói có khi đã có lịch deploy độc lập và ranh giới team vốn đã không muốn dùng chung một pipeline. Cái giá: version shared-dep, nhân đôi byte framework, UX lệch, điều phối incident, LCP/INP so với monolith. Shell vẫn chịu trách nhiệm routing, auth, và design token.
- Analog React: cùng cái giá. Next + Module Federation vẫn là chuyện coupling lúc runtime. Nuxt: một app kèm package là mặc định;
layerskhông phải micro-frontend.
// Mặc định: package trong một deploy. Không phải remoteEntry.
// apps/web phụ thuộc packages/ui và packages/domain-orders.
export default defineConfig({
// federation chỉ khi đã có hai lịch deploy
plugins: [
// federation({ name: "shell", remotes: { checkout: "checkout@/remoteEntry.js" },
// shared: { vue: { singleton: true, requiredVersion: "^3.5" } } })
],
})
// Nếu vue không singleton, bạn tốn hai runtime và gãy provide/inject xuyên seam.
export const shouldFederate =
independentDeploysPerWeek >= 1 && teamBoundaries.length >= 2Điều gì xảy ra: Config mặc định không có remote. Federation được comment với vue là singleton: đó là seam mà provide/inject và Pinia phụ thuộc. Boolean là chuyện tổ chức, không phải slogan về bundle size.
| Shape | Deploy độc lập | Cái giá |
|---|---|---|
| Package monorepo | Không (trừ khi publish) | Thấp nhất. Mặc định. |
| Module Federation | Có | Coupling runtime; Vue share hoặc hai Vue |
| Web component | Có | API Vue vụng; đau đầu a11y/form |
| iframe / SPA tách | Có | UX/a11y tệ nhất, hoặc ổn cho product tách biệt |
Góc nhìn senior
- Micro-frontend là nước đi tổ chức, kèm cái giá hiệu năng. Tám dev Vue: không.
- User vẫn có một document, một thứ tự tab, một cookie, một ngôn ngữ design.
- Vue share kiểu singleton thì nhỏ hơn và khóa version. Nhân đôi Vue là team “tự do” và seam provide/inject gãy.
- iframe cho admin không tin cậy hoặc legacy. Hard navigation cho product vốn tách biệt.
- Kiểm chứng: deploy độc lập không cần shell phối hợp; LCP/INP so với monolith; byte framework trùng.
Lỗi hay gặp
- Triệu chứng: hai runtime Vue, provide/inject chết. Nguyên nhân:
sharedsót vue. Cách xử lý: vue singleton, hoặc đừng federate.- Triệu chứng: LCP tệ hơn monolith. Nguyên nhân: thêm round-trip cho remote. Cách xử lý: package trong một deploy.
- Triệu chứng: design lệch. Nguyên nhân: không có token chung. Cách xử lý: một package DS; federation không thay được cái đó.
- Triệu chứng: tám người, bốn remote. Nguyên nhân: theo mốt. Cách xử lý: package trong monorepo.
Nhà tuyển dụng hay hỏi gì
Có nên làm micro-frontend? Tám dev Vue, một product, staff engineer muốn Module Federation. Mặc định của senior là không, trừ khi lịch deploy và ranh giới team buộc phải làm.
Trả lời khi phỏng vấn
Micro-frontend là giải pháp tổ chức kèm cái giá hiệu năng, không phải pattern scale cho tám dev Vue. User vẫn có một document, một thứ tự tab, một cookie auth, và một ngôn ngữ design. Mình ưu tiên monorepo với package (design system, ui, domain-orders) và một deploy đến khi cách đó không còn chịu nổi. Mình chỉ nói có khi lịch deploy đã độc lập và ranh giới team vốn đã không chịu chung một pipeline. Module Federation phải share Vue như singleton, không thì provide/inject gãy. iframe dành cho admin không tin cậy hoặc legacy. Ở admin nội bộ, team mình vẫn dùng package; không federate checkout.
Nhớ nhanh
- Micro-frontend là nước đi tổ chức, phải trả giá. Tám dev Vue: không.
- Mặc định: package trong monorepo, một deploy. Federation cần hai lịch deploy.
- Share Vue như singleton, không thì
provide/injectchết xuyên seam. - iframe cho thứ không tin cậy/legacy. Hard nav cho product vốn tách biệt.
Câu hỏi nối tiếp thường gặp
- Vite khác Webpack cho Federation? Vẫn là chuyện Webpack/Rspack; plugin Vite thì có. Vue share vẫn là seam.
- Khi nào iframe đáng dùng? Admin third-party không tin cậy, hoặc app legacy bạn sẽ không rewrite.
- Nuxt
layerscho gì thay? Tái sử dụng code trong một deploy, không phải remote độc lập. - Version design system chung xuyên remote thế nào? Một package, semver, không copy-paste token.
Tóm tắt: Tổ chức theo feature, không theo type (components/, composables/, stores/ ở root). Feature chịu trách nhiệm component, composable, module API và test của nó, và export một public index.ts. Ranh giới được enforce bằng lint (không import sâu), không phải bằng README.
Vì sao quan trọng: Thư mục theo type trông gọn đến khi checkout đụng vào internals của orders/components/OrderRow.vue và một lần đổi tên gãy ba app. Người phỏng vấn muốn public API của feature, shared/ được biết gì, và rule lint bạn sẽ thêm ngày đầu của codebase 100 feature. Thư mục không phải kiến trúc (16.1 nói chuyện đó), nhưng chúng là cách kiến trúc hiện ra trong git.
- Cách hoạt động:
features/orders/giữcomponents/,composables/useOrders.ts,api/orders.api.ts,types.ts, vàindex.ts(import duy nhất feature khác được dùng).pages/(hoặc Nuxtpages/) mỏng: chúng compose feature.shared/uilà composite mức app, không phải package design system (15.6).shared/liblàhttp.tsvà helper ngày tháng: cố ý giữ nhỏ. - Enforce bằng
eslint-plugin-importno-restricted-paths, hoặc Nx/Viteenforce-module-boundaries:checkoutđược import@app/orders, không bao giờ@app/orders/components/OrderRow.vue. Feature-sliced design (FSD) là một scheme có tên; phần bắt buộc là public API, không phải tên thư mục kiểu Nga. - Cùng kiểu React/Next:
app/(dashboard)/orders/kèm packagefeatures/orders; đừng nhét hết vàocomponents/. Cùng kiểu Vue: Nuxt layers để tái sử dụng xuyên app, vẫn một public API mỗi feature.
app/
features/
orders/
components/ # OrderTable, không phải Button
composables/useOrders.ts
api/orders.api.ts
types.ts
index.ts # public API: import duy nhất feature khác dùng
checkout/
shared/
ui/ # composite của app, không phải package DS (15.6)
lib/http.ts
pages/ # mỏng: compose feature
packages/ui/ # design system
# lint: checkout được import @app/orders, không bao giờ orders/components/*Điều gì xảy ra: Checkout nói với orders qua index.ts. Đổi tên OrderRow.vue là local. pages/ vẫn mỏng. Package DS không nằm trong shared/ui. Lint, không phải wiki, giữ lằn ranh.
| Từ | Được import | Không được |
|---|---|---|
pages/ | Public API của feature | Nội bộ feature |
features/orders | File của mình + shared + package DS | Nội bộ checkout/components/* |
shared | lib/*, primitive DS | Mọi feature |
packages/ui | Không gì từ app/ | id order, Pinia, tên route |
Góc nhìn senior
- Thư mục feature thắng thư mục type khi có team thứ hai.
index.tspublic mới là kiến trúc. - Enforce bằng lint. README chỉ là gợi ý; rule restricted-path mới là ranh giới.
shared/mà import feature là cycle đang chờ xảy ra. Giữsharednhàm.- FSD là cách đặt tên tùy chọn. Đừng cargo-cult
entities//widgets/nếu team không biết những từ đó. - Khác 16.1: thư mục không phải câu trả lời system-design. Chúng là cách câu trả lời hiện trong git.
Lỗi hay gặp
- Triệu chứng:
components/,composables/,stores/ở root, mỗi cái 400 file. Nguyên nhân: thư mục theo type. Cách xử lý: slice feature + index public.- Triệu chứng: checkout import
orders/components/OrderRow.vue. Nguyên nhân: không ranh giới lint. Cách xử lý: restricted-paths.- Triệu chứng:
shared/uibiết orders. Nguyên nhân: shared thành chỗ chứa lộn xộn. Cách xử lý: dời vàofeatures/orders.- Triệu chứng: câu kiến trúc chỉ là cây thư mục. Nguyên nhân: bỏ 16.1. Cách xử lý: nêu rõ box, SLA, kịch bản lỗi, rồi thư mục.
Nhà tuyển dụng hay hỏi gì
Bạn cấu trúc app Vue 100 feature thế nào? Họ muốn public API mỗi feature và một rule lint, không phải components/ ở root.
Trả lời khi phỏng vấn
Mình tổ chức theo feature, không theo type. features/orders chịu trách nhiệm table, useOrders, module API và test, rồi export index public. Pages mỏng và compose feature. shared giữ nhàm: http và composite của app, không phải widget domain. Design system là một package, không phải thư mục trong shared. Mình enforce ranh giới bằng lint: checkout được import @app/orders, không bao giờ đường dẫn component sâu. Tên feature-sliced là tùy chọn; public API thì không. Thư mục không phải câu trả lời system-design (cái đó là 16.1), nhưng đây là cách câu trả lời hiện trong git. Ở admin nội bộ, checkout thôi import internals của OrderRow sau rule restricted-path.
Nhớ nhanh
- Thư mục feature,
index.tspublic. Không thư mục type ở root. - Lint enforce ranh giới. README thì không.
shared/không được import feature. Giữ nó nhàm.- Thư mục không phải kiến trúc (16.1). Chúng là cách nó hiện trong git.
Câu hỏi nối tiếp thường gặp
- Rule lint ngày đầu của 100 feature? Không import sâu xuyên feature; chỉ index public.
- Store Pinia sống ở đâu? Trong feature chịu trách nhiệm client state, không phải
stores/ở root. - Nuxt layers khác thư mục feature? Layers tái sử dụng xuyên app. Feature vẫn cần public API.
- Khi nào FSD đáng học vocabulary? Nhiều team, đã được train. Không thì feature + index là đủ.
Tóm tắt: Design system ship token kèm primitive accessible (Button, TextField, Dialog) như package có version với peer vue. Component của app (OrderTable, VatField, CheckoutStepper) biết id domain và store. Primitive bị rò (Button đòi orderId) là kịch bản fail phỏng vấn hay gặp.
Vì sao quan trọng: Bốn product team, một “Button” mọc thêm orderId và một import Pinia. Ownership, API ổn định, và token mới là câu phỏng vấn, không phải Storybook đẹp. Padding đẩy layout là major. Widget checkout domain ở lại trong app.
- Cách hoạt động: Token là biến CSS với tên semantic (
--color-danger). Dark mode đổi token; class trên<html>lấy từ cookie để tránh flash lúc load. Primitive theo APG: dialog có focus trap; button là<button>. Package cóexportsmap, entrypoint riêng từng component,sideEffectscho CSS, vàvuelà peerDependency. - App ghim semver và compose. Không fork Button, và không bọc mọi primitive “phòng khi”. Primitive mới đi qua RFC; request riêng của app nhận lời không kèm cách compose thay thế. Visual breaking change = major. Changelog + codemod cho các bản major.
- Ownership là guild với roadmap công khai, không phải ba
Button.vuekhông có owner. DS React + Vue: share token, primitive native mỗi framework, đừng bọc Vue trong React. Visual regression: Playwright/Chromatic với token làm fixture (xem testing 8.6).
<!-- Rò: DS giờ biết về orders. Mọi product team trả giá. -->
<!-- <Button order-id="1001" variant="pay">Pay</Button> -->
<!-- Primitive đúng nghĩa: không domain props. Composition nằm ở app. -->
<script setup lang="ts">
defineProps<{ variant?: "primary" | "ghost"; loading?: boolean }>()
</script>
<template>
<button type="button" :data-variant="variant" :aria-busy="loading || undefined">
<slot />
</button>
</template>
<!-- App: <PayButton :order-id="id" /> dùng Button DS + usePay(orderId). -->Điều gì xảy ra: Primitive biết variant và loading. orderId nằm ở PayButton của app. Thêm Pay không bump DS. order-id rò trên Button sẽ buộc mọi team hiểu checkout.
| Trong DS | Trong app |
|---|---|
Button, TextField, Dialog, shell DataTable | OrderTable, VatField, CheckoutStepper |
| Token, contrast, focus ring, primitive APG | Thứ gì cần order id hoặc store Pinia |
Package có version; peerDependency vue | Consumer ghim version; không sửa node_modules |
| RFC cho primitive mới | Compose, hoặc nhận lời không |
Góc nhìn senior
- Việc sai phải khó làm trong API primitive.
orderIdtrênButtonlà chỗ rò. - A11y không thương lượng. Dialog không có focus trap thì không phải primitive.
- Padding đẩy layout là major. Changelog + codemod.
- Styled + token nhanh hơn cho một brand; headless nếu brand lệch nhau mạnh.
- Đừng bundle Vue trong DS. Peer dep. App ghim semver.
Lỗi hay gặp
- Triệu chứng:
ButtoncóorderId. Nguyên nhân: domain rò vào DS. Cách xử lý:PayButtontrong app.- Triệu chứng: ba
Button.vue, không có owner. Nguyên nhân: copy-paste. Cách xử lý: một package, một guild, một roadmap.- Triệu chứng: library bundle Vue. Nguyên nhân: thiếu peerDependency. Cách xử lý: peer
vue; app ghim.- Triệu chứng: chỉ chạy Storybook, Nuxt SSR fail. Nguyên nhân:
windowtrong setup. Cách xử lý: story SSR +ClientOnlychỉ chỗ cần.
Nhà tuyển dụng hay hỏi gì
Thiết kế component library cho bốn product team. Họ muốn token, primitive accessible, package có version, và một lời không với widget domain. Không phải một Storybook cho đẹp.
Trả lời khi phỏng vấn
Mình ship token và primitive thành package có version với peer Vue. App ghim semver rồi compose, không fork Button. A11y không thương lượng: dialog không có focus trap thì không phải primitive. Padding làm lệch layout là major. Widget checkout domain để trong app. Chỗ rò phải từ chối là orderId trên Button. Ownership là guild với roadmap công khai. Visual regression dùng token làm fixture. Ở sản phẩm admin, PayButton nằm cạnh checkout; Button trong DS chỉ biết variant và loading.
Nhớ nhanh
- DS = token + primitive accessible, có version, peer
vue. - Component app được biết order id và store. Primitive thì không.
orderIdtrênButtonlà chỗ rò. Padding làm lệch layout là major.- Ownership là guild kèm roadmap, không phải ba Button không có owner.
Câu hỏi nối tiếp thường gặp
- Visual-regression test thế nào? Chromatic/Playwright trên primitive; token làm fixture. Không phải mọi chart app.
- DS React + Vue? Dùng chung token. Primitive native mỗi framework. Đừng bọc Vue trong React.
- Khi nào thay đổi visual là major? Nó làm lệch layout hoặc phá contract đã ghi (padding, role).
- Bạn nói không điều gì từ product team? Prop domain trên primitive. Đề xuất compose trong app.