Bundler thường được mô tả là chương trình ghép nhiều file JavaScript thành một file. Cách nói này đúng với những công cụ nối script đầu tiên, nhưng bỏ qua gần như mọi phần thú vị của bundler hiện đại. Từ một entry module, công cụ ngày nay phải hiểu cú pháp của nhiều ngôn ngữ, mô phỏng quy tắc tìm module của runtime, dựng dependency graph, loại code mà không đổi hành vi quan sát được, chia phần còn lại thành các chunk dễ cache, rồi phối hợp JavaScript với CSS, ảnh, worker và source map. Trong lúc phát triển, nó còn phải lặp lại một phần công việc đó sau mỗi lần sửa code, thường trước khi lập trình viên kịp rời tay khỏi bàn phím.

Mental model hữu ích không phải là “máy nối file thông minh”, mà là compiler tăng dần hoạt động trên module graph. Output phụ thuộc vào cấu trúc graph và metadata của package không kém gì source text. Khi nhìn theo cách này, những tùy chọn như sideEffects, dynamic import(), content hash và hot module replacement không còn là các tính năng rời rạc.

Pipeline và các sản phẩm trung gian

Một production build thường đi qua các stage dưới đây. Implementation thực tế có thể chạy song song hoặc gối đầu nhiều bước, nhưng quan hệ logic vẫn như sau:

flowchart LR E[Entry module] --> R[Resolve specifier] R --> L[Load source] L --> P[Parse thành AST] P --> T[Transform cú pháp] T --> G[Dựng và phân tích graph] G --> S[Tree-shake symbol] S --> C[Lập kế hoạch chunk] C --> M[Render JS và CSS] M --> H[Hash và ghi asset] P --> SM[Mapping source map] T --> SM M --> SM SM --> H

Mỗi stage nên tạo dữ liệu có cấu trúc thay vì buộc stage sau khám phá lại thông tin từ chuỗi ký tự.

Stage Sản phẩm quan trọng Nguyên nhân invalidate thường gặp
Resolve và load Module ID chuẩn hóa cùng source byte Tạo file, đổi alias hoặc package metadata
Parse AST, import, export và scope Source hoặc parser option thay đổi
Transform AST/code tương thích target cùng map Source, target, plugin hoặc define thay đổi
Phân tích graph Reachability, symbol use, side effect Import/export edge hoặc metadata thay đổi
Lập chunk Ánh xạ module vào chunk Entry, dynamic edge hoặc ngưỡng dùng chung đổi
Render và hash File, reference và tên cuối Bất kỳ byte render hoặc naming option đổi

Plugin thường can thiệp ở resolve, load, transform và lúc sinh output. Nhờ vậy virtual module, CSS hay SVG có thể đi vào cùng graph với JavaScript. Đây cũng là lý do thứ tự plugin và cache key quan trọng. Nếu plugin đọc environment variable hoặc một file phụ mà không khai báo dependency đó, incremental rebuild có thể tái sử dụng kết quả đã cũ.

Một cache đúng phải mô tả đầy đủ input của stage: source bytes, option liên quan, phiên bản plugin và kết quả resolution. Cache nhanh nhưng thiếu một input vẫn là lỗi correctness, chỉ khó tái hiện hơn.

Parsing biến text thành ý nghĩa

Quét text để tìm đoạn trông giống import là chưa đủ. Từ đó có thể nằm trong comment hoặc string; specifier có thể chứa escape; TypeScript thêm cú pháp browser không chạy được; JSX dùng dấu ngoặc nhọn cho mục đích khác phép so sánh. Parser xử lý các điểm mơ hồ rồi tạo abstract syntax tree (AST), trong đó node biểu diễn declaration, call, literal, import và lexical scope.

Ba import sau gây ra ba hệ quả hoàn toàn khác nhau:

ts
import type { User } from './model.js';
import { format } from './format.js';
const locale = await import(`./locales/${language}.js`);

Type-only import có thể biến mất sau khi xóa type của TypeScript. Static import là một edge chính xác ở build time. Template dynamic import là một tập edge khả dĩ phụ thuộc runtime; bundler phải reject, giữ lại cho host runtime, hoặc biến một thư mục có thể liệt kê tĩnh thành context module. Coi nó như một file thông thường duy nhất là sai.

Transform nên thao tác trên AST node và giữ source location. Xóa type TypeScript, hạ JSX, hạ optional chaining, thay compile-time constant và minify đều có thể viết lại cây. Mỗi lần viết lại có thể đóng góp source-map segment nối vị trí generated về file gốc. Nếu plugin in code mới rồi vứt location, mọi mapping phía sau chỉ còn gần đúng.

Note

Bundler thường xóa cú pháp TypeScript chứ không thay thế full type checker. Build nhanh có thể thành công trong khi tsc --noEmit vẫn báo lỗi type, nên type checking vẫn là trách nhiệm riêng của CI hoặc editor.

Parsing còn cho phép quyết định theo scope. Đổi tên local variable không được đổi một property trùng tên. Thay process.env.MODE không được đụng vào biến process bị shadow trong hàm. Xóa một export cần biết nó trỏ tới binding nào và initializer có hành vi quan sát được hay không. Đây là bài toán syntax và scope, không phải bài toán regular expression.

AST thường đi kèm symbol table. Identifier ở vị trí sử dụng được liên kết với declaration tương ứng; import binding được liên kết với export của module khác. Chính liên kết này cho phép minifier đổi tên an toàn và tree-shaker truyền trạng thái “đang được dùng” qua nhiều file.

Resolution dựng module graph

Một import chứa specifier, không nhất thiết là path. ./math.js, react, #configpkg/feature tuân theo các luật khác nhau. Resolution xét importer, extension, alias, trường exports của package, condition như browser, symlink và đôi khi cả TypeScript path mapping. Kết quả phải là module ID chuẩn hóa để hai cách viết của cùng một file không tạo hai module instance.

Mô hình TypeScript rút gọn dưới đây chỉ xử lý relative module. Dù vậy nó vẫn thể hiện bốn thuộc tính quan trọng: resolve tương đối với importer, danh sách extension candidate là tường minh, module được đánh dấu trước khi đệ quy để cycle kết thúc, và static edge được giữ riêng với dynamic edge. Ta giả sử analyzeImports duyệt AST thay vì quét source text.

ts
import { posix } from 'node:path';

type ImportFacts = {
  staticSpecifiers: string[];
  dynamicSpecifiers: string[];
};

type ModuleNode = {
  id: string;
  source: string;
  staticDeps: string[];
  dynamicDeps: string[];
  transformed: string;
};

type AnalyzeImports = (source: string, id: string) => ImportFacts;
type TransformModule = (source: string, id: string) => string;

function resolveRelative(
  specifier: string,
  importer: string,
  files: ReadonlyMap<string, string>,
): string {
  if (!specifier.startsWith('.')) {
    throw new Error(`This example supports relative imports only: ${specifier}`);
  }

  const base = posix.normalize(posix.join(posix.dirname(importer), specifier));
  const candidates = posix.extname(base)
    ? [base]
    : [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}/index.ts`];
  const resolved = candidates.find((candidate) => files.has(candidate));

  if (!resolved) throw new Error(`Cannot resolve ${specifier} from ${importer}`);
  return resolved;
}

function buildGraph(
  entry: string,
  files: ReadonlyMap<string, string>,
  analyzeImports: AnalyzeImports,
  transformModule: TransformModule,
): Map<string, ModuleNode> {
  const graph = new Map<string, ModuleNode>();

  function visit(id: string): void {
    if (graph.has(id)) return;
    const source = files.get(id);
    if (source === undefined) throw new Error(`Missing module: ${id}`);

    const facts = analyzeImports(source, id);
    const staticDeps = facts.staticSpecifiers.map((value) =>
      resolveRelative(value, id, files),
    );
    const dynamicDeps = facts.dynamicSpecifiers.map((value) =>
      resolveRelative(value, id, files),
    );

    graph.set(id, {
      id,
      source,
      staticDeps,
      dynamicDeps,
      transformed: transformModule(source, id),
    });
    for (const dependency of [...staticDeps, ...dynamicDeps]) visit(dependency);
  }

  visit(entry);
  return graph;
}

Đưa node vào graph trước khi thăm dependency là điều bắt buộc với cycle hợp lệ như a.ts -> b.ts -> a.ts. Nó ngăn traversal vô hạn, nhưng renderer vẫn phải bảo toàn live binding và initialization order của ECMAScript. Bundler không thể tùy tiện compile import thành giá trị copy nếu việc đó làm thay đổi hành vi trong cycle.

Resolver production xử lý thêm bare package, package condition, query, virtual namespace và platform path. Nó cache cả lần tìm thành công lẫn thất bại, nhưng invalidation khá tinh tế. Nếu ./panel ban đầu không tồn tại rồi panel.ts được tạo, negative cache phải bị xóa. Trường exports cũng ngăn thói quen cũ là thử mọi path dưới node_modules: chỉ subpath được package công bố mới là public.

Tree-shaking là phân tích graph và side effect

Tree-shaking bắt đầu từ các observable root: entry được thực thi, export đang được dùng và side effect được yêu cầu giữ lại. Nó truyền trạng thái sống của symbol qua import/export link, sau đó loại declaration vừa unreachable vừa an toàn để xóa. ES module hỗ trợ việc này vì static import và export làm lộ cấu trúc trước execution.

Chỉ biết reachability vẫn chưa đủ. Xét export const registry = registerPlugin(). Dù không ai import registry, xóa call có thể đổi chương trình. Ngược lại, unused function declaration thường xóa được vì khai báo hàm không chạy body ngay.

Cấu trúc Thường xóa được khi không dùng? Lý do
function helper() { ... } Declaration không tạo effect tức thời
const answer = 6 * 7 Initializer thuần và biết được tĩnh
const item = factory() Không nếu chưa có bằng chứng Call có thể mutate state hoặc throw
import './polyfill.js' Không Mục đích của import chính là execution
Đọc getter object.value Mặc định không an toàn Getter có thể chạy user code

sideEffects: false ở cấp package là lời hứa của package author rằng module không có top-level effect cần giữ. Danh sách như sideEffects: ["*.css", "./register.js"] tạo ngoại lệ. Lời hứa sai có thể sinh bundle nhỏ hơn nhưng hỏng hành vi.

Warning

Tree-shaking không phải “xóa mọi dòng không được dùng”. Nó là một phép chứng minh bảo thủ. Dynamic property, eval, CommonJS mutation, getter, decorator và unknown call đều làm giảm những gì bundler có thể chứng minh an toàn.

Transform ảnh hưởng trực tiếp đến phép chứng minh đó. Thay if (import.meta.env.PROD) bằng if (true) mở đường cho dead-branch elimination. Ngược lại, hạ class field thành helper call có thể khiến operation vốn rõ ràng trở nên có vẻ effectful nếu optimizer không hiểu helper. Bundler trưởng thành phối hợp lowering và optimization thay vì xem chúng như các text filter độc lập.

CommonJS khó tree-shake hơn vì module.exports có thể bị mutate theo control flow và require() có thể dùng giá trị tính ở runtime. Công cụ thường bọc CommonJS trong runtime wrapper hoặc suy luận một tập export gần đúng, nhưng kết quả ít chính xác hơn static ESM. Chuyển thư viện sang ESM thật sự thường đem lại nhiều giá trị hơn việc thêm annotation ngẫu nhiên.

Code splitting, rendering và content hash

Static import phải sẵn sàng khi importer chạy, nên nó thường nằm cùng chunk với importer hoặc trong shared chunk được tải đồng bộ. Dynamic import('./chart.js') tạo asynchronous boundary: chart cùng static dependency của nó có thể chuyển sang lazy chunk. Nếu nhiều boundary dùng chung một dependency lớn, planner có thể tách shared chunk để tránh lặp, đổi lại browser phải thực hiện thêm request.

Phần tiếp theo của mô hình rút gọn tính static closure, không đưa module đã có trong entry chunk vào lazy chunk, render module theo thứ tự ổn định và tạo filename từ emitted bytes. Renderer thật còn phải viết lại module reference sang chunk filename và cung cấp runtime loader.

ts
import { createHash } from 'node:crypto';

type Chunk = { name: string; modules: Set<string> };

function staticClosure(roots: Iterable<string>, graph: ReadonlyMap<string, ModuleNode>) {
  const seen = new Set<string>();
  const visit = (id: string): void => {
    if (seen.has(id)) return;
    seen.add(id);
    const node = graph.get(id);
    if (!node) throw new Error(`Unknown module: ${id}`);
    for (const dependency of node.staticDeps) visit(dependency);
  };
  for (const root of roots) visit(root);
  return seen;
}

function planChunks(entry: string, graph: ReadonlyMap<string, ModuleNode>): Chunk[] {
  const mainModules = staticClosure([entry], graph);
  const chunks: Chunk[] = [{ name: 'main', modules: mainModules }];
  const dynamicRoots = new Set(
    [...graph.values()].flatMap((module) => module.dynamicDeps),
  );

  for (const root of dynamicRoots) {
    const modules = staticClosure([root], graph);
    for (const id of mainModules) modules.delete(id);
    chunks.push({ name: posix.basename(root, posix.extname(root)), modules });
  }
  return chunks;
}

function emitChunk(chunk: Chunk, graph: ReadonlyMap<string, ModuleNode>) {
  const code = [...chunk.modules]
    .sort()
    .map((id) => `define(${JSON.stringify(id)}, ${graph.get(id)!.transformed});`)
    .join('\n');
  const hash = createHash('sha256').update(code).digest('hex').slice(0, 10);
  return { fileName: `${chunk.name}.${hash}.js`, code };
}

Planner mang tính giáo dục này có thể lặp một module chỉ được dùng bởi hai lazy chunk. Planner production phát hiện overlap, áp minimum size, tôn trọng manual chunk rule và tránh tạo hàng loạt file quá nhỏ. Cách split tốt nhất là một cost model cân bằng byte lặp, số request, khả năng chạy song song và xác suất route được truy cập; nó không đơn giản là tạo càng nhiều chunk càng tốt.

Content hash hỗ trợ HTTP cache dài hạn: chart.a81c...js có thể được cache dạng immutable vì byte thay đổi sẽ nhận URL mới. Thứ tự deterministic là bắt buộc; nếu traversal order ngẫu nhiên làm output đổi, hash sẽ churn dù semantics không đổi. Hash còn có tính đệ quy vì parent chunk chứa hashed filename của child. Bundler giải quyết bằng staged identifier, placeholder replacement hoặc một hashing graph ổn định.

CSS, asset, source map và hai vòng build

Import không phải JavaScript trở thành node có type trong graph. CSS loader parse @importurl(), viết lại asset reference, có thể scope tên của CSS Module, rồi sinh code inject style hoặc file CSS tách riêng. Image loader có thể inline file nhỏ thành data URL và phát file lớn với tên có hash. Web worker và WebAssembly thường tạo output boundary riêng. Trong mọi trường hợp, bundler phải theo dõi dependency để font hoặc ảnh vừa sửa invalidate đúng consumer.

Development và production tối ưu cho hai kết quả khác nhau:

Mối quan tâm Development server Production build
Mục tiêu chính Edit-to-feedback latency thấp Output nhỏ, ổn định, dễ cache
Cách serve module Thường native ESM hoặc transform nhẹ Chunk đã được lập kế hoạch và link
Tối ưu Tối thiểu hoặc đã cache Tree-shaking, minify, extraction
Update In-memory invalidation và HMR Tập artifact deterministic đầy đủ
Source map Nhanh, đôi khi kém compact External hoặc hidden map chính xác
Filename URL ổn định, dễ đọc URL immutable có content hash

Khi file đổi, dev server đi ngược graph edge để tìm importer. Với hot module replacement (HMR), nó tìm lên trên một module chấp nhận update, gửi module mới cùng metadata tới browser, dispose instance cũ rồi reevaluate accepted boundary. Nếu không boundary nào nhận, server reload cả trang. HMR vì thế cần framework hoặc application hợp tác; việc giữ component state qua replacement không thể được graph traversal tổng quát tự suy ra.

Source map tạo thành một chuỗi mapping. TypeScript transform map JavaScript đã lower về TypeScript; minifier map code đã minify về input của nó; chunk renderer cộng offset của mỗi module trong file. Bundler compose các map để stack frame production trỏ về source gốc. Map có thể chứa source text và làm lộ code riêng, nên deployment production thường upload riêng lên error service thay vì public file đó.

Khi build chậm, cần đo trước khi đổi option. Hãy tách cold start khỏi incremental rebuild, rồi đo resolution, parsing, plugin transform, optimization, source-map generation, compression và disk output. Nguyên nhân phổ biến gồm transform chạy cả node_modules, plugin thực hiện network I/O tuần tự, generated module khổng lồ, glob import quá rộng, minification đắt và cache bị invalidate bởi config object không ổn định.

Khi bundle lỗi, hãy giữ graph và intermediate artifact. Cần hỏi resolver condition nào đã chọn file, vì sao module được giữ, plugin nào transform nó cuối cùng, và chunk nào sở hữu nó. Metafile, bundle visualizer, resolver trace và sourcesContent hữu ích hơn việc nhìn chằm chằm vào minified output. Cũng phải tái tạo production define và condition ở máy local: nhiều “lỗi minifier” thật ra đến từ environment replacement hoặc metadata side effect khác nhau.

Điều cần nhớ

  • Bundler hiện đại là incremental compiler với module graph làm cấu trúc dữ liệu trung tâm.
  • AST cung cấp dữ kiện về syntax, scope, import/export và source location để transform đúng.
  • Resolution phải theo luật runtime và package, đồng thời trả về module identity chuẩn hóa, cache-aware.
  • Tree-shaking bảo toàn hành vi reachable chứ không chỉ text được tham chiếu; khai báo side effect là một phần của correctness.
  • Dynamic import tạo chunk boundary khả dĩ, còn deterministic rendering và content hash tạo nền tảng cho cache dài hạn.
  • CSS, asset, worker, HMR và source map đều thuộc cùng mô hình dependency và invalidation.
  • Tối ưu performance bắt đầu từ timing theo stage và chẩn đoán graph, vì cache nhanh nhưng sai vẫn là sai.

Khi xem các phần này như một pipeline thống nhất, hành vi của bundler dễ dự đoán hơn nhiều. Một output file bất ngờ thường giải thích được bằng resolution rule, graph edge, effect mà optimizer không thể bác bỏ hoặc constraint của chunk planner. Công cụ có thể rất phức tạp, nhưng nghĩa vụ cốt lõi vẫn cụ thể: giữ nguyên semantics của chương trình, làm dependency trở nên tường minh và không lặp lại công việc khi input chưa thay đổi.