Chương 08 · Modules, Bundlers & Runtime

Modules, bundler và runtime hiện đại

25 năm đầu, JavaScript không có module system native. Cộng đồng tự chế: IIFE, AMD, UMD, CommonJS. ES6 (2015) chuẩn hóa ESM — module syntax chính thức. Hiểu lịch sử này = hiểu vì sao package.json có nhiều field kỳ lạ, vì sao đôi khi require đôi khi import, và bundler thật sự làm gì.

Độ dài: ~900 dòng Bài tập: 5 Quiz: 7 Prerequisites: Chương 1-7
🎯 Mục tiêu chương
  • Biết lịch sử module JS: IIFE → AMD/UMD → CommonJS → ESM (ES6 2015) và vì sao có nhiều format đến vậy.
  • Phân biệt CommonJS (Node truyền thống, sync, dynamic) vs ESM (chuẩn, static, async).
  • Master named export vs default export, re-export barrel, dynamic import().
  • Hiểu tree-shaking hoạt động ra sao và tại sao chỉ ESM mới làm được.
  • Nắm landscape bundler 2026: Vite, esbuild, Webpack, Rollup, Parcel, tsup — chọn cái nào khi nào.
  • Đọc hiểu các field main, module, exports, type trong package.json.
  • Setup tối thiểu Node ESM và Vite SPA từ zero.

1. Lịch sử module JS — vì sao rối

Khi Brendan Eich tạo JavaScript năm 1995, nó được nhắm cho việc viết vài dòng script gắn vào HTML. Không có khái niệm module. Mọi <script> đều share global scope. Khi project lớn lên, điều này thành thảm họa: biến đè nhau, không biết file nào export gì, không cách nào reuse code thật sự.

Cộng đồng phải tự chế giải pháp. Suốt 20 năm, JS có 4 format module cùng tồn tại:

FormatNămMôi trườngĐặc điểm
IIFE (Immediately Invoked Function Expression)~2000BrowserWrap code trong (function(){ ... })() để tạo private scope. Expose qua global object.
CommonJS2009Node.jsrequire() + module.exports. Sync, dynamic. Default của Node 17+ năm.
AMD (Asynchronous Module Definition)2011Browser (RequireJS)define([deps], factory). Async load — phù hợp browser nhưng syntax verbose.
UMD (Universal Module Definition)2014Cả haiWrapper detect runtime, tương thích với CJS, AMD, hoặc global. Hỗn loạn nhưng "chạy mọi nơi".
ESM (ES6 Modules)2015Chuẩn ECMAScriptimport/export. Static, async, tree-shakeable. Browser native 2017, Node hỗ trợ 2019.

Xem qua syntax từng cái để cảm nhận:

IIFE — module sơ khai
// math.js — gắn vào window/global
var Math2 = (function() {
  function add(a, b) { return a + b; }
  function sub(a, b) { return a - b; }
  return { add: add, sub: sub };   // expose public
})();

// dùng:
Math2.add(1, 2);  // 3
CommonJS — Node truyền thống
// math.js
function add(a, b) { return a + b; }
module.exports = { add: add };

// main.js
const math = require('./math');
math.add(1, 2);
ESM — chuẩn hiện tại
// math.js
export function add(a, b) { return a + b; }

// main.js
import { add } from './math.js';
add(1, 2);
🧠 Mental model — vì sao 2 format vẫn tồn tại

ESM xuất hiện năm 2015 nhưng Node phải đợi đến v13.2 (2019) mới hỗ trợ stable. Suốt giai đoạn đó, hàng triệu package npm đã viết bằng CommonJS. Không ai migrate được toàn bộ ecosystem ngay → ESM và CJS phải chạy song song.

Đó là lý do năm 2026 vẫn thấy require() trong code Node, package.json có cả main (CJS) và module (ESM), và đôi khi gặp lỗi "ERR_REQUIRE_ESM". Hiểu lịch sử = bớt hoang mang.

2. CommonJS — module Node truyền thống

CommonJS sinh ra cho Node năm 2009. Đặc điểm: synchronous (đọc file blocking), dynamic (có thể require trong if/loop/function bất cứ đâu). Mặc định Node coi mọi .js là CJS, trừ khi package.json set "type": "module".

CommonJS — full ví dụ
// utils.js — nhiều cách export
module.exports = { foo, bar };           // object export
module.exports.baz = function() {};       // gán dần
exports.qux = 1;                       // shorthand (cẩn thận: không reassign exports trực tiếp)

// main.js
const utils = require('./utils');          // .js extension optional
const { foo } = require('./utils');        // destructure
const express = require('express');         // package từ node_modules

// Dynamic require — chạy ở mọi nơi
if (process.env.NODE_ENV === 'development') {
  const devTool = require('./dev-only');
}

for (const name of pluginNames) {
  const plugin = require(`./plugins/${name}`);
}

Cơ chế: khi gọi require(), Node:

  1. Resolve path: tìm file (relative/absolute/node_modules).
  2. Load file: đọc nội dung từ disk (sync).
  3. Wrap code trong function: (function(exports, require, module, __filename, __dirname) { ... }).
  4. Execute: chạy code, trả module.exports.
  5. Cache: lần require thứ 2 không load lại — trả từ cache.
🔥 Gotcha — exports vs module.exports

exports chỉ là alias của module.exports lúc đầu. Nếu reassign trực tiếp exports = ..., alias bị mất, không export gì.

// ❌ Sai
exports = { foo: 1 };  // alias bị reassign → không có effect

// ✅ Đúng — 2 cách
module.exports = { foo: 1 };
exports.foo = 1;

3. ESM — ES6 Modules chuẩn

ESM là format chính thức của ECMAScript từ 2015. Đặc điểm khác CJS:

CommonJSESM
Syntaxrequire / module.exportsimport / export
LoadingSynchronous, blockingAsynchronous (browser), top-level await được
Static / DynamicDynamic — require trong if/loop OKStatic — import ở top-level. Có import() dynamic riêng.
Tree-shaking❌ Không (dynamic)✅ Có (static analysis)
Default trong NodeMặc địnhCần "type": "module" hoặc .mjs
Browser supportPhải bundleNative từ 2017 (<script type="module">)
File extension trong importOptional (./mod)Bắt buộc (./mod.js)
this ở top-levelmodule.exportsundefined
Path/URL helpers__dirname, __filenameimport.meta.url
ESM — đầy đủ syntax
// math.js — export nhiều cách
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export class Calculator { /* ... */ }

// Hoặc gom cuối file:
function sub(a, b) { return a - b; }
function mul(a, b) { return a * b; }
export { sub, mul };

// Default export — 1 cái/file
export default function multiply(a, b) { return a * b; }

// main.js — import
import { PI, add } from './math.js';        // named
import multiply from './math.js';             // default
import multiply, { PI, add } from './math.js'; // mix
import * as math from './math.js';            // namespace
import { PI as CIRCLE_PI } from './math.js';   // rename
import './side-effects.js';                  // chỉ chạy code, không import gì
🔥 Static-only — không if/loop

import phải ở top-level và path phải là literal string. Không thể:

// ❌ SyntaxError
if (condition) {
  import foo from './foo.js';
}

// ❌ SyntaxError
import mod from `./${name}.js`;

Ràng buộc này có lý do: bundler/runtime phải biết module graph trước khi chạy → tree-shaking, async loading, code splitting đều dựa vào tính static. Khi cần dynamic, dùng import() function (mục 6).

3.1. ESM trong browser native

Từ 2017 mọi browser hỗ trợ ESM native qua attribute type="module":

Browser ESM
<!-- index.html -->
<script type="module" src="main.js"></script>

// main.js
import { add } from './math.js';   // bắt buộc có .js
console.log(add(1, 2));

Điều browser native bắt buộc khác Node:

  • CORS: file phải serve qua HTTP (không chạy được file://).
  • MIME type: server phải trả Content-Type: text/javascript.
  • Full path: ./math.js chứ không phải ./math (Node chấp nhận thiếu ext khi CJS, ESM không).
  • Bare specifier (import x from 'lodash') không chạy native — phải qua importmap hoặc bundler.

4. Named export vs Default export

2 cách export, mỗi cái có triết lý riêng:

Named exportDefault export
Syntax exportexport const foo = ...export default ...
Syntax importimport { foo } from ...import Foo from ...
Số lượng / fileKhông giới hạnTối đa 1
Rename khi import{ foo as bar }Tự do — viết tên nào cũng được
Refactor an toàn✅ Tên cố định, IDE rename được⚠️ Mỗi file đặt tên khác nhau
Tree-shaking✅ Tốt✅ Tốt nhưng default thường là object lớn
Default rename — vấn đề
// Button.jsx
export default function Button() {}

// Mỗi file import có thể đặt tên khác nhau:
import Button from './Button';        // OK
import MyBtn from './Button';         // OK — tên tùy ý
import X from './Button';             // OK — đặt tên dở cũng được

// Khi grep "Button" trong project, có thể miss MyBtn/X
💡 Khi nào default, khi nào named
  • Named cho 90% case. Helper, utility, type, hằng số — rõ ràng, refactor-safe.
  • Default khi file có 1 thứ chính nổi bật (React component, page Next.js, class chính của library). Tên file thường = tên default.
  • Một số codebase (Airbnb style guide) cấm hoàn toàn default để tránh rename chaos.
  • TypeScript hỗ trợ import { type Foo } chỉ cho named — thêm lý do thiên về named.

5. Re-export & barrel files

Barrel là file (thường index.js/index.ts) chỉ re-export từ các file khác. Mục đích: import gọn, ẩn cấu trúc folder bên trong.

Barrel pattern
// utils/string.js
export function capitalize(s) { /* ... */ }
export function slugify(s) { /* ... */ }

// utils/number.js
export function clamp(n, min, max) { /* ... */ }

// utils/index.js — BARREL
export * from './string.js';          // re-export tất cả named
export * from './number.js';
export { capitalize as cap } from './string.js';  // chọn lọc + rename
export { default as Button } from './Button.jsx';  // re-export default

// main.js — gọn
import { capitalize, clamp } from './utils';
// thay vì:
// import { capitalize } from './utils/string';
// import { clamp } from './utils/number';
🔥 Đánh đổi của barrel

Pro: import gọn, API ổn định kể cả khi reorganize folder bên trong.

Con:

  • Tree-shaking khó hơn — bundler phải đọc qua barrel để biết symbol nào từ đâu. Một số config cũ kéo cả module dù chỉ dùng 1 function.
  • Circular dependency dễ xảy ra hơn — A và B đều đi qua barrel, có thể vô tình tham chiếu vòng.
  • Cold start chậm trong dev (Vite, Jest) — phải resolve cả barrel rồi mới biết file thật cần load.

Khuyến nghị 2026: dùng barrel ở top-level public API của library, hạn chế ở internal folder. React core team có blog "Why we removed barrel files" — tham khảo.

6. Dynamic import()

import() (có dấu ngoặc) là function-like syntax — async, trả Promise, có thể gọi ở bất cứ đâu, path có thể là expression. Đây là cầu nối giữa "static analysis" và "dynamic loading".

Dynamic import
// Lazy load — chỉ load khi user click
button.addEventListener('click', async () => {
  const { default: Chart } = await import('./Chart.js');
  new Chart().render();
});

// Conditional load — A/B test
const theme = await import(
  user.experimental ? './theme-new.js' : './theme.js'
);

// Polyfill khi cần
if (!Array.prototype.at) {
  await import('./polyfills/array-at.js');
}

// Path từ variable
async function loadLocale(name) {
  return import(`./locales/${name}.js`);
}

Bundler hiểu import() = ranh giới code splitting. Mỗi import() sinh ra một chunk JS riêng. Browser chỉ tải chunk khi cần → first paint nhanh hơn.

💡 React lazy + Suspense
import { lazy, Suspense } from 'react';

const Chart = lazy(() => import('./Chart.jsx'));

function App() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Chart />
    </Suspense>
  );
}

Vite/Webpack tự động split Chart.jsx ra chunk riêng. Khi Chart được render lần đầu, browser fetch chunk đó. Đây là pattern phổ biến cho SPA lớn.

7. Tree-shaking — bundler dọn rác

Tree-shaking = bundler phân tích static module graph, loại bỏ code không được dùng. Tên ẩn dụ: cây code rung sẽ rụng lá khô.

Tree-shake hoạt động
// utils.js
export function used() { return 'A'; }
export function unused() { return 'B'; }

// main.js
import { used } from './utils.js';
console.log(used());

// Output bundle: chỉ chứa used(), unused() bị loại

Điều kiện để tree-shaking hoạt động:

  1. ESM static syntax — bundler phân tích được. CJS dynamic → không tree-shake được.
  2. Pure exports — không có side effect ở top-level (như đăng ký global event, mutate object).
  3. Đánh dấu "sideEffects": false trong package.json — báo bundler "an toàn cắt".
  4. Production build — dev mode thường tắt để rebuild nhanh.
sideEffects field
// package.json
{
  "name": "my-lib",
  "sideEffects": false            // tất cả file đều "pure", an toàn cắt
}

// Hoặc whitelist file có side effect:
{
  "sideEffects": ["./src/polyfill.js", "*.css"]
}
🔥 Side effect ngầm phá tree-shake
// utils.js
window.myGlobal = 'hi';                  // ❌ side effect ở top-level
Array.prototype.myExtension = function() {};  // ❌ mutate global

export function unused() {}            // bundler KHÔNG dám cắt cả file vì sợ mất 2 dòng trên

CSS import (import './style.css') cũng là side effect — không có symbol nào được dùng nhưng phải giữ. Đó là lý do "sideEffects": ["*.css"] thường có trong config.

8. Bundler — vì sao cần

Cuộc đời một SPA modern:

Bundler pipeline
src/
  index.tsx        // TypeScript + JSX
  App.tsx
  components/
    Button.tsx
    Form.tsx
  utils/
    api.ts
    format.ts
  styles/
    main.css

node_modules/
  react/           // CommonJS
  lodash-es/       // ESM
  ...300 packages

         ↓ Bundler

dist/
  index.html
  assets/
    main-a3f5.js          // entry chunk (minified)
    vendor-b1e8.js        // node_modules split
    Chart-c2d9.js         // lazy chunk
    main-a3f5.css
    main-a3f5.js.map      // source map cho debug

Công việc bundler:

  1. Resolve: từ entry, đi theo import dựng module graph.
  2. Transform: chạy TS → JS, JSX → JS, SCSS → CSS, PNG → base64/URL.
  3. Tree-shake: loại bỏ export không dùng.
  4. Code split: tách import() ra chunk riêng, tách vendor riêng.
  5. Minify: rename biến ngắn, xóa whitespace, dead code elimination.
  6. Source map: file .js.map map code minified ngược về source để debug.
  7. Hash & cache: filename có hash (main-a3f5.js) — đổi content = đổi tên = bypass CDN cache.

9. Landscape bundler 2026

ToolViết bằngTốc độUse case
esbuildGo⚡⚡⚡ Cực nhanh (10-100×)Build tool nội bộ, transform TS, dùng làm engine cho tool khác.
ViteJS (esbuild + Rollup)⚡⚡⚡ Dev tức thờiMặc định cho SPA 2026. Dev: ESM native + esbuild prebundle. Prod: Rollup.
WebpackJS⚡ ChậmProject lớn legacy, ecosystem plugin khổng lồ. Vẫn dùng nhiều ở enterprise.
RollupJS⚡⚡ Khá nhanhBuild library (output sạch, tree-shake tốt). Vite dùng nội bộ.
ParcelJS/Rust⚡⚡ Khá nhanhZero-config, đơn giản — phù hợp demo, prototype.
tsupJS (wrap esbuild)⚡⚡⚡ NhanhLibrary TypeScript — output CJS + ESM + types nhanh gọn.
TurbopackRust⚡⚡⚡ NhanhNext.js mới — kế thừa Webpack trong Next.
RolldownRust⚡⚡⚡ NhanhRollup viết lại bằng Rust. Tương lai của Vite.
💡 Chọn cái nào 2026
  • SPA mới (React/Vue/Svelte): Vite. Hết bàn.
  • Next.js app: Turbopack (default Next 15+).
  • Library publish lên npm: tsup hoặc Rollup.
  • Project Webpack cũ: giữ đến khi rảnh migrate. Vite có Webpack-to-Vite guide.
  • Build tool riêng / CLI: gọi esbuild API trực tiếp — nhanh nhất.

9.1. Vite làm gì khác Webpack

Vite chiến thắng nhờ chia 2 mode rõ rệt:

Dev modeProduction build
Module source codeServe qua ESM native — không bundleBundle bằng Rollup
node_modulesPrebundle bằng esbuild 1 lần (cache)Bundle cùng source
Transform TS/JSXesbuild (Go, nhanh)esbuild + Rollup
HMR (Hot Module Reload)Chỉ invalidate module thay đổi
Start time< 1s cả project lớn

Trong dev, Vite không bundle source code → browser fetch từng file .js/.ts qua HTTP. Mỗi file Vite transform on-the-fly (TS → JS). Khi edit 1 file, chỉ file đó được invalidate — HMR cực nhanh.

10. package.json — các field module

Module fields trong package.json tích lũy qua nhiều năm. Hiểu từng cái:

package.json modern
{
  "name": "my-lib",
  "version": "1.0.0",

  "type": "module",        // 🔑 mọi .js là ESM (Node 12+)

  "main": "./dist/cjs/index.cjs",    // CJS entry — Node cũ, tool cũ
  "module": "./dist/esm/index.js",   // ESM entry — bundler nhận diện (de-facto)
  "types": "./dist/index.d.ts",     // TypeScript definitions

  "exports": {                // 🔑 modern — conditional, Node 12.7+, ưu tiên hơn main/module
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.js"
    },
    "./helpers": "./dist/helpers.js",
    "./package.json": "./package.json"
  },

  "sideEffects": false,    // tree-shake aggressive

  "files": ["dist"],       // chỉ publish folder dist lên npm

  "engines": { "node": ">=18" }
}
FieldVai tròNăm
mainEntry mặc định cho CommonJS. Field cổ nhất, mọi tool đều hiểu.~2010
moduleEntry ESM. De-facto standard nhưng không trong spec Node. Webpack/Rollup/Vite đều check.~2017
type"module" = file .js là ESM; "commonjs" (default) = CJS.Node 12 (2019)
exportsModern — conditional export theo runtime/condition. Có thể "đóng" subpath, ngăn import nội bộ.Node 12.7 (2019)
types / typingsĐường dẫn file .d.ts cho TypeScript.
sideEffectsHint cho bundler tree-shake.Webpack ~2017
browserOverride entry cho browser (vd: thay fs bằng shim). Bundler-specific.~2014
🧠 Thứ tự ưu tiên khi resolve

Khi bundler/Node tìm entry của một package:

  1. exports — nếu có, dùng cái này, bỏ qua các field khác.
  2. module — bundler thường ưu tiên hơn main.
  3. main — fallback cuối cùng.

exports còn cho phép "đóng cửa": chỉ những path declared mới import được. Trước đó, mọi file trong package đều có thể bị import — gây phá API ngẫu nhiên khi update.

11. Setup tối thiểu — Node ESM

Mục tiêu: chạy 1 file .js dùng import/export trong Node.

terminal — tạo project
mkdir my-esm-app && cd my-esm-app
npm init -y
# mở package.json thêm "type": "module"
package.json
{
  "name": "my-esm-app",
  "version": "1.0.0",
  "type": "module",              // 🔑 dòng quan trọng nhất
  "scripts": { "start": "node index.js" }
}
math.js — module
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }
index.js — entry
import { add, sub } from './math.js';   // 🔑 BẮT BUỘC có .js

console.log(add(1, 2));     // 3
console.log(sub(5, 3));     // 2

// __dirname không tồn tại — dùng import.meta.url
console.log(import.meta.url);
🔥 Gotcha hay gặp khi setup ESM
  • Quên "type": "module"SyntaxError: Cannot use import statement outside a module.
  • Quên .js extensionERR_MODULE_NOT_FOUND dù file tồn tại.
  • Dùng requireReferenceError: require is not defined in ES module scope.
  • Dùng __dirname → undefined. Thay bằng new URL('.', import.meta.url).
  • Import package CJS → có thể không destructure được named export, phải import pkg from 'pkg'; const { foo } = pkg;.

12. Setup tối thiểu — Vite SPA

Mục tiêu: project web có HMR, ESM native, build production sẵn sàng.

terminal — create Vite
npm create vite@latest my-app
# Chọn: Vanilla → JavaScript (hoặc TypeScript)

cd my-app
npm install
npm run dev          # http://localhost:5173 — start < 1s
npm run build        # output vào dist/
npm run preview      # serve dist/ để test

Cấu trúc Vite Vanilla tạo ra:

my-app/
  index.html         // 🔑 entry — Vite tìm script type="module" trong đây
  package.json
  vite.config.js     // (optional) config bundler
  public/            // static assets — copy nguyên
  src/
    main.js
    style.css
    counter.js
index.html
<!DOCTYPE html>
<html>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
src/main.js — entry
import './style.css';                // CSS — Vite hiểu
import { setupCounter } from './counter.js';

document.querySelector('#app').innerHTML = `
  <h1>Hello Vite</h1>
  <button id="cnt">Count</button>
`;

setupCounter(document.getElementById('cnt'));

// HMR API (Vite-specific)
if (import.meta.hot) {
  import.meta.hot.accept();
}

Mở DevTools → Network khi npm run dev: bạn sẽ thấy browser fetch từng file (main.js, counter.js, style.css) qua HTTP — không bundle. Edit counter.js → chỉ file đó reload qua WebSocket, UI giữ nguyên state.

Khi npm run build: Vite gọi Rollup, output minified bundle vào dist/. Sẵn sàng deploy lên CDN.

Bài tập

Bài 1 — Tree-shake với esbuild

Tạo 3 file:

  • math.js: export 2 function add, subtract.
  • string.js: export 1 function capitalize.
  • index.js: re-export tất cả từ 2 file trên (barrel).
  • main.js: import { add } from './index.js', chỉ dùng add.

Cài esbuild, build với cờ --bundle --minify. Kiểm tra bundle output — subtractcapitalize có còn trong file không?

Lệnh + kết quả mong đợi
npm i -D esbuild
npx esbuild main.js --bundle --minify --outfile=bundle.js
cat bundle.js | grep -E "subtract|capitalize"
# Không match → đã tree-shake

Lý do: ESM static, esbuild đọc graph, thấy main.js chỉ dùng add, các symbol còn lại bị loại.

Thử lại với version barrel có console.log('init') ở top-level index.js — esbuild có thể phải giữ cả file vì side effect.

Bài 2 — Migrate CJS sang ESM

Cho module CJS:

// db.js
const fs = require('fs');
function read(path) { return fs.readFileSync(path, 'utf-8'); }
function write(path, data) { fs.writeFileSync(path, data); }
module.exports = { read, write };

// app.js
const { read } = require('./db');
console.log(read('./data.txt'));

Chuyển sang ESM. Liệt kê 3 thay đổi bắt buộc.

Đáp án

3 thay đổi:

  1. Thêm "type": "module" vào package.json.
  2. Thay require/module.exports bằng import/export.
  3. Thêm .js vào path import.
// db.js
import fs from 'node:fs';        // dùng prefix node: cho rõ
export function read(path) { return fs.readFileSync(path, 'utf-8'); }
export function write(path, data) { fs.writeFileSync(path, data); }

// app.js
import { read } from './db.js';
console.log(read('./data.txt'));

Bonus: nếu code có __dirname, thay bằng new URL('.', import.meta.url).pathname.

Bài 3 — Code splitting với Vite

Setup Vite Vanilla. Tạo:

  • greeting.js: export function sayHi(name) in alert.
  • main.js: 1 button. Khi click, dùng await import('./greeting.js') rồi gọi sayHi.

Mở DevTools → Network → Disable cache. Chạy npm run dev, refresh page. Click button. Quan sát: greeting.js chỉ fetch khi click, chứ không lúc load trang.

Code mẫu
// greeting.js
export function sayHi(name) {
  alert(`Xin chào, ${name}!`);
}

// main.js
document.getElementById('btn').addEventListener('click', async () => {
  const { sayHi } = await import('./greeting.js');
  sayHi('Việt');
});

Sau khi npm run build, kiểm tra folder dist/assets/greeting sẽ là 1 chunk riêng (file greeting-XXXX.js). Đây là code splitting tự động.

Bài 4 — Đọc package.json 3 thư viện

Mở node_modules/<pkg>/package.json của 3 package:

  1. lodash-es — bản ESM của lodash.
  2. axios — HTTP client phổ biến.
  3. react — framework UI.

Với mỗi cái, ghi lại main, module, exports, type, sideEffects. Trả lời:

  • Cái nào có "sideEffects": false? Vì sao?
  • Cái nào có "exports" conditional? Có những condition nào?
  • Cái nào còn dùng main CJS? Vì sao chưa migrate?
Gợi ý quan sát
  • lodash-es: "sideEffects": false, mỗi function là 1 file ESM riêng → tree-shake cực tốt. import { debounce } from 'lodash-es' chỉ kéo debounce.
  • axios: "exports" phân nhánh browser/node/default để chọn entry phù hợp runtime (XHR cho browser, http cho Node).
  • react: vẫn dùng cả main (CJS) — vì hàng triệu legacy app + Webpack 4 chưa tốt với pure ESM. React 19 đã bắt đầu publish dual.

Bài 5 — Mini bundler bằng esbuild API

Viết script Node build.js dùng esbuild JavaScript API (không CLI) để bundle:

  • Entry: src/index.js (có nhiều import).
  • Output: dist/bundle.js.
  • Options: bundle, minify, source map, target ES2020.
  • In ra thời gian build và kích thước bundle.
Code mẫu
// build.js
import esbuild from 'esbuild';
import { statSync } from 'node:fs';

const start = performance.now();

await esbuild.build({
  entryPoints: ['src/index.js'],
  outfile: 'dist/bundle.js',
  bundle: true,
  minify: true,
  sourcemap: true,
  target: 'es2020',
  format: 'esm',
});

const elapsed = performance.now() - start;
const size = statSync('dist/bundle.js').size;

console.log(`✓ Build ${elapsed.toFixed(1)}ms, ${(size / 1024).toFixed(2)} KB`);

Chạy: node build.js. Bạn sẽ thấy build < 100ms cho project nhỏ. So với Webpack cùng config: thường 3-5 giây.

Bonus: thêm watch: true để rebuild khi save file.

Quiz

Q1

Trong Node.js, file .js mặc định là module CommonJS hay ESM?

Xem đáp án
✓ Đáp án

CommonJS mặc định, trừ khi package.json set "type": "module". Ngoài ra .mjs luôn là ESM, .cjs luôn là CJS — bất kể setting nào.

Q2

Browser native dùng module format nào? Cần khai báo thế nào trong HTML?

Xem đáp án
✓ Đáp án

Browser dùng ESM. Cần <script type="module" src="main.js"></script>. Lưu ý: phải serve qua HTTP (không chạy file://), path import phải có .js extension, không hỗ trợ bare specifier (import x from 'lodash') trừ khi có <script type="importmap">.

Q3

Tree-shaking có hoạt động với CommonJS không? Vì sao?

Xem đáp án
✓ Đáp án

Không. CJS là dynamic: require() có thể gọi trong if/loop, path là expression, module.exports có thể gán động. Bundler không thể static analyze để biết export nào unused. Chỉ ESM (static, top-level) cho phép tree-shaking. Đây là lý do nhiều library mới publish dual hoặc bỏ hẳn CJS.

Q4

import.meta.url là gì? Dùng để làm gì?

Xem đáp án
✓ Đáp án

import.meta.urlURL của module hiện tại, chỉ tồn tại trong ESM. Ví dụ: file:///Users/me/app/index.js. Dùng để thay thế __dirname/__filename của CJS:

import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = new URL('.', import.meta.url).pathname;

Cũng dùng để load file relative module: new URL('./data.json', import.meta.url).

Q5

Vite trong dev mode có bundle code không? Khác Webpack thế nào?

Xem đáp án
✓ Đáp án

Không bundle source code. Vite tận dụng ESM native của browser: serve từng file .js/.ts riêng qua HTTP, transform on-the-fly bằng esbuild. node_modules được prebundle 1 lần bằng esbuild (cache lại), vì có quá nhiều file. Khi bạn edit 1 file, chỉ file đó invalidate qua WebSocket → HMR cực nhanh.

Webpack ngược lại: bundle toàn bộ thành 1-2 file ngay trong dev → start chậm vài giây, rebuild cũng chậm vì phải walk graph lại.

Khi build production, Vite chuyển sang Rollup để bundle thực sự — vì ESM native lúc deploy không tối ưu (quá nhiều round-trip).

Q6

Khi nào dùng default export vs named export?

Xem đáp án
✓ Đáp án

Named cho 90% trường hợp — helper, utility, hằng số, type. Refactor an toàn (IDE rename tracking được), import grep được, không bị mỗi file đặt một tên khác.

Default khi file có 1 export chính nổi bật và tự nhiên = tên file: React component (Button.jsx export default Button), page Next.js, class chính của library. Một số style guide (Airbnb, base của Next.js) thực sự khuyến nghị tránh default hoàn toàn.

Q7

"sideEffects": false trong package.json có ý nghĩa gì?

Xem đáp án
✓ Đáp án

Tín hiệu cho bundler (Webpack/Rollup/Vite) rằng mọi file trong package này pure — chỉ định nghĩa export, không có code chạy ở top-level ảnh hưởng bên ngoài (global mutation, polyfill, đăng ký listener, CSS import). Khi đó bundler có thể tree-shake aggressive: loại bỏ cả file nếu không export nào được dùng.

Nếu có file ngoại lệ (vd polyfill), whitelist: "sideEffects": ["./src/polyfill.js", "*.css"].

Ngược lại, "sideEffects": true (default) buộc bundler giữ mọi file dù không dùng export — vì sợ làm vỡ side effect ngầm.

Tổng kết

Sau chương 8, bạn nên đã master:

  • Lịch sử module: IIFE → CommonJS → AMD/UMD → ESM. Hiểu vì sao 2026 vẫn còn 2 format chính (CJS & ESM) song song.
  • CommonJS: require/module.exports, sync, dynamic, default Node trừ khi "type": "module".
  • ESM: import/export, static, async, tree-shakeable. Phải có .js trong path, không dùng require/__dirname.
  • Named vs default: named 90%, default cho main export. Re-export barrel có pro/con.
  • Dynamic import(): async, path expression, code splitting, lazy load.
  • Tree-shaking: chỉ ESM static, cần "sideEffects": false, chỉ bật production.
  • Bundler: resolve + transform + tree-shake + split + minify + source map + hash. Landscape 2026: Vite (SPA), tsup (lib), esbuild (engine), Webpack (legacy).
  • package.json: type, main, module, exports, sideEffects — đọc hiểu là kỹ năng.
  • Setup tối thiểu: Node ESM (chỉ cần "type": "module") và Vite SPA (npm create vite).

Kết nối

  • Chương 9 (TypeScript Type System) — đây là chương đầu của nhánh TypeScript trong sub-pillar JS. Module syntax giữ nguyên ESM nhưng có thêm import type { ... } và type re-export.
  • Chương 12 (TS Compiler & tsconfig) — option module trong tsconfig.json (CommonJS, ESNext, NodeNext) quyết định tsc output format nào, gắn chặt với những gì chương này nói.
  • Chương 1 (Hello JavaScript) — quay lại đọc kỹ package.json đã setup, nay đã hiểu mọi field.
  • Dart/Flutter (Pillar 9 nhánh kia) — Dart không có bundler runtime; thay vào đó là AOT compile sinh native binary. Triết lý khác: JS bundle & ship cho browser, Dart compile sẵn cho VM/native.
🎓 Hoàn thành JS core (Chương 1-8)

Đây là chương cuối của nhánh JavaScript core. Bạn đã đi qua: hello world, types & coercion, functions & closures, objects & prototypes, arrays & iteration, async & promises, errors & debugging, và bây giờ modules & bundlers.

Chương 9 trở đi mở ra phần TypeScript — gắn type system lên JS. Mọi thứ học được vẫn áp dụng nguyên, TypeScript chỉ thêm tầng kiểm tra tĩnh. Hẹn gặp ở chương 9 với type, interface, generic, narrowing và discriminated union.