Kỹ Thuật Chuyên Sâu • 17 phút đọc • 05/10/2026

Webpack, Vite Và Rspack/Rsbuild: Bóc Tách Kiến Trúc Bên Trong Ba Thế Hệ Build Tool

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Dây chuyền lắp ráp
Ảnh: Connecticut. · No restrictions

Ba tháng trước mình ngồi đo lại thời gian build của một monorepo: cold start dev server 41 giây, HMR trung bình 1.4 giây, CI build 9 phút. Đổi bundler xong, hai con số đầu rơi còn 4 giây và 180 mili giây. CI chỉ giảm khoảng một phần ba.

Lệch nhau thấy rõ. Mà lý do không nằm ở “Rust nhanh hơn JavaScript”. Ba build tool này làm việc ở những thời điểm khác nhau, và trong hai con số kia chỉ một cái thực sự đo đúng cái thời điểm đó.

webpack, Vite và Rspack/Rsbuild khác nhau ở ba chỗ: khi nào công việc được thực thi, engine chạy bằng ngôn ngữ gì, và hợp đồng với hệ sinh thái cũ chặt đến đâu. Phần nặng nằm ở internals — vòng đời build, transform pipeline, ranh giới giữa các tầng. Hiệu năng thật nằm ở đó.

1. Ba thế hệ, ba mô hình thời gian

Cách phân loại hữu ích nhất không phải theo năm ra đời, mà theo câu hỏi: browser nhận được byte đầu tiên khi nào, và nó nhận được gì?

  • webpack (2012): browser chỉ thấy bundle đã hoàn chỉnh. Toàn bộ module graph được dựng và tối ưu xong trước khi bất kỳ request nào được phục vụ.
  • Vite (2020): dev server không bundle gì cả. Browser tự đi lấy từng module qua native ESM, server transform theo yêu cầu. Production vẫn bundle.
  • Rspack (2024): giữ nguyên mô hình bundle-eager của webpack, nhưng thay engine JavaScript bằng Rust và transform bằng SWC.
timeline
    title Ba thế hệ build tool
    2012 : webpack 1 — phổ cập mô hình "mọi thứ là module"
    2020 : webpack 5 — persistent cache và Module Federation
         : Vite 1.0 — dev dùng native ESM, prod dùng Rollup
    2024 : Rspack 1.0 và Rsbuild 1.0 — engine Rust, API tương thích webpack
    2026 : Vite 8 — Rolldown thay cả esbuild lẫn Rollup
         : Rspack 2.x — ESM core, nhanh gấp đôi so với 1.0

Cả ba đều cho ra bundle tốt. Khác biệt nằm ở thời điểm trả giá. webpack trả trước, trả một lần, trả bằng thời gian chờ của developer. Vite trả sau, trả từng phần — trong dev thì gần như không trả. Rspack cũng trả trước, nhưng hóa đơn nhỏ hơn nhiều lần.

2. Webpack bên trong: một vòng đời cố định, hai pha nặng

Webpack coi mọi thứ — JS, CSS, ảnh, font, WASM — là một node trong cùng một module graph. Nhưng thứ đáng hiểu không phải “nó có graph”. Vòng đời build của nó là một chuỗi pha cố định, và mọi plugin đều bám vào chuỗi đó.

flowchart TB
  subgraph SETUP["Khởi tạo — chạy một lần"]
    A1["compiler created"] --> A2["environment → afterEnvironment<br/>entryOption → afterPlugins → afterResolvers → initialize"]
  end
  subgraph BUILD["Mỗi lần build tạo MỘT Compilation mới"]
    B1["beforeRun → run"] --> B2["beforeCompile → compile<br/>thisCompilation → compilation"]
    B2 --> B3["MAKE<br/>addEntry → resolve → buildModule → succeedModule<br/>…lặp cho mọi dependency…<br/>finishMake → finishModules"]
    B3 --> B4["SEAL<br/>seal → optimizeDependencies → beforeChunks → afterChunks<br/>→ optimize → optimizeModules → optimizeChunks → optimizeTree<br/>→ moduleIds → chunkIds → codeGeneration → runtimeRequirements<br/>→ beforeHash → afterHash → processAssets → afterSeal"]
    B4 --> B5["emit: afterCompile → shouldEmit → emit → assetEmitted → afterEmit"]
    B5 --> B6["done → afterDone"]
  end
  A2 --> B1
  B6 -.->|"watch mode: compiler tái sử dụng,<br/>Compilation mới, chạy lại từ compile"| B2

Hai pha mang gần hết công việc, và chúng khác nhau về bản chất:

Pha make dựng module graph. Mỗi entry được đưa cho một module factory, nó resolve request thành file, rồi module được build: chuỗi loader chạy trên source, kết quả được parse để tìm dependency. Mỗi dependency tìm thấy lại được resolve và build y hệt, nên graph lớn dần ra cho tới khi không còn gì mới. finishModules là thời điểm đầu tiên toàn bộ graph tồn tại.

Pha seal biến graph thành output. Module được gán vào chunk, chunk graph được tối ưu, id và hash được cấp, code được sinh ra — và chỉ sau đó asset mới tồn tại. Thứ tự này giải thích một lỗi kinh điển: muốn sửa asset thì phải làm ở processAssets, không phải ở hook emit của compiler. Lúc emit chạy thì compilation đã seal xong.

Về mặt kiến trúc, Compilation là đơn vị của một lần build. Ở watch mode, compiler được tái sử dụng, nhưng mọi thứ từ compile trở xuống chạy lại trên một Compilation mới. Nên plugin giữ state trong biến instance của compiler thì sống sót qua rebuild. State giữ trên Compilation thì bị reset. Nguồn bug khó tìm.

Về module và chunk, có hai tầng graph chứ không phải một: ModuleGraph (module import module nào) và ChunkGraph (chunk nào chứa module nào, chunk nào load chunk nào). Một entry tạo ra một chunk group chứa một chunk; SplitChunksPlugin tách chunk đó thành nhiều chunk. Chunk có hai loại — initial (nạp cùng entry) và non-initial (lazy, sinh ra bởi dynamic import hoặc splitChunks) — và mỗi chunk ánh xạ một-một với một file output.

Phần mở rộng cũng chia đúng hai tầng đó:

  • Loader biến đổi một file thành một module, chạy theo chuỗi từ phải sang trái: ["style-loader", "css-loader", "sass-loader"] nghĩa là Sass chạy trước, rồi CSS, rồi style.
  • Plugin không đụng vào file; nó tap vào hook của Compiler và Compilation. Hai nhóm hook khác nhau về phạm vi: hook của compiler đánh dấu các pha, hook của compilation bao trùm mọi thứ bên trong make và seal.

Vì sao mô hình này chậm? Không phải vì JavaScript “yếu”. Mọi bước phải hoàn tất trước khi bước sau bắt đầu, trên một luồng chính. Resolve hàng nghìn import, parse AST, sinh source map, dựng chunk graph — tuần tự hết. webpack 5 có cache: { type: "filesystem" } và song song hóa ở vài bước phụ, nhưng thân pipeline vẫn đơn luồng.

Đổi lại, bạn được quyền kiểm soát tới từng byte output, Module Federation để chia sẻ module giữa các app ở runtime, và cả hệ sinh thái hơn mười năm. webpack cũng chưa dừng: roadmap 2026 hướng tới webpack 6 với TypeScript tích hợp trong core (bỏ ts-loader), HTML entry native (bỏ html-webpack-plugin), CSS native ở mức thử nghiệm, và một tối ưu “lazy barrel” — thừa nhận lấy cảm hứng từ Rspack.

3. Vite bên trong: transform theo yêu cầu, và Environment API

Vite đi trước ở đúng một chỗ: trong dev thì không cần bundle. Browser hiện đại hiểu ESM, nên dev server chỉ cần trả đúng file browser xin, rồi để browser tự đi theo import graph.

Nhưng “không bundle” không có nghĩa “không có pipeline”. Vite vẫn có module graph. Chỉ là graph đó sống trên server, và được dựng dần theo từng request thay vì dựng một lần trước khi phục vụ.

flowchart LR
  B["Browser"] -->|"GET /src/main.tsx"| E["environment.transformRequest(url)"]
  E --> P1["resolveId<br/>plugin container"]
  P1 --> P2["load<br/>đọc file hoặc virtual module"]
  P2 --> P3["transform<br/>plugin pipeline"]
  P3 --> MG["environment.moduleGraph<br/>ghi lại import vừa tìm thấy"]
  MG --> RES["trả ESM đã rewrite<br/>import trỏ về URL thật"]
  RES --> B
  B -->|"native import — không qua server"| B
  DEP["Dependency trong node_modules"] -.->|"pre-bundle một lần"| PB["deps cache"]
  PB -.-> RES
  WS["Watcher"] -->|"file đổi"| INV["invalidate module trong graph"]
  INV -->|"WebSocket"| B

Cơ chế cốt lõi là environment.transformRequest(url): nó dùng plugin pipeline để resolve url thành module id, load nội dung (từ file, hoặc từ plugin làm virtual module), rồi transform code. Trong lúc transform, các import và metadata được ghi vào module graph của environment bằng cách tạo hoặc cập nhật module node tương ứng. Bí mật của Vite dev chỉ có vậy — không có phép thuật nào ngoài làm đúng một module, khi nó được hỏi.

Hai chi tiết hay bị hiểu sai. Thứ nhất, Vite vẫn dùng bundler trong dev, nhưng chỉ cho node_modules, gọi là pre-bundling. Nhiều package vẫn là CJS/UMD, và một package như lodash-es có hàng trăm file sẽ tạo hàng trăm request nếu không gộp. Thứ hai, cold start không phụ thuộc số module của app bạn, mà phụ thuộc số dependency. Thêm 200 file nguồn không làm chậm khởi động. Thêm một dependency nặng thì có.

Phần thú vị nhất về mặt kiến trúc là Environment API (từ Vite 6). Trước đó, một dev server luôn có đúng hai môi trường (client và ssr) nhưng chỉ có một module graph trộn lẫn: node có clientImportedModules và ssrImportedModules riêng, và một transform result phải mang theo cờ ssr boolean. Hệ quả: mọi API đều phải nhận thêm tham số ssr, và không thể biết một node thuộc môi trường nào khi chỉ có id.

Từ Vite 6, mọi thứ tách theo environment: environment.moduleGraph, environment.pluginContainer, environment.hot, environment.transformRequest(). Trong plugin hook, this.environment cho biết môi trường hiện tại kèm config và graph của nó. Và số môi trường không còn bị giới hạn ở hai — bạn có thể định nghĩa client, ssr, edge, worker, hay bất kỳ tên nào. Về build, môi trường client luôn tồn tại, ssr chỉ có nếu được cấu hình tường minh.

Tất cả những điều trên nói về dev. Production từ Vite 1 tới Vite 7 là chuyện khác hẳn: dev dùng esbuild, prod dùng Rollup. Hai engine nghĩa là hai mô hình cấu hình (esbuild options cho dev, rollupOptions cho prod). Tệ hơn về mặt nhận thức: dev và prod có thể cho kết quả khác nhau ở những ca hiếm. Cái giá của việc tối ưu cho dev là bạn tạm thời từ bỏ tính chính xác của “prod build”.

Vite 8 (stable 3/2026) xoá cái giá đó. Rolldown — bundler Rust của VoidZero — thay thế cả hai: nó làm cả dev transform, pre-bundling dependency lẫn production build. Oxc đảm nhiệm parse/transform JS/TS, Lightning CSS cho CSS. Hệ quả kiến trúc quan trọng hơn cả chuyện tốc độ: trước đây parse và transform xảy ra trong esbuild, rồi graph phải được serialize qua lại để Rollup dựng bundle. Giờ Rolldown và Oxc chia sẻ cùng một biểu diễn dữ liệu trong Rust, nên bớt được một ranh giới tuần tự hoá. rollupOptions vẫn tồn tại vì Rolldown được thiết kế tương thích API Rollup.

Đổi engine không miễn phí: khi esbuild và Rollup rời pipeline, plugin dựa vào hành vi riêng của chúng phải theo. Vì vậy đường migration được khuyến nghị là hai bước — đổi vite sang package rolldown-vite trên Vite 7 trước, để tách lỗi do bundler khỏi lỗi do Vite 8.

4. Rspack bên trong: ranh giới Rust–JavaScript chính là bài toán

Rspack tiếp cận từ hướng ngược lại Vite: giữ nguyên mô hình của webpack, thay phần thực thi. Nhưng “viết lại bằng Rust” nghe đơn giản hơn nhiều so với việc thực sự làm. Loader và plugin của cả một hệ sinh thái là JavaScript, và mỗi lần gọi qua lại giữa hai ngôn ngữ đều có chi phí.

Cấu trúc crate của Rspack nói rõ điều đó:

flowchart TB
  subgraph JS["Phía JavaScript (Node.js)"]
    PKG["@rspack/core<br/>API tương thích webpack"]
    LDR["Loaders và plugins của cộng đồng"]
  end
  subgraph BRIDGE["Cầu nối"]
    NAPI["rspack_napi — Node-API"]
    BIND["rspack_binding_api + node_binding<br/>sinh native module"]
  end
  subgraph RUST["Phía Rust"]
    CORE["rspack_core<br/>module system, dependency graph,<br/>compilation pipeline"]
    MAIN["rspack<br/>tích hợp core + plugins"]
    SWCP["swc_plugin_import<br/>swc_plugin_ts_collector"]
    ALLOC["rspack_allocator<br/>mimalloc"]
  end
  PKG --> BIND --> NAPI --> CORE
  LDR --> BIND
  MAIN --> CORE
  CORE --> SWCP
  ALLOC --- CORE

rspack_core là engine thật: module system, dependency graph, compilation pipeline. rspack_binding_api và node_binding sinh ra native module để JavaScript gọi vào. @rspack/core là lớp JS cung cấp API tương thích webpack. Có cả rspack_allocator dùng mimalloc trên Linux/macOS — chi tiết nhỏ nhưng nói lên rằng đây là hệ thống được tối ưu ở mức cấp phát bộ nhớ, không chỉ mức thuật toán.

Vì loader và plugin là JS, Rspack phải đối xử với ranh giới Rust–JS như một tài nguyên đắt. Vài tối ưu họ công bố ở Rspack 1.0:

  • Nếu một hook phía JS không được dùng, phía Rust sẽ không gọi sang JS chút nào. Không có chi phí cho thứ bạn không dùng.
  • Nhiều message object được lazy load: kể cả khi object lớn, nếu JS chỉ đọc vài thuộc tính thì Rspack chỉ truyền đúng phần được dùng.
  • Kế hoạch dài hạn là mở Rust API ổn định cho các tool bậc cao hơn, để những tích hợp nặng khỏi phải đi qua cầu nối JS — vốn là nút cổ chai thật của mô hình này.

Trên nền đó, Rspack giữ được hợp đồng quan trọng nhất: module.rules, resolve, optimization.splitChunks, loader API, plugin API với cùng hệ hook. Migration vì thế thường không phải viết lại config, mà là viết một adapter: copy config gốc, override đúng những giá trị và plugin không tương thích. Yelp mô tả chính xác cách này khi migrate hệ thống của họ — giữ file config webpack nguyên vẹn và để adapter nêu rõ từng khác biệt, thay vì trộn hai mô hình vào nhau.

Rspack 2.x mang thêm ESM core, output hiện đại hơn, ít dependency hơn, và cả vài tối ưu output kiểu lazy: optimization.splitChunks.dedupDepth (mặc định 1 ở production, 0 ở development) cho phép đánh đổi thời gian build để tìm code trùng lặp giữa các route, có ca giảm gần 10% tổng kích thước output. 2.1 bổ sung React Compiler bản Rust, import.meta.glob, hỗ trợ TypeScript 7 và tự dọn persistent cache.

xychart-beta
    title "Rspack 10k React — production build (giây, thấp hơn là tốt hơn)"
    x-axis ["1.7 no-cache", "1.7 cached", "2.0 no-cache", "2.0 cached", "2.1 no-cache", "2.1 cached"]
    y-axis "giây" 0 --> 3.5
    bar [3.12, 2.09, 2.66, 1.36, 2.22, 1.20]
xychart-beta
    title "HMR (ms) trên cùng benchmark"
    x-axis ["1.7.11", "2.0.0", "2.1.0"]
    y-axis "ms" 0 --> 150
    bar [129, 113, 107]

Nhìn đường cong 1.7 → 2.0 → 2.1 thì thấy phần lớn lợi ích đến từ cache và tăng dần, chứ không phải từ tốc độ thô của Rust.

5. Rsbuild bên trong: một hệ plugin, hai tầng hook

Cần phân biệt rạch ròi hai thứ thường bị gọi chung:

  • Rspack là bundler — tương đương webpack về vai trò, không có defaults cho React/TS/HTML.
  • Rsbuild là build tool trên nền Rspack, tương đương Vite về vai trò: có sẵn dev server, HTML entry, xử lý CSS/asset/TS, và một config gọn hơn nhiều.

Kiến trúc của Rsbuild đặt cược vào một ý tưởng: hầu hết chức năng là plugin, core giữ mỏng. Plugin là một hàm nhận options và trả về object có name + setup(api) — cố tình giống esbuild hơn là giống webpack, để viết plugin ngắn hơn.

Điểm thiết kế hay nhất nằm ở việc Rsbuild không chiếm hook của Rspack. Bạn có hai tầng hook độc lập, dùng tầng nào cũng được:

flowchart TB
  subgraph L1["Tầng Rsbuild — vòng đời build tool"]
    direction LR
    H1["modifyRsbuildConfig"] --> H2["modifyEnvironmentConfig"] --> H3["modifyBundlerChain"] --> H4["modifyRspackConfig"]
    H4 --> H5["onBeforeCreateCompiler → onAfterCreateCompiler"]
    H5 --> H6["onBeforeEnvironmentCompile → onAfterEnvironmentCompile"]
    H6 --> H7["modifyHTMLTags → modifyHTML"]
    H7 --> H8["onAfterBuild / onAfterDevCompile"]
  end
  subgraph L2["Tầng Rspack/webpack — từng module, từng dependency"]
    direction LR
    R1["normalModuleFactory<br/>resolve, buildModule"] --> R2["succeedModule → finishModules"] --> R3["optimizeChunks → processAssets"]
  end
  L1 --> L2
  G["Global hooks: chạy một lần<br/>Environment hooks: chạy mỗi environment"] -.-> L1

Tầng Rsbuild chia thành global hook và environment hook. modifyRsbuildConfig, onBeforeStartDevServer, onBeforeBuild, onAfterBuild là global — chạy một lần, mọi environment chia sẻ dev server, middleware và WebSocket. modifyEnvironmentConfig, modifyBundlerChain, modifyRspackConfig, modifyHTMLTags, onBeforeEnvironmentCompile là environment hook — chạy theo từng environment, và có thể chạy nhiều lần nếu cùng plugin được đăng ký ở nhiều environment. Đây là cái bẫy thật khi viết plugin đa environment: side effect trong setup sẽ nhân lên theo số environment.

Thứ tự hook cũng được định nghĩa tường minh, và khi nhiều plugin đăng ký cùng một hook, callback chạy theo thứ tự đăng ký — trừ khi bạn khai báo order: "pre" | "post". Đây là thứ webpack không có: bên đó bạn phải dùng stage trên từng hook hoặc tapable tap options.

Và khi tầng trên không đủ, bạn gọi xuống tầng dưới: api.modifyRspackConfig để đẩy thẳng một Rspack/webpack plugin vào config. Đó chính là cách Rsbuild vừa giữ API đơn giản vừa không đóng cửa với hệ sinh thái cũ.

Đổi lại, có một chi tiết đáng suy nghĩ: Rsbuild 2.0 đã bỏ hẳn hỗ trợ webpack. Ở v1, provider: 'webpack' tồn tại chủ yếu để chứng minh Rspack tương thích với webpack — một công cụ kiểm chứng, không phải tính năng sản phẩm. Khi tương thích đã đủ chắc, họ xoá nó cùng tools.webpack và tools.webpackChain.

Cũng vì lý do đó, việc chọn Rspack trực tiếp hay Rsbuild phụ thuộc vào config bạn đang có, không phải vào tốc độ. Nếu config đã đầy đủ và tinh chỉnh sâu, lớp defaults của Rsbuild có thể trùng lặp plugin và tạo ra thay đổi ngoài dự kiến — đó là lý do Yelp đi thẳng với Rspack.

6. So sánh trực diện

Nếu chỉ vẽ một biểu đồ, mình vẽ biểu đồ đánh đổi giữa tốc độ và độ giàu của hệ sinh thái:

quadrantChart
    title Tốc độ khi scale và độ tương thích hệ sinh thái
    x-axis "Chậm" --> "Nhanh"
    y-axis "Hệ sinh thái hẹp" --> "Hệ sinh thái rộng"
    quadrant-1 "Nhanh và giàu hệ sinh thái"
    quadrant-2 "Chậm nhưng giàu hệ sinh thái"
    quadrant-3 "Chậm và hẹp"
    quadrant-4 "Nhanh nhưng hẹp"
    "webpack 5": [0.22, 0.95]
    "Vite 7": [0.72, 0.70]
    "Vite 8 + Rolldown": [0.88, 0.68]
    "Rspack 2.x": [0.90, 0.88]
    "Rsbuild 2.x": [0.85, 0.62]

Ba trục khác nhau thật sự:

  • Thời điểm thực thi: webpack và Rspack làm hết trước khi phục vụ; Vite làm dần theo từng request trong dev. Đây là khác biệt lớn nhất về trải nghiệm, và không có cách nào bù bằng tốc độ engine.
  • Ngôn ngữ engine: webpack là JavaScript đơn luồng. Vite (từ 8) là Rust với một biểu diễn dữ liệu thống nhất giữa parse, transform và bundle. Rspack là Rust đa luồng, nhưng phải trả thuế ở ranh giới Rust–JS mỗi khi loader/plugin JS được dùng.
  • Hợp đồng hệ sinh thái: Rspack chọn tương thích API webpack (đổi engine, giữ config); Vite chọn API riêng (đổi cả cách nghĩ); Rsbuild chọn API riêng ở tầng trên nhưng mở cửa xuống tầng Rspack.

Về đánh đổi theo từng tool:

  • webpack 5: chậm nhất, kiểm soát output chi tiết nhất; Module Federation; hệ sinh thái không có đối thủ. Chọn khi bạn phụ thuộc vào một plugin chỉ tồn tại cho webpack, hoặc cần chia chunk ở mức không tool nào cho phép.
  • Vite: trải nghiệm dev tốt nhất trong phần lớn dự án, prod build đủ tốt, plugin API dễ viết. Cần cân là số plugin cộng đồng vẫn ít hơn webpack một bậc.
  • Rspack/Rsbuild: đường migration rẻ nhất từ webpack và tốc độ gần Vite. Cần cân là bạn phụ thuộc vào một dự án trẻ hơn — hãy đọc changelog trước khi lên major.

7. Đo thế nào cho không tự lừa mình

Benchmark của vendor luôn dùng project 10.000 component, độc lập, ít plugin. Repo thật của bạn có CSS Modules, monorepo, plugin nội bộ, và một tsconfig với project references. Con số sẽ khác — thường kém ấn tượng hơn nhiều.

Bốn chỉ số đáng đo trước và sau, theo thứ tự ưu tiên:

  • Cold start dev server (máy nguội, xoá cache trước): quyết định trải nghiệm hằng ngày.
  • HMR p95, không phải trung bình: đuôi mới là thứ làm bạn mất tập trung. p95 gấp năm lần trung bình nghĩa là có module đang phá vỡ HMR boundary.
  • Production build có cache và không cache, tách riêng: nếu CI luôn build sạch, số “có cache” là con số đẹp vô nghĩa.
  • Peak RAM: bundler Rust thường nhanh hơn nhưng cũng ăn RAM nhiều hơn; trên máy dev 16GB hoặc CI runner nhỏ, đây có thể là giới hạn thật.

Và một cảnh báo: đừng đo bằng “thời gian chạy lệnh build”. Hãy đo bằng thời gian tới lần render đầu tiên trong dev, vì đó mới là thứ developer cảm nhận. Mình từng thấy một migration giảm 60% thời gian build nhưng cold start dev lại chậm hơn, do cache khởi tạo tốn thời gian hơn.

8. Cây quyết định

flowchart TB
  A["Bắt đầu"] --> B{"Phụ thuộc plugin<br/>chỉ có cho webpack?"}
  B -->|"có"| C["Rspack: adapter config<br/>giữ hợp đồng webpack"]
  B -->|"không"| D{"Repo có phải<br/>hệ thống cũ lớn?"}
  D -->|"có"| E{"Config webpack<br/>đã tinh chỉnh sâu?"}
  E -->|"có"| F["Rspack trực tiếp"]
  E -->|"không"| G["Rsbuild: defaults + tools.rspack"]
  D -->|"không, dự án mới"| H["Vite 8 + Rolldown"]
  C --> I["Đo cold start, HMR p95,<br/>prod build có/không cache, RAM"]
  F --> I
  G --> I
  H --> I
  I --> J{"Đạt mục tiêu?"}
  J -->|"chưa"| K["Soi module graph<br/>trước khi đổi tool lần nữa"]
  J -->|"rồi"| L["Xong"]

Điểm cuối trong cây này quan trọng hơn nó trông: nếu đổi bundler mà vẫn chậm, vấn đề thường không nằm ở bundler. Một barrel file export 500 thứ, một plugin quét toàn repo mỗi lần build, hay vòng import tròn kéo cả graph vào một chunk — đổi tool không sửa được mấy thứ đó.

9. Migration thực chiến

webpack → Rspack. Đừng viết lại config. Tạo adapter giữ config webpack gốc và chỉ override phần không tương thích, để diff giữa hai mô hình luôn hiện ra rõ ràng. Hai nhóm hay phải sửa nhất là plugin dùng hook nội bộ của webpack, và CSS pipeline nếu bạn đang dùng loader cũ. Lưu ý về state: plugin giữ state trên Compilation sẽ hoạt động ở watch mode, còn state trên compiler thì phải tự quản việc reset.

webpack → Rsbuild. Cách này khác: bạn đang bỏ phần lớn config chứ không chuyển nó. Babel biến mất — Rsbuild dùng SWC và tự hạ cấp theo browserslist, nên @babel/preset-env không còn cần thiết. Phần config còn thật sự cần thì đưa vào tools.rspack, vì cấu hình Rspack và webpack gần như tương đương. Cái giá là mất quyền kiểm soát ở những chỗ trước đây bạn tự tay chỉnh.

Vite → Vite 8. Hai bước: rolldown-vite trên Vite 7 trước, chạy test suite và build thật, rồi mới lên Vite 8. Audit plugin trước khi nâng, ưu tiên plugin còn được bảo trì tích cực — và để ý plugin dùng this.environment hoặc các API per-environment, vì đó là chỗ hành vi dễ đổi nhất.

Checklist sau migration: build output phải tương đương ở mức chunk và kích thước (lệch vài phần trăm là bình thường, lệch gấp đôi nghĩa là tree-shaking hoặc splitChunks đang hành xử khác); source map vẫn map đúng; và HMR không làm mất state ở những chỗ trước đây vẫn giữ.

10. Kết

Không có tool nào “nhanh nhất” theo nghĩa tuyệt đối, vì ba tool này không giải cùng một bài toán theo cùng một cách. webpack tối ưu cho kiểm soát, Vite tối ưu cho vòng lặp dev, Rspack tối ưu cho chi phí chuyển đổi — và cả ba đang hội tụ về cùng một điểm: engine viết bằng Rust, tương thích API cũ, một engine duy nhất cho cả dev lẫn prod.

Điều đáng rút ra từ chính lịch sử của ba tool này là: Vite phải mất tám phiên bản để sửa cái giá của kiến trúc hai engine; Rspack phải thiết kế cả một tầng binding để trả thuế cho ranh giới ngôn ngữ; còn webpack thì đang thêm TypeScript và HTML vào core theo cách các plugin vẫn làm mười năm nay. Không tool nào thắng bằng cách đúng ngay từ đầu — chúng thắng bằng cách sửa được điểm yếu của chính mình trước khi đối thủ kịp làm điều tương tự.

Nên câu hỏi đúng không phải “tool nào nhanh nhất”, mà là “cái giá nào tôi đang trả hằng ngày, và tool nào giảm được đúng cái giá đó”. Với mình, câu trả lời nằm ở cold start và HMR p95 — hai con số mà không benchmark vendor nào đo thay bạn được.

Cần người cho dự án contract hoặc freelance?

Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.