Hub

Công cụ build

Bài này nói về build tool ở góc nhìn senior frontend: vì sao dev server của Vite và production bundle là hai thứ khác hẳn nhau, khi nào vẫn nên chọn Webpack / Rspack / Turbopack, và cách debug một chunk main quá nặng. Người phỏng vấn biết thừa Vite “nhanh”. Bạn ghi điểm khi nói rõ được ESM và pre-bundling, cái giá của HMR, source map, và hiểu Module Federation là chuyện của Webpack/Rspack.

Đọc 12.1 trước (bảng so sánh và các mốc version của Vite). Vite 8 (tháng 3/2026) dùng Rolldown làm bundler mặc định; với Vite 5–7 thì dev vẫn chạy esbuild optimizeDeps, còn prod là Rollup. Sau đó đọc 12.2 để bớt phút CI. 12.3 phân tích từng bước một bundle 3 MB. Vue 3 / Nuxt mặc định dùng Vite; Webpack giờ chủ yếu còn được nhắc khi nói về Federation và loader legacy.


Tóm tắt: dev server của Vite serve source app dạng native ESM và pre-bundle node_modules. Build production là bundler riêng. Vite 8 (tháng 3/2026) mặc định dùng Rolldown. Vite 5–7 thì dev vẫn là esbuild optimizeDeps, prod là Rollup. Webpack walk graph rồi serve bundle ở cả hai mode.

Vì sao quan trọng: “Vite khác Webpack: chọn cái nào?” không phải khẩu hiệu tốc độ. Họ muốn biết vì sao request đầu sau cold start lại là module graph, khi nào Module Federation vẫn bắt buộc Webpack hoặc Rspack, và bạn có biết Vite 8 đã đổi Rollup sang Rolldown không. “Vite không có bundler production” là fail.

  1. Cách hoạt động: Source app được serve dạng ESM. Browser xin /src/pages/orders.vue, không phải main.js. Dependency được pre-bundle (esbuild trên Vite 5–7; Rolldown trên Vite 8) vào cache của Vite (optimizeDeps) vì CJS thô trong node_modules sẽ thành hàng nghìn round-trip.
  2. HMR: Vite invalidate module vừa đổi cùng importer của nó qua WebSocket. Đổi một SFC leaf thì rẻ. Đổi store Pinia hoặc plugin Nuxt mà nhiều route import thì đắt. Webpack HMR vá graph đã build sẵn: ổn khi đã warm, chậm khi còn cold.
  3. Production phải bundle: HTTP/2 không biến 4.000 request ESM thành LCP tốt. Vite 8 = Rolldown (Rust, plugin tương thích Rollup, build.rolldownOptions). Vite 7 trở xuống = Rollup (build.rollupOptions); compat layer trên Vite 8 vẫn map option cũ. Nêu rõ team đang ở version nào: app Nuxt 3 có thể vẫn Vite 6/7.
  4. Module Federation (exposes, remotes, shared: { vue: { singleton: true } }) vẫn là platform Webpack 5 / Rspack. Vite làm được MF qua plugin; đó không phải runtime chuẩn của org. Share Vue dạng singleton mới là cả ván. “Chạy trên vite dev” không phải “chạy như remote”.
  5. Source map: mặc định prod là sourcemap: "hidden": emit .map, không link từ JS, upload lên Sentry đúng release. Map cheap dành cho dev. Map inline trên prod là main 3 MB kinh điển. Env: chỉ VITE_* / NUXT_PUBLIC_* mới hiện phía client. Đừng nhét secret vào đó. define là hằng lúc compile, không phải dịch vụ flag runtime.
import { defineConfig } from "vite"
import { visualizer } from "rollup-plugin-visualizer"
export default defineConfig({
  define: { __PAYMENTS_V2__: JSON.stringify(false) }, // lúc compile, không phải secret
  build: {
    sourcemap: "hidden", // emit .map, không link từ JS; upload lên Sentry
    rolldownOptions: { // Vite 8. Vite 7 dùng build.rollupOptions (vẫn được map)
      output: { codeSplitting: { groups: [{ name: "echarts", test: /node_modules\/echarts/ }] } },
    },
  },
  plugins: [visualizer({ gzipSize: true, emitFile: true })],
})

Điều gì xảy ra: __PAYMENTS_V2__ bị thay lúc compile: dead code bị loại nếu bạn if (__PAYMENTS_V2__). Map hidden không bao giờ lên CDN. Vite 8 đọc rolldownOptions; config Vite 7 với rollupOptions vẫn chạy qua compat layer. HTML visualizer là artifact CI, không phải đoán mò.

ToolChọn khiCái giá
Vite 8 + RolldownVue 3 / Nuxt greenfield trên Vite 8Loader Webpack dị; MF là plugin
Vite 5–7 + RollupNuxt 3 đang chạy Vite 6/7Prod vẫn là Rollup; đừng nhận là Rolldown
Webpack 5Loader custom, MF đã ổn, mê cung Vue CLICold build chậm trừ khi đầu tư cache
Rspack / RsbuildGraph Webpack + tốc độ Rust + MFTương thích plugin: hầu hết, không phải tất cả
TurbopackTeam Next.js App RouterKhông phải bundler prod Vue/Nuxt

Góc nhìn senior

  • Tốc độ dev server và tối ưu graph production là hai việc khác nhau. Vite thắng cái đầu; Webpack/Rspack vẫn thắng loader dị và Federation first-class.
  • Vite 8 (tháng 3/2026) thống nhất trên Rolldown; Rolldown 1.0 ship tháng 5/2026. Đừng nói “prod luôn là Rollup” khi chưa nêu version.
  • optimizeDeps.include dành cho package CJS làm scanner bất ngờ. Thiếu include là trang trắng sau lần upgrade dep, không phải “Vite hỏng”.
  • Kiểm chứng: request vite cold đầu tiên là module ESM; Network lúc production hiện bundle đã hash; release Sentry khớp SHA của map hidden.

Lỗi hay gặp

  • “Vite không có bundler production”. Triệu chứng: trượt vòng. Nguyên nhân: nhầm ESM lúc dev với prod. Sửa: nêu rõ Rollup (Vite 7) hoặc Rolldown (Vite 8).
  • Secret trong VITE_*. Triệu chứng: key nằm trong JS. Nguyên nhân: chỉ prefix bị inline. Sửa: NUXT_* phía server / env runtime.
  • Source map public. Triệu chứng: source Vue nằm trên CDN. Nguyên nhân: sourcemap: true lúc prod. Sửa: hidden + upload Sentry.
  • Chọn Vite cho Module Federation vì nhanh. Triệu chứng: hai Vue, provide/inject gãy. Nguyên nhân: MF là runtime Webpack/Rspack. Sửa: Rspack hoặc plugin bạn vận hành được, không phải khẩu hiệu.

Nhà tuyển dụng hay hỏi gì

Vite khác Webpack: bạn chọn cái nào và vì sao? Nếu Vite không bundle lúc dev, production vận hành thế nào? Team cần Module Federation: vậy có bắt buộc Webpack không? HMR chậm: bạn nhìn vào đâu?

Trả lời khi phỏng vấn

Vue 3 greenfield thì mình chọn Vite vì dev server ESM và bundle production là hai sản phẩm khác nhau, và mình muốn cả hai. Vite 8, tháng 3/2026, mặc định dùng Rolldown: mình nói vậy chứ không nói “prod là Rollup”, trừ khi app còn Vite 7. Mình không chọn Vite cho Module Federation; đó vẫn là Webpack 5 hoặc Rspack với Vue singleton được share. Source map hidden đưa lên Sentry, không bao giờ lên CDN. VITE_ luôn là client. Nếu HMR chậm, mình nhìn ai import file vừa lưu (root Pinia hoặc plugin Nuxt), không nhìn thương hiệu bundler. Turbopack là câu trả lời cho Next, không phải Vue.

Nhớ nhanh

  • Dev là ESM kèm dep đã pre-bundle. Prod là một bundler. Nêu rõ version Vite.
  • Vite 8 mặc định là Rolldown. Production Vite 5–7 vẫn là Rollup.
  • Module Federation vẫn là Webpack / Rspack. Map hidden, không public.
  • VITE_ và NUXT_PUBLIC_ luôn là client. define là lúc compile.

Câu hỏi nối tiếp thường gặp

  • Nuxt dùng Vite khác Nitro thế nào? Vite (hoặc Rolldown trên Vite 8) bundle client. Nitro bundle server. Đừng quên server bundle trên treemap.
  • optimizeDeps.include để làm gì? Package CJS mà scanner bỏ sót. Thêm chúng để cold start không 500.
  • Giữ HMR nhanh trong monorepo thế nào? Tránh plugin root mà mọi package import. SFC leaf vẫn rẻ.
  • Vì sao prod miss default export CJS mà esbuild cho lúc dev? Bundler khác nhau. Chứng minh bằng production build, không phải vite dev.

Tóm tắt: Phút CI là metric của sản phẩm. Pipeline PR Vue gồm lint, typecheck, unit, production build, và ngân sách bundle, chạy song song khi chúng không phụ thuộc, với store pnpm và cache remote khóa theo lockfile. Đừng xóa vue-tsc để tiết kiệm tám phút.

Vì sao quan trọng: “Pipeline Vue 18 phút. Bạn cắt gì?” Họ muốn cache install, job song song, monorepo chỉ build phần affected, và một gate bạn không bỏ. Typecheck bắt bug mà Vite không bắt. Env preview rò là 14.3; topic này là chuyện thời gian.

  1. Cách hoạt động: Cache store pnpm theo pnpm-lock.yaml với frozen lockfile. Đừng cache node_modules xuyên các version Node: binary native sẽ gãy. node_modules/.vite là cache optimize local của Vite; restore theo từng branch, khóa theo lockfile kèm patch.
  2. Tách vue-tsc --noEmit khỏi vite build / nuxt build. Chúng tranh CPU và RAM. Chạy lint, type, và Vitest song song sau install. Production build kèm ngân sách bundle là job PR bắt buộc, không phải bất ngờ nightly.
  3. Monorepo: cache remote Turborepo / Nx cho typecheck và build. PR chỉ docs thì đừng rebuild app. Graph affected mới là thắng; pnpm -r build khổng lồ chính là mùi 18 phút.
  4. E2E trên PR là smoke nhỏ nhắm URL preview, không phải cả suite Playwright. Suite đầy đủ chạy trên main. Coi chừng OOM: nuxt build trên CI ít RAM hơn laptop. NODE_ENV=development trong lệnh Docker build là cách ship JS chưa minify.
# Phác GitHub Actions: cache install, tách job quá nặng
- uses: actions/setup-node@v4
  with: { node-version: "22", cache: "pnpm" }
- run: pnpm install --frozen-lockfile
- run: pnpm lint & pnpm exec vue-tsc --noEmit & pnpm test --run & wait
- run: pnpm build   # production. NODE_ENV=production ngầm
- run: node scripts/check-bundle-budget.mjs
# turbo: pnpm exec turbo run build --filter=...[origin/main]
# đừng cache node_modules xuyên Node 20 → 22 (addon native)

Điều gì xảy ra: Install được cache theo lockfile. Lint, vue-tsc, và Vitest chia một runner qua & wait (hoặc tốt hơn: ba job CI). Build là production. Script ngân sách fail PR. Filter turbo bỏ package mà diff không đụng. Version Node nằm trong cache key.

JobVì sao giữCache
pnpm installGraph tái lập đượcStore theo lockfile + version Node
vue-tsc --noEmitBắt bug Vite không bắtRemote Turbo/Nx nếu monorepo
vite/nuxt build + ngân sáchTree-shake và 12.3 dưới dạng CIĐừng cache dist xuyên SHA
Playwright smokeLogin/pay trên previewBrowser một lần mỗi image

Góc nhìn senior

  • Fail PR theo ngân sách byte (vượt gzip tuyệt đối hoặc JS tăng 5%), không theo coverage diễn kịch. Coverage là bản đồ rủi ro (doc kiểm thử).
  • Build một lần, promote digest (14.1). Rebuild trên cluster prod là cách staging và prod lệch nhau.
  • Cache ~/.npm xuyên lần bump Node thì gãy binary esbuild / sharp. Key theo version Node.
  • Kiểm chứng: wall clock của pipeline PR, tỷ lệ cache hit, OOM kill lúc nuxt build.

Lỗi hay gặp

  • Xóa typecheck “tạm thời”. Triệu chứng: undefined trên prop Vue lúc prod. Nguyên nhân: lấy phút CI làm metric duy nhất. Sửa: tách job, cache, giữ gate.
  • Cache node_modules xuyên Node 20 → 22. Triệu chứng: segfault trong esbuild. Nguyên nhân: addon native. Sửa: dùng store cache và đưa Node vào key.
  • Build với NODE_ENV=development trong Docker. Triệu chứng: 3 MB chưa minify. Nguyên nhân: copy nhầm lệnh. Sửa: production build trên CI, copy dist.
  • Preview dùng VITE_* của prod. Triệu chứng: PII trên URL public. Nguyên nhân: copy file env. Đó là 14.3. Đừng debug như lỗi bundler.

Nhà tuyển dụng hay hỏi gì

Pipeline Vue của team đang 18 phút. Bạn cắt gì? Typecheck có nên nằm cùng job với vite build không? Cache một monorepo pnpm + Vite thế nào?

Trả lời khi phỏng vấn

Mình không xóa typecheck để tiết kiệm 8 phút. Mình tách vue-tsc và vite build, cache pnpm theo lockfile và version Node, rồi remote-cache những package không đổi. Lint, types, và unit chạy song song sau install. Production build kèm ngân sách bundle vẫn nằm trên PR. E2E trên PR là smoke nhắm URL preview; suite đầy đủ chạy trên main. Trong monorepo, PR chỉ sửa docs không được rebuild app. Mình để ý OOM lúc nuxt build trên CI. Laptop nói dối về RAM. NODE_ENV development trong Docker là cách ship main chưa minify, không phải thắng cache.

Nhớ nhanh

  • Cache pnpm theo lockfile và version Node. Đừng cache node_modules khi nhảy version Node.
  • Tách vue-tsc khỏi vite build. Giữ cả hai gate.
  • Monorepo: affected kèm remote cache. PR docs không được rebuild app.
  • Ngân sách PR kèm production build. Playwright đầy đủ là main, không mọi PR.

Câu hỏi nối tiếp thường gặp

  • Fail PR chỗ nào: JS tăng 5% hay vượt gzip 200 KB tuyệt đối? Cả hai hơn phần trăm coverage. Chọn một số team thấy được trên visualizer.
  • Typecheck SFC Vue trên CI thế nào? vue-tsc --noEmit, không phải tsc một mình. Takeover cần plugin Vue.
  • Build một lần rồi deploy cùng artifact lên staging và prod? Có. Đó là 14.1. Env runtime, không rebuild.
  • Làm sao để node_modules/.vite không poison branch khác? Key theo lockfile kèm salt; đừng share cache ghi được xuyên SHA.

Tóm tắt: main 3 MB là một quy trình, không phải khẩu hiệu. Xác nhận transferred khác parsed, tạo treemap từ đúng SHA CI đã build, rồi lần lượt xử lý import route đồng bộ, vendor nặng, barrel, map inline, và package trùng. Đổi bundler là nước cuối.

Vì sao quan trọng: Người phỏng vấn nói “main production khoảng 3 MB. Đi từng bước”. Họ muốn bạn ngừng đoán. Entry chunk phải đủ nhỏ để first paint và INP sống được trên điện thoại tầm trung. “Nên code-split” mà không có treemap là câu junior.

  1. Cách hoạt động: Panel Network: xác nhận đúng assets/entry-*.js (hoặc Nuxt entry), không phải route lazy bạn đọc nhầm. Ghi transferred (gzip) khác parsed (chưa nén). Người phỏng vấn thường nêu parsed.
  2. Tái lập với rollup-plugin-visualizer (Vite 5–7 / Rollup) hoặc visualizer Rolldown trên Vite 8, từ đúng commit CI đã build. Sort theo parsed size. Mười module đầu là nghi phạm. Mở cả gzip size: JSON 1 MB bản dịch là một fix khác với Vue 1 MB.
  3. Thủ phạm Vue/Nuxt điển hình: một router import tĩnh mọi page; một layout import chart; barrel index.ts re-export 80 SFC; hai bản vue hoặc date-fns; sourcemap: true hoặc map inline; dump CSS của design-system nằm trên critical path; remote Federation mang theo Vue riêng của nó.
  4. Fix theo thứ tự: defineAsyncComponent cấp route / Nuxt pages/ (đã tách) kèm layout không import cả thế giới; manualChunks / Rolldown codeSplitting.groups chỉ cho vendor vừa khổng lồ và không nằm màn đầu; sideEffects: false trên package bạn phụ trách; xóa barrel; dedupe trong pnpm.overrides; map hidden. Đổi bundler là nước cuối.
import { visualizer } from "rollup-plugin-visualizer"
export default defineConfig({
  plugins: [visualizer({ gzipSize: true, brotliSize: true, emitFile: true, filename: "stats.html" })]
  // CI: mở stats.html từ cùng SHA. Sort parsed size.
})
// Chỉ tách vendor bạn biết khổng lồ VÀ không nằm màn đầu.
function manualChunks(id: string) {
  if (id.includes("node_modules/echarts")) return "echarts"
  if (id.includes("monaco-editor")) return "monaco"
}
// defineAsyncComponent cấp route / pages Nuxt nên làm phần còn lại.

Điều gì xảy ra: CI emit stats.html cạnh build. Bạn sort parsed size, đừng tin lore. echarts thành chunk riêng chỉ vì route dashboard load nó; route login thì không được. Tách route làm phần còn lại. Migrate sang Rspack không phải bước một.

Nghi phạmCách nhận raSửa
Import tĩnh mọi routeTreemap: pages trong entryAsync cấp route / pages Nuxt
Barrel re-export 80 SFCMột index.ts kéo cả thế giớiDeep import; sideEffects: false
Hai bản vue / date-fnsThư mục trùng trên mappnpm.overrides / resolutions
Map prod inline hoặc có link.map trong JS parsedsourcemap: "hidden"

Góc nhìn senior

  • Một round-trip thêm cho chunk chart rẻ hơn 3 MB lúc login. Đừng tách những vendor đang nằm màn đầu.
  • Layout Nuxt import một widget nặng thì không phải page-split. Layout là entry của mọi child.
  • CSS của cả design system có thể nằm trên critical path khi JS trông ổn. Đọc cả pane CSS.
  • Kiểm chứng: gzip trước/sau trên cùng route, LCP/INP trên điện thoại tầm trung, treemap từ cùng SHA.

Lỗi hay gặp

  • Bắt đầu bằng “nên chuyển sang Rspack”. Triệu chứng: migrate hai tháng vì một file barrel. Nguyên nhân: bỏ qua treemap. Sửa: đo, rồi mới tách.
  • manualChunks cho mọi package. Triệu chứng: 80 request nhỏ, INP tệ hơn. Nguyên nhân: tách quá tay. Sửa: chỉ vendor khổng lồ, không nằm màn đầu.
  • Đọc Lighthouse trên laptop Wi-Fi. Triệu chứng: “ổn”. Nguyên nhân: điện thoại tầm trung mới là user. Sửa: trace throttled kèm INP field.
  • Quên server bundle Nitro. Triệu chứng: client trông 200 KB, SSR vẫn 8 s. Nguyên nhân: treemap chỉ client. Sửa: cũng soi .output/server.

Nhà tuyển dụng hay hỏi gì

Main production khoảng 3 MB. Đi từng bước. Họ muốn một quy trình, không phải “nên code-split”.

Trả lời khi phỏng vấn

Mình không bắt đầu bằng “nên code-split”. Mình mở panel Network, xác nhận transferred khác parsed trên entry chunk, rồi mở treemap từ đúng SHA CI đã build. Nếu router import tĩnh mọi page, đó là việc đầu. Rồi barrel, vue trùng, map inline, layout import echarts. Mình chỉ tách vendor vừa khổng lồ vừa không nằm màn đầu. Pages Nuxt đã tách route; layout thì không. Đổi bundler là nước cuối. Ở admin nội bộ, 3 MB là monaco kèm một barrel, không phải Webpack vs Vite.

Nhớ nhanh

  • Đo transferred khác parsed. Treemap từ cùng SHA. Rồi mới tách.
  • Xử lý route sync, barrel, bản trùng, map inline, import trong layout.
  • manualChunks chỉ cho vendor khổng lồ không nằm màn đầu.
  • Đổi bundler là nước cuối. Đừng quên server bundle Nitro.

Câu hỏi nối tiếp thường gặp

  • defineAsyncComponent khác tách cấp route chỗ nào? Async là một component. Cấp route là một chunk mỗi page. Layout vẫn nằm trên mọi child.
  • Giữ package design-system tree-shake được thế nào? Entrypoint từng component, sideEffects false, không barrel import CSS của mọi thứ.
  • Gate điểm Lighthouse hay ngân sách byte? Ngân sách byte trên entry chunk. Lighthouse noisy trên CI.
  • Khi nào main hơi lớn còn chấp nhận? Khi tách sẽ thêm round-trip trên đường LCP. Chứng minh bằng trace.